Repository: theacodes/nox
Pull request #94 (2018-07-27 20:50:22)

Make Nox imperative; Drop 2.7 and 3.4; Remove legacy naming support

theacodes: @lukesneeringer thank you so much for taking the time to review this. I think it's ready for another round.
theacodes: Okay, @lukesneeringer, I think that's everything? I'm going to go ahead and merge so I can send some simple follow-ups, but please let me know if I missed anything or if you have further concerns and I'll address them.

Thanks again!

Patch:
diff --git a/.gitignore b/.gitignore
index 7704f0f..757d025 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ __pycache__/
 # Distribution / packaging
 .Python
 env/
+venv/
 build/
 develop-eggs/
 dist/
diff --git a/.travis.yml b/.travis.yml
index 3fed0a9..84a63ab 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,16 +1,16 @@
 language: python
 matrix:
   include:
-  - python: '2.7'
+  - python: '3.6'
     env: NOX_SESSION=lint
-  - python: '2.7'
-    env: NOX_SESSION="interpreters(version='2.7')"
-  - python: '3.4'
-    env: NOX_SESSION="interpreters(version='3.4')"
   - python: '3.5'
-    env: NOX_SESSION="interpreters(version='3.5')"
+    env: NOX_SESSION="tests-3.5"
   - python: '3.6'
-    env: NOX_SESSION="interpreters(version='3.6')"
+    env: NOX_SESSION="tests-3.6"
+  # Travis does not support 3.7 yet.
+  # https://github.com/travis-ci/travis-ci/issues/9815
+  # - python: '3.7'
+  #   env: NOX_SESSION="tests-3.7"
 install:
   - pip install --upgrade pip setuptools
   - pip install .
@@ -24,4 +24,4 @@ deploy:
     tags: true
     distributions: sdist bdist_wheel
     repo: theacodes/nox
-    condition: "$NOX_SESSION = \"interpreters(version='2.7')\""
+    condition: "$NOX_SESSION = \"interpreters(version='3.6')\""
diff --git a/appveyor.yml b/appveyor.yml
index 8253027..3ba586a 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -10,29 +10,23 @@ environment:
     # a later point release.
     # See: http://www.appveyor.com/docs/installed-software#python
 
-    - PYTHON: "C:\\Python27"
-      NOX_SESSION: "interpreters(version='2.7')"
-
-    - PYTHON: "C:\\Python27-x64"
-      NOX_SESSION: "interpreters(version='2.7')"
-
-    - PYTHON: "C:\\Python34"
-      NOX_SESSION: "interpreters(version='3.4')"
-
-    - PYTHON: "C:\\Python34-x64"
-      NOX_SESSION: "interpreters(version='3.4')"
-
     - PYTHON: "C:\\Python35"
-      NOX_SESSION: "interpreters(version='3.5')"
+      NOX_SESSION: "tests-3.5"
 
     - PYTHON: "C:\\Python35-x64"
-      NOX_SESSION: "interpreters(version='3.5')"
+      NOX_SESSION: "tests-3.5"
 
     - PYTHON: "C:\\Python36"
-      NOX_SESSION: "interpreters(version='3.6')"
+      NOX_SESSION: "tests-3.6"
 
     - PYTHON: "C:\\Python36-x64"
-      NOX_SESSION: "interpreters(version='3.6')"
+      NOX_SESSION: "tests-3.6"
+
+    - PYTHON: "C:\\Python37"
+      NOX_SESSION: "tests-3.7"
+
+    - PYTHON: "C:\\Python37-x64"
+      NOX_SESSION: "tests-3.7"
 
 install:
   # Prepend newly installed Python to the PATH of this build (this cannot be
@@ -46,7 +40,7 @@ install:
 
   # Upgrade to the latest version of pip to avoid it displaying warnings
   # about it being out of date.
-  - "pip install --disable-pip-version-check --user --upgrade pip"
+  - "python -m pip install --disable-pip-version-check --user --upgrade pip"
 
   # Install the build dependencies of the project. If some dependencies contain
   # compiled extensions and are not provided as pre-built wheel packages,
@@ -65,5 +59,4 @@ build_script:
 
 test_script:
   # Run the project tests
-  - "nox.exe --session default"
   - "nox.exe --session \"%NOX_SESSION%\""
diff --git a/docs/config.rst b/docs/config.rst
index 03ccee7..a96ea04 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -13,46 +13,78 @@ Defining sessions
 .. autofunction:: nox.session
 
 Nox sessions are configured via standard Python functions that are decorated
-with ``@nox.session`` or start with ``session_``. For example, these are all
-sessions::
+with ``@nox.session``. For example::
 
     import nox
 
-    def session_a(session):
+    @nox.session
+    def tests(session):
+        session.run('pytest')
+
+You can also configure sessions to run against multiple Python versions as described in :ref:`virtualenv config` and  parametrize sessions as described in :ref:`parametrized sessions <parametrized>`.
+
+.. _virtualenv config:
+
+Configuring a session's virtualenv
+----------------------------------
+
+By default, Nox will create a new virtualenv for each session using the same interpreter that Nox uses. If you installed Nox using Python 3.6, Nox will use Python 3.6 by default for all of your sessions.
+
+You can tell Nox to use a different Python interpreter/version by specifying the ``python`` argument to ``@nox.session``::
+
+    @nox.session(python='2.7')
+    def tests(session):
         pass
 
-    def session_123(session):
+You can also tell Nox to run your session against multiple Python interpreters. Nox will create a separate virtualenv and run the session for each interpreter you specify. For example, this session will run twice - once for Python 2.7 and once for Python 3.6::
+
+    @nox.session(python=['2.7', '3.6'])
+    def tests(session):
         pass
 
-    @nox.session
-    def b(session):
+When you provide a version number, Nox automatically prepends python to determine the name of the executable. However, Nox also accepts the full executable name. If you want to test using pypy, for example::
+
+    @nox.session(python=['2.7', '3.6', 'pypy-6.0'])
+    def tests(session):
         pass
 
-These are **not**::
+When collecting your sessions, Nox will create a separate session for each interpreter. You can see these sesions when running ``nox --list-sessions``. For example this Noxfile::
 
-    def some_session(session):
+    @nox.session(python=['2.7', '3.5', 3.6', '3.7'])
+    def tests(session):
         pass
 
-    def other_func(session):
+Will produce these sessions::
+
+    * tests-2.7
+    * tests-3.5
+    * tests-3.6
+    * tests-3.7
+
+Note that this expansion happens *before* parameterization occurs, so you can still parametrize sessions with multiple interpreters.
+
+If you want to disable virtualenv creation altogether, you can set ``python`` to ``False``:
+
+    @nox.session(python=False)
+    def tests(session):
         pass
 
-You may define sessions using either the decorator or the naming convention.
-This can affect the execution order as described in the
-:ref:`usage docs<session_execution_order>`.
+Finally you can also specify that the virtualenv should *always* be reused instead of recreated every time::
+
+    @nox.session(
+        python=['2.7', '3.6'],
+        reuse_venv=True)
+    def tests(session):
+        pass
 
-You can also parametrize sessions as described in
-:ref:`parametrized sessions <parametrized>`.
 
-Configuring sessions
---------------------
+Using the session object
+------------------------
 
 .. module:: nox.sessions
 
-Nox will call your session functions with a :class:`SessionConfig` object. You
-use this object to tell nox how to create your session and which actions to
-run. Session configuration is *declarative*, nox runs your session function
-first to gather the list of things to do, then executes them separately.
+Nox will call your session functions with a :class:`Session` object. You use this object to to run various commands in your session.
 
-.. autoclass:: SessionConfig
+.. autoclass:: Session
     :members:
     :undoc-members:
diff --git a/docs/tutorial.rst b/docs/tutorial.rst
index dd6d54b..e8ce373 100644
--- a/docs/tutorial.rst
+++ b/docs/tutorial.rst
@@ -23,7 +23,7 @@ The simplest way of running Nox will run all sessions defined in `nox.py`::
 
 However, if you wish to run a single session or subset of sessions you can use the ``-s`` argument::
 
-    nox --sessions lint py27
+    nox --sessions lint tests-2.7
     nox -s lint
 
 You can read more about invoking Nox in :doc:`usage`.
@@ -34,19 +34,13 @@ Creating a noxfile
 
 When you run ``nox``, it looks for a file named `nox.py` in the current directory. This file contains all of the session definitions. A *session* is an environment and a set of commands to run in that environment. Sessions are analogous to *environments* in tox.
 
-Sessions can be defined in two ways. The preferred way is to define a session with the ``@nox.session`` decorator::
+Sessions are declared using the ``@nox.session`` decorator::
 
     @nox.session
     def lint(session):
         session.install('flake8')
         session.run('flake8')
 
-Alternatively, sessions may be designated by the naming convention of beginning a function with ``session_``::
-
-    def session_lint(session):
-        session.install('flake8')
-        session.run('flake8')
-
 If you run this via ``nox`` you should see output similar to this::
 
     nox > Running session lint
@@ -56,23 +50,14 @@ If you run this via ``nox`` you should see output similar to this::
     nox > Session lint successful. :)
 
 
-.. note::
-
-    You may define sessions using either the decorator or the naming convention. There is one difference between these: if you use the decorator, then sessions will be run by nox in the order that they appear in the `nox.py` file. If you define sessions using the naming convention, they run in alphabetical order.
-
-    If you mix and match the two methods, all sessions defined using the decorator are run first (in order), followed by all sessions defined by the naming convention, alphabetically.
-
-
 Setting up virtualenvs and installing dependencies
 --------------------------------------------------
 
-Nox automatically creates a separate `virtualenv`_ for every session. You can choose which Python interpreter to use when creating the session. When you install dependencies or run commands within a session, they all use the session's virtualenv. Here's an example of a session that uses Python 2.7, installs dependencies in various ways, and runs a command::
+Nox automatically creates a separate `virtualenv`_ for every session. You can choose which Python interpreter to use when declaring the session. When you install dependencies or run commands within a session, they all use the session's virtualenv. Here's an example of a session that uses Python 2.7, installs dependencies in various ways, and runs a command::
 
 
-    @nox.session
-    def py27(session):
-        # Use Python 2.7.
-        session.interpreter = 'python2.7'
+    @nox.session(python='2.7')
+    def tests(session):
         # Install py.test
         session.install('pytest')
         # Install everything in requirements-dev.txt
@@ -83,67 +68,35 @@ Nox automatically creates a separate `virtualenv`_ for every session. You can ch
         session.run('py.test')
 
 
-You can create as many session as you want. Typically, you will create one session per interpreter version you want to test on.
+You can create as many session as you want and sessions can use multiple Python versions, for example::
+
+    @nox.session(python=['2.7', '3.6'])
+    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::
+
+    * tests-2.7
+    * tests-3.6
 
 You can read more about configuring sessions in :doc:`config`.
 
+
 Running commands
 ----------------
 
 Running a command in a session is easy - just pass the command name and arguments to :func:`session.run`::
 
     @nox.session
-    def py27(session):
+    def tests(session):
         session.install('pytest')
         session.run('pytest', '-k', 'not slow')
 
-Sessions are **declarative**. Your commands are not run immediately, instead, Nox gathers up the commands from your session and then executes all of the commands in order. This means if you do something like change directory in your session it won't necessarily work the way you expect. Changing directory can be handled by :func:`session.chdir`::
+There are some other helpful methods on :class:`nox.sessions.Session`. For example, to chage the current working director you can use :func:`session.chdir`::
 
     session.chdir('docs')
     session.run('sphinx-build', 'html')
 
-If you want to run arbitrary Python, you can pass a function directly into :func:`session.run`::
-
-    def clean_up():
-        shutil.rmtree('build')
-
-    @nox.session
-    def build_wheel(session):
-        session.run('python', 'setup.py', 'bdist_wheel')
-        session.run(clean_up)
-
-Nox will make sure your function is executed in the right order with the rest of the commands. The ``nox`` output will also reflect that it was executed, for example::
-
-    nox > Running session build_wheel
-    nox > python setup.py bdist_wheel
-    running bdist_wheel
-    ...
-    nox > clean_up(args=(), kwargs={})
-    nox > Session build_wheel successful. :)
-
-This makes it easier to keep track of exactly what's executed during your session.
-
-Sharing configuration between sessions
---------------------------------------
-
-If you want a bunch of sessions to do the same thing but use different interpreters, you can just define a function that sets up all of the common stuff::
-
-    def common(session):
-        session.install('-r', 'requirements-dev.txt')
-        session.run('py.test')
-
-    @nox.session
-    def py27(session):
-        session.interpreter = 'python2.7'
-        common(session)
-
-    @nox.session
-    def py34(session):
-        session.interpreter = 'python3.4'
-        common(session)
-
-Remember, Nox only recognizes functions as sessions if they are decorated with ``@nox.session`` or start with ``session_``.
-
 
 Passing arguments into sessions
 -------------------------------
@@ -183,26 +136,22 @@ Then nox will run::
 Parametrizing sessions
 ----------------------
 
-Session arguments can be parametrized with the :func:`nox.parametrize` decorator. Here's a typical example of parametrizing Python intepreter versions::
+Session arguments can be parametrized with the :func:`nox.parametrize` decorator. Here's a typical example of parametrizing the Django version to install::
 
     @nox.session
-    @nox.parametrize('python_version', ['2.7', '3.4', '3.5'])
-    def tests(session, python_version):
-        session.interpreter = 'python' + python_version
-        session.install('pytest')
+    @nox.parametrize('django', ['1.9', '2.0'])
+    def tests(session, django):
+        session.install(f'django=={django}')
         session.run('py.test')
 
 When you run ``nox``, it will create a three distinct sessions::
 
     $ nox
-    nox > Running session tests(python_version='2.7')
-    nox > virtualenv ./.nox/tests -p python2.7
+    nox > Running session tests(django='1.9')
+    nox > pip install --upgrade django==1.9
     ...
-    nox > Running session tests(python_version='3.4')
-    nox > virtualenv ./.nox/tests -p python3.4
-    ...
-    nox > Running session tests(python_version='3.5')
-    nox > virtualenv ./.nox/tests -p python3.5
+    nox > Running session tests(djano='2.0')
+    nox > pip install --upgrade django==2.0
 
 
 :func:`nox.parametrize` has an interface and usage intentionally similar to `py.test's parametrize <https://pytest.org/latest/parametrize.html#_pytest.python.Metafunc.parametrize>`_.
@@ -212,20 +161,18 @@ When you run ``nox``, it will create a three distinct sessions::
 You can also stack the decorator to produce sessions that are a combination of the arguments, for example::
 
     @nox.session
-    @nox.parametrize('python_version', ['2.7', '3.4'])
-    @nox.parametrize('django_version', ['1.8', '1.9'])
-    def tests(session, python_version, django_version):
-        session.interpreter = 'python' + python_version
-        session.install('pytest', 'django==' + django_version)
-        session.run('py.test')
+    @nox.parametrize('django', ['1.9', '2.0'])
+    @nox.parametrize('database', ['postgres', 'mysql'])
+    def tests(session, django, database):
+        ...
 
 
 If you run ``nox --list-sessions``, you'll see that this generates the following set of sessions::
 
-    * tests(django_version='1.8', python_version='2.7')
-    * tests(django_version='1.9', python_version='2.7')
-    * tests(django_version='1.8', python_version='3.4')
-    * tests(django_version='1.9', python_version='3.4')
+    * tests(django='1.9', database='postgres')
+    * tests(django='2.0', database='mysql')
+    * tests(django='1.9', database='postgres')
+    * tests(django='2.0', database='mysql')
 
 
 If you only want to run one of the parametrized sessions, see :ref:`running_paramed_sessions`.
diff --git a/docs/usage.rst b/docs/usage.rst
index 7c103d4..87958c1 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -39,24 +39,16 @@ You can run every session by just executing `nox` without any arguments:
 
     nox
 
-The order that sessions are executed is determined by how they are defined in
-your noxfile. if you use the :func:`decorator <nox.session>`, ``@nox.session``,
-then sessions will be run by nox in the order that they appear in the noxfile.
-If you define sessions using the naming convention, ``def session_<name>```
-they run in alphabetical order.
-
-If you mix and match the two methods, all sessions defined using the decorator
-are run first (in order), followed by all sessions defined by the naming
-convention, alphabetically.
+The order that sessions are executed is the order that they appear in the Noxfile.
 
 Specifying one or more sessions
 -------------------------------
 
 By default nox will run all sessions defined in the noxfile. However, you can choose to run a particular set of them using ``--session``, ``-s``, or ``-e``::
 
-    nox --session py27
-    nox -s lint py27
-    nox -e py34
+    nox --session tests
+    nox -s lint tests
+    nox -e lint
 
 Nox will run these sessions in the same order they are specified.
 
@@ -74,14 +66,14 @@ Specifying parametrized sessions
 
 If you have a :ref:`parametrized <parametrized>` session such as::
 
-    @nox.parametrize('python_version', ['2.7', '3.4', '3.5'])
-    def session_tests(session, python_version):
+    @nox.parametrize('django', ['1.9', '2.0'])
+    def session_tests(session, django):
         ...
 
 Then running ``nox --session tests`` will actually run all parametrized versions of the session. If you want the run the session with a particular set of parametrized arguments, you can specify them with the session name::
 
-    nox --session "tests(python_version='2.7')"
-    nox --session "tests(python_version='3.4')"
+    nox --session "tests(django='1.9')"
+    nox --session "tests(django='2.0')"
 
 
 Re-using virtualenvs
diff --git a/nox.py b/nox.py
index 55229d5..1bccaa0 100644
--- a/nox.py
+++ b/nox.py
@@ -20,8 +20,8 @@ import nox
 ON_APPVEYOR = os.environ.get('APPVEYOR') == 'True'
 
 
-@nox.session
-def default(session):
+@nox.session(python=['3.5', '3.6', '3.7'])
+def tests(session):
     session.install('-r', 'requirements-test.txt')
     session.install('-e', '.[tox_to_nox]')
     tests = session.posargs or ['tests/']
@@ -31,13 +31,6 @@ def default(session):
     session.notify('cover')
 
 
-@nox.session
-@nox.parametrize('version', ['2.7', '3.4', '3.5', '3.6'])
-def interpreters(session, version):
-    default(session)
-    session.interpreter = 'python' + version
-
-
 @nox.session
 def cover(session):
     session.install('coverage')
diff --git a/nox/command.py b/nox/command.py
index e89d462..8d82d69 100644
--- a/nox/command.py
+++ b/nox/command.py
@@ -53,12 +53,13 @@ def which(program, path):
 
 class Command(object):
     def __init__(self, args, env=None, silent=False, path=None,
-                 success_codes=None):
+                 success_codes=None, log=True):
         self.args = args
         self.silent = silent
         self.env = env
         self.path = path
         self.success_codes = success_codes or [0]
+        self.log = log
 
     def run(self, path_override=None, env_fallback=None, **kwargs):
         path = self.path if path_override is None else path_override
@@ -73,7 +74,8 @@ class Command(object):
         cmd, args = self.args[0], self.args[1:]
         full_cmd = ' '.join(self.args)
 
-        logger.info(full_cmd)
+        if self.log:
+            logger.info(full_cmd)
 
         cmd_path = which(cmd, path)
 
@@ -114,70 +116,3 @@ class Command(object):
 
     def __call__(self, *args, **kwargs):
         return self.run(*args, **kwargs)
-
-
-class FunctionCommand(object):
-    def __init__(self, func, args=None, kwargs=None, debug=False):
-        self.func = func
-        self.debug = debug
-        self.args = args or ()
-        self.kwargs = kwargs or {}
-        try:
-            self._func_name = self.func.__name__
-        except AttributeError:
-            self._func_name = '{!r}'.format(self.func)
-
-    def run(self):
-        if self.debug:
-            logger.debug(str(self))
-        else:
-            logger.info(str(self))
-
-        try:
-            self.func(*self.args, **self.kwargs)
-            return True
-        except Exception as e:
-            logger.exception('Function {} raised {}.'.format(
-                self._func_name, e))
-
-            raise CommandFailed(e)
-
-    def __call__(self, *args, **kwargs):
-        return self.run(*args, **kwargs)
-
-    def __str__(self):
-        return '{}(args={!r}, kwargs={!r})'.format(
-            self._func_name, self.args, self.kwargs)
-
-
-class ChdirCommand(FunctionCommand):
-    def __init__(self, path, debug=False):
-        self.path = path
-        super(ChdirCommand, self).__init__(os.chdir, args=(self.path,),
-                                           debug=debug)
-
-    def __str__(self):
-        return 'chdir {}'.format(self.path)
-
-
-class InstallCommand(object):
-    def __init__(self, deps):
-        self.deps = deps
-
-    def run(self, venv):
-        venv.install(*self.deps)
-
-    def __call__(self, *args, **kwargs):
-        return self.run(*args, **kwargs)
-
-
-class NotifyCommand(Command):
-    """Notify the given session and add it to the queue."""
-
-    def __init__(self, target, debug=False):
-        self.target = target
-        self.debug = debug
-
-    def __call__(self, session, **kwargs):
-        logger.info('Notifying session: %s' % self.target)
-        session.manifest.notify(self.target)
diff --git a/nox/manifest.py b/nox/manifest.py
index 8adf9a2..1ff465e 100644
--- a/nox/manifest.py
+++ b/nox/manifest.py
@@ -14,10 +14,26 @@
 
 from __future__ import absolute_import
 
+import copy
+import functools
 import itertools
+import types
 
 from nox._parametrize import generate_calls
-from nox.sessions import Session
+from nox.sessions import SessionRunner
+
+
+def _copy_func(src, name=None):
+    dst = types.FunctionType(
+        src.__code__,
+        src.__globals__,
+        name=name or src.__name__,
+        argdefs=src.__defaults__,
+        closure=src.__closure__)
+    dst.__dict__.update(copy.deepcopy(src.__dict__))
+    dst = functools.update_wrapper(dst, src)
+    dst.__kwdefaults__ = src.__kwdefaults__
+    return dst
 
 
 class Manifest(object):
@@ -138,25 +154,48 @@ class Manifest(object):
             Sequence[~nox.session.Session]: A sequence of Session objects
                 bound to this manifest and configuration.
         """
+        sessions = []
+
+        # If the func has the python attribute set to a list, we'll need
+        # to expand them.
+        if isinstance(func.python, (list, tuple, set)):
+            for python in func.python:
+                single_func = _copy_func(func)
+                single_func.python = python
+                sessions.extend(self.make_session(name, single_func))
+
+            return sessions
+
         # Simple case: If this function is not parametrized, then make
         # a simple session
         if not hasattr(func, 'parametrize'):
-            session = Session(name, None, func, self._config, self)
-            return (session,)
+            if func.python:
+                long_name = '{}-{}'.format(name, func.python)
+            else:
+                long_name = name
+            session = SessionRunner(name, long_name, func, self._config, self)
+            return [session]
 
         # Since this function is parametrized, we need to add a distinct
         # session for each permutation.
-        sessions = []
         calls = generate_calls(func, func.parametrize)
         for call in calls:
-            long_name = name + call.session_signature
-            sessions.append(Session(name, long_name, call, self._config, self))
+            if func.python:
+                long_name = '{}-{}{}'.format(
+                    name, func.python, call.session_signature)
+            else:
+                long_name = '{}{}'.format(
+                    name, call.session_signature)
+
+            sessions.append(
+                SessionRunner(name, long_name, call, self._config, self))
 
         # Edge case: If the parameters made it such that there were no valid
         # calls, add an empty, do-nothing session.
         if not calls:
             sessions.append(
-                Session(name, None, _null_session_func, self._config, self),
+                SessionRunner(
+                    name, None, _null_session_func, self._config, self),
             )
 
         # Return the list of sessions.
diff --git a/nox/registry.py b/nox/registry.py
index 562ba2d..6405ea6 100644
--- a/nox/registry.py
+++ b/nox/registry.py
@@ -2,23 +2,36 @@ from __future__ import absolute_import
 
 import collections
 import copy
-
+import functools
 
 _REGISTRY = collections.OrderedDict()
 
 
-def session_decorator(func):
+def session_decorator(
+        func=None, python=None, reuse_venv=None):
     """Designate the decorated function as a session."""
-    # This adds the given function to the _REGISTRY ordered dictionary, which
-    # is checked by `nox.main.discover_session_functions`.
+    # If `func` is provided, then this is the decorator call with the function
+    # being sent as part of the Python syntax (`@nox.session`).
+    # If `func` is None, however, then this is a plain function call, and it
+    # must return the decorator that ultimately is applied
+    # (`@nox.session(...)`).
+    #
+    # This is what makes the syntax with and without parentheses both work.
+    if func is None:
+        return functools.partial(
+            session_decorator, python=python, reuse_venv=reuse_venv)
+
+    func.python = python
+    func.reuse_venv = reuse_venv
     _REGISTRY[func.__name__] = func
+
     return func
 
 
 def get():
     """Return a shallow copy of the registry.
 
-    This ensures that the reigstry is not accidentally modified by
+    This ensures that the registry is not accidentally modified by
     calling code that retrieves a registry and mutates it.
     """
     return copy.copy(_REGISTRY)
diff --git a/nox/sessions.py b/nox/sessions.py
index 48892e6..bdc779d 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -22,12 +22,8 @@ import py
 import six
 
 from nox import utils
-from nox.command import ChdirCommand
 from nox.command import Command
 from nox.command import CommandFailed
-from nox.command import FunctionCommand
-from nox.command import InstallCommand
-from nox.command import NotifyCommand
 from nox.logger import logger
 from nox.virtualenv import ProcessEnv, VirtualEnv
 
@@ -74,48 +70,57 @@ class Status(enum.Enum):
     SKIPPED = 2
 
 
-class SessionConfig(object):
-    """SessionConfig is passed into the session function defined in the
-    user's *nox.py*. The session function uses this object to configure the
-    virtualenv and tell nox which commands to run within the session.
+class Session(object):
+    """The Session object is passed into each user-defined session function.
+
+    This is your primary means for installing package and running commands in
+    your Nox session.
     """
-    def __init__(self, posargs=None):
-        self._commands = []
-        self.env = {}
+    def __init__(self, runner):
+        self._runner = runner
+
+    @property
+    def env(self):
         """A dictionary of environment variables to pass into all commands."""
-        self.virtualenv = True
-        """A boolean indicating whether or not to create a virtualenv. Defaults
-        to True."""
-        self.virtualenv_dirname = None
-        """An optional string that determines the name of the directory
-        where the virtualenv will be created. This is relative to the
-        --envdir option passed into the nox command. By default, nox will
-        generate the name based on the function signature."""
-        self.interpreter = None
-        """``None`` or a string indicating the name of the Python interpreter
-        to use in the session's virtualenv. If None, the default system
-        interpreter is used."""
-        self.posargs = posargs or []
-        """``None`` or a list of strings. This is set to any extra arguments
+        return self._runner.venv.env
+
+    @property
+    def posargs(self):
+        """This is set to any extra arguments
         passed to ``nox`` on the commandline."""
-        self.reuse_existing_virtualenv = False
-        """A boolean indicating whether to recreate or reuse the session's
-        virtualenv. If True, then any existing virtualenv will be used. This
-        can also be specified globally using the
-        ``--reuse-existing-virtualenvs`` argument when running ``nox``."""
+        return self._runner.global_config.posargs
 
-    def chdir(self, dir, debug=False):
-        """Set the working directory for any commands that run in this
-        session after this point.
+    @property
+    def virtualenv(self):
+        """The virtualenv that all commands are run in."""
+        return self._runner.venv
 
-        cd() is an alias for chdir()."""
-        self._commands.append(ChdirCommand(dir, debug=debug))
+    @property
+    def bin(self):
+        """The bin directory for the virtualenv."""
+        return self._runner.venv.bin
+
+    def chdir(self, dir):
+        """Change the current working directory."""
+        self.log('cd {}'.format(dir))
+        os.chdir(dir)
 
     cd = chdir
+    """An alias for :meth:`chdir`."""
+
+    def _run_func(self, func, args, kwargs):
+        """Legacy support for running a function through :func`run`."""
+        self.log('{}(args={!r}, kwargs={!r})'.format(
+            func, args, kwargs))
+        try:
+            return func(*args, **kwargs)
+        except Exception as e:
+            logger.exception('Function {!r} raised {!r}.'.format(
+                func, e))
+            raise CommandFailed()
 
     def run(self, *args, **kwargs):
-        """
-        Schedule a command or function to in the session.
+        """Run a command.
 
         Commands must be specified as a list of strings, for example::
 
@@ -149,20 +154,19 @@ class SessionConfig(object):
         :param success_codes: A list of return codes that are considered
             successful. By default, only ``0`` is considered success.
         :type success_codes: list, tuple, or None
-
-        Functions can be scheduled just by passing the function and any args,
-        just like :func:`functools.partial`::
-
-            session.run(shutil.rmtree, 'docs/_build')
-
         """
         if not args:
             raise ValueError('At least one argument required to run().')
+
+        # Legacy support - run a function given.
         if callable(args[0]):
-            self._commands.append(
-                FunctionCommand(args[0], args[1:], kwargs))
-        else:
-            self._commands.append(Command(args=args, **kwargs))
+            return self._run_func(args[0], args[1:], kwargs)
+
+        # Run a shell command.
+        return Command(args=args, **kwargs)(
+            env_fallback=self.env,
+            path_override=self.bin,
+        )
 
     def install(self, *args):
         """Install invokes `pip`_ to install packages inside of the session's
@@ -187,12 +191,12 @@ class SessionConfig(object):
 
         .. _pip: https://pip.readthedocs.org
         """
-        if not self.virtualenv:
+        if not isinstance(self.virtualenv, VirtualEnv):
             raise ValueError(
                 'A session without a virtualenv can not install dependencies.')
         if not args:
             raise ValueError('At least one argument required to install().')
-        self._commands.append(InstallCommand(args))
+        self.virtualenv.install(*args)
 
     def notify(self, target):
         """Place the given session at the end of the queue.
@@ -202,10 +206,10 @@ class SessionConfig(object):
 
         Args:
             target (Union[str, Callable]): The session to be notified. This
-                may be specified as the appropropriate string or using
-                the function object.
+                may be specified as the appropriate string (same as used for
+                ``nox -s``) or using the function object.
         """
-        self._commands.append(NotifyCommand(target))
+        self._runner.manifest.notify(target)
 
     def log(self, *args, **kwargs):
         """Outputs a log during the session."""
@@ -224,75 +228,49 @@ class SessionConfig(object):
         raise _SessionSkip()
 
 
-class Session(object):
+class SessionRunner(object):
     def __init__(self, name, signature, func, global_config, manifest=None):
         self.name = name
         self.signature = signature
         self.func = func
         self.global_config = global_config
         self.manifest = manifest
+        self.venv = None
 
     def __str__(self):
         return utils.coerce_str(self.signature or self.name)
 
-    def _create_config(self):
-        self.config = SessionConfig(posargs=self.global_config.posargs)
-
-        # By default, nox should quietly change to the directory where
-        # the nox.py file is located.
-        cwd = os.path.realpath(os.path.dirname(self.global_config.noxfile))
-        self.config.chdir(cwd, debug=True)
-
-        # Run the actual session function, passing it the newly-created
-        # SessionConfig object.
-        self.func(self.config)
-
     def _create_venv(self):
-        if not self.config.virtualenv:
+        if self.func.python is False:
             self.venv = ProcessEnv()
             return
 
-        virtualenv_name = (
-            self.config.virtualenv_dirname or self.signature or self.name)
+        name = self.signature or self.name
+        path = _normalize_path(self.global_config.envdir, name)
+        reuse_existing = (
+            self.func.reuse_venv or
+            self.global_config.reuse_existing_virtualenvs)
         self.venv = VirtualEnv(
-            _normalize_path(
-                self.global_config.envdir, virtualenv_name),
-            interpreter=self.config.interpreter,
-            reuse_existing=(
-                self.config.reuse_existing_virtualenv or
-                self.global_config.reuse_existing_virtualenvs))
+            path,
+            interpreter=self.func.python,
+            reuse_existing=reuse_existing)
         self.venv.create()
 
-    def _run_commands(self):
-        env = self.venv.env.copy()
-        env.update(self.config.env)
-
-        for command in self.config._commands:
-            if isinstance(command, Command):
-                command(
-                    env_fallback=env,
-                    path_override=self.venv.bin,
-                    session=self,
-                )
-            elif isinstance(command, InstallCommand):
-                command(self.venv)
-            else:
-                command()
-
     def execute(self):
         session_friendly_name = self.signature or self.name
         logger.warning('Running session {}'.format(session_friendly_name))
 
         try:
-            # Set up the SessionConfig object (which session functions refer
-            # to as `session`) and then the virtualenv.
-            self._create_config()
-            self._create_venv()
+            # By default, nox should quietly change to the directory where
+            # the nox.py file is located.
+            cwd = py.path.local(
+                os.path.realpath(
+                    os.path.dirname(self.global_config.noxfile))).as_cwd()
 
-            # Run the actual commands prescribed by the session.
-            cwd = py.path.local(os.getcwd()).as_cwd()
             with cwd:
-                self._run_commands()
+                self._create_venv()
+                session = Session(self)
+                self.func(session)
 
             # Nothing went wrong; return a success.
             return Result(self, Status.SUCCESS)
@@ -310,6 +288,11 @@ class Session(object):
             logger.error('Session {} interrupted.'.format(self))
             raise
 
+        except Exception as exc:
+            logger.exception(
+                'Session {} raised exception {!r}'.format(self, exc))
+            return Result(self, Status.FAILED)
+
 
 class Result(object):
     """An object representing the result of a session."""
@@ -318,7 +301,8 @@ class Result(object):
         """Initialize the Result object.
 
         Args:
-            session (~nox.sessions.Session): The session which ran.
+            session (~nox.sessions.SessionRunner):
+                The session runner which ran.
             status (~nox.sessions.Status): The final result status.
         """
         self.session = session
diff --git a/nox/tasks.py b/nox/tasks.py
index dab5414..810e146 100644
--- a/nox/tasks.py
+++ b/nox/tasks.py
@@ -15,13 +15,10 @@
 from __future__ import absolute_import, print_function
 
 import imp
-import inspect
 import io
 import json
 import os
 
-import six
-
 from nox import registry
 from nox.logger import logger
 from nox.manifest import Manifest
@@ -73,14 +70,6 @@ def discover_manifest(module, global_config):
     # sorted by decorator call time.
     functions = registry.get()
 
-    # Find any function conforming to the session_* naming convention.
-    # Sort these in alphabetical order.
-    for name in sorted(six.iterkeys(module.__dict__)):
-        obj = module.__dict__[name]
-        if name.startswith('session_') and inspect.isfunction(obj):
-            session_name = name.split('session_', 1).pop()
-            functions[session_name] = obj
-
     # Return the final dictionary of session functions.
     return Manifest(functions, global_config)
 
diff --git a/nox/virtualenv.py b/nox/virtualenv.py
index 3c101b9..b2a4774 100644
--- a/nox/virtualenv.py
+++ b/nox/virtualenv.py
@@ -52,14 +52,15 @@ class ProcessEnv(object):
     def bin(self):
         return self._bin
 
-    def run(self, args, in_venv=True):
+    def run(self, args, in_venv=True, **kwargs):
         """Runs a command. By default, the command runs within the
         environment."""
         return Command(
             args=args,
             env=self.env if in_venv else None,
             silent=True,
-            path=self.bin if in_venv else None).run()
+            path=self.bin if in_venv else None,
+            **kwargs).run()
 
 
 def locate_via_py(version):
@@ -112,12 +113,16 @@ class VirtualEnv(ProcessEnv):
 
         Based heavily on tox's implementation (tox/interpreters.py).
         """
-        # Sanity check: If there is no assigned interpreter, then
-        # do nothing.
+        # If there is no assigned interpreter, then use the same one used by
+        # Nox.
         if self.interpreter is None:
-            return self.interpreter
+            return sys.executable
+
+        # If this is just a X.Y or X.Y.Z string, stick `python` in front of it.
+        if re.match(r'^\d\.\d\.?\d?$', self.interpreter):
+            self.interpreter = 'python{}'.format(self.interpreter)
 
-        # Sanity check: We only need special behavior on Windows.
+        # Sanity check: We only need the rest of this behavior on Windows.
         if platform.system() != 'Windows':
             return self.interpreter
 
@@ -163,7 +168,10 @@ class VirtualEnv(ProcessEnv):
         if self.interpreter:
             cmd.extend(['-p', self._resolved_interpreter])
 
-        self.run(cmd, in_venv=False)
+        logger.info(
+            'Creating virtualenv using {} in {}'.format(
+                os.path.basename(self._resolved_interpreter), self.location))
+        self.run(cmd, in_venv=False, log=False)
 
         return True
 
diff --git a/setup.py b/setup.py
index e8cbeca..4f52776 100644
--- a/setup.py
+++ b/setup.py
@@ -46,12 +46,10 @@ setup(
         'Environment :: Console',
 
         'Programming Language :: Python',
-        'Programming Language :: Python :: 2',
         'Programming Language :: Python :: 3',
-        'Programming Language :: Python :: 2.7',
-        'Programming Language :: Python :: 3.4',
         'Programming Language :: Python :: 3.5',
         'Programming Language :: Python :: 3.6',
+        'Programming Language :: Python :: 3.7',
 
         'Operating System :: POSIX',
         'Operating System :: MacOS',


Test patch:
diff --git a/requirements-test.txt b/requirements-test.txt
index 82f9f49..b9a5f39 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -1,5 +1,4 @@
 flask
-mock; python_version < '3'
 pytest
 pytest-cov
 contexter
diff --git a/tests/test_command.py b/tests/test_command.py
index 4797d87..b09997d 100644
--- a/tests/test_command.py
+++ b/tests/test_command.py
@@ -218,56 +218,3 @@ def test_interrupt(make_one):
     with mock.patch('subprocess.Popen', return_value=mock_proc):
         with pytest.raises(KeyboardInterrupt):
             command.run()
-
-
-@pytest.fixture
-def make_one_func():
-    def factory(*args, **kwargs):
-        return nox.command.FunctionCommand(*args, **kwargs)
-    return factory
-
-
-def test_function_command(make_one_func):
-    mock_func = mock.MagicMock()
-    mock_func.__name__ = lambda self: 'mock_func'
-
-    command = make_one_func(mock_func, [1], {'two': 3})
-
-    assert command.run()
-    mock_func.assert_called_with(1, two=3)
-
-
-def test_function_command_fail(make_one_func):
-    mock_func = mock.MagicMock(side_effect=ValueError('123'))
-    mock_func.__name__ = lambda self: 'mock_func'
-
-    command = make_one_func(mock_func, [], {})
-
-    with pytest.raises(nox.command.CommandFailed):
-        command.run()
-
-
-def test_function_command_callable(make_one_func):
-    mock_func = mock.MagicMock()
-
-    command = make_one_func(mock_func, [1], {'two': 3})
-
-    assert command.run()
-    mock_func.assert_called_with(1, two=3)
-
-
-def test_function_command_debug(make_one_func):
-    mock_func = mock.MagicMock()
-    mock_func.__name__ = lambda self: 'mock_func'
-
-    command = make_one_func(mock_func, [1], {'two': 3}, debug=True)
-
-    assert command.run()
-    mock_func.assert_called_with(1, two=3)
-
-
-def test_notify_command():
-    command = nox.command.NotifyCommand('foo')
-    m = mock.Mock()
-    command(m)
-    m.manifest.notify.assert_called_once_with('foo')
diff --git a/tests/test_manifest.py b/tests/test_manifest.py
index 435bbc6..870a15b 100644
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -24,8 +24,10 @@ from nox.manifest import Manifest
 
 def create_mock_sessions():
     sessions = collections.OrderedDict()
-    sessions['foo'] = mock.Mock(spec=())
-    sessions['bar'] = mock.Mock(spec=())
+    sessions['foo'] = mock.Mock(
+        spec=(), python=None)
+    sessions['bar'] = mock.Mock(
+        spec=(), python=None)
     return sessions
 
 
@@ -143,11 +145,27 @@ def test_filter_by_keyword():
 
 def test_add_session_plain():
     manifest = Manifest({}, mock.sentinel.CONFIG)
-    for session in manifest.make_session('my_session', lambda session: None):
+    session_func = mock.Mock(
+        spec=(), python=None)
+    for session in manifest.make_session('my_session', session_func):
         manifest.add_session(session)
     assert len(manifest) == 1
 
 
+def test_add_session_multiple_pythons():
+    manifest = Manifest({}, mock.sentinel.CONFIG)
+
+    def session_func():
+        pass
+
+    session_func.python = ['3.5', '3.6']
+
+    for session in manifest.make_session('my_session', session_func):
+        manifest.add_session(session)
+
+    assert len(manifest) == 2
+
+
 def test_add_session_parametrized():
     manifest = Manifest({}, mock.sentinel.CONFIG)
 
@@ -156,12 +174,30 @@ def test_add_session_parametrized():
     def my_session(session, param):
         pass
 
+    my_session.python = None
+
     # Add the session to the manifest.
     for session in manifest.make_session('my_session', my_session):
         manifest.add_session(session)
     assert len(manifest) == 3
 
 
+def test_add_session_parametrized_multiple_pythons():
+    manifest = Manifest({}, mock.sentinel.CONFIG)
+
+    # Define a session with parameters.
+    @nox.parametrize('param', ('a', 'b'))
+    def my_session(session, param):
+        pass
+
+    my_session.python = ['2.7', '3.6']
+
+    # Add the session to the manifest.
+    for session in manifest.make_session('my_session', my_session):
+        manifest.add_session(session)
+    assert len(manifest) == 4
+
+
 def test_add_session_parametrized_noop():
     manifest = Manifest({}, mock.sentinel.CONFIG)
 
@@ -170,6 +206,8 @@ def test_add_session_parametrized_noop():
     def my_session(session, param):
         pass
 
+    my_session.python = None
+
     # Add the session to the manifest.
     for session in manifest.make_session('my_session', my_session):
         manifest.add_session(session)
@@ -183,9 +221,13 @@ def test_notify():
     def my_session(session):
         pass
 
+    my_session.python = None
+
     def notified(session):
         pass
 
+    notified.python = None
+
     # Add the sessions to the manifest.
     for session in manifest.make_session('my_session', my_session):
         manifest.add_session(session)
@@ -208,8 +250,12 @@ def test_notify_noop():
     # Define a session and add it to the manifest.
     def my_session(session):
         pass
+
+    my_session.python = None
+
     for session in manifest.make_session('my_session', my_session):
         manifest.add_session(session)
+
     assert len(manifest) == 1
 
     # Establish idempotency; notifying a session already in the queue no-ops.
@@ -225,7 +271,9 @@ def test_notify_error():
 
 def test_add_session_idempotent():
     manifest = Manifest({}, mock.sentinel.CONFIG)
-    for session in manifest.make_session('my_session', lambda session: None):
+    session_func = mock.Mock(
+        spec=(), python=None)
+    for session in manifest.make_session('my_session', session_func):
         manifest.add_session(session)
         manifest.add_session(session)
     assert len(manifest) == 1
diff --git a/tests/test_registry.py b/tests/test_registry.py
index 0074cde..a2cb2a9 100644
--- a/tests/test_registry.py
+++ b/tests/test_registry.py
@@ -39,6 +39,31 @@ def test_session_decorator(cleanup_registry):
     answer = registry.get()
     assert 'unit_tests' in answer
     assert answer['unit_tests'] is unit_tests
+    assert unit_tests.python is None
+
+
+def test_session_decorator_single_python(cleanup_registry):
+    @registry.session_decorator(python='3.6')
+    def unit_tests(session):
+        pass
+
+    assert unit_tests.python == '3.6'
+
+
+def test_session_decorator_list_of_pythons(cleanup_registry):
+    @registry.session_decorator(python=['3.5', '3.6'])
+    def unit_tests(session):
+        pass
+
+    assert unit_tests.python == ['3.5', '3.6']
+
+
+def test_session_decorator_reuse(cleanup_registry):
+    @registry.session_decorator(reuse_venv=True)
+    def unit_tests(session):
+        pass
+
+    assert unit_tests.reuse_venv is True
 
 
 def test_get(cleanup_registry):
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
index 1a2786a..efe6261 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -12,7 +12,9 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import logging
 import os
+import sys
 from unittest import mock
 
 import pytest
@@ -20,6 +22,8 @@ import pytest
 from nox._testing import Namespace
 import nox.command
 from nox.logger import logger
+import nox.manifest
+import nox.registry
 import nox.sessions
 import nox.virtualenv
 
@@ -53,426 +57,319 @@ def test__normalize_path_give_up():
     assert 'any-path' in norm_path
 
 
-@pytest.fixture
-def make_one_config():
-    def factory(*args, **kwargs):
-        config = nox.sessions.SessionConfig(*args, **kwargs)
-        return config
-    return factory
+class TestSession:
+    def make_session_and_runner(self):
+        runner = nox.sessions.SessionRunner(
+            name='test',
+            signature='test',
+            func=mock.sentinel.func,
+            global_config=Namespace(posargs=mock.sentinel.posargs),
+            manifest=mock.create_autospec(nox.manifest.Manifest))
+        runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
+        runner.venv.env = {}
+        return nox.sessions.Session(runner=runner), runner
 
+    def test_properties(self):
+        session, runner = self.make_session_and_runner()
 
-def test_config_constructor_defaults(make_one_config):
-    config = make_one_config()
-    assert config.interpreter is None
-    assert config._commands == []
-    assert config.env == {}
-    assert config.posargs == []
-    assert config.reuse_existing_virtualenv is False
+        assert session.env is runner.venv.env
+        assert session.posargs is runner.global_config.posargs
+        assert session.virtualenv is runner.venv
+        assert session.bin is runner.venv.bin
 
+    def test_chdir(self, tmpdir):
+        cdto = str(tmpdir.join('cdbby').ensure(dir=True))
+        current_cwd = os.getcwd()
 
-def test_config_constructor_args(make_one_config):
-    config = make_one_config([1, 2, 3])
-    assert config.posargs == [1, 2, 3]
+        session, _ = self.make_session_and_runner()
 
+        session.chdir(cdto)
 
-def test_config_chdir(make_one_config):
-    config = make_one_config()
-    config.chdir('meep')
-    command = config._commands[0]
-    assert command.func == os.chdir
-    assert command.args == ('meep',)
-    assert str(command) == 'chdir meep'
+        assert os.getcwd() == cdto
+        os.chdir(current_cwd)
 
+    def test_run_bad_args(self):
+        session, _ = self.make_session_and_runner()
 
-def test_config_notify(make_one_config):
-    config = make_one_config()
-    config.notify('foo')
-    command = config._commands[0]
-    assert isinstance(command, nox.command.NotifyCommand)
+        with pytest.raises(ValueError, match='arg'):
+            session.run()
 
+    def test_run_with_func(self):
+        session, _ = self.make_session_and_runner()
 
-def test_config_run(make_one_config):
-    def test_func():
-        pass
+        assert session.run(lambda a, b: a+b, 1, 2) == 3
 
-    config = make_one_config()
-    config.run('echo')
-    config.run('echo', '1', '2')
-    config.run('echo', '1', '2', silent=True)
-    config.run(test_func, 1, 2, three=4)
+    def test_run_with_func_error(self):
+        session, _ = self.make_session_and_runner()
 
-    assert config._commands[0].args == ('echo',)
-    assert config._commands[1].args == ('echo', '1', '2')
-    assert config._commands[2].args == ('echo', '1', '2')
-    assert config._commands[2].silent is True
-    assert config._commands[3].func == test_func
-    assert config._commands[3].args == (1, 2)
-    assert config._commands[3].kwargs == {'three': 4}
+        def raise_value_error():
+            raise ValueError('meep')
 
-    with pytest.raises(ValueError):
-        config.run()
+        with pytest.raises(nox.sessions.CommandFailed):
+            assert session.run(raise_value_error)
 
+    def test_run_success(self):
+        session, _ = self.make_session_and_runner()
+        result = session.run(sys.executable, '-c', 'print(123)')
+        assert result
 
-def test_config_install(make_one_config):
-    config = make_one_config()
-    config.install('mock')
-    config.install('mock', 'pytest')
-    config.install('-r', 'somefile.txt')
-    config.install('-e', 'dir')
+    def test_run_error(self):
+        session, _ = self.make_session_and_runner()
 
-    assert config._commands[0].deps == ('mock',)
-    assert config._commands[1].deps == ('mock', 'pytest')
-    assert config._commands[2].deps == ('-r', 'somefile.txt')
-    assert config._commands[3].deps == ('-e', 'dir')
+        with pytest.raises(nox.sessions.CommandFailed):
+            session.run(sys.executable, '-c', 'import sys; sys.exit(1)')
 
-    with pytest.raises(ValueError):
-        config.install()
+    def test_install_bad_args(self):
+        session, _ = self.make_session_and_runner()
 
-    with pytest.raises(ValueError):
-        config.virtualenv = False
-        config.install('mock')
+        with pytest.raises(ValueError, match='arg'):
+            session.install()
 
+    def test_install_not_a_virtualenv(self):
+        session, runner = self.make_session_and_runner()
 
-def test_config_log(make_one_config):
-    config = make_one_config()
-    config.log('test', '1', '2')
+        runner.venv = None
 
+        with pytest.raises(ValueError, match='virtualenv'):
+            session.install()
 
-def test_config_error(make_one_config):
-    config = make_one_config()
+    def test_install(self):
+        session, runner = self.make_session_and_runner()
 
-    with pytest.raises(nox.sessions._SessionQuit):
-        config.error()
+        session.install('requests', 'urllib3')
 
-    with pytest.raises(nox.sessions._SessionQuit):
-        config.error('test', '1', '2')
+        runner.venv.install.assert_called_once_with('requests', 'urllib3')
 
+    def test_notify(self):
+        session, runner = self.make_session_and_runner()
 
-def test_config_skip(make_one_config):
-    config = make_one_config()
+        session.notify('other')
 
-    with pytest.raises(nox.sessions._SessionSkip):
-        config.skip()
+        runner.manifest.notify.assert_called_once_with('other')
 
-    with pytest.raises(nox.sessions._SessionSkip):
-        config.skip('test', '1', '2')
+    def test_log(self, caplog):
+        caplog.set_level(logging.INFO)
+        session, _ = self.make_session_and_runner()
 
+        session.log('meep')
 
-@pytest.fixture
-def make_one():
-    def factory(*args, **kwargs):
-        session = nox.sessions.Session(*args, **kwargs)
-        return session
-    return factory
+        assert 'meep' in caplog.text
 
+    def test_error(self, caplog):
+        caplog.set_level(logging.ERROR)
+        session, _ = self.make_session_and_runner()
 
-class MockConfig(nox.sessions.SessionConfig):
-    def __init__(self, **kwargs):
-        super(MockConfig, self).__init__()
-        self.__dict__['noxfile'] = './nox.py'
-        self.__dict__.update(**kwargs)
+        with pytest.raises(nox.sessions._SessionQuit):
+            session.error('meep')
 
+        assert 'meep' in caplog.text
 
-def test_constructor(make_one):
-    def mock_func():
-        pass
+    def test_error_no_log(self):
+        session, _ = self.make_session_and_runner()
 
-    global_config = MockConfig()
-    session = make_one('test', 'sig', mock_func, global_config)
+        with pytest.raises(nox.sessions._SessionQuit):
+            session.error()
 
-    assert session.name == 'test'
-    assert session.signature == 'sig'
-    assert session.func == mock_func
-    assert session.global_config == global_config
+    def test_skip_no_log(self):
+        session, _ = self.make_session_and_runner()
 
+        with pytest.raises(nox.sessions._SessionSkip):
+            session.skip()
 
-def test__create_config(make_one):
-    mock_func = mock.Mock()
-    global_config = MockConfig(posargs=[1, 2, 3])
-    session = make_one('test', 'sig', mock_func, global_config)
 
-    session._create_config()
+class TestSessionRunner:
+    def make_runner(self):
+        func = mock.Mock()
+        func.python = None
+        func.reuse_venv = False
+        runner = nox.sessions.SessionRunner(
+            name='test',
+            signature='test(1, 2)',
+            func=func,
+            global_config=Namespace(
+                noxfile=os.path.join(os.getcwd(), 'nox.py'),
+                envdir='envdir',
+                posargs=mock.sentinel.posargs,
+                reuse_existing_virtualenvs=False),
+            manifest=mock.create_autospec(nox.manifest.Manifest))
+        return runner
 
-    mock_func.assert_called_with(session.config)
-    assert session.config.posargs == [1, 2, 3]
+    def test_properties(self):
+        runner = self.make_runner()
 
+        assert runner.name == 'test'
+        assert runner.signature == 'test(1, 2)'
+        assert runner.func is not None
+        assert runner.global_config.posargs == mock.sentinel.posargs
+        assert isinstance(runner.manifest, nox.manifest.Manifest)
 
-def test__create_config_auto_chdir(make_one):
-    def session_func(sess):
-        pass
-    global_config = MockConfig(posargs=[1, 2, 3], noxfile='/path/to/nox.py')
-    session = make_one('test', 'sig', session_func, global_config)
-    session._create_config()
-    assert len(session.config._commands) == 1
-    assert isinstance(session.config._commands[0], nox.command.ChdirCommand)
-    assert session.config._commands[0].path.endswith(
-        os.path.join('path', 'to'))
+    def test__create_venv_process_env(self):
+        runner = self.make_runner()
+        runner.func.python = False
 
+        runner._create_venv()
 
-class MockVenv(object):
-    def __init__(self, path, interpreter, reuse_existing=False):
-        self.path = path
-        self.interpreter = interpreter
-        self.reuse_existing = reuse_existing
-        self.install_called = False
+        assert isinstance(runner.venv, nox.virtualenv.ProcessEnv)
 
-    def create(self):
-        return not self.reuse_existing
+    @mock.patch('nox.virtualenv.VirtualEnv.create', autospec=True)
+    def test__create_venv(self, create):
+        runner = self.make_runner()
 
-    def install(self):
-        self.install_called = True
+        runner._create_venv()
 
+        create.assert_called_once_with(runner.venv)
+        assert isinstance(runner.venv, nox.virtualenv.VirtualEnv)
+        assert runner.venv.location.endswith(
+            os.path.join('envdir', 'test-1-2'))
+        assert runner.venv.interpreter is None
+        assert runner.venv.reuse_existing is False
 
-@pytest.fixture
-def venv_session(make_one):
-    global_config = MockConfig(
-        envdir='envdir',
-        reuse_existing_virtualenvs=False)
-    session = make_one('test', 'sig', mock.Mock(), global_config)
-    session.config = MockConfig(
-        interpreter='interpreter',
-        reuse_existing_virtualenv=False,
-        virtualenv=True)
+    @mock.patch('nox.virtualenv.VirtualEnv.create', autospec=True)
+    def test__create_venv_options(self, create):
+        runner = self.make_runner()
+        runner.func.python = 'coolpython'
+        runner.func.reuse_venv = True
 
-    with mock.patch('nox.sessions.VirtualEnv', MockVenv):
-        yield global_config, session
+        runner._create_venv()
 
+        create.assert_called_once_with(runner.venv)
+        assert isinstance(runner.venv, nox.virtualenv.VirtualEnv)
+        assert runner.venv.interpreter is 'coolpython'
+        assert runner.venv.reuse_existing is True
 
-def test__create_venv(venv_session):
-    global_config, session = venv_session
+    def make_runner_with_mock_venv(self):
+        runner = self.make_runner()
+        runner._create_venv = mock.Mock()
+        return runner
 
-    session._create_venv()
-    assert session.venv.path == os.path.join('envdir', 'sig')
-    assert session.venv.interpreter == 'interpreter'
-    assert session.venv.reuse_existing is False
+    def test_execute_noop_success(self, caplog):
+        caplog.set_level(logging.DEBUG)
 
+        runner = self.make_runner_with_mock_venv()
 
-def test__create_venv_global_reuse(venv_session):
-    global_config, session = venv_session
-    global_config.reuse_existing_virtualenvs = True
-    session._create_venv()
-    assert session.venv.path == os.path.join('envdir', 'sig')
-    assert session.venv.interpreter == 'interpreter'
-    assert session.venv.reuse_existing is True
+        result = runner.execute()
 
+        assert result
+        runner.func.assert_called_once_with(mock.ANY)
+        assert 'Running session test(1, 2)' in caplog.text
 
-def test__create_venv_local_reuse(venv_session):
-    global_config, session = venv_session
-    global_config.reuse_existing_virtualenvs = False
-    session.config.reuse_existing_virtualenv = True
-    session._create_venv()
-    assert session.venv.path == os.path.join('envdir', 'sig')
-    assert session.venv.interpreter == 'interpreter'
-    assert session.venv.reuse_existing is True
+    def test_execute_quit(self):
+        runner = self.make_runner_with_mock_venv()
 
+        def func(session):
+            session.error('meep')
 
-def test__create_venv_virtualenv_false(venv_session):
-    global_config, session = venv_session
-    session.config.virtualenv = False
-    session._create_venv()
-    assert isinstance(session.venv, nox.virtualenv.ProcessEnv)
+        runner.func = func
 
+        result = runner.execute()
 
-def test__create_venv_explicit_name(venv_session):
-    global_config, session = venv_session
-    session.config.virtualenv_dirname = 'meep'
-    session._create_venv()
-    assert session.venv.path == os.path.join('envdir', 'meep')
-    assert session.venv.interpreter == 'interpreter'
-    assert session.venv.reuse_existing is False
+        assert result.status == nox.sessions.Status.ABORTED
 
+    def test_execute_skip(self):
+        runner = self.make_runner_with_mock_venv()
 
-def test__run_commands(make_one):
-    session = make_one('test', 'sig', mock.Mock(), MockConfig())
+        def func(session):
+            session.skip('meep')
 
-    cmd1 = mock.Mock(spec=nox.command.Command)
-    cmd2 = mock.Mock(spec=nox.command.FunctionCommand)
+        runner.func = func
 
-    session.config = MockConfig(
-        _commands=[cmd1, cmd2],
-        env={'SIGIL2': '345'})
-    session.venv = mock.Mock()
-    session.venv.env = {'SIGIL': '123'}
-    session.venv.bin = '/venv/bin'
+        result = runner.execute()
 
-    session._run_commands()
+        assert result.status == nox.sessions.Status.SKIPPED
 
-    assert cmd1.called
-    assert cmd1.call_args[1]['env_fallback'] == {
-        'SIGIL': '123', 'SIGIL2': '345'}
-    assert cmd1.call_args[1]['path_override'] == session.venv.bin
-    assert cmd2.called
+    def test_execute_failed(self):
+        runner = self.make_runner_with_mock_venv()
 
+        def func(session):
+            raise nox.sessions.CommandFailed()
 
-def test_execute(make_one):
-    session = make_one('test', 'sig', mock.Mock(), MockConfig())
+        runner.func = func
 
-    session.config = MockConfig()
-    session._create_config = mock.Mock()
-    session._create_venv = mock.Mock()
-    session._run_commands = mock.Mock()
+        result = runner.execute()
 
-    assert session.execute()
+        assert result.status == nox.sessions.Status.FAILED
 
-    assert session._create_config.called
-    assert session._create_venv.called
-    assert session._run_commands.called
+    def test_execute_interrupted(self):
+        runner = self.make_runner_with_mock_venv()
 
+        def func(session):
+            raise KeyboardInterrupt()
 
-def test_execute_install(make_one):
-    session = make_one('test', 'sig', mock.Mock(), MockConfig())
-    session.venv = mock.Mock()
-    commands = [
-        nox.command.InstallCommand(['-r', 'requirements.txt'])
-    ]
-    session.config = MockConfig(_commands=commands, env={})
+        runner.func = func
 
-    session._run_commands()
+        with pytest.raises(KeyboardInterrupt):
+            runner.execute()
 
-    session.venv.install.assert_called_with('-r', 'requirements.txt')
+    def test_execute_exception(self):
+        runner = self.make_runner_with_mock_venv()
 
+        def func(session):
+            raise ValueError('meep')
 
-def test_execute_chdir(make_one, tmpdir):
-    tmpdir.join('dir2').ensure(dir=True)
+        runner.func = func
 
-    session = make_one('test', 'sig', mock.Mock(), MockConfig())
-    session.venv = mock.Mock()
-    session.venv.env = {}
+        result = runner.execute()
 
-    current_cwd = os.getcwd()
-    observed_cwds = []
+        assert result.status == nox.sessions.Status.FAILED
 
-    def observe_cwd():
-        observed_cwds.append(os.getcwd())
 
-    commands = [
-        nox.command.FunctionCommand(os.chdir, [tmpdir.strpath]),
-        nox.command.FunctionCommand(observe_cwd),
-        nox.command.FunctionCommand(os.chdir, [tmpdir.join('dir2').strpath]),
-        nox.command.FunctionCommand(observe_cwd),
-    ]
+class TestResult:
+    def test_init(self):
+        result = nox.sessions.Result(
+            session=mock.sentinel.SESSION,
+            status=mock.sentinel.STATUS,
+        )
+        assert result.session == mock.sentinel.SESSION
+        assert result.status == mock.sentinel.STATUS
 
-    session.config = MockConfig(_commands=commands, env={})
-    session._create_config = mock.Mock()
-    session._create_venv = mock.Mock()
+    def test__bool_true(self):
+        for status in (
+                nox.sessions.Status.SUCCESS, nox.sessions.Status.SKIPPED):
+            result = nox.sessions.Result(session=object(), status=status)
+            assert bool(result)
+            assert result.__bool__()
+            assert result.__nonzero__()
 
-    assert session.execute()
+    def test__bool_false(self):
+        for status in (
+                nox.sessions.Status.FAILED, nox.sessions.Status.ABORTED):
+            result = nox.sessions.Result(session=object(), status=status)
+            assert not bool(result)
+            assert not result.__bool__()
+            assert not result.__nonzero__()
 
-    assert observed_cwds == [
-        tmpdir.strpath,
-        tmpdir.join('dir2').strpath]
+    def test__imperfect(self):
+        result = nox.sessions.Result(object(), nox.sessions.Status.SUCCESS)
+        assert result.imperfect == 'was successful'
+        result = nox.sessions.Result(object(), nox.sessions.Status.FAILED)
+        assert result.imperfect == 'failed'
 
-    assert os.getcwd() == current_cwd
+    def test__log_success(self):
+        result = nox.sessions.Result(object(), nox.sessions.Status.SUCCESS)
+        with mock.patch.object(logger, 'success') as success:
+            result.log('foo')
+            success.assert_called_once_with('foo')
 
+    def test__log_warning(self):
+        result = nox.sessions.Result(object(), nox.sessions.Status.SKIPPED)
+        with mock.patch.object(logger, 'warning') as warning:
+            result.log('foo')
+            warning.assert_called_once_with('foo')
 
-def test_execute_error(make_one, tmpdir):
-    session = make_one('test', 'sig', mock.Mock(), MockConfig())
+    def test__log_error(self):
+        result = nox.sessions.Result(object(), nox.sessions.Status.FAILED)
+        with mock.patch.object(logger, 'error') as error:
+            result.log('foo')
+            error.assert_called_once_with('foo')
 
-    def mock_run_commands():
-        raise nox.command.CommandFailed('test')
-
-    session.config = MockConfig(_dir='.')
-    session._create_config = mock.Mock()
-    session._create_venv = mock.Mock()
-    session._run_commands = mock_run_commands
-
-    assert not session.execute()
-
-
-def test_execute_interrupted(make_one, tmpdir):
-    session = make_one('test', 'sig', mock.Mock(), MockConfig())
-
-    def mock_run_commands():
-        raise KeyboardInterrupt()
-
-    session.config = MockConfig(_dir='.')
-    session._create_config = mock.Mock()
-    session._create_venv = mock.Mock()
-    session._run_commands = mock_run_commands
-
-    with pytest.raises(KeyboardInterrupt):
-        session.execute()
-
-
-def test_execute_session_quit(make_one):
-    def bad_config(session):
-        session.error('meep')
-
-    session = make_one('test', 'sig', bad_config, MockConfig(posargs=[]))
-    result = session.execute()
-    assert result.status == nox.sessions.Status.ABORTED
-    assert not result
-
-
-def test_execute_session_skip(make_one):
-    def skip_config(session):
-        session.skip('meep')
-
-    session = make_one('test', 'sig', skip_config, MockConfig(posargs=[]))
-
-    assert session.execute().status == nox.sessions.Status.SKIPPED
-
-
-def test_result_init():
-    result = nox.sessions.Result(
-        session=mock.sentinel.SESSION,
-        status=mock.sentinel.STATUS,
-    )
-    assert result.session == mock.sentinel.SESSION
-    assert result.status == mock.sentinel.STATUS
-
-
-def test_result_bool_true():
-    for status in (nox.sessions.Status.SUCCESS, nox.sessions.Status.SKIPPED):
-        result = nox.sessions.Result(session=object(), status=status)
-        assert bool(result)
-        assert result.__bool__()
-        assert result.__nonzero__()
-
-
-def test_result_bool_false():
-    for status in (nox.sessions.Status.FAILED, nox.sessions.Status.ABORTED):
-        result = nox.sessions.Result(session=object(), status=status)
-        assert not bool(result)
-        assert not result.__bool__()
-        assert not result.__nonzero__()
-
-
-def test_result_imperfect():
-    result = nox.sessions.Result(object(), nox.sessions.Status.SUCCESS)
-    assert result.imperfect == 'was successful'
-    result = nox.sessions.Result(object(), nox.sessions.Status.FAILED)
-    assert result.imperfect == 'failed'
-
-
-def test_result_log_success():
-    result = nox.sessions.Result(object(), nox.sessions.Status.SUCCESS)
-    with mock.patch.object(logger, 'success') as success:
-        result.log('foo')
-        success.assert_called_once_with('foo')
-
-
-def test_result_log_warning():
-    result = nox.sessions.Result(object(), nox.sessions.Status.SKIPPED)
-    with mock.patch.object(logger, 'warning') as warning:
-        result.log('foo')
-        warning.assert_called_once_with('foo')
-
-
-def test_result_log_error():
-    result = nox.sessions.Result(object(), nox.sessions.Status.FAILED)
-    with mock.patch.object(logger, 'error') as error:
-        result.log('foo')
-        error.assert_called_once_with('foo')
-
-
-def test_result_serialize():
-    result = nox.sessions.Result(
-        session=Namespace(signature='siggy', name='namey', func=mock.Mock()),
-        status=nox.sessions.Status.SUCCESS,
-    )
-    answer = result.serialize()
-    assert answer['name'] == 'namey'
-    assert answer['result'] == 'success'
-    assert answer['result_code'] == 1
-    assert answer['signature'] == 'siggy'
+    def test__serialize(self):
+        result = nox.sessions.Result(
+            session=Namespace(
+                signature='siggy', name='namey', func=mock.Mock()),
+            status=nox.sessions.Status.SUCCESS,
+        )
+        answer = result.serialize()
+        assert answer['name'] == 'namey'
+        assert answer['result'] == 'success'
+        assert answer['result_code'] == 1
+        assert answer['signature'] == 'siggy'
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
index 9f690e6..f384f25 100644
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -28,6 +28,13 @@ from nox.manifest import Manifest
 RESOURCES = os.path.join(os.path.dirname(__file__), 'resources')
 
 
+def session_func():
+    pass
+
+
+session_func.python = None
+
+
 def test_load_nox_module():
     config = Namespace(noxfile=os.path.join(RESOURCES, 'noxfile.py'))
     noxfile_module = tasks.load_nox_module(config)
@@ -39,33 +46,6 @@ def test_load_nox_module_not_found():
     assert tasks.load_nox_module(config) == 2
 
 
-def test_discover_manifest_naming_convention():
-    # Define sessions with the correct naming convention.
-    def session_1():
-        pass
-
-    def session_2():
-        pass
-
-    def notasession():
-        pass
-
-    # Mock up a nox.py module and configuration.
-    mock_module = Namespace(
-        __name__='irrelevant',
-        session_1=session_1,
-        session_2=session_2,
-        notasession=notasession,
-    )
-    config = Namespace(sessions=(), keywords=())
-
-    # Get the manifest and establish that it looks like what we expect.
-    manifest = tasks.discover_manifest(mock_module, config)
-    sessions = list(manifest)
-    assert [s.func for s in sessions] == [session_1, session_2]
-    assert [str(i) for i in sessions] == ['1', '2']
-
-
 def test_discover_session_functions_decorator():
     # Define sessions using the decorator.
     @nox.session
@@ -95,39 +75,11 @@ def test_discover_session_functions_decorator():
     assert [str(i) for i in sessions] == ['foo', 'bar']
 
 
-def test_discover_session_functions_mix():
-    # Define sessions using both mechanisms.
-    @nox.session
-    def foo():
-        pass
-
-    def session_bar():
-        pass
-
-    def notasession():
-        pass
-
-    # Mock up a nox.py module and configuration.
-    mock_module = Namespace(
-        __name__=foo.__module__,
-        foo=foo,
-        session_bar=session_bar,
-        notasession=notasession,
-    )
-    config = Namespace(sessions=(), keywords=())
-
-    # Get the manifest and establish that it looks like what we expect.
-    manifest = tasks.discover_manifest(mock_module, config)
-    sessions = list(manifest)
-    assert [s.func for s in sessions] == [foo, session_bar]
-    assert [str(i) for i in sessions] == ['foo', 'bar']
-
-
 def test_filter_manifest():
     config = Namespace(sessions=(), keywords=())
     manifest = Manifest({
-        'foo': mock.Mock(spec=()),
-        'bar': mock.Mock(spec=()),
+        'foo': session_func,
+        'bar': session_func,
     }, config)
     return_value = tasks.filter_manifest(manifest, config)
     assert return_value is manifest
@@ -137,8 +89,8 @@ def test_filter_manifest():
 def test_filter_manifest_not_found():
     config = Namespace(sessions=('baz',), keywords=())
     manifest = Manifest({
-        'foo': mock.Mock(spec=()),
-        'bar': mock.Mock(spec=()),
+        'foo': session_func,
+        'bar': session_func,
     }, config)
     return_value = tasks.filter_manifest(manifest, config)
     assert return_value == 3
@@ -147,9 +99,9 @@ def test_filter_manifest_not_found():
 def test_filter_manifest_keywords():
     config = Namespace(sessions=(), keywords='foo or bar')
     manifest = Manifest({
-        'foo': mock.Mock(spec=()),
-        'bar': mock.Mock(spec=()),
-        'baz': mock.Mock(spec=()),
+        'foo': session_func,
+        'bar': session_func,
+        'baz': session_func,
     }, config)
     return_value = tasks.filter_manifest(manifest, config)
     assert return_value is manifest
@@ -182,7 +134,7 @@ def test_verify_manifest_empty():
 
 def test_verify_manifest_nonempty():
     config = Namespace(sessions=(), keywords=())
-    manifest = Manifest({'session': lambda session: None}, config)
+    manifest = Manifest({'session': session_func}, config)
     return_value = tasks.verify_manifest_nonempty(
         manifest,
         global_config=config,
@@ -194,8 +146,8 @@ def test_run_manifest():
     # Set up a valid manifest.
     config = Namespace(stop_on_first_error=False)
     sessions_ = [
-        mock.Mock(spec=sessions.Session),
-        mock.Mock(spec=sessions.Session),
+        mock.Mock(spec=sessions.SessionRunner),
+        mock.Mock(spec=sessions.SessionRunner),
     ]
     manifest = Manifest({}, config)
     manifest._queue = copy.copy(sessions_)
@@ -223,8 +175,8 @@ def test_run_manifest_abort_on_first_failure():
     # Set up a valid manifest.
     config = Namespace(stop_on_first_error=True)
     sessions_ = [
-        mock.Mock(spec=sessions.Session),
-        mock.Mock(spec=sessions.Session),
+        mock.Mock(spec=sessions.SessionRunner),
+        mock.Mock(spec=sessions.SessionRunner),
     ]
     manifest = Manifest({}, config)
     manifest._queue = copy.copy(sessions_)
diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py
index c74316c..7fa66b2 100644
--- a/tests/test_virtualenv.py
+++ b/tests/test_virtualenv.py
@@ -14,6 +14,7 @@
 
 import os
 import platform
+import sys
 from unittest import mock
 
 import py
@@ -199,7 +200,16 @@ def test__resolved_interpreter_none(make_one):
     # Establish that the _resolved_interpreter method is a no-op if the
     # interpeter is not set.
     venv, _ = make_one(interpreter=None)
-    assert venv._resolved_interpreter is venv.interpreter is None
+    assert venv._resolved_interpreter == sys.executable
+
+
+def test__resolved_interpreter_numerical_non_windows(make_one):
+    # Establish that specifying just '3.6' expands to 'python3.6'
+    venv, _ = make_one(interpreter='3.6')
+    with mock.patch.object(platform, 'system') as system:
+        system.return_value = 'Linux'
+        assert venv._resolved_interpreter == 'python3.6'
+        system.assert_called_once_with()
 
 
 def test__resolved_interpreter_non_windows(make_one):