Skip to content

Commit

Permalink
Merge branch 'almost_equal_to_approx' of github.com:massich/nose2pyte…
Browse files Browse the repository at this point in the history
…st into massich-almost_equal_to_approx
  • Loading branch information
Oliver Schoenborn authored and Oliver Schoenborn committed Nov 17, 2022
2 parents 47fef2b + 7918d3a commit f0c3457
Show file tree
Hide file tree
Showing 12 changed files with 1,398 additions and 205 deletions.
2 changes: 1 addition & 1 deletion .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .idea/nose2pytest.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions .idea/runConfigurations/RST____HTML.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

974 changes: 974 additions & 0 deletions .idea/workspace.xml

Large diffs are not rendered by default.

216 changes: 151 additions & 65 deletions README.html

Large diffs are not rendered by default.

84 changes: 73 additions & 11 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@


Overview
------------
-------------

This package provides a Python script and pytest plugin to help convert Nose-based tests into pytest-based
tests. Specifically, the script transforms ``nose.tools.assert_*`` function calls into raw assert statements,
Expand Down Expand Up @@ -124,9 +124,7 @@ assert_is_none(a[, msg]) assert a is None[, msg]
assert_is_not_none(a[, msg]) assert a is not None[, msg]
-------------------------------------------- -----------------------------------------------------------------
assert_equal(a,b[, msg]) assert a == b[, msg]
assert_equals(a,b[, msg]) assert a == b[, msg]
assert_not_equal(a,b[, msg]) assert a != b[, msg]
assert_not_equals(a,b[, msg]) assert a != b[, msg]
assert_list_equal(a,b[, msg]) assert a == b[, msg]
assert_dict_equal(a,b[, msg]) assert a == b[, msg]
assert_set_equal(a,b[, msg]) assert a == b[, msg]
Expand All @@ -147,10 +145,12 @@ assert_count_equal(a,b[, msg]) assert collections.Counter(a) == co
assert_not_regex(a,b[, msg]) assert not re.search(b, a)[, msg]
assert_regex(a,b[, msg]) assert re.search(b, a)[, msg]
-------------------------------------------- -----------------------------------------------------------------
assert_almost_equal(a,b, delta[, msg]) assert abs(a - b) <= delta[, msg]
assert_almost_equals(a,b, delta[, msg]) assert abs(a - b) <= delta[, msg]
assert_not_almost_equal(a,b, delta[, msg]) assert abs(a - b) > delta[, msg]
assert_not_almost_equals(a,b, delta[, msg]) assert abs(a - b) > delta[, msg]
assert_almost_equal(a,b[, msg]) assert a == pytest.approx(b, abs=1e-7)[, msg]
assert_almost_equal(a,b, delta[, msg]) assert a == pytest.approx(b, abs=delta)[, msg]
assert_almost_equal(a, b, places[, msg]) assert a == pytest.approx(b, abs=1e-places)[, msg]
assert_not_almost_equal(a,b[, msg]) assert a != pytest.approx(b, abs=1e-7)[, msg]
assert_not_almost_equal(a,b, delta[, msg]) assert a != pytest.approx(b, abs=delta)[, msg]
assert_not_almost_equal(a,b, places[, msg]) assert a != pytest.approx(b, abs=1e-places)[, msg]
============================================ =================================================================

The script adds parentheses around ``a`` and/or ``b`` if operator precedence would change the interpretation of the
Expand Down Expand Up @@ -179,10 +179,6 @@ Not every ``assert_*`` function from ``nose.tools`` is converted by nose2pytest:

2. Some Nose functions could be transformed but the readability would be decreased:

- ``assert_almost_equal(a, b, places)`` -> ``assert round(abs(b-a), places) == 0``
- ``assert_almost_equal(a, b)`` -> ``assert round(abs(b-a), 7) == 0``
- ``assert_not_almost_equal(a, b, places)`` -> ``assert round(abs(b-a), places) != 0``
- ``assert_not_almost_equal(a, b)`` -> ``assert round(abs(b-a), 7) != 0``
- ``assert_dict_contains_subset(a,b)`` -> ``assert set(b.keys()) >= a.keys() and {k: b[k] for k in a if k in b} == a``

The nose2pytest distribution contains a module, ``assert_tools.py`` which defines these utility functions to
Expand Down Expand Up @@ -359,6 +355,72 @@ last paragraph of his `Extending 2to3 <http://python3porting.com/fixers.html>`_
``assert a == b in c)``.


Contributing
------------

Patches and extensions are welcome. Please fork, branch, then submit PR. Nose2pytest uses `lib2to3.pytree`,
in particular the Leaf and Node classes. There are a few particularly challenging aspects to transforming
nose test expressions to equivalent pytest expressions:

#. Finding expressions that match a pattern: If the code you want to transform does not already match one
of the uses cases in script.py, you will have to determine the lib2to3 pattern expression
that describes it (this is similar to regular expressions, but for AST representation of code,
instead of text strings). Various expression patterns already exist near the top of
nose2pytest/script.py. This is largely trial and error as there is (as of this writing) no good
documentation.
#. Inserting the sub-expressions extracted by lib2to3 in step 1 into the target "expression template". For
example to convert `assert_none(a)` to `assert a is None`, the `a` sub-expression extracted via the lib2to3
pattern must be inserted into the correct "placeholder" node of the target expression. If step 1 was
necessary, then step 2 like involves creating a new class that derives from `FixAssertBase`.
#. Parentheses and priority of operators: sometimes, it is necessary to add parentheses around an extracted
subexpression to protect it against higher-priority operators. For example, in `assert_none(a)` the `a`
could be an arbitrary Python expression, such as `var1 and var2`. The meaning of `assert_none(var1 and var2)`
is not the same as `assert var1 and var2 is None`; parentheses must be added i.e. the target expression
must be `assert (var1 and var2) is None`. Whether this is necessary depends on the transformation. The
`wrap_parens_*` functions provide examples of how and when to do this.
#. Spacing: white space and newlines in code must be preserved as much as possible, and removed
when unnecessary. For example, `assert_equal(a, b)` convers to `assert a == b`; the latter already has a
a space before the b, but so does the original; the `lib2to3.pytree` captures such 'non-code' information
so that generating Python code from a Node yields the same as the input if no transformations were applied.
This is done via the `Node.prefix` property.

When the pattern is correctly defined in step 1, adding a test in tests/test_script.py for a string that
contains Python code that matches it will cause the `FixAssertBase.transform(node, results)` to be called,
with `node` being the Node for which the children match the defined pattern. The `results` is map of object
names defined in the pattern, to the Node subtree representing the sub-expression matched. For example,
a pattern for `assert_none(a)` (where `a` could be any sub-expression such as `1+2` or `sqrt(5)` or
`var1+var2`) will cause `results` to contain the sub-expression that `a` represents. The objective of
`transform()` is then to put the extracted results at the correct location into a new Node tree that
represents the target (transformed) expression.

Nodes form a tree, each Node has a `children` property, containing 0 or more Node and/or Leaf. For example,
if `node` represents `assert a/2 == b`, then the tree might be something like this::

node (Node)
assert (Leaf)
node (node)
node (node)
a (Leaf)
/ (Leaf)
2 (Leaf)
== (Leaf)
b (Leaf)

Sometimes you may be able to guess what the tree is for a given expression, however most often it is best to use
a debugger to run a test that attempts to transform your expression of interest (there are several examples of
how to do this in tests/test_script.py), break at the beginning of the `FixAssertBase.transform()` method, and
explore the `node.children` tree to find the subexpressions that you need to extract. In the above example,
the `assert` leaf node is child at index 0 of `node.children`, whereas child 1 is another Node; the `a` leaf
is child 0 of child 0 of child 1 of `node.children`, i.e. it is `node.children[0].children[0].children[1]`.
Therefore the "path" from `node` to reach 'a' is (0, 0, 1).

The main challenge for this step of nose2test extension is then to find the paths to reach the desired
"placeholder" objects in the target expression. For example if `assert_almost_equal(a, b, delta=value)`
must be converted to `assert a == pytest.approx(b, delta=value)`, then the nodes of interest are a, b, and
delta, and their paths are 0, (2, 2, 1, 0) and (2, 2, 1, 2, 2) respectively (when a path contains only
1 item, there is no need to use a tuple).


Acknowledgements
----------------

Expand Down
37 changes: 1 addition & 36 deletions nose2pytest/assert_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@

import pytest
import unittest
import pytest


__all__ = [
'assert_almost_equal',
'assert_not_almost_equal',
'assert_dict_contains_subset',

'assert_raises_regex',
Expand All @@ -26,40 +25,6 @@
]


def assert_almost_equal(a, b, places=7, msg=None):
"""
Fail if the two objects are unequal as determined by their
difference rounded to the given number of decimal places
and comparing to zero.
Note that decimal places (from zero) are usually not the same
as significant digits (measured from the most signficant digit).
See the builtin round() function for places parameter.
"""
if msg is None:
assert round(abs(b - a), places) == 0
else:
assert round(abs(b - a), places) == 0, msg


def assert_not_almost_equal(a, b, places=7, msg=None):
"""
Fail if the two objects are equal as determined by their
difference rounded to the given number of decimal places
and comparing to zero.
Note that decimal places (from zero) are usually not the same
as significant digits (measured from the most signficant digit).
See the builtin round() function for places parameter.
"""
if msg is None:
assert round(abs(b - a), places) != 0
else:
assert round(abs(b - a), places) != 0, msg


def assert_dict_contains_subset(subset, dictionary, msg=None):
"""
Checks whether dictionary is a superset of subset. If not, the assertion message will have useful details,
Expand Down
94 changes: 69 additions & 25 deletions nose2pytest/script.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from lib2to3.pgen2 import token
from lib2to3.fixer_util import parenthesize

__version__ = "1.0.6"
__version__ = "1.0.9"

log = logging.getLogger('nose2pytest')

Expand Down Expand Up @@ -73,8 +73,9 @@ def decorator(func):

PATTERN_ALMOST_ARGS = """
power< '{}' trailer< '('
( arglist< aaa=any ',' bbb=any ',' delta=any [','] >
| arglist< aaa=any ',' bbb=any ',' delta=any ',' msg=any > )
( arglist< aaa=any ',' bbb=any [','] >
| arglist< aaa=any ',' bbb=any ',' arg3=any [','] >
| arglist< aaa=any ',' bbb=any ',' arg3=any ',' arg4=any > )
')' > >
"""

Expand Down Expand Up @@ -112,7 +113,7 @@ def decorator(func):
GENERATOR_TYPE = 261

else:
raise RuntimeError('nose2pytest must be run using Python in [3.4, 3.5, 3.6, 3.7, 3.8]')
raise RuntimeError('nose2pytest must be run using Python 3.4 to 3.11')

# these operators require parens around function arg if binop is + or -
ADD_SUB_GROUP_TOKENS = (
Expand Down Expand Up @@ -474,7 +475,6 @@ class FixAssertBinOp(FixAssert2Args):
eq_='a == b',
assert_equals='a == b',
assert_not_equal='a != b',
assert_not_equals='a != b',

assert_list_equal='a == b',
assert_dict_equal='a == b',
Expand Down Expand Up @@ -506,43 +506,86 @@ class FixAssertAlmostEq(FixAssertBase):

# The args node paths are the same for every assert function: the first tuple is for
# arg a, the second for arg b, the third for arg c (delta).
DEFAULT_ARG_PATHS = ((0, 1, 1, 0), (0, 1, 1, 2), 2)
DEFAULT_ARG_PATHS = (0, (2, 2, 1, 0), (2, 2, 1, 2, 2))

conversions = dict(
assert_almost_equal='abs(a - b) <= delta',
assert_almost_equals='abs(a - b) <= delta',
assert_not_almost_equal='abs(a - b) > delta',
assert_not_almost_equals='abs(a - b) > delta',
assert_almost_equal='a == pytest.approx(b, abs=delta)',
assert_not_almost_equal='a != pytest.approx(b, abs=delta)',
)

@override(FixAssertBase)
def _transform_dest(self, assert_arg_test_node: PyNode, results: {str: PyNode}) -> bool:
delta = results["delta"].clone()
if not delta.children:
return False

aaa = results["aaa"].clone()
bbb = results["bbb"].clone()

# first arg
dest1 = self._get_node(assert_arg_test_node, self._arg_paths[0])
new_aaa = wrap_parens_for_addsub(aaa)
dest1.replace(new_aaa)
adjust_prefix_first_arg(new_aaa, results["aaa"].prefix)

# second arg
dest2 = self._get_node(assert_arg_test_node, self._arg_paths[1])
dest2.replace(wrap_parens_for_addsub(bbb))
new_bbb = wrap_parens_for_addsub(bbb)
if get_prev_sibling(dest2).type in NEWLINE_OK_TOKENS:
new_bbb.prefix = ''
dest2.replace(new_bbb)

# third arg (optional)
dest3 = self._get_node(assert_arg_test_node, self._arg_paths[2])
if delta.children[0] == PyLeaf(token.NAME, 'delta'):
delta_val = delta.children[2]
delta_val.prefix = " "
dest3.replace(wrap_parens_for_comparison(delta_val))

elif delta.children[0] == PyLeaf(token.NAME, 'msg'):
delta_val = results['msg'].children[2]
delta_val.prefix = " "
dest3.replace(wrap_parens_for_comparison(delta_val))
results['msg'] = delta
if "arg3" not in results:
# then only 2 args so `places` defaults to '7', delta to None and 'msg' to "":
self._use_places_default(dest3)
return True

# NOTE: arg3 could be places or delta, or even msg
arg3 = results["arg3"].clone()
if "arg4" not in results:
if arg3.children[0] == PyLeaf(token.NAME, 'msg'):
self._fix_results_err_msg_arg(results, arg3)
self._use_places_default(dest3)
return True

return self._process_if_arg_is_places_or_delta(arg3, dest3)

# we have 4 args: msg could be last, or it could be third:
# first try assuming 3rd arg is places/delta:
if self._process_if_arg_is_places_or_delta(arg3, dest3):
self._fix_results_err_msg_arg(results, results["arg4"].clone())
return True

# arg3 was not places/delta, try msg:
if arg3.children[0] == PyLeaf(token.NAME, 'msg'):
self._fix_results_err_msg_arg(results, arg3)
delta_or_places = results["arg4"].clone()
return self._process_if_arg_is_places_or_delta(delta_or_places, dest3)

else:
# if arg4 name is not msg, no match:
return False

def _use_places_default(self, abs_dest: PyNode):
places_node = PyLeaf(token.NUMBER, '7', prefix="1e-")
abs_dest.replace(places_node)

def _fix_results_err_msg_arg(self, results: {str: PyNode}, err_msg_node: PyNode):
# caller will look for 'msg' not 'arg3' so fix this:
err_msg_node.children[2].prefix = ""
results['msg'] = err_msg_node # the caller will look for this

def _process_if_arg_is_places_or_delta(self, arg3: PyNode, dest3: PyNode) -> bool:
if arg3.children[0] == PyLeaf(token.NAME, 'delta'):
arg3_val = arg3.children[2]
arg3_val.prefix = ""
wrapped_delta_val = wrap_parens_for_comparison(arg3_val)
dest3.replace(wrapped_delta_val)

elif arg3.children[0] == PyLeaf(token.NAME, 'places'):
arg3_val = arg3.children[2]
arg3_val.prefix = "1e-"
wrapped_places_val = wrap_parens_for_comparison(arg3_val)
dest3.replace(wrapped_places_val)

else:
return False
Expand Down Expand Up @@ -571,7 +614,6 @@ def get_fixers(self):

return pre_fixers, post_fixers


def setup():
# from nose import tools as nosetools
# import inspect
Expand All @@ -587,6 +629,8 @@ def setup():
help='disable overwriting of original files')
parser.add_argument('-v', dest='verbose', action='store_true',
help='verbose output (list files changed, etc)')
parser.add_argument('--version', action='version',
version='%(prog)s {0}'.format(__version__))

return parser.parse_args()

Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[bumpversion]
current_version = 1.0.5
current_version = 1.0.9
files = setup.py README.rst nose2pytest/script.py

5 changes: 4 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

setup(
name='nose2pytest',
version='1.0.8',
version='1.0.9',
packages=['nose2pytest'],
# py_modules=['assert_tools', 'nose2pytest'],
entry_points={
Expand Down Expand Up @@ -41,5 +41,8 @@
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
],
)
Loading

0 comments on commit f0c3457

Please sign in to comment.