Skip to content

Commit

Permalink
Add the ability to give parametrized sessions a custom ID (#186)
Browse files Browse the repository at this point in the history
* Add the ability to give parametrized sessions a custom ID

Closes #96

* Address review comments
  • Loading branch information
theacodes committed May 15, 2019
1 parent d199eab commit 722ff26
Show file tree
Hide file tree
Showing 5 changed files with 301 additions and 51 deletions.
109 changes: 97 additions & 12 deletions docs/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ This tutorial will walk you through installing, configuring, and running Nox.
Installation
------------

Nox can be easily installed via `pip`_::
Nox can be easily installed via `pip`_:

.. code-block:: console
pip install --upgrade nox
Expand All @@ -18,11 +20,15 @@ If you're interested in running ``nox`` within docker, you can use the `thekevja
Running Nox
-----------

The simplest way of running Nox will run all sessions defined in `noxfile.py`::
The simplest way of running Nox will run all sessions defined in `noxfile.py`:

.. code-block:: console
nox
However, if you wish to run a single session or subset of sessions you can use the ``-s`` argument::
However, if you wish to run a single session or subset of sessions you can use the ``-s`` or ``--sessions`` argument:

.. code-block:: console
nox --sessions lint tests-2.7
nox -s lint
Expand All @@ -42,10 +48,12 @@ Sessions are declared using the ``@nox.session`` decorator::
session.install('flake8')
session.run('flake8')

If you run this via ``nox`` you should see output similar to this::
If you run this via ``nox`` you should see output similar to this:

.. code-block:: console
nox > Running session lint
nox > virtualenv /tmp/example/.nox/lint
nox > virtualenv .nox/lint
nox > pip install flake8
nox > flake8
nox > Session lint successful. :)
Expand Down Expand Up @@ -75,7 +83,9 @@ You can create as many session as you want and sessions can use multiple Python
def tests(session):
...

If you specify multiple Python versions, Nox will create separate sessions for each Python version. If you run ``nox --list-sessions``, you'll see that this generates the following set of sessions::
If you specify multiple Python versions, Nox will create separate sessions for each Python version. If you run ``nox --list``, you'll see that this generates the following set of sessions:

.. code-block:: console
* tests-2.7
* tests-3.6
Expand Down Expand Up @@ -115,19 +125,31 @@ Often it's useful to pass arguments into your test session. Here's a quick examp

session.run('pytest', *test_files)

Now you if you run::
Now you if you run:


.. code-block:: console
nox
Then nox will run::
Then nox will run:

.. code-block:: console
pytest test_a.py test_b.py
But if you run::
But if you run:

.. code-block:: console
nox -- test_c.py
Then nox will run::
Then nox will run:

.. code-block:: console
pytest test_c.py
Expand All @@ -145,7 +167,9 @@ Session arguments can be parametrized with the :func:`nox.parametrize` decorator
session.install(f'django=={django}')
session.run('pytest')

When you run ``nox``, it will create a two distinct sessions::
When you run ``nox``, it will create a two distinct sessions:

.. code-block:: console
$ nox
nox > Running session tests(django='1.9')
Expand All @@ -168,7 +192,9 @@ You can also stack the decorator to produce sessions that are a combination of t
...


If you run ``nox --list-sessions``, you'll see that this generates the following set of sessions::
If you run ``nox --list``, you'll see that this generates the following set of sessions:

.. code-block:: console
* tests(django='1.9', database='postgres')
* tests(django='2.0', database='mysql')
Expand All @@ -178,6 +204,65 @@ If you run ``nox --list-sessions``, you'll see that this generates the following
If you only want to run one of the parametrized sessions, see :ref:`running_paramed_sessions`.

Giving friendly names to parametrized sessions
----------------------------------------------

The automatically generated names for parametrized sessions, such as ``tests(django='1.9', database='postgres')``, can be long and unwieldy to work with even with using :ref:`keyword filtering <opt-sessions-and-keywords>`. You can give parametrized sessions custom IDs to help in this scenario. These two examples are equivalent:

::

@nox.session
@nox.parametrize('django',
['1.9', '2.0'],
ids=['old', 'new'])
def tests(session, django):
...

::

@nox.session
@nox.parametrize('django', [
nox.param('1.9', id='old'),
nox.param('2.0', id='new'),
])
def tests(session, django):
...

When running ``nox --list`` you'll see their new IDs:

.. code-block:: console
* tests(old)
* tests(new)
And you can run them with ``nox --sessions "tests(old)"`` and so on.

This works with stacked parameterizations as well. The IDs are combined during the combination. For example:

::

@nox.session
@nox.parametrize(
'django',
['1.9', '2.0'],
ids=["old", "new"])
@nox.parametrize(
'database',
['postgres', 'mysql'],
ids=["psql", "mysql"])
def tests(session, django, database):
...

Produces these sessions when running ``nox --list``:

.. code-block:: console
* tests(psql, old)
* tests(mysql, old)
* tests(psql, new)
* tests(mysql, new)
.. _pip: https://pip.readthedocs.org
.. _flake8: https://flake8.readthedocs.org
.. _thekevjames/nox images: https://hub.docker.com/r/thekevjames/nox
Expand Down
3 changes: 2 additions & 1 deletion nox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
# limitations under the License.

from nox._options import options
from nox._parametrize import Param as param
from nox._parametrize import parametrize_decorator as parametrize
from nox.registry import session_decorator as session

__all__ = ["parametrize", "session", "options"]
__all__ = ["parametrize", "param", "session", "options"]
137 changes: 104 additions & 33 deletions nox/_parametrize.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,74 @@
# limitations under the License.

import functools
import itertools


def parametrize_decorator(arg_names, arg_values_list):
class Param:
"""A class that encapsulates a single set of parameters to a parametrized
session.
Args:
args (List[Any]): The list of args to pass to the invoked function.
arg_names (Sequence[str]): The names of the args.
id (str): An optional ID for this set of parameters. If unspecified,
it will be generated from the parameters.
"""

def __init__(self, *args, arg_names=None, id=None):
self.args = tuple(args)
self.id = id

if arg_names is None:
arg_names = ()

self.arg_names = tuple(arg_names)

@property
def call_spec(self):
return dict(zip(self.arg_names, self.args))

def __str__(self):
if self.id:
return self.id
else:
call_spec = self.call_spec
keys = sorted(call_spec.keys(), key=str)
args = ["{}={}".format(k, repr(call_spec[k])) for k in keys]
return ", ".join(args)

__repr__ = __str__

def copy(self):
new = self.__class__(*self.args, arg_names=self.arg_names, id=self.id)
return new

def update(self, other):
self.id = ", ".join([str(self), str(other)])
self.args = self.args + other.args
self.arg_names = self.arg_names + other.arg_names

def __eq__(self, other):
if isinstance(other, self.__class__):
return (
self.args == other.args
and self.arg_names == other.arg_names
and self.id == other.id
)
elif isinstance(other, dict):
return dict(zip(self.arg_names, self.args)) == other

raise NotImplementedError


def _apply_param_specs(param_specs, f):
previous_param_specs = getattr(f, "parametrize", None)
new_param_specs = update_param_specs(previous_param_specs, param_specs)
setattr(f, "parametrize", new_param_specs)
return f


def parametrize_decorator(arg_names, arg_values_list, ids=None):
"""Parametrize a session.
Add new invocations to the underlying session function using the list of
Expand All @@ -32,70 +97,76 @@ def parametrize_decorator(arg_names, arg_values_list):
argument names were specified, this must be a list of N-tuples,
where each tuple-element specifies a value for its respective
argument name, for example ``[(1, 'a'), (2, 'b')]``.
ids (Sequence[str]): Optional sequence of test IDs to use for the
parametrized arguments.
"""

# Allow args to be specified as any of 'arg', 'arg,arg2' or ('arg', 'arg2')
# Allow args names to be specified as any of 'arg', 'arg,arg2' or ('arg', 'arg2')
if not isinstance(arg_names, (list, tuple)):
arg_names = list(filter(None, [arg.strip() for arg in arg_names.split(",")]))

# If there's only one arg_name, arg_values_list should be a single item
# or list. Transform it so it'll work with the combine step.
if len(arg_names) == 1:
# In this case, the arg_values_list can also just be a single item.
if not isinstance(arg_values_list, (list, tuple)):
if isinstance(arg_values_list, tuple):
# Must be mutable for the transformation steps
arg_values_list = list(arg_values_list)
if not isinstance(arg_values_list, list):
arg_values_list = [arg_values_list]
arg_values_list = [[value] for value in arg_values_list]

# Combine arg names and values into a list of dictionaries. These are
# 'call specs' that will be used to generate calls.
# [{arg: value1}, {arg: value2}, ...]
call_specs = []
for arg_values in arg_values_list:
call_spec = dict(zip(arg_names, arg_values))
call_specs.append(call_spec)
for n, value in enumerate(arg_values_list):
if not isinstance(value, Param):
arg_values_list[n] = [value]

# if ids aren't specified at all, make them an empty list for zip.
if not ids:
ids = []

# Generate params for each item in the param_args_values list.
param_specs = []
for param_arg_values, param_id in itertools.zip_longest(arg_values_list, ids):
if isinstance(param_arg_values, Param):
param_spec = param_arg_values
param_spec.arg_names = tuple(arg_names)
else:
param_spec = Param(*param_arg_values, arg_names=arg_names, id=param_id)

def inner(f):
previous_call_specs = getattr(f, "parametrize", None)
new_call_specs = update_call_specs(previous_call_specs, call_specs)
setattr(f, "parametrize", new_call_specs)
return f
param_specs.append(param_spec)

return inner
return functools.partial(_apply_param_specs, param_specs)


def update_call_specs(call_specs, new_specs):
if not call_specs:
call_specs = [{}]
def update_param_specs(param_specs, new_specs):
"""Produces all combinations of the given sets of specs."""
if not param_specs:
return new_specs

# New specs must be combined with old specs by *multiplying* them.
combined_specs = []
for new_spec in new_specs:
for spec in call_specs:
for spec in param_specs:
spec = spec.copy()
spec.update(new_spec)
combined_specs.append(spec)
return combined_specs


def generate_session_signature(func, call_spec):
args = ["{}={}".format(k, repr(call_spec[k])) for k in sorted(call_spec.keys())]
return "({})".format(", ".join(args))


def generate_calls(func, call_specs):
def generate_calls(func, param_specs):
calls = []
for call_spec in call_specs:
for param_spec in param_specs:

def make_call_wrapper(call_spec):
def make_call_wrapper(param_spec):
@functools.wraps(func)
def call_wrapper(*args, **kwargs):
kwargs.update(call_spec)
kwargs.update(param_spec.call_spec)
return func(*args, **kwargs)

return call_wrapper

call = make_call_wrapper(call_spec)
call.session_signature = generate_session_signature(func, call_spec)
call.call_spec = call_spec
call = make_call_wrapper(param_spec)
call.session_signature = "({})".format(param_spec)
call.param_spec = param_spec
calls.append(call)

return calls
6 changes: 6 additions & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,9 @@ def docs(session):
sphinx_args.insert(0, "--open-browser")

session.run(sphinx_cmd, *sphinx_args)


@nox.session
@nox.parametrize("django", [nox.param("1.9", id="old"), nox.param("2.0", id="new")])
def django(session, django):
pass

0 comments on commit 722ff26

Please sign in to comment.