diff --git a/.gitignore b/.gitignore index 8f2ada9..81ecbba 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ coverage.xml *.cover .hypothesis/ .pytest_cache/ +.stestr/ # Translations *.mo diff --git a/.stestr.conf b/.stestr.conf new file mode 100644 index 0000000..f90b1f5 --- /dev/null +++ b/.stestr.conf @@ -0,0 +1,3 @@ +[DEFAULT] +test_path=tests +top_dir=./ diff --git a/doc/source/git.rst b/doc/source/git.rst new file mode 100644 index 0000000..e4ec1f6 --- /dev/null +++ b/doc/source/git.rst @@ -0,0 +1,2 @@ +.. automodule:: fixtures_git.git + :members: diff --git a/fixtures_git/__init__.py b/fixtures_git/__init__.py new file mode 100644 index 0000000..c7f023e --- /dev/null +++ b/fixtures_git/__init__.py @@ -0,0 +1,20 @@ +# Copyright (c) 2018 Hewlett Packard Enterprise Development Company LP +# +# 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. + +from fixtures_git.gitfixture import GitFixture + + +__all__ = [ + 'GitFixture' +] diff --git a/fixtures_git/gitfixture.py b/fixtures_git/gitfixture.py new file mode 100644 index 0000000..b5bc1d5 --- /dev/null +++ b/fixtures_git/gitfixture.py @@ -0,0 +1,286 @@ +# Copyright (c) 2013-2016 Hewlett-Packard Development Company, L.P. +# Copyright (c) 2018 Hewlett Packard Enterprise Development Company LP +# +# 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 faker +import os +import shutil +import tempfile + +import fixtures +import git + +from fixtures_git import utils + + +class GitTree(object): + """Helper class to build a git repository from a graph definition + + Helper class to build a git repository from a graph definition of + nodes and their parent nodes. A list of branches may be provided + where each element has two members corresponding to the name and the + target node it references. + + Supports unordered graphs, only requirement is that there is a commit + defined with no parents, which will become the root commit. + + Root commits can specified by an empty list as the second member: + + ('NodeA', []) + + Merge commits are specified by multiple nodes: + + ('NodeMerge', ['Node1', 'Node2']) + + + An '=' prefix can be used in front of one of the parents of a merge + commit to indicate that the merge commit's tree should be copied + directly from that parent ignoring any contributions from the other + parents' trees (like what git enables with the '-s ours' merge + strategy. + + This is used when needing to preserve history but not interested in + the changes from the other branches. One tool that makes extensive + usage of this is _git-upstream. + + E.g., the following will result in a merge commit 'C', with parents + 'P1' and 'P2', but will have the same tree as 'P1'. + + ('C', ['=P1', 'P2']) + + + The tree building code can handle a graph definition being out of + order but will fail to find certain circular dependencies and may + result in an infinite loop. + + Examples: + + [('A', []), ('B', ['A']), ('C', ['B'])] + [('A', []), ('C', ['B']), ('B', ['A'])] + + .. _git-upstream: https://pypi.python.org/pypi/git-upstream + """ + + def __init__(self, gitrepo, tree, branches): + self.graph = {} + self.gitrepo = gitrepo + self.repo = gitrepo.repo + self._build_git_tree(tree, branches) + + def _commit(self, node): + p_node = utils._get_node_to_pick(node) + if p_node: + self.repo.git.cherry_pick(self.graph[p_node], x=True) + else: + # standard commit + self.gitrepo.add_commits(1, ref="HEAD", + message_prefix="[%s]" % node) + + def _merge_commit(self, node, parents): + # merge commits + parent_nodes = [p.lstrip("=") for p in parents] + commits = [str(self.graph[p]) for p in parent_nodes[1:]] + + if any([p.startswith("=") for p in parents]): + # special merge commit using inverse of 'ours' by + # emptying the current index and then reading in any + # trees of the nodes prefixed with '=' + use = [str(self.graph[p.lstrip("=")]) + for p in parents if p.startswith("=")] + try: + self.repo.git.merge(*commits, s="ours", no_commit=True) + except git.exc.GitCommandError as exc: + if 'refusing to merge unrelated histories' in exc.stderr: + self.repo.git.merge(*commits, s="ours", no_commit=True, + allow_unrelated_histories=True) + else: + raise + self.repo.git.read_tree(empty=True) + self.repo.git.read_tree(empty=True) + self.repo.git.read_tree(*use, u=True, reset=True) + elif len(commits) < 2: + # standard merge + try: + self.repo.git.merge(*commits, no_commit=True) + except git.exc.GitCommandError as exc: + if 'refusing to merge unrelated histories' in exc.stderr: + self.repo.git.merge(*commits, no_commit=True, + allow_unrelated_histories=True) + else: + raise + else: + # multi-branch merge, git is not great at handling + # merging multiple orphaned branches + try: + self.repo.git.merge(*commits, s="ours", no_commit=True) + except git.exc.GitCommandError as exc: + if 'refusing to merge unrelated histories' in exc.stderr: + self.repo.git.merge(*commits, s="ours", no_commit=True, + allow_unrelated_histories=True) + else: + raise + self.repo.git.read_tree(empty=True) + self.repo.git.read_tree("HEAD", *commits) + self.repo.git.checkout("--", ".") + self.repo.git.commit(m="[%s] Merging %s into %s" % + (node, ",".join(parent_nodes[1:]), + parent_nodes[0])) + self.repo.git.clean(f=True, d=True, x=True) + + def _build_git_tree(self, graph_def, branches=[]): + + # require that graphs must have at least 1 node with no + # parents, which is a root commit in git + if not any([True for _, parents in graph_def if not parents]): + assert("No root commit defined in test graph") + + for node, parents in utils._reverse_toposort(graph_def): + if not parents: + # root commit + self.repo.git.symbolic_ref("HEAD", "refs/heads/%s" % node) + self.repo.git.rm(".", r=True, cached=True, + with_exceptions=False) + self.repo.git.clean(f=True, d=True, x=True) + self.gitrepo.add_commits(1, ref="HEAD", + message_prefix="[%s]" % node) + # only explicitly listed branches should exist afterwards + self.repo.git.checkout(self.repo.commit()) + self.repo.git.branch(node, D=True) + + else: + # checkout the dependent node + self.repo.git.checkout(self.graph[parents[0].lstrip('=')]) + if len(parents) > 1: + # merge commits + self._merge_commit(node, parents) + else: + self._commit(node) + self.graph[node] = self.repo.commit() + + for name, node in branches: + self.repo.git.branch(name, str(self.graph[node]), f=True) + + # return to master + self.repo.git.checkout("master") + + def commits_from_nodes(self, nodes=[]): + + return [self.graph[n] for n in nodes] + + +class GitFixture(fixtures.Fixture): + """Create a git repo in which to operate. + + By default creates an empty git repository under a temporary + directory and deletes it after use. + + It accepts options to automatically define a git repository + layout based on list of commits setting the given branches to + the relevant node once built. + + :ivar graph: Iterable describing the tree of git commits to create. + :ivar branches: Dict of node to branch names to set once finished. + :ivar path: Custom path to use, otherwise will create a temporary + directory to use and set the 'path' attribute to it. + :ivar user: Dict describing a user to use for commits, defaults + to 'Example User ', + :ivar clean_on_exit: Control whether to delete the tempoary path + once complete, defaults to 'True', but is ignored if 'path' + is provided. + """ + + def __init__(self, graph=None, branches=None, path=None, user=None, + clean_on_exit=True): + # set attributes for use + self.path = path + self.gittree = None + self.repo = None + + # internal attributes + self._graph = graph or [] + self._branches = branches or [] + self._user = { + 'name': 'Example User', + 'email': 'user@example.com', + } + self._user.update(user or {}) + + self._clean_on_exit = clean_on_exit + # for text generation + self._faker = faker.Faker() + + def _setUp(self): + self._file_list = set() + if not self.path: + tempdir = self.useFixture(fixtures.TempDir()) + self.path = os.path.join(tempdir.path, 'git') + if self._clean_on_exit is True: + self.addCleanup(shutil.rmtree, tempdir.path) + + os.mkdir(self.path) + g = git.Git(self.path) + g.init() + + self.repo = git.Repo(self.path) + self.repo.git.config('user.email', self._user['email']) + self.repo.git.config('user.name', self._user['name']) + self.repo.git.commit(m="Initialize empty repo", allow_empty=True) + + if self._graph: + self.gittree = GitTree(self, self._graph, self._branches) + + def _create_file(self): + contents = "\n\n".join(self._faker.paragraphs(3)) + + # always want to ensure the files added to the repo are unique no + # matter which branch they are added to, as otherwise there may + # be conflicts caused by replaying local changes and performing + # merges + while True: + tmpfile = tempfile.NamedTemporaryFile( + dir=self.repo.working_dir, delete=False) + if tmpfile.name not in self._file_list: + self._file_list.add(tmpfile.name) + break + # case where same filename in use on a different branch + tmpfile.close() + os.remove(tmpfile.name) + + tmpfile.write(contents.encode('utf-8')) + tmpfile.close() + return tmpfile.name + + def _create_file_commit(self, change_id=None, message_prefix=None): + filename = self._create_file() + self.repo.git.add(filename) + message = "Adding %s" % os.path.basename(filename) + if message_prefix: + message = "%s %s" % (message_prefix, message) + if change_id: + message = message + "\n\nChange-Id: %s" % change_id + self.repo.git.commit(m=message) + + def add_commits(self, num=None, ref="HEAD", change_ids=[], + message_prefix=None): + """Create the given number of commits using generated files""" + if ref != "HEAD": + self.repo.git.checkout(ref) + + if num is None: + num = max(1, len(change_ids)) + ids = list(change_ids) + [None] * (num - len(change_ids)) + + for x in range(num): + self._create_file_commit( + change_id=ids[x], message_prefix=message_prefix) diff --git a/fixtures_git/utils.py b/fixtures_git/utils.py new file mode 100644 index 0000000..aef5628 --- /dev/null +++ b/fixtures_git/utils.py @@ -0,0 +1,81 @@ +# Copyright (c) 2013-2016 Hewlett-Packard Development Company, L.P. +# Copyright (c) 2018 Hewlett Packard Enterprise Development Company LP +# +# 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 re + + +def _get_node_to_pick(node): + m = re.search(r'(.*)(\d+)$', node) + if m: + # get copy of a another change + node_number = int(m.group(2)) - 1 + node_name = m.group(1) + if node_number > 0: + node_name += str(node_number) + return node_name + return None + + +_NOT_VISITED = 0 +_VISITED = 1 +_FINISHED = 2 + + +def _reverse_toposort(data): + + # convert to dict for linear lookup times when returning + data = dict(data) + + # keep track of nodes visited and processed + # by checking if a child has been visited before but not processed you + # can detect a back edge and abort since the graph is not acyclic + visited = dict() + + # DFS algorithm with customization to handle use of '=' notation for merge + # commits and also the additional dependency for cherry-picking + nodes_to_visit = [] + for i in data.keys(): + if i not in visited: + nodes_to_visit.append(i) + + while nodes_to_visit: + node = nodes_to_visit.pop() + if visited.get(node) is _VISITED: + # already visited so just return it with it's deps + yield (node, data[node]) + visited[node] = _FINISHED + continue + elif visited.get(node) is _FINISHED: + continue + + visited[node] = _VISITED + nodes_to_visit.append(node) + # special case for cherry-picking changes + c_node = _get_node_to_pick(node) + if c_node and c_node not in visited: + nodes_to_visit.append(c_node) + + for d in data[node]: + r_d = d.strip('=') + if r_d not in visited: + nodes_to_visit.append(r_d) + else: + # if we've already visited a dep but not processed it, + # then we have a back edge of some kind + if visited[r_d] is _VISITED: + message = ("Graph is not acyclic: %s is a dependency " + "of %s, but has been visited without being " + "processed before it." % (r_d, node)) + raise RuntimeError(message) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f41870e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +faker>=0.8.16 +fixtures>=3.0.0 +GitPython>=1.0.1 diff --git a/test-requirements.txt b/test-requirements.txt index 6966869..c0eca26 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1 +1,5 @@ -sphinx +flake8 +hacking +sphinx>=1.6.5 +stestr>=1.0.0 +testtools>=2.2.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/acceptance/__init__.py b/tests/acceptance/__init__.py new file mode 100644 index 0000000..edff0f2 --- /dev/null +++ b/tests/acceptance/__init__.py @@ -0,0 +1,28 @@ +# +# Copyright (c) 2018 Hewlett Packard Enterprise Development Company LP +# +# 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 logging + +import fixtures +import testtools + + +class BaseTestCase(testtools.TestCase): + + def setUp(self): + super(BaseTestCase, self).setUp() + self.logger = self.useFixture(fixtures.FakeLogger(level=logging.DEBUG)) diff --git a/tests/acceptance/test_gitfixture.py b/tests/acceptance/test_gitfixture.py new file mode 100644 index 0000000..e554520 --- /dev/null +++ b/tests/acceptance/test_gitfixture.py @@ -0,0 +1,129 @@ +# Copyright (c) 2018 Hewlett Packard Enterprise Development Company LP +# +# 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. +# + +from testtools import matchers + +from fixtures_git.gitfixture import GitFixture +from tests import acceptance + + +class TestGitFixture(acceptance.BaseTestCase): + + def test_basic(self): + gitfixture = self.useFixture( + GitFixture( + [['A', []], + ['B', ['A']], + ['C', ['B']], + ], + [['master', 'C']], + ), + ) + nodes = gitfixture.gittree.graph + + self.assertEqual(len(list(gitfixture.repo.iter_commits())), 3) + self.assertTrue(gitfixture.repo.is_ancestor(nodes['A'], nodes['C'])) + self.assertTrue(gitfixture.repo.is_ancestor(nodes['A'], nodes['B'])) + self.assertEqual(gitfixture.repo.commit('master'), nodes['C']) + + def test_merge(self): + gitfixture = self.useFixture( + GitFixture( + [['A', []], + ['B', ['A']], + ['C', ['B']], + ['D', ['A']], + ['E', ['D']], + ['F', ['C', 'E']], + ] + ), + ) + nodes = gitfixture.gittree.graph + + self.assertTrue(gitfixture.repo.is_ancestor(nodes['B'], nodes['F'])) + self.assertTrue(gitfixture.repo.is_ancestor(nodes['D'], nodes['F'])) + self.assertEqual(len(gitfixture.repo.commit(nodes['F']).parents), 2) + self.assertEqual(gitfixture.repo.merge_base(nodes['E'], nodes['C']), + [nodes['A']]) + + node_f_files = gitfixture.repo.git.ls_files( + with_tree=nodes['F']).split('\n') + node_e_files = gitfixture.repo.git.ls_files( + with_tree=nodes['E']).split('\n') + node_c_files = gitfixture.repo.git.ls_files( + with_tree=nodes['C']).split('\n') + self.assertThat( + sorted(node_f_files), + matchers.NotEquals(sorted(node_c_files)) + ) + self.assertThat( + sorted(node_f_files), + matchers.Equals(sorted(set(node_c_files + node_e_files))) + ) + + def test_merge_and_replace(self): + gitfixture = self.useFixture( + GitFixture( + [['A', []], + ['B', ['A']], + ['C', ['B']], + ['D', ['A']], + ['E', ['D']], + ['F', ['=C', 'E']], + ] + ), + ) + nodes = gitfixture.gittree.graph + + node_f_files = gitfixture.repo.git.ls_files( + with_tree=nodes['F']).split('\n') + node_c_files = gitfixture.repo.git.ls_files( + with_tree=nodes['C']).split('\n') + self.assertThat( + sorted(node_f_files), + matchers.Equals(sorted(node_c_files)) + ) + + def test_unrelated_history(self): + gitfixture = self.useFixture( + GitFixture( + [['A', []], + ['B', ['A']], + ['C', ['B']], + ['D', []], + ['E', ['D']], + ['F', ['C', 'E']], + ] + ), + ) + nodes = gitfixture.gittree.graph + + self.assertFalse(gitfixture.repo.is_ancestor(nodes['A'], nodes['D'])) + self.assertFalse(gitfixture.repo.is_ancestor(nodes['D'], nodes['A'])) + self.assertEqual(gitfixture.repo.merge_base(nodes['C'], nodes['E']), + []) + + node_f_files = gitfixture.repo.git.ls_files( + with_tree=nodes['F']).split('\n') + node_e_files = gitfixture.repo.git.ls_files( + with_tree=nodes['E']).split('\n') + node_c_files = gitfixture.repo.git.ls_files( + with_tree=nodes['C']).split('\n') + self.assertThat( + sorted(node_f_files), + matchers.Equals(sorted(set(node_c_files + node_e_files)) + ) + ) diff --git a/tests/base.py b/tests/base.py new file mode 100644 index 0000000..70bebd5 --- /dev/null +++ b/tests/base.py @@ -0,0 +1,45 @@ +# Copyright (c) 2018 Hewlett Packard Enterprise Development LP +# +# 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. +# + + +class IsOrderedSubsetOfMismatch(object): + def __init__(self, subset, set): + self.subset = list(subset) + self.set = list(set) + + def describe(self): + return "set %r is not an ordered subset of %r" % ( + self.subset, self.set) + + def get_details(self): + return {} + + +class IsOrderedSubsetOf(object): + """Matches if the actual matches the order of iterable.""" + + def __init__(self, iterable): + self.iterable = iterable + + def __str__(self): + return 'IsOrderedSubsetOf(%s)' % self.iterable + + def match(self, actual): + iterable = iter(self.iterable) + if all(item in iterable for item in actual): + return None + else: + return IsOrderedSubsetOfMismatch(actual, self.iterable) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/utils/__init__.py b/tests/unit/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/utils/test_toposort.py b/tests/unit/utils/test_toposort.py new file mode 100644 index 0000000..d559714 --- /dev/null +++ b/tests/unit/utils/test_toposort.py @@ -0,0 +1,115 @@ +# Copyright (c) 2018 Hewlett Packard Enterprise Development LP +# +# 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 testtools + +from fixtures_git import utils +from tests import base + + +class TestResolve(testtools.TestCase): + + def test_ordered(self): + nodes = [ + ('A', []), + ('B', ['A']), + ('C', ['B']), + ] + self.assertEqual( + nodes, + list(utils._reverse_toposort(nodes)), + ) + + def test_unordered(self): + nodes = [ + ('B', ['A']), + ('C', ['B']), + ('A', []), + ] + self.assertEqual( + [('A', []), ('B', ['A']), ('C', ['B'])], + list(utils._reverse_toposort(nodes)) + ) + + def test_merge(self): + nodes = [ + ('B', ['A']), + ('C', ['B']), + ('A', []), + ('E', ['D', 'C']), + ('D', ['A']), + ] + sorted = list(utils._reverse_toposort(nodes)) + self.assertThat( + (nodes[2], nodes[0], nodes[1], nodes[3]), + base.IsOrderedSubsetOf(sorted) + ) + self.assertThat( + (nodes[2], nodes[4], nodes[3]), + base.IsOrderedSubsetOf(sorted) + ) + + def test_multiple_merges(self): + nodes = [ + ('B', ['A']), + ('C', ['B']), + ('A', []), + ('E', ['D', 'C']), + ('D', ['A']), + ('G', ['F', 'C']), + ('F', ['A']), + ] + sorted = list(utils._reverse_toposort(nodes)) + # A -> B -> C -> E + self.assertThat( + (nodes[2], nodes[0], nodes[1], nodes[3]), + base.IsOrderedSubsetOf(sorted) + ) + # A -> D -> E + self.assertThat( + (nodes[2], nodes[4], nodes[3]), + base.IsOrderedSubsetOf(sorted) + ) + # A -> F -> G + self.assertThat( + (nodes[2], nodes[6], nodes[5]), + base.IsOrderedSubsetOf(sorted) + ) + # A -> B -> C -> G + self.assertThat( + (nodes[2], nodes[0], nodes[1], nodes[5]), + base.IsOrderedSubsetOf(sorted) + ) + + def test_merge_multiple_roots(self): + nodes = [ + ('B', ['A']), + ('C', []), # root commit + ('A', []), # root commit + ('D', ['B', 'C']), + ] + sorted = list(utils._reverse_toposort(nodes)) + # assert partial ordering because nodes A & B may come + # before or after node C. Just make sure that node D + # is defined after them. + self.assertThat( + (nodes[2], nodes[0], nodes[3]), + base.IsOrderedSubsetOf(sorted), + ) + self.assertThat( + (nodes[1], nodes[3]), + base.IsOrderedSubsetOf(sorted), + ) diff --git a/tox.ini b/tox.ini index 1823f75..08ffc3e 100644 --- a/tox.ini +++ b/tox.ini @@ -6,10 +6,25 @@ skip_missing_interpreters = True [testenv] usedevelop = True +setenv = + LANG=en_US.UTF-8 + PYTHONDONTWRITEBYTECODE=1 + SUBUNIT_FORMATTER=tee testr_subunit_log + VIRTUAL_ENV={envdir} + install_command = pip install -U {opts} {packages} -deps = -r{toxinidir}/test-requirements.txt +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt commands = - python setup.py test + stestr run --slowest {posargs} + +[testenv:pep8] +commands = flake8 + +[testenv:cover] +commands = + python setup.py stestr --coverage --coverage-package-name=fixtures_git + coverage report [testenv:docs] commands = @@ -17,3 +32,8 @@ commands = [testenv:venv] commands = {posargs} + +[flake8] +ignore=H236,H40 +show-source = True +exclude = .venv,.tox,dist,doc,build,*.egg