Skip to content

Commit bc60877

Browse files
author
NyanKiyoshi
authored
Merge pull request #22 from NyanKiyoshi/feature/diff
Implement the diff command (raw term with colors)
2 parents d231310 + 6bc7246 commit bc60877

File tree

14 files changed

+399
-49
lines changed

14 files changed

+399
-49
lines changed

docs/_static/diff_results.png

40.7 KB
Loading

docs/diff.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.. _diff_usage:
2+
3+
The Diff Command
4+
----------------

docs/index.rst

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,18 @@ Quick Start
5353
| module3 | |
5454
+---------+-------------------------+
5555
56-
4. Or for a nicer presentation, use ``django-queries html > results.txt`` to export the results as HTML. See `this example <./html_export_results.html>`_ for a demo!
56+
4. Or for a nicer presentation, use ``django-queries html > results.html`` to export the results as HTML. See `this example <./html_export_results.html>`_ for a demo!
5757

5858
.. image:: _static/html_export_results.png
5959
:width: 500 px
6060
:align: center
6161

62+
5. By running it twice with the option described :ref:`here <diff_usage>` and by running ``django-queries diff`` you will get something like this:
63+
64+
.. image:: _static/diff_results.png
65+
:width: 500 px
66+
:align: center
67+
6268

6369
Getting Help
6470
============
@@ -76,6 +82,7 @@ More Topics
7682
.. toctree::
7783
:maxdepth: 2
7884

85+
diff
7986
customize
8087
usage
8188
contributing

docs/usage.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,17 @@ The SHOW Command
3939
View a given rapport.
4040
4141
Options: none
42+
43+
44+
The DIFF Command
45+
++++++++++++++++
46+
47+
.. code-block:: text
48+
49+
Usage: django-queries diff [OPTIONS] [LEFT_FILE] [RIGHT_FILE]
50+
51+
Render the diff as a console table with colors.
52+
53+
Options: none
54+
55+
:ref:`More details on how to use the diff command properly. <diff_usage>`

pytest_django_queries/cli.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,18 @@
66
from jinja2 import Template
77
from jinja2 import exceptions as jinja_exceptions
88

9-
from pytest_django_queries.plugin import DEFAULT_RESULT_FILENAME
9+
from pytest_django_queries.diff import DiffGenerator
10+
from pytest_django_queries.entry import flatten_entries
11+
from pytest_django_queries.plugin import (
12+
DEFAULT_RESULT_FILENAME, DEFAULT_OLD_RESULT_FILENAME)
1013
from pytest_django_queries.tables import print_entries, print_entries_as_html
1114

1215
HERE = dirname(__file__)
1316
DEFAULT_TEMPLATE_PATH = abspath(pathjoin(HERE, 'templates', 'default_bootstrap.jinja2'))
1417

18+
DIFF_TERM_COLOR = {'-': 'red', '+': 'green'}
19+
DEFAULT_TERM_DIFF_COLOR = None
20+
1521

1622
class JsonFileParamType(click.File):
1723
name = 'integer'
@@ -66,5 +72,27 @@ def html(input_file, template):
6672
return print_entries_as_html(input_file, template)
6773

6874

75+
@main.command()
76+
@click.argument(
77+
'left_file', type=JsonFileParamType('r'), default=DEFAULT_OLD_RESULT_FILENAME)
78+
@click.argument(
79+
'right_file', type=JsonFileParamType('r'), default=DEFAULT_RESULT_FILENAME)
80+
def diff(left_file, right_file):
81+
"""Render the diff as a console table with colors."""
82+
left = flatten_entries(left_file)
83+
right = flatten_entries(right_file)
84+
first_line = True
85+
for module_name, lines in DiffGenerator(left, right):
86+
if not first_line:
87+
click.echo()
88+
else:
89+
first_line = False
90+
91+
click.echo('# %s' % module_name)
92+
for line in lines:
93+
fg_color = DIFF_TERM_COLOR.get(line[0], DEFAULT_TERM_DIFF_COLOR)
94+
click.secho(line, fg=fg_color)
95+
96+
6997
if __name__ == '__main__':
7098
main()

pytest_django_queries/diff.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# coding=utf-8
2+
from collections import namedtuple
3+
from pytest_django_queries.entry import Entry
4+
from pytest_django_queries.filters import format_underscore_name_to_human
5+
6+
_ROW_FIELD = namedtuple('_RowField', ('comp_field', 'align_char', 'length_field'))
7+
_ROW_FIELDS = (
8+
_ROW_FIELD('test_name', '<', 'test_name'),
9+
_ROW_FIELD('left_count', '>', 'query_count'),
10+
_ROW_FIELD('right_count', '>', 'query_count'),
11+
)
12+
_ROW_PREFIX = ' '
13+
_NA_CHAR = '-'
14+
15+
16+
def entry_row(entry_comp, lengths):
17+
cols = []
18+
19+
for field, align, length_key in _ROW_FIELDS:
20+
fmt = '{cmp.%s: %s{lengths[%s]}}' % (field, align, length_key)
21+
cols.append(fmt.format(cmp=entry_comp, lengths=lengths))
22+
23+
return '%(diff_char)s %(results)s' % ({
24+
'diff_char': entry_comp.diff,
25+
'results': '\t'.join(cols)})
26+
27+
28+
def get_header_row(lengths):
29+
sep_row = []
30+
head_row = []
31+
32+
for field, _, length_key in _ROW_FIELDS:
33+
length = lengths[length_key]
34+
sep_row.append('%s' % ('-' * length))
35+
head_row.append('{field: <{length}}'.format(
36+
field=field.replace('_', ' '), length=length))
37+
38+
return '%(prefix)s%(head)s\n%(prefix)s%(sep)s' % ({
39+
'prefix': _ROW_PREFIX,
40+
'head': '\t'.join(head_row),
41+
'sep': '\t'.join(sep_row)})
42+
43+
44+
class DiffChars(object):
45+
NEGATIVE = '-'
46+
NEUTRAL = ' '
47+
POSITIVE = '+'
48+
49+
@classmethod
50+
def convert(cls, diff):
51+
if diff < 0:
52+
return DiffChars.POSITIVE
53+
if diff > 0:
54+
return DiffChars.NEGATIVE
55+
return DiffChars.NEUTRAL
56+
57+
58+
class SingleEntryComparison(object):
59+
__slots__ = ["left", "right"]
60+
61+
def __init__(self, left=None, right=None):
62+
"""
63+
:param left: Previous version.
64+
:type left: Entry
65+
66+
:param right: Newest version.
67+
:type right: Entry
68+
"""
69+
70+
self.left = left
71+
self.right = right
72+
73+
def _diff_from_newest(self):
74+
"""
75+
Returns the query count difference from the previous version.
76+
If there is no older version, we assume it's an "improvement" (positive output)
77+
If there is no new version, we assume it's not an improvement (negative output)
78+
"""
79+
if self.left is None:
80+
return DiffChars.POSITIVE
81+
if self.right is None:
82+
return DiffChars.NEGATIVE
83+
return DiffChars.convert(self.right.query_count - self.left.query_count)
84+
85+
@property
86+
def test(self):
87+
return self.left or self.right
88+
89+
@property
90+
def test_name(self):
91+
return format_underscore_name_to_human(self.test.test_name)
92+
93+
@property
94+
def left_count(self):
95+
return str(self.left.query_count) if self.left else _NA_CHAR
96+
97+
@property
98+
def right_count(self):
99+
return str(self.right.query_count) if self.right else _NA_CHAR
100+
101+
@property
102+
def diff(self):
103+
return self._diff_from_newest()
104+
105+
def to_string(self, lengths):
106+
return entry_row(self, lengths=lengths)
107+
108+
109+
class DiffGenerator(object):
110+
def __init__(self, entries_left, entries_right):
111+
"""
112+
Generates the diffs from two files.
113+
114+
:param entries_left:
115+
:type entries_left: List[Entry]
116+
:param entries_right:
117+
:type entries_right: List[Entry]
118+
"""
119+
120+
self.entries_left = entries_left
121+
self.entries_right = entries_right
122+
123+
self._mapping = {}
124+
self._generate_mapping()
125+
self.longest_props = self._get_longest_per_prop({'query_count', 'test_name'})
126+
self.header_rows = get_header_row(lengths=self.longest_props)
127+
128+
def _get_longest_per_prop(self, props):
129+
"""
130+
:param props:
131+
:type props: set
132+
:return:
133+
"""
134+
135+
longest = {prop: 0 for prop in props}
136+
entries = (
137+
self.entries_left + self.entries_right + [field for field, _, _ in _ROW_FIELDS]
138+
)
139+
140+
for entry in entries:
141+
for prop in props:
142+
if isinstance(entry, Entry):
143+
current_length = len(str(getattr(entry, prop, None)))
144+
else:
145+
current_length = len(entry)
146+
if current_length > longest[prop]:
147+
longest[prop] = current_length
148+
149+
return longest
150+
151+
def _map_side(self, entries, side_name):
152+
for entry in entries:
153+
module_map = self._mapping.setdefault(entry.module_name, {})
154+
155+
if entry.test_name not in module_map:
156+
module_map[entry.test_name] = SingleEntryComparison()
157+
158+
setattr(module_map[entry.test_name], side_name, entry)
159+
160+
def _generate_mapping(self):
161+
self._map_side(self.entries_left, 'left')
162+
self._map_side(self.entries_right, 'right')
163+
164+
def _iter_module(self, module_entries):
165+
yield self.header_rows
166+
for _, test_comparison in sorted(module_entries.items()): # type: SingleEntryComparison
167+
yield test_comparison.to_string(lengths=self.longest_props)
168+
169+
def _iter_modules(self):
170+
for module_name, module_entries in sorted(self._mapping.items()):
171+
yield (
172+
format_underscore_name_to_human(module_name),
173+
self._iter_module(module_entries))
174+
175+
def __iter__(self):
176+
return self._iter_modules()

pytest_django_queries/entry.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from pytest_django_queries.utils import assert_type, raise_error
2+
3+
4+
class Entry(object):
5+
BASE_FIELDS = [
6+
('test_name', 'Test Name')
7+
]
8+
REQUIRED_FIELDS = [
9+
('query-count', 'Queries'),
10+
]
11+
FIELDS = BASE_FIELDS + REQUIRED_FIELDS
12+
13+
def __init__(self, test_name, module_name, data):
14+
"""
15+
:param data: The test entry's data.
16+
:type data: dict
17+
"""
18+
19+
assert_type(data, dict)
20+
21+
self._raw_data = data
22+
self.test_name = test_name
23+
self.module_name = module_name
24+
25+
for field, _ in self.REQUIRED_FIELDS:
26+
setattr(self, field, self._get_required_key(field))
27+
28+
def __getitem__(self, item):
29+
return getattr(self, item)
30+
31+
@property
32+
def query_count(self):
33+
return self['query-count']
34+
35+
def _get_required_key(self, key):
36+
if key in self._raw_data:
37+
return self._raw_data.get(key)
38+
raise_error('Got invalid data. It is missing a required key: %s' % key)
39+
40+
41+
def iter_entries(entries):
42+
for module_name, module_data in sorted(entries.items()):
43+
assert_type(module_data, dict)
44+
45+
yield module_name, (
46+
Entry(test_name, module_name, test_data)
47+
for test_name, test_data in sorted(module_data.items()))
48+
49+
50+
def flatten_entries(file_content):
51+
entries = []
52+
for _, data in iter_entries(file_content):
53+
entries += list(data)
54+
return entries

pytest_django_queries/filters.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
def format_underscore_name_to_human(name):
2+
if name.startswith('test'):
3+
_, name = name.split('test', 1)
4+
return name.replace('_', ' ').strip()

pytest_django_queries/plugin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# Defines the plugin marker name
77
PYTEST_QUERY_COUNT_MARKER = 'count_queries'
88
DEFAULT_RESULT_FILENAME = '.pytest-queries'
9+
DEFAULT_OLD_RESULT_FILENAME = '.pytest-queries.old'
910

1011

1112
def _set_session(config, new_session):

0 commit comments

Comments
 (0)