diff --git a/openstack_requirements/project.py b/openstack_requirements/project.py new file mode 100644 index 0000000000..5471b86e77 --- /dev/null +++ b/openstack_requirements/project.py @@ -0,0 +1,180 @@ +# Copyright 2012 OpenStack Foundation +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""The project abstraction.""" + +import collections +import errno +import io +import os +from six.moves import configparser + +from parsley import makeGrammar + +from openstack_requirements import requirement + +# PURE logic from here until the IO marker below. + + +_Comment = collections.namedtuple('Comment', ['line']) +_Extra = collections.namedtuple('Extra', ['name', 'content']) + + +_extras_grammar = """ +ini = (line*:p extras?:e line*:l final:s) -> (''.join(p), e, ''.join(l+[s])) +line = ~extras <(~'\\n' anything)* '\\n'> +final = <(~'\\n' anything)* > +extras = '[' 'e' 'x' 't' 'r' 'a' 's' ']' '\\n'+ body*:b -> b +body = comment | extra +comment = <'#' (~'\\n' anything)* '\\n'>:c '\\n'* -> comment(c) +extra = name:n ' '* '=' line:l cont*:c '\\n'* -> extra(n, ''.join([l] + c)) +name = <(anything:x ?(x not in '\\n \\t='))+> +cont = ' '+ <(~'\\n' anything)* '\\n'> +""" +_extras_compiled = makeGrammar( + _extras_grammar, {"comment": _Comment, "extra": _Extra}) + + +Error = collections.namedtuple('Error', ['message']) +File = collections.namedtuple('File', ['filename', 'content']) +StdOut = collections.namedtuple('StdOut', ['message']) +Verbose = collections.namedtuple('Verbose', ['message']) + + +def extras(project): + """Return a dict of extra-name:content for the extras in setup.cfg.""" + c = configparser.SafeConfigParser() + c.readfp(io.StringIO(project['setup.cfg'])) + if not c.has_section('extras'): + return dict() + return dict(c.items('extras')) + + +def merge_setup_cfg(old_content, new_extras): + # This is ugly. All the existing libraries handle setup.cfg's poorly. + prefix, extras, suffix = _extras_compiled(old_content).ini() + out_extras = [] + if extras is not None: + for extra in extras: + if type(extra) is _Comment: + out_extras.append(extra) + elif type(extra) is _Extra: + if extra.name not in new_extras: + out_extras.append(extra) + continue + e = _Extra( + extra.name, + requirement.to_content( + new_extras[extra.name], ':', ' ', False)) + out_extras.append(e) + else: + raise TypeError('unknown type %r' % extra) + if out_extras: + extras_str = ['[extras]\n'] + for extra in out_extras: + if type(extra) is _Comment: + extras_str.append(extra.line) + else: + extras_str.append(extra.name + ' =') + extras_str.append(extra.content) + if suffix: + extras_str.append('\n') + extras_str = ''.join(extras_str) + else: + extras_str = '' + return prefix + extras_str + suffix + + +# IO from here to the end of the file. + +def _safe_read(project, filename, output=None): + if output is None: + output = project + try: + path = project['root'] + '/' + filename + with io.open(path, 'rt', encoding="utf-8") as f: + output[filename] = f.read() + except IOError as e: + if e.errno != errno.ENOENT: + raise + + +def read(root): + """Read into memory the packaging data for the project at root. + + :param root: A directory path. + :return: A dict representing the project with the following keys: + - root: The root dir. + - setup.py: Contents of setup.py. + - setup.cfg: Contents of setup.cfg. + - requirements: Dict of requirement file name: contents. + """ + result = {'root': root} + _safe_read(result, 'setup.py') + _safe_read(result, 'setup.cfg') + requirements = {} + result['requirements'] = requirements + target_files = [ + 'requirements.txt', 'tools/pip-requires', + 'test-requirements.txt', 'tools/test-requires', + ] + for py_version in (2, 3): + target_files.append('requirements-py%s.txt' % py_version) + target_files.append('test-requirements-py%s.txt' % py_version) + for target_file in target_files: + _safe_read(result, target_file, output=requirements) + return result + + +def write(project, actions, stdout, verbose, noop=False): + """Write actions into project. + + :param project: A project metadata dict. + :param actions: A list of action tuples - File or Verbose - that describe + what actions are to be taken. + Error objects write a message to stdout and trigger an exception at + the end of _write_project. + File objects describe a file to have content placed in it. + StdOut objects describe a message to write to stdout. + Verbose objects will write a message to stdout when verbose is True. + :param stdout: Where to write content for stdout. + :param verbose: If True Verbose actions will be written to stdout. + :param noop: If True nothing will be written to disk. + :return None: + :raises IOError: If the IO operations fail, IOError is raised. If this + happens some actions may have been applied and others not. + """ + error = False + for action in actions: + if type(action) is Error: + error = True + stdout.write(action.message + '\n') + elif type(action) is File: + if noop: + continue + fullname = project['root'] + '/' + action.filename + tmpname = fullname + '.tmp' + with open(tmpname, 'wt') as f: + f.write(action.content) + os.rename(tmpname, fullname) + elif type(action) is StdOut: + stdout.write(action.message) + elif type(action) is Verbose: + if verbose: + stdout.write(u"%s\n" % (action.message,)) + else: + raise Exception("Invalid action %r" % (action,)) + if error: + raise Exception("Error occured processing %s" % (project['root'])) diff --git a/openstack_requirements/requirement.py b/openstack_requirements/requirement.py new file mode 100644 index 0000000000..4fd7a1f314 --- /dev/null +++ b/openstack_requirements/requirement.py @@ -0,0 +1,117 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Requirements handling.""" + +# This module has no IO at all, and none should be added. + +import collections + +import pkg_resources + + +# A header for the requirements file(s). +# TODO(lifeless): Remove this once constraints are in use. +_REQS_HEADER = [ + '# The order of packages is significant, because pip processes ' + 'them in the order\n', + '# of appearance. Changing the order has an impact on the overall ' + 'integration\n', + '# process, which may cause wedges in the gate later.\n', +] + + +Requirement = collections.namedtuple( + 'Requirement', ['package', 'specifiers', 'markers', 'comment']) +Requirements = collections.namedtuple('Requirements', ['reqs']) + + +def parse(content): + return to_dict(to_reqs(content)) + + +def parse_line(req_line): + """Parse a single line of a requirements file. + + requirements files here are a subset of pip requirements files: we don't + try to parse URL entries, or pip options like -f and -e. Those are not + permitted in global-requirements.txt. If encountered in a synchronised + file such as requirements.txt or test-requirements.txt, they are illegal + but currently preserved as-is. + + They may of course be used by local test configurations, just not + committed into the OpenStack reference branches. + """ + end = len(req_line) + hash_pos = req_line.find('#') + if hash_pos < 0: + hash_pos = end + 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) + marker_pos = max(semi_pos, colon_pos) + if marker_pos < 0: + marker_pos = hash_pos + markers = req_line[marker_pos + 1:hash_pos].strip() + if hash_pos != end: + comment = req_line[hash_pos:] + else: + comment = '' + req_line = req_line[:marker_pos] + + if req_line: + parsed = pkg_resources.Requirement.parse(req_line) + name = parsed.project_name + specifier = str(parsed.specifier) + else: + name = '' + specifier = '' + return Requirement(name, specifier, markers, comment) + + +def to_content(reqs, marker_sep=';', line_prefix='', prefix=True): + lines = [] + if prefix: + lines += _REQS_HEADER + for req in reqs.reqs: + comment_p = ' ' if req.package else '' + 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)) + return u''.join(lines) + + +def to_dict(req_sequence): + reqs = dict() + for req, req_line in req_sequence: + if req is not None: + reqs.setdefault(req.package.lower(), []).append((req, req_line)) + return reqs + + +def _pass_through(req_line): + """Identify unparsable lines.""" + return (req_line.startswith('http://tarballs.openstack.org/') or + req_line.startswith('-e') or + req_line.startswith('-f')) + + +def to_reqs(content): + for content_line in content.splitlines(True): + req_line = content_line.strip() + if _pass_through(req_line): + yield None, content_line + else: + yield parse_line(req_line), content_line diff --git a/openstack_requirements/tests/common.py b/openstack_requirements/tests/common.py index 53d48d29e0..75e4975e86 100644 --- a/openstack_requirements/tests/common.py +++ b/openstack_requirements/tests/common.py @@ -15,6 +15,8 @@ import shutil import fixtures +from openstack_requirements import project +from openstack_requirements import requirement from openstack_requirements import update @@ -82,10 +84,10 @@ class GlobalRequirements(fixtures.Fixture): # Static data for unit testing. def make_project(fixture): with fixture: - return update._read_project(fixture.root) + return project.read(fixture.root) -global_reqs = update._parse_reqs( +global_reqs = requirement.parse( open("openstack_requirements/tests/files/gr-base.txt", "rt").read()) pbr_project = make_project(pbr_fixture) project_project = make_project(project_fixture) @@ -94,13 +96,13 @@ oslo_project = make_project(oslo_fixture) def project_file( - fail, project, action_filename, suffix=None, softupdate=None, + fail, proj, action_filename, suffix=None, softupdate=None, non_std_reqs=False): actions = update._process_project( - project, global_reqs, suffix, softupdate, None, + proj, global_reqs, suffix, softupdate, None, non_std_reqs) for action in actions: - if type(action) is update.File: + if type(action) is project.File: if action.filename == action_filename: return action.content.splitlines() fail('File %r not found in %r' % (action_filename, actions)) diff --git a/openstack_requirements/tests/test_integration.py b/openstack_requirements/tests/test_integration.py index 5f1fa28c30..dcf4a4c288 100644 --- a/openstack_requirements/tests/test_integration.py +++ b/openstack_requirements/tests/test_integration.py @@ -13,7 +13,7 @@ from packaging import specifiers import testtools -from openstack_requirements import update +from openstack_requirements import requirement def check_compatible(global_reqs, constraints): @@ -62,14 +62,14 @@ class TestRequirements(testtools.TestCase): def test_constraints_compatible(self): global_req_content = open('global-requirements.txt', 'rt').read() constraints_content = open('upper-constraints.txt', 'rt').read() - global_reqs = update._parse_reqs(global_req_content) - constraints = update._parse_reqs(constraints_content) + global_reqs = requirement.parse(global_req_content) + constraints = requirement.parse(constraints_content) self.assertEqual([], check_compatible(global_reqs, constraints)) def test_check_compatible(self): - global_reqs = update._parse_reqs("foo>=1.2\n") - good_constraints = update._parse_reqs("foo===1.2.5\n") - bad_constraints = update._parse_reqs("foo===1.1.2\n") + global_reqs = requirement.parse("foo>=1.2\n") + good_constraints = requirement.parse("foo===1.2.5\n") + bad_constraints = requirement.parse("foo===1.1.2\n") self.assertEqual([], check_compatible(global_reqs, good_constraints)) self.assertNotEqual( [], check_compatible(global_reqs, bad_constraints)) diff --git a/openstack_requirements/tests/test_project.py b/openstack_requirements/tests/test_project.py new file mode 100644 index 0000000000..111bfb0eb7 --- /dev/null +++ b/openstack_requirements/tests/test_project.py @@ -0,0 +1,277 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import io +import textwrap + +import fixtures +import parsley +import testscenarios +import testtools +from testtools import matchers + +from openstack_requirements import project +from openstack_requirements import requirement +from openstack_requirements.tests import common + + +load_tests = testscenarios.load_tests_apply_scenarios + + +class TestReadProject(testtools.TestCase): + + def test_pbr(self): + root = self.useFixture(common.pbr_fixture).root + proj = project.read(root) + self.expectThat(proj['root'], matchers.Equals(root)) + setup_py = open(root + '/setup.py', 'rt').read() + self.expectThat(proj['setup.py'], matchers.Equals(setup_py)) + setup_cfg = open(root + '/setup.cfg', 'rt').read() + self.expectThat(proj['setup.cfg'], matchers.Equals(setup_cfg)) + self.expectThat( + proj['requirements'], + matchers.KeysEqual('requirements.txt', 'test-requirements.txt')) + + def test_no_setup_py(self): + root = self.useFixture(fixtures.TempDir()).path + proj = project.read(root) + self.expectThat( + proj, matchers.Equals({'root': root, 'requirements': {}})) + + +class TestProjectExtras(testtools.TestCase): + + def test_smoke(self): + proj = {'setup.cfg': textwrap.dedent(u""" + [extras] + 1 = + foo + 2 = + foo # fred + bar + """)} + expected = { + '1': '\nfoo', + '2': '\nfoo # fred\nbar' + } + self.assertEqual(expected, project.extras(proj)) + + def test_none(self): + proj = {'setup.cfg': u"[metadata]\n"} + self.assertEqual({}, project.extras(proj)) + + +class TestExtrasParsing(testtools.TestCase): + + def test_none(self): + old_content = textwrap.dedent(u""" + [metadata] + # something something + name = fred + + [entry_points] + console_scripts = + foo = bar:quux + """) + ini = project._extras_compiled(old_content).ini() + self.assertEqual(ini, (old_content, None, '')) + + def test_no_eol(self): + old_content = textwrap.dedent(u""" + [metadata] + # something something + name = fred + + [entry_points] + console_scripts = + foo = bar:quux""") + expected1 = textwrap.dedent(u""" + [metadata] + # something something + name = fred + + [entry_points] + console_scripts = + """) + suffix = ' foo = bar:quux' + ini = project._extras_compiled(old_content).ini() + self.assertEqual(ini, (expected1, None, suffix)) + + def test_two_extras_raises(self): + old_content = textwrap.dedent(u""" + [metadata] + # something something + name = fred + + [extras] + a = b + [extras] + b = c + + [entry_points] + console_scripts = + foo = bar:quux + """) + with testtools.ExpectedException(parsley.ParseError): + project._extras_compiled(old_content).ini() + + def test_extras(self): + # We get an AST for extras we can use to preserve comments. + old_content = textwrap.dedent(u""" + [metadata] + # something something + name = fred + + [extras] + # comment1 + a = + b + c + # comment2 + # comment3 + d = + e + # comment4 + + [entry_points] + console_scripts = + foo = bar:quux + """) + prefix = textwrap.dedent(u""" + [metadata] + # something something + name = fred + + """) + suffix = textwrap.dedent(u"""\ + [entry_points] + console_scripts = + foo = bar:quux + """) + extras = [ + project._Comment('# comment1\n'), + project._Extra('a', '\nb\nc\n'), + project._Comment('# comment2\n'), + project._Comment('# comment3\n'), + project._Extra('d', '\ne\n'), + project._Comment('# comment4\n')] + ini = project._extras_compiled(old_content).ini() + self.assertEqual(ini, (prefix, extras, suffix)) + + +class TestMergeSetupCfg(testtools.TestCase): + + def test_merge_none(self): + old_content = textwrap.dedent(u""" + [metadata] + # something something + name = fred + + [entry_points] + console_scripts = + foo = bar:quux + """) + merged = project.merge_setup_cfg(old_content, {}) + self.assertEqual(old_content, merged) + + def test_merge_extras(self): + old_content = textwrap.dedent(u""" + [metadata] + name = fred + + [extras] + # Comment + a = + b + # comment + c = + d + + [entry_points] + console_scripts = + foo = bar:quux + """) + blank = requirement.Requirement('', '', '', '') + r1 = requirement.Requirement('b', '>=1', "python_version=='2.7'", '') + r2 = requirement.Requirement('d', '', '', '# BSD') + reqs = { + 'a': requirement.Requirements([blank, r1]), + 'c': requirement.Requirements([blank, r2])} + merged = project.merge_setup_cfg(old_content, reqs) + expected = textwrap.dedent(u""" + [metadata] + name = fred + + [extras] + # Comment + a = + b>=1:python_version=='2.7' + # comment + c = + d # BSD + + [entry_points] + console_scripts = + foo = bar:quux + """) + self.assertEqual(expected, merged) + + +class TestWriteProject(testtools.TestCase): + + def test_smoke(self): + stdout = io.StringIO() + root = self.useFixture(fixtures.TempDir()).path + proj = {'root': root} + actions = [ + project.File('foo', '123\n'), + project.File('bar', '456\n'), + project.Verbose(u'fred')] + project.write(proj, actions, stdout, True) + foo = open(root + '/foo', 'rt').read() + self.expectThat(foo, matchers.Equals('123\n')) + bar = open(root + '/bar', 'rt').read() + self.expectThat(bar, matchers.Equals('456\n')) + self.expectThat(stdout.getvalue(), matchers.Equals('fred\n')) + + def test_non_verbose(self): + stdout = io.StringIO() + root = self.useFixture(fixtures.TempDir()).path + proj = {'root': root} + actions = [project.Verbose(u'fred')] + project.write(proj, actions, stdout, False) + self.expectThat(stdout.getvalue(), matchers.Equals('')) + + def test_bad_action(self): + root = self.useFixture(fixtures.TempDir()).path + stdout = io.StringIO() + proj = {'root': root} + actions = [('foo', 'bar')] + with testtools.ExpectedException(Exception): + project.write(proj, actions, stdout, True) + + def test_stdout(self): + stdout = io.StringIO() + root = self.useFixture(fixtures.TempDir()).path + proj = {'root': root} + actions = [project.StdOut(u'fred\n')] + project.write(proj, actions, stdout, True) + self.expectThat(stdout.getvalue(), matchers.Equals('fred\n')) + + def test_errors(self): + stdout = io.StringIO() + root = self.useFixture(fixtures.TempDir()).path + proj = {'root': root} + actions = [project.Error(u'fred')] + with testtools.ExpectedException(Exception): + project.write(proj, actions, stdout, True) + self.expectThat(stdout.getvalue(), matchers.Equals('fred\n')) diff --git a/openstack_requirements/tests/test_requirement.py b/openstack_requirements/tests/test_requirement.py new file mode 100644 index 0000000000..f2f6926343 --- /dev/null +++ b/openstack_requirements/tests/test_requirement.py @@ -0,0 +1,84 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import pkg_resources +import testscenarios +import testtools + +from openstack_requirements import requirement + + +load_tests = testscenarios.load_tests_apply_scenarios + + +class TestParseRequirement(testtools.TestCase): + + scenarios = [ + ('package', dict( + line='swift', + req=requirement.Requirement('swift', '', '', ''))), + ('specifier', dict( + line='alembic>=0.4.1', + req=requirement.Requirement('alembic', '>=0.4.1', '', ''))), + ('specifiers', dict( + line='alembic>=0.4.1,!=1.1.8', + req=requirement.Requirement('alembic', '!=1.1.8,>=0.4.1', '', ''))), + ('comment-only', dict( + line='# foo', + req=requirement.Requirement('', '', '', '# foo'))), + ('comment', dict( + line='Pint>=0.5 # BSD', + req=requirement.Requirement('Pint', '>=0.5', '', '# BSD'))), + ('comment-with-semicolon', dict( + line='Pint>=0.5 # BSD;fred', + req=requirement.Requirement('Pint', '>=0.5', '', '# BSD;fred'))), + ('case', dict( + line='Babel>=1.3', + req=requirement.Requirement('Babel', '>=1.3', '', ''))), + ('markers', dict( + line="pywin32;sys_platform=='win32'", + req=requirement.Requirement('pywin32', '', "sys_platform=='win32'", + ''))), + ('markers-with-comment', dict( + line="Sphinx<=1.2; python_version=='2.7'# Sadface", + req=requirement.Requirement('Sphinx', '<=1.2', + "python_version=='2.7'", '# Sadface')))] + + def test_parse(self): + parsed = requirement.parse_line(self.line) + self.assertEqual(self.req, parsed) + + +class TestParseRequirementFailures(testtools.TestCase): + + scenarios = [ + ('url', dict(line='http://tarballs.openstack.org/oslo.config/' + 'oslo.config-1.2.0a3.tar.gz#egg=oslo.config')), + ('-e', dict(line='-e git+https://foo.com#egg=foo')), + ('-f', dict(line='-f http://tarballs.openstack.org/'))] + + def test_does_not_parse(self): + with testtools.ExpectedException(pkg_resources.RequirementParseError): + requirement.parse_line(self.line) + + +class TestToContent(testtools.TestCase): + + def test_smoke(self): + reqs = requirement.to_content(requirement.Requirements( + [requirement.Requirement( + 'foo', '<=1', "python_version=='2.7'", '# BSD')]), + marker_sep='!') + self.assertEqual( + ''.join(requirement._REQS_HEADER + + ["foo<=1!python_version=='2.7' # BSD\n"]), + reqs) diff --git a/openstack_requirements/tests/test_update.py b/openstack_requirements/tests/test_update.py index afeb2d5613..3b7e0fa775 100644 --- a/openstack_requirements/tests/test_update.py +++ b/openstack_requirements/tests/test_update.py @@ -14,18 +14,17 @@ from __future__ import print_function -import io import StringIO import sys import textwrap import fixtures -import parsley -import pkg_resources import testscenarios import testtools from testtools import matchers +from openstack_requirements import project +from openstack_requirements import requirement from openstack_requirements.tests import common from openstack_requirements import update @@ -123,16 +122,16 @@ class UpdateTest(testtools.TestCase): common.oslo_project, common.global_reqs, None, None, None, False) for action in actions: - if type(action) is update.File: + if type(action) is project.File: self.assertNotEqual(action.filename, 'setup.py') # These are tests which don't need to run the project update in advance def test_requirement_not_in_global(self): actions = update._process_project( common.bad_project, common.global_reqs, None, None, None, False) - errors = [a for a in actions if type(a) is update.Error] + errors = [a for a in actions if type(a) is project.Error] msg = u"'thisisnotarealdepedency' is not in global-requirements.txt" - self.assertEqual([update.Error(message=msg)], errors) + self.assertEqual([project.Error(message=msg)], errors) def test_requirement_not_in_global_non_fatal(self): reqs = common.project_file( @@ -152,7 +151,7 @@ class UpdateTest(testtools.TestCase): common.project_project, common.global_reqs, None, None, None, False) capture = StringIO.StringIO() - update._write_project( + project.write( common.project_project, actions, capture, False, True) expected = ('Version change for: greenlet, SQLAlchemy, eventlet, PasteDeploy, routes, WebOb, wsgiref, boto, kombu, pycrypto, python-swiftclient, lxml, jsonschema, python-keystoneclient\n' # noqa """Updated %(project)s/requirements.txt: @@ -184,7 +183,7 @@ Updated %(project)s/test-requirements.txt: common.project_project, common.global_reqs, None, None, None, False) capture = StringIO.StringIO() - update._write_project( + project.write( common.project_project, actions, capture, True, True) expected = ("""Syncing %(project)s/requirements.txt Version change for: greenlet, SQLAlchemy, eventlet, PasteDeploy, routes, WebOb, wsgiref, boto, kombu, pycrypto, python-swiftclient, lxml, jsonschema, python-keystoneclient\n""" # noqa @@ -215,78 +214,6 @@ Syncing setup.py self.assertEqual(expected, capture.getvalue()) -class TestReadProject(testtools.TestCase): - - def test_pbr(self): - root = self.useFixture(common.pbr_fixture).root - project = update._read_project(root) - self.expectThat(project['root'], matchers.Equals(root)) - setup_py = open(root + '/setup.py', 'rt').read() - self.expectThat(project['setup.py'], matchers.Equals(setup_py)) - setup_cfg = open(root + '/setup.cfg', 'rt').read() - self.expectThat(project['setup.cfg'], matchers.Equals(setup_cfg)) - self.expectThat( - project['requirements'], - matchers.KeysEqual('requirements.txt', 'test-requirements.txt')) - - def test_no_setup_py(self): - root = self.useFixture(fixtures.TempDir()).path - project = update._read_project(root) - self.expectThat( - project, matchers.Equals({'root': root, 'requirements': {}})) - - -class TestWriteProject(testtools.TestCase): - - def test_smoke(self): - stdout = io.StringIO() - root = self.useFixture(fixtures.TempDir()).path - project = {'root': root} - actions = [ - update.File('foo', '123\n'), - update.File('bar', '456\n'), - update.Verbose(u'fred')] - update._write_project(project, actions, stdout, True) - foo = open(root + '/foo', 'rt').read() - self.expectThat(foo, matchers.Equals('123\n')) - bar = open(root + '/bar', 'rt').read() - self.expectThat(bar, matchers.Equals('456\n')) - self.expectThat(stdout.getvalue(), matchers.Equals('fred\n')) - - def test_non_verbose(self): - stdout = io.StringIO() - root = self.useFixture(fixtures.TempDir()).path - project = {'root': root} - actions = [update.Verbose(u'fred')] - update._write_project(project, actions, stdout, False) - self.expectThat(stdout.getvalue(), matchers.Equals('')) - - def test_bad_action(self): - root = self.useFixture(fixtures.TempDir()).path - stdout = io.StringIO() - project = {'root': root} - actions = [('foo', 'bar')] - with testtools.ExpectedException(Exception): - update._write_project(project, actions, stdout, True) - - def test_stdout(self): - stdout = io.StringIO() - root = self.useFixture(fixtures.TempDir()).path - project = {'root': root} - actions = [update.StdOut(u'fred\n')] - update._write_project(project, actions, stdout, True) - self.expectThat(stdout.getvalue(), matchers.Equals('fred\n')) - - def test_errors(self): - stdout = io.StringIO() - root = self.useFixture(fixtures.TempDir()).path - project = {'root': root} - actions = [update.Error(u'fred')] - with testtools.ExpectedException(Exception): - update._write_project(project, actions, stdout, True) - self.expectThat(stdout.getvalue(), matchers.Equals('fred\n')) - - class TestMain(testtools.TestCase): def test_smoke(self): @@ -315,56 +242,6 @@ class TestMain(testtools.TestCase): update.main(['-o', 'global', '/dev/zero'], _worker=check_params) -class TestParseRequirement(testtools.TestCase): - - scenarios = [ - ('package', dict( - line='swift', - req=update.Requirement('swift', '', '', ''))), - ('specifier', dict( - line='alembic>=0.4.1', - req=update.Requirement('alembic', '>=0.4.1', '', ''))), - ('specifiers', dict( - line='alembic>=0.4.1,!=1.1.8', - req=update.Requirement('alembic', '!=1.1.8,>=0.4.1', '', ''))), - ('comment-only', dict( - line='# foo', - req=update.Requirement('', '', '', '# foo'))), - ('comment', dict( - line='Pint>=0.5 # BSD', - req=update.Requirement('Pint', '>=0.5', '', '# BSD'))), - ('comment-with-semicolon', dict( - line='Pint>=0.5 # BSD;fred', - req=update.Requirement('Pint', '>=0.5', '', '# BSD;fred'))), - ('case', dict( - line='Babel>=1.3', - req=update.Requirement('Babel', '>=1.3', '', ''))), - ('markers', dict( - line="pywin32;sys_platform=='win32'", - req=update.Requirement('pywin32', '', "sys_platform=='win32'", ''))), - ('markers-with-comment', dict( - line="Sphinx<=1.2; python_version=='2.7'# Sadface", - req=update.Requirement('Sphinx', '<=1.2', "python_version=='2.7'", - '# Sadface')))] - - def test_parse(self): - parsed = update._parse_requirement(self.line) - self.assertEqual(self.req, parsed) - - -class TestParseRequirementFailures(testtools.TestCase): - - scenarios = [ - ('url', dict(line='http://tarballs.openstack.org/oslo.config/' - 'oslo.config-1.2.0a3.tar.gz#egg=oslo.config')), - ('-e', dict(line='-e git+https://foo.com#egg=foo')), - ('-f', dict(line='-f http://tarballs.openstack.org/'))] - - def test_does_not_parse(self): - with testtools.ExpectedException(pkg_resources.RequirementParseError): - update._parse_requirement(self.line) - - class TestSyncRequirementsFile(testtools.TestCase): def test_multiple_lines_in_global_one_in_project(self): @@ -375,18 +252,19 @@ class TestSyncRequirementsFile(testtools.TestCase): project_content = textwrap.dedent("""\ foo """) - global_reqs = update._parse_reqs(global_content) - project_reqs = list(update._content_to_reqs(project_content)) + global_reqs = requirement.parse(global_content) + project_reqs = list(requirement.to_reqs(project_content)) actions, reqs = update._sync_requirements_file( global_reqs, project_reqs, 'f', False, False, False) - self.assertEqual(update.Requirements([ - update.Requirement('foo', '<2', "python_version=='2.7'", ''), - update.Requirement('foo', '>1', "python_version!='2.7'", '')]), + self.assertEqual(requirement.Requirements([ + requirement.Requirement('foo', '<2', "python_version=='2.7'", ''), + requirement.Requirement( + 'foo', '>1', "python_version!='2.7'", '')]), reqs) - self.assertEqual(update.StdOut( + self.assertEqual(project.StdOut( " foo " "-> foo<2;python_version=='2.7'\n"), actions[2]) - self.assertEqual(update.StdOut( + self.assertEqual(project.StdOut( " " "-> foo>1;python_version!='2.7'\n"), actions[3]) self.assertThat(actions, matchers.HasLength(4)) @@ -401,14 +279,14 @@ class TestSyncRequirementsFile(testtools.TestCase): # mumbo gumbo foo>1;python_version!='2.7' """) - global_reqs = update._parse_reqs(global_content) - project_reqs = list(update._content_to_reqs(project_content)) + global_reqs = requirement.parse(global_content) + project_reqs = list(requirement.to_reqs(project_content)) actions, reqs = update._sync_requirements_file( global_reqs, project_reqs, 'f', False, False, False) - self.assertEqual(update.Requirements([ - update.Requirement('foo', '<2', "python_version=='2.7'", ''), - update.Requirement('foo', '>1', "python_version!='2.7'", ''), - update.Requirement('', '', '', "# mumbo gumbo")]), + self.assertEqual(requirement.Requirements([ + requirement.Requirement('foo', '<2', "python_version=='2.7'", ''), + requirement.Requirement('foo', '>1', "python_version!='2.7'", ''), + requirement.Requirement('', '', '', "# mumbo gumbo")]), reqs) self.assertThat(actions, matchers.HasLength(0)) @@ -422,19 +300,19 @@ class TestSyncRequirementsFile(testtools.TestCase): # mumbo gumbo foo>0.9;python_version!='2.7' """) - global_reqs = update._parse_reqs(global_content) - project_reqs = list(update._content_to_reqs(project_content)) + global_reqs = requirement.parse(global_content) + project_reqs = list(requirement.to_reqs(project_content)) actions, reqs = update._sync_requirements_file( global_reqs, project_reqs, 'f', False, False, False) - self.assertEqual(update.Requirements([ - update.Requirement('foo', '<2', "python_version=='2.7'", ''), - update.Requirement('foo', '>1', "python_version!='2.7'", ''), - update.Requirement('', '', '', "# mumbo gumbo")]), + self.assertEqual(requirement.Requirements([ + requirement.Requirement('foo', '<2', "python_version=='2.7'", ''), + requirement.Requirement('foo', '>1', "python_version!='2.7'", ''), + requirement.Requirement('', '', '', "# mumbo gumbo")]), reqs) - self.assertEqual(update.StdOut( + self.assertEqual(project.StdOut( " foo<1.8;python_version=='2.7' -> " "foo<2;python_version=='2.7'\n"), actions[2]) - self.assertEqual(update.StdOut( + self.assertEqual(project.StdOut( " foo>0.9;python_version!='2.7' -> " "foo>1;python_version!='2.7'\n"), actions[3]) self.assertThat(actions, matchers.HasLength(4)) @@ -448,13 +326,14 @@ class TestSyncRequirementsFile(testtools.TestCase): foo<2;python_version=='2.7' foo>1;python_version!='2.7' """) - global_reqs = update._parse_reqs(global_content) - project_reqs = list(update._content_to_reqs(project_content)) + global_reqs = requirement.parse(global_content) + project_reqs = list(requirement.to_reqs(project_content)) actions, reqs = update._sync_requirements_file( global_reqs, project_reqs, 'f', False, False, False) - self.assertEqual(update.Requirements([ - update.Requirement('foo', '<2', "python_version=='2.7'", ''), - update.Requirement('foo', '>1', "python_version!='2.7'", '')]), + self.assertEqual(requirement.Requirements([ + requirement.Requirement('foo', '<2', "python_version=='2.7'", ''), + requirement.Requirement( + 'foo', '>1', "python_version!='2.7'", '')]), reqs) self.assertThat(actions, matchers.HasLength(0)) @@ -466,211 +345,20 @@ class TestSyncRequirementsFile(testtools.TestCase): foo<2;python_version=='2.7' foo>1;python_version!='2.7' """) - global_reqs = update._parse_reqs(global_content) - project_reqs = list(update._content_to_reqs(project_content)) + global_reqs = requirement.parse(global_content) + project_reqs = list(requirement.to_reqs(project_content)) actions, reqs = update._sync_requirements_file( global_reqs, project_reqs, 'f', False, False, False) - self.assertEqual(update.Requirements([ - update.Requirement('foo', '>1', "", '')]), + self.assertEqual(requirement.Requirements([ + requirement.Requirement('foo', '>1', "", '')]), reqs) - self.assertEqual(update.StdOut( + self.assertEqual(project.StdOut( " foo<2;python_version=='2.7' -> foo>1\n"), actions[2]) - self.assertEqual(update.StdOut( + self.assertEqual(project.StdOut( " foo>1;python_version!='2.7' -> \n"), actions[3]) self.assertThat(actions, matchers.HasLength(4)) -class TestReqsToContent(testtools.TestCase): - - def test_smoke(self): - reqs = update._reqs_to_content(update.Requirements( - [update.Requirement( - 'foo', '<=1', "python_version=='2.7'", '# BSD')]), - marker_sep='!') - self.assertEqual( - ''.join(update._REQS_HEADER - + ["foo<=1!python_version=='2.7' # BSD\n"]), - reqs) - - -class TestProjectExtras(testtools.TestCase): - - def test_smoke(self): - project = {'setup.cfg': textwrap.dedent(u""" -[extras] -1 = - foo -2 = - foo # fred - bar -""")} - expected = { - '1': '\nfoo', - '2': '\nfoo # fred\nbar' - } - self.assertEqual(expected, update._project_extras(project)) - - def test_none(self): - project = {'setup.cfg': u"[metadata]\n"} - self.assertEqual({}, update._project_extras(project)) - - -class TestExtras(testtools.TestCase): - - def test_none(self): - old_content = textwrap.dedent(u""" - [metadata] - # something something - name = fred - - [entry_points] - console_scripts = - foo = bar:quux - """) - ini = update.extras_compiled(old_content).ini() - self.assertEqual(ini, (old_content, None, '')) - - def test_no_eol(self): - old_content = textwrap.dedent(u""" - [metadata] - # something something - name = fred - - [entry_points] - console_scripts = - foo = bar:quux""") - expected1 = textwrap.dedent(u""" - [metadata] - # something something - name = fred - - [entry_points] - console_scripts = - """) - suffix = ' foo = bar:quux' - ini = update.extras_compiled(old_content).ini() - self.assertEqual(ini, (expected1, None, suffix)) - - def test_two_extras_raises(self): - old_content = textwrap.dedent(u""" - [metadata] - # something something - name = fred - - [extras] - a = b - [extras] - b = c - - [entry_points] - console_scripts = - foo = bar:quux - """) - with testtools.ExpectedException(parsley.ParseError): - update.extras_compiled(old_content).ini() - - def test_extras(self): - # We get an AST for extras we can use to preserve comments. - old_content = textwrap.dedent(u""" - [metadata] - # something something - name = fred - - [extras] - # comment1 - a = - b - c - # comment2 - # comment3 - d = - e - # comment4 - - [entry_points] - console_scripts = - foo = bar:quux - """) - prefix = textwrap.dedent(u""" - [metadata] - # something something - name = fred - - """) - suffix = textwrap.dedent(u"""\ - [entry_points] - console_scripts = - foo = bar:quux - """) - extras = [ - update.Comment('# comment1\n'), - update.Extra('a', '\nb\nc\n'), - update.Comment('# comment2\n'), - update.Comment('# comment3\n'), - update.Extra('d', '\ne\n'), - update.Comment('# comment4\n')] - ini = update.extras_compiled(old_content).ini() - self.assertEqual(ini, (prefix, extras, suffix)) - - -class TestMergeSetupCfg(testtools.TestCase): - - def test_merge_none(self): - old_content = textwrap.dedent(u""" - [metadata] - # something something - name = fred - - [entry_points] - console_scripts = - foo = bar:quux - """) - merged = update._merge_setup_cfg(old_content, {}) - self.assertEqual(old_content, merged) - - def test_merge_extras(self): - old_content = textwrap.dedent(u""" - [metadata] - name = fred - - [extras] - # Comment - a = - b - # comment - c = - d - - [entry_points] - console_scripts = - foo = bar:quux - """) - blank = update.Requirement('', '', '', '') - r1 = update.Requirement('b', '>=1', "python_version=='2.7'", '') - r2 = update.Requirement('d', '', '', '# BSD') - reqs = { - 'a': update.Requirements([blank, r1]), - 'c': update.Requirements([blank, r2])} - merged = update._merge_setup_cfg(old_content, reqs) - expected = textwrap.dedent(u""" - [metadata] - name = fred - - [extras] - # Comment - a = - b>=1:python_version=='2.7' - # comment - c = - d # BSD - - [entry_points] - console_scripts = - foo = bar:quux - """) - self.assertEqual(expected, merged) - - class TestCopyRequires(testtools.TestCase): def test_extras_no_change(self): @@ -690,14 +378,14 @@ class TestCopyRequires(testtools.TestCase): opt = freddy """) - project = {} - project['root'] = '/dev/null' - project['requirements'] = {} - project['setup.cfg'] = setup_cfg - global_reqs = update._parse_reqs(global_content) + proj = {} + proj['root'] = '/dev/null' + proj['requirements'] = {} + proj['setup.cfg'] = setup_cfg + global_reqs = requirement.parse(global_content) actions = update._copy_requires( - u'', False, False, project, global_reqs, False) + u'', False, False, proj, global_reqs, False) self.assertEqual([ - update.Verbose('Syncing extra [opt]'), - update.Verbose('Syncing extra [test]'), - update.File('setup.cfg', setup_cfg)], actions) + project.Verbose('Syncing extra [opt]'), + project.Verbose('Syncing extra [test]'), + project.File('setup.cfg', setup_cfg)], actions) diff --git a/openstack_requirements/tests/test_update_pbr.py b/openstack_requirements/tests/test_update_pbr.py index 333ff01b63..9e58c1d125 100644 --- a/openstack_requirements/tests/test_update_pbr.py +++ b/openstack_requirements/tests/test_update_pbr.py @@ -20,6 +20,7 @@ from __future__ import print_function import testtools +from openstack_requirements import project from openstack_requirements.tests import common from openstack_requirements import update @@ -47,5 +48,5 @@ class UpdateTestPbr(testtools.TestCase): common.pbr_project, common.global_reqs, None, None, None, False) for action in actions: - if type(action) is update.File: + if type(action) is project.File: self.assertNotEqual(action.filename, 'setup.py') diff --git a/openstack_requirements/tests/test_update_suffix.py b/openstack_requirements/tests/test_update_suffix.py index cacbbc0287..5d7c61b667 100644 --- a/openstack_requirements/tests/test_update_suffix.py +++ b/openstack_requirements/tests/test_update_suffix.py @@ -16,6 +16,7 @@ from __future__ import print_function import testtools +from openstack_requirements import project from openstack_requirements.tests import common from openstack_requirements import update @@ -59,5 +60,5 @@ class UpdateTestWithSuffix(testtools.TestCase): common.oslo_project, common.global_reqs, 'global', None, None, False) for action in actions: - if type(action) is update.File: + if type(action) is project.File: self.assertNotEqual(action.filename, 'setup.py') diff --git a/openstack_requirements/update.py b/openstack_requirements/update.py index 456b56bcca..fb51b740c5 100644 --- a/openstack_requirements/update.py +++ b/openstack_requirements/update.py @@ -26,18 +26,14 @@ updated to match the global requirements. Requirements not in the global files will be dropped. """ -import collections -import errno -import io import itertools import optparse import os import os.path import sys -from parsley import makeGrammar -import pkg_resources -from six.moves import configparser +from openstack_requirements import project +from openstack_requirements import requirement _setup_py_text = """# Copyright (c) 2013 Hewlett-Packard Development Company, L.P. # @@ -70,37 +66,9 @@ setuptools.setup( pbr=True) """ -# A header for the requirements file(s). -# TODO(lifeless): Remove this once constraints are in use. -_REQS_HEADER = [ - '# The order of packages is significant, because pip processes ' - 'them in the order\n', - '# of appearance. Changing the order has an impact on the overall ' - 'integration\n', - '# process, which may cause wedges in the gate later.\n', -] - - -Comment = collections.namedtuple('Comment', ['line']) -Extra = collections.namedtuple('Extra', ['name', 'content']) - - -extras_grammar = """ -ini = (line*:p extras?:e line*:l final:s) -> (''.join(p), e, ''.join(l+[s])) -line = ~extras <(~'\\n' anything)* '\\n'> -final = <(~'\\n' anything)* > -extras = '[' 'e' 'x' 't' 'r' 'a' 's' ']' '\\n'+ body*:b -> b -body = comment | extra -comment = <'#' (~'\\n' anything)* '\\n'>:c '\\n'* -> comment(c) -extra = name:n ' '* '=' line:l cont*:c '\\n'* -> extra(n, ''.join([l] + c)) -name = <(anything:x ?(x not in '\\n \\t='))+> -cont = ' '+ <(~'\\n' anything)* '\\n'> -""" -extras_compiled = makeGrammar( - extras_grammar, {"comment": Comment, "extra": Extra}) - # Pure -- + class Change(object): def __init__(self, name, old, new): self.name = name @@ -111,78 +79,19 @@ class Change(object): return "%-30.30s -> %s" % (self.old, self.new) -Error = collections.namedtuple('Error', ['message']) -File = collections.namedtuple('File', ['filename', 'content']) -StdOut = collections.namedtuple('StdOut', ['message']) -Verbose = collections.namedtuple('Verbose', ['message']) - - -Requirement = collections.namedtuple( - 'Requirement', ['package', 'specifiers', 'markers', 'comment']) -Requirements = collections.namedtuple('Requirements', ['reqs']) - - -def _parse_requirement(req_line): - """Parse a single line of a requirements file. - - requirements files here are a subset of pip requirements files: we don't - try to parse URL entries, or pip options like -f and -e. Those are not - permitted in global-requirements.txt. If encountered in a synchronised - file such as requirements.txt or test-requirements.txt, they are illegal - but currently preserved as-is. - - They may of course be used by local test configurations, just not - committed into the OpenStack reference branches. - """ - end = len(req_line) - hash_pos = req_line.find('#') - if hash_pos < 0: - hash_pos = end - 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) - marker_pos = max(semi_pos, colon_pos) - if marker_pos < 0: - marker_pos = hash_pos - markers = req_line[marker_pos + 1:hash_pos].strip() - if hash_pos != end: - comment = req_line[hash_pos:] - else: - comment = '' - req_line = req_line[:marker_pos] - - if req_line: - parsed = pkg_resources.Requirement.parse(req_line) - name = parsed.project_name - specifier = str(parsed.specifier) - else: - name = '' - specifier = '' - return Requirement(name, specifier, markers, comment) - - -def _pass_through(req_line): - """Identify unparsable lines.""" - return (req_line.startswith('http://tarballs.openstack.org/') or - req_line.startswith('-e') or - req_line.startswith('-f')) - - -def _check_setup_py(project): +def _check_setup_py(proj): actions = [] # If it doesn't have a setup.py, then we don't want to update it - if 'setup.py' not in project: + if 'setup.py' not in proj: return actions # If it doesn't use pbr, we don't want to update it. - elif 'pbr' not in project['setup.py']: + elif 'pbr' not in proj['setup.py']: return actions # We don't update pbr's setup.py because it can't use itself. - if 'setup.cfg' in project and 'name = pbr' in project['setup.cfg']: + if 'setup.cfg' in proj and 'name = pbr' in proj['setup.cfg']: return actions - actions.append(Verbose("Syncing setup.py")) - actions.append(File('setup.py', _setup_py_text)) + actions.append(project.Verbose("Syncing setup.py")) + actions.append(project.File('setup.py', _setup_py_text)) return actions @@ -190,18 +99,19 @@ def _sync_requirements_file( source_reqs, dest_sequence, dest_label, softupdate, hacking, non_std_reqs): actions = [] - dest_reqs = _reqs_to_dict(dest_sequence) + dest_reqs = requirement.to_dict(dest_sequence) changes = [] output_requirements = [] processed_packages = set() for req, req_line in dest_sequence: # Skip the instructions header - if req_line in _REQS_HEADER: + if req_line in requirement._REQS_HEADER: continue elif req is None: # Unparsable lines. - output_requirements.append(Requirement('', '', '', req_line)) + output_requirements.append( + requirement.Requirement('', '', '', req_line)) continue elif not req.package: # Comment-only lines @@ -250,25 +160,25 @@ def _sync_requirements_file( # override. For those we support NON_STANDARD_REQS=1 # environment variable to turn this into a warning only. # However this drops the unknown requirement. - actions.append(Error( + actions.append(project.Error( "'%s' is not in global-requirements.txt" % req.package)) # always print out what we did if we did a thing if changes: - actions.append(StdOut( + actions.append(project.StdOut( "Version change for: %s\n" % ", ".join([x.name for x in changes]))) - actions.append(StdOut("Updated %s:\n" % dest_label)) + actions.append(project.StdOut("Updated %s:\n" % dest_label)) for change in changes: - actions.append(StdOut(" %s\n" % change)) - return actions, Requirements(output_requirements) + actions.append(project.StdOut(" %s\n" % change)) + return actions, requirement.Requirements(output_requirements) def _copy_requires( - suffix, softupdate, hacking, project, global_reqs, non_std_reqs): + suffix, softupdate, hacking, proj, global_reqs, non_std_reqs): """Copy requirements files.""" actions = [] - for source, content in sorted(project['requirements'].items()): - dest_path = os.path.join(project['root'], source) + for source, content in sorted(proj['requirements'].items()): + dest_path = os.path.join(proj['root'], source) # this is specifically for global-requirements gate jobs so we don't # modify the git tree if suffix: @@ -276,20 +186,20 @@ def _copy_requires( dest_name = "%s.%s" % (source, suffix) else: dest_name = source - dest_sequence = list(_content_to_reqs(content)) - actions.append(Verbose("Syncing %s" % dest_path)) + dest_sequence = list(requirement.to_reqs(content)) + actions.append(project.Verbose("Syncing %s" % dest_path)) _actions, reqs = _sync_requirements_file( global_reqs, dest_sequence, dest_path, softupdate, hacking, non_std_reqs) actions.extend(_actions) - actions.append(File(dest_name, _reqs_to_content(reqs))) - extras = _project_extras(project) + actions.append(project.File(dest_name, requirement.to_content(reqs))) + extras = project.extras(proj) output_extras = {} for extra, content in sorted(extras.items()): dest_name = 'extra-%s' % extra - dest_path = "%s[%s]" % (project['root'], extra) - dest_sequence = list(_content_to_reqs(content)) - actions.append(Verbose("Syncing extra [%s]" % extra)) + dest_path = "%s[%s]" % (proj['root'], extra) + dest_sequence = list(requirement.to_reqs(content)) + actions.append(project.Verbose("Syncing extra [%s]" % extra)) _actions, reqs = _sync_requirements_file( global_reqs, dest_sequence, dest_path, softupdate, hacking, non_std_reqs) @@ -298,68 +208,11 @@ def _copy_requires( dest_path = 'setup.cfg' if suffix: dest_path = "%s.%s" % (dest_path, suffix) - actions.append(File( - dest_path, _merge_setup_cfg(project['setup.cfg'], output_extras))) + actions.append(project.File( + dest_path, project.merge_setup_cfg(proj['setup.cfg'], output_extras))) return actions -def _merge_setup_cfg(old_content, new_extras): - # This is ugly. All the existing libraries handle setup.cfg's poorly. - prefix, extras, suffix = extras_compiled(old_content).ini() - out_extras = [] - if extras is not None: - for extra in extras: - if type(extra) is Comment: - out_extras.append(extra) - elif type(extra) is Extra: - if extra.name not in new_extras: - out_extras.append(extra) - continue - e = Extra( - extra.name, - _reqs_to_content( - new_extras[extra.name], ':', ' ', False)) - out_extras.append(e) - else: - raise TypeError('unknown type %r' % extra) - if out_extras: - extras_str = ['[extras]\n'] - for extra in out_extras: - if type(extra) is Comment: - extras_str.append(extra.line) - else: - extras_str.append(extra.name + ' =') - extras_str.append(extra.content) - if suffix: - extras_str.append('\n') - extras_str = ''.join(extras_str) - else: - extras_str = '' - return prefix + extras_str + suffix - - -def _project_extras(project): - """Return a dict of extra-name:content for the extras in setup.cfg.""" - c = configparser.SafeConfigParser() - c.readfp(io.StringIO(project['setup.cfg'])) - if not c.has_section('extras'): - return dict() - return dict(c.items('extras')) - - -def _reqs_to_content(reqs, marker_sep=';', line_prefix='', prefix=True): - lines = [] - if prefix: - lines += _REQS_HEADER - for req in reqs.reqs: - comment_p = ' ' if req.package else '' - 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)) - return u''.join(lines) - - def _process_project( project, global_reqs, suffix, softupdate, hacking, non_std_reqs): """Project a project. @@ -372,99 +225,7 @@ def _process_project( return actions -def _content_to_reqs(content): - for content_line in content.splitlines(True): - req_line = content_line.strip() - if _pass_through(req_line): - yield None, content_line - else: - yield _parse_requirement(req_line), content_line - - -def _parse_reqs(content): - return _reqs_to_dict(_content_to_reqs(content)) - - -def _reqs_to_dict(req_sequence): - reqs = dict() - for req, req_line in req_sequence: - if req is not None: - reqs.setdefault(req.package.lower(), []).append((req, req_line)) - return reqs - - # IO -- -def _safe_read(project, filename, output=None): - if output is None: - output = project - try: - path = project['root'] + '/' + filename - with io.open(path, 'rt', encoding="utf-8") as f: - output[filename] = f.read() - except IOError as e: - if e.errno != errno.ENOENT: - raise - - -def _read_project(root): - result = {'root': root} - _safe_read(result, 'setup.py') - _safe_read(result, 'setup.cfg') - requirements = {} - result['requirements'] = requirements - target_files = [ - 'requirements.txt', 'tools/pip-requires', - 'test-requirements.txt', 'tools/test-requires', - ] - for py_version in (2, 3): - target_files.append('requirements-py%s.txt' % py_version) - target_files.append('test-requirements-py%s.txt' % py_version) - for target_file in target_files: - _safe_read(result, target_file, output=requirements) - return result - - -def _write_project(project, actions, stdout, verbose, noop=False): - """Write actions into project. - - :param project: A project metadata dict. - :param actions: A list of action tuples - File or Verbose - that describe - what actions are to be taken. - Error objects write a message to stdout and trigger an exception at - the end of _write_project. - File objects describe a file to have content placed in it. - StdOut objects describe a message to write to stdout. - Verbose objects will write a message to stdout when verbose is True. - :param stdout: Where to write content for stdout. - :param verbose: If True Verbose actions will be written to stdout. - :param noop: If True nothing will be written to disk. - :return None: - :raises IOError: If the IO operations fail, IOError is raised. If this - happens some actions may have been applied and others not. - """ - error = False - for action in actions: - if type(action) is Error: - error = True - stdout.write(action.message + '\n') - elif type(action) is File: - if noop: - continue - fullname = project['root'] + '/' + action.filename - tmpname = fullname + '.tmp' - with open(tmpname, 'wt') as f: - f.write(action.content) - os.rename(tmpname, fullname) - elif type(action) is StdOut: - stdout.write(action.message) - elif type(action) is Verbose: - if verbose: - stdout.write(u"%s\n" % (action.message,)) - else: - raise Exception("Invalid action %r" % (action,)) - if error: - raise Exception("Error occured processing %s" % (project['root'])) - def main(argv=None, stdout=None, _worker=None): parser = optparse.OptionParser() @@ -499,13 +260,13 @@ def _do_main( root, source, suffix, softupdate, hacking, stdout, verbose, non_std_reqs): """No options or environment variable access from here on in.""" - project = _read_project(root) + proj = project.read(root) global_req_content = open( os.path.join(source, 'global-requirements.txt'), 'rt').read() - global_reqs = _parse_reqs(global_req_content) + global_reqs = requirement.parse(global_req_content) actions = _process_project( - project, global_reqs, suffix, softupdate, hacking, non_std_reqs) - _write_project(project, actions, stdout=stdout, verbose=verbose) + proj, global_reqs, suffix, softupdate, hacking, non_std_reqs) + project.write(proj, actions, stdout=stdout, verbose=verbose) if __name__ == "__main__":