From 63e574700196743d764ebcd538d55114a1ff231a Mon Sep 17 00:00:00 2001 From: Robert Collins <rbtcollins@hp.com> Date: Fri, 3 Jul 2015 13:23:26 +1200 Subject: [PATCH] Fix editing constraints files with urls in them. constraints files, unlike global_requirements, need to be able to contain urls (specifically file:/// paths) so that we can constrain libraries to be run from their git trees, rather than having them flap all over the place as different servers specify different bounds. The first edit-constraints implementation failed to parse such lines, throwing errors instead. This patch makes the support for urls optional, preventing changing the semantics of requirements update.py logic. Change-Id: I33bd2d9dff9cb7dc1a50177db7286b7317966784 Depends-On: I0f07858e96ea3baf46f8a453e253b9ed29c7f7e2 --- .../cmds/edit_constraint.py | 2 +- openstack_requirements/requirement.py | 77 ++++++++++++++----- .../tests/test_edit_constraint.py | 20 +++++ .../tests/test_requirement.py | 45 ++++++++++- 4 files changed, 123 insertions(+), 21 deletions(-) diff --git a/openstack_requirements/cmds/edit_constraint.py b/openstack_requirements/cmds/edit_constraint.py index 3a2ece224c..1fea91aee0 100644 --- a/openstack_requirements/cmds/edit_constraint.py +++ b/openstack_requirements/cmds/edit_constraint.py @@ -65,7 +65,7 @@ def main(argv=None, stdout=None): _validate_options(options, args) args = args + [""] content = open(args[0], 'rt').read() - reqs = requirement.parse(content) + reqs = requirement.parse(content, permit_urls=True) out_reqs = edit(reqs, args[1], args[2]) out = requirement.to_content(out_reqs, prefix=False) with open(args[0] + '.tmp', 'wt') as f: diff --git a/openstack_requirements/requirement.py b/openstack_requirements/requirement.py index b396e1bf44..d4af2586b2 100644 --- a/openstack_requirements/requirement.py +++ b/openstack_requirements/requirement.py @@ -15,6 +15,7 @@ # This module has no IO at all, and none should be added. import collections +import re import pkg_resources @@ -35,11 +36,16 @@ Requirement = collections.namedtuple( Requirements = collections.namedtuple('Requirements', ['reqs']) -def parse(content): - return to_dict(to_reqs(content)) +url_re = re.compile( + '^(?P<url>\s*(?:-e\s)?\s*(?:(?:git+)?https|http|file)://.*)' + '#egg=(?P<name>[-\w]+)') -def parse_line(req_line): +def parse(content, permit_urls=False): + return to_dict(to_reqs(content, permit_urls=permit_urls)) + + +def parse_line(req_line, permit_urls=False): """Parse a single line of a requirements file. requirements files here are a subset of pip requirements files: we don't @@ -50,16 +56,40 @@ def parse_line(req_line): They may of course be used by local test configurations, just not committed into the OpenStack reference branches. + + :param permit_urls: If True, urls are parsed into Requirement tuples. + By default they are not, because they cannot be reflected into + setuptools kwargs, and thus the default is conservative. When + urls are permitted, -e *may* be supplied at the start of the line. """ end = len(req_line) hash_pos = req_line.find('#') if hash_pos < 0: hash_pos = end + # Don't find urls that are in comments. if '://' in req_line[:hash_pos]: - # Trigger an early failure before we look for ':' - pkg_resources.Requirement.parse(req_line) - semi_pos = req_line.find(';', 0, hash_pos) - colon_pos = req_line.find(':', 0, hash_pos) + if permit_urls: + # We accept only a subset of urls here - they have to have an egg + # name so that we can tell what project its for without doing + # network access. Egg markers use a fragment, so we need to pull + # out url from the entire line. + m = url_re.match(req_line) + name = m.group('name') + location = m.group('url') + parse_start = m.end('name') + hash_pos = req_line[parse_start:].find('#') + if hash_pos < 0: + hash_pos = end + else: + hash_pos = hash_pos + parse_start + else: + # Trigger an early failure before we look for ':' + pkg_resources.Requirement.parse(req_line) + else: + parse_start = 0 + location = '' + semi_pos = req_line.find(';', parse_start, hash_pos) + colon_pos = req_line.find(':', parse_start, hash_pos) marker_pos = max(semi_pos, colon_pos) if marker_pos < 0: marker_pos = hash_pos @@ -68,16 +98,21 @@ def parse_line(req_line): comment = req_line[hash_pos:] else: comment = '' - req_line = req_line[:marker_pos] + req_line = req_line[parse_start:marker_pos] - if req_line: + if parse_start: + # We parsed a url before + specifier = '' + elif req_line: + # Pulled out a requirement parsed = pkg_resources.Requirement.parse(req_line) name = parsed.project_name specifier = str(parsed.specifier) else: + # Comments / blank lines etc. name = '' specifier = '' - return Requirement(name, '', specifier, markers, comment) + return Requirement(name, location, specifier, markers, comment) def to_content(reqs, marker_sep=';', line_prefix='', prefix=True): @@ -89,7 +124,9 @@ def to_content(reqs, marker_sep=';', line_prefix='', prefix=True): comment = (comment_p + req.comment if req.comment else '') marker = marker_sep + req.markers if req.markers else '' package = line_prefix + req.package if req.package else '' - lines.append('%s%s%s%s\n' % (package, req.specifiers, marker, comment)) + location = req.location + '#egg=' if req.location else '' + lines.append('%s%s%s%s%s\n' % ( + location, package, req.specifiers, marker, comment)) return u''.join(lines) @@ -101,17 +138,21 @@ def to_dict(req_sequence): return reqs -def _pass_through(req_line): +def _pass_through(req_line, permit_urls=False): """Identify unparsable lines.""" - return (req_line.startswith('http://tarballs.openstack.org/') or - req_line.startswith('-e') or - req_line.startswith('-f')) + if permit_urls: + return (req_line.startswith('http://tarballs.openstack.org/') or + req_line.startswith('-f')) + else: + return (req_line.startswith('http://tarballs.openstack.org/') or + req_line.startswith('-e') or + req_line.startswith('-f')) -def to_reqs(content): +def to_reqs(content, permit_urls=False): for content_line in content.splitlines(True): req_line = content_line.strip() - if _pass_through(req_line): + if _pass_through(req_line, permit_urls=permit_urls): yield None, content_line else: - yield parse_line(req_line), content_line + yield parse_line(req_line, permit_urls=permit_urls), content_line diff --git a/openstack_requirements/tests/test_edit_constraint.py b/openstack_requirements/tests/test_edit_constraint.py index 1f83f632d2..9bad8e6db8 100644 --- a/openstack_requirements/tests/test_edit_constraint.py +++ b/openstack_requirements/tests/test_edit_constraint.py @@ -12,6 +12,7 @@ import io import os +import textwrap import fixtures import testscenarios @@ -38,6 +39,25 @@ class SmokeTest(testtools.TestCase): content = open(constraints_path, 'rt').read() self.assertEqual('-e /path/to/foo\nbar===1\nquux==3\n', content) + def test_edit_paths(self): + stdout = io.StringIO() + tmpdir = self.useFixture(fixtures.TempDir()).path + constraints_path = os.path.join(tmpdir, 'name.txt') + with open(constraints_path, 'wt') as f: + f.write(textwrap.dedent("""\ + file:///path/to/foo#egg=foo + -e file:///path/to/bar#egg=bar + """)) + rv = edit.main( + [constraints_path, 'foo', '--', '-e file:///path/to/foo#egg=foo'], + stdout) + self.assertEqual(0, rv) + content = open(constraints_path, 'rt').read() + self.assertEqual(textwrap.dedent("""\ + -e file:///path/to/foo#egg=foo + -e file:///path/to/bar#egg=bar + """), content) + class TestEdit(testtools.TestCase): diff --git a/openstack_requirements/tests/test_requirement.py b/openstack_requirements/tests/test_requirement.py index eb530feb18..d9faa08d84 100644 --- a/openstack_requirements/tests/test_requirement.py +++ b/openstack_requirements/tests/test_requirement.py @@ -22,7 +22,7 @@ load_tests = testscenarios.load_tests_apply_scenarios class TestParseRequirement(testtools.TestCase): - scenarios = [ + dist_scenarios = [ ('package', dict( line='swift', req=requirement.Requirement('swift', '', '', '', ''))), @@ -53,9 +53,22 @@ class TestParseRequirement(testtools.TestCase): line="Sphinx<=1.2; python_version=='2.7'# Sadface", req=requirement.Requirement('Sphinx', '', '<=1.2', "python_version=='2.7'", '# Sadface')))] + url_scenarios = [ + ('url', dict( + line='file:///path/to/thing#egg=thing', + req=requirement.Requirement('thing', 'file:///path/to/thing', '', '', + ''), + permit_urls=True)), + ('editable', dict( + line='-e file:///path/to/bar#egg=bar', + req=requirement.Requirement('bar', '-e file:///path/to/bar', '', '', + ''), + permit_urls=True))] + scenarios = dist_scenarios + url_scenarios def test_parse(self): - parsed = requirement.parse_line(self.line) + parsed = requirement.parse_line( + self.line, permit_urls=getattr(self, 'permit_urls', False)) self.assertEqual(self.req, parsed) @@ -83,3 +96,31 @@ class TestToContent(testtools.TestCase): ''.join(requirement._REQS_HEADER + ["foo<=1!python_version=='2.7' # BSD\n"]), reqs) + + def test_location(self): + reqs = requirement.to_content(requirement.Requirements( + [requirement.Requirement( + 'foo', 'file://foo', '', "python_version=='2.7'", '# BSD')])) + self.assertEqual( + ''.join(requirement._REQS_HEADER + + ["file://foo#egg=foo;python_version=='2.7' # BSD\n"]), + reqs) + + +class TestToReqs(testtools.TestCase): + + def test_editable(self): + line = '-e file:///foo#egg=foo' + reqs = list(requirement.to_reqs(line, permit_urls=True)) + req = requirement.Requirement('foo', '-e file:///foo', '', '', '') + self.assertEqual(reqs, [(req, line)]) + + def test_urls(self): + line = 'file:///foo#egg=foo' + reqs = list(requirement.to_reqs(line, permit_urls=True)) + req = requirement.Requirement('foo', 'file:///foo', '', '', '') + self.assertEqual(reqs, [(req, line)]) + + def test_not_urls(self): + with testtools.ExpectedException(pkg_resources.RequirementParseError): + list(requirement.to_reqs('file:///foo#egg=foo'))