Michael Merickel
2018-08-04 6174a79eaabc6391018f1f59227c21fdde7fb30f
Merge pull request #3318 from mmerickel/pshell-setup-generator

enable the setup function in pshell to wrap the command lifecycle
7 files modified
270 ■■■■ changed files
CHANGES.rst 12 ●●●●● patch | view | raw | blame | history
docs/narr/commandline.rst 59 ●●●●● patch | view | raw | blame | history
pyramid/scripts/pshell.py 112 ●●●● patch | view | raw | blame | history
pyramid/tests/test_scripts/dummy.py 2 ●●●●● patch | view | raw | blame | history
pyramid/tests/test_scripts/test_pshell.py 29 ●●●●● patch | view | raw | blame | history
pyramid/tests/test_util.py 38 ●●●●● patch | view | raw | blame | history
pyramid/util.py 18 ●●●● patch | view | raw | blame | history
CHANGES.rst
@@ -41,6 +41,14 @@
  exception/response object for a HTTP 308 redirect.
  See https://github.com/Pylons/pyramid/pull/3302
- Within ``pshell``, allow the user-defined ``setup`` function to be a
  generator, in which case it may wrap the command's lifecycle.
  See https://github.com/Pylons/pyramid/pull/3318
- Within ``pshell``, variables defined by the ``[pshell]`` settings are
  available within the user-defined ``setup`` function.
  See https://github.com/Pylons/pyramid/pull/3318
Bug Fixes
---------
@@ -76,6 +84,10 @@
  ``pyramid.session.UnencryptedCookieSessionFactoryConfig``.
  See https://github.com/Pylons/pyramid/pull/3300
- Variables defined in the ``[pshell]`` section of the settings will no
  longer override those set by the ``setup`` function.
  See https://github.com/Pylons/pyramid/pull/3318
Documentation Changes
---------------------
docs/narr/commandline.rst
@@ -196,51 +196,61 @@
It is convenient when using the interactive shell often to have some variables
significant to your application already loaded as globals when you start the
``pshell``. To facilitate this, ``pshell`` will look for a special ``[pshell]``
section in your INI file and expose the subsequent key/value pairs to the
section in your ``.ini`` file and expose the subsequent key/value pairs to the
shell.  Each key is a variable name that will be global within the pshell
session; each value is a :term:`dotted Python name`. If specified, the special
key ``setup`` should be a :term:`dotted Python name` pointing to a callable
that accepts the dictionary of globals that will be loaded into the shell. This
allows for some custom initializing code to be executed each time the
``pshell`` is run. The ``setup`` callable can also be specified from the
commandline using the ``--setup`` option which will override the key in the INI
commandline using the ``--setup`` option which will override the key in the ``.ini``
file.
For example, you want to expose your model to the shell along with the database
session so that you can mutate the model on an actual database. Here, we'll
assume your model is stored in the ``myapp.models`` package.
assume your model is stored in the ``myapp.models`` package and that you're
using ``pyramid_tm`` to configure a transaction manager on the request as
``request.tm``.
.. code-block:: ini
   :linenos:
   [pshell]
   setup = myapp.lib.pshell.setup
   m = myapp.models
   session = myapp.models.DBSession
   t = transaction
   models = myapp.models
By defining the ``setup`` callable, we will create the module
``myapp.lib.pshell`` containing a callable named ``setup`` that will receive
the global environment before it is exposed to the shell. Here we mutate the
environment's request as well as add a new value containing a WebTest version
of the application to which we can easily submit requests.
By defining the ``setup`` callable, we will create the module ``myapp.lib.pshell`` containing a callable named ``setup`` that will receive the global environment before it is exposed to the shell. Here we mutate the environment's request as well as add a new value containing a WebTest version of the application to which we can easily submit requests. The ``setup`` callable can also be a generator which can wrap the entire shell lifecycle, executing code when the shell exits.
.. code-block:: python
    :linenos:
    # myapp/lib/pshell.py
    from contextlib import suppress
    from transaction.interfaces import NoTransaction
    from webtest import TestApp
    def setup(env):
        env['request'].host = 'www.example.com'
        env['request'].scheme = 'https'
        request = env['request']
        request.host = 'www.example.com'
        request.scheme = 'https'
        env['testapp'] = TestApp(env['app'])
When this INI file is loaded, the extra variables ``m``, ``session`` and ``t``
will be available for use immediately. Since a ``setup`` callable was also
specified, it is executed and a new variable ``testapp`` is exposed, and the
request is configured to generate urls from the host
``http://www.example.com``. For example:
        # start a transaction which can be used in the shell
        request.tm.begin()
        # if using the alchemy cookiecutter, the dbsession is connected
        # to the transaction manager above
        env['tm'] = request.tm
        env['dbsession'] = request.dbsession
        try:
            yield
        finally:
            with suppress(NoTransaction):
                request.tm.abort()
When this ``.ini`` file is loaded, the extra variable ``models`` will be available for use immediately. Since a ``setup`` callable was also specified, it is executed and new variables ``testapp``, ``tm``, and ``dbsession`` are exposed, and the request is configured to generate URLs from the host ``http://www.example.com``. For example:
.. code-block:: text
@@ -258,14 +268,21 @@
      testapp      <webtest.TestApp object at ...>
    Custom Variables:
      m            myapp.models
      session      myapp.models.DBSession
      t            transaction
      dbsession
      model        myapp.models
      tm
    >>> testapp.get('/')
    <200 OK text/html body='<!DOCTYPE...l>\n'/3337>
    >>> request.route_url('home')
    'https://www.example.com/'
    >>> user = dbsession.query(models.User).get(1)
    >>> user.name = 'Joe'
    >>> tm.commit()
    >>> tm.begin()
    >>> user = dbsession.query(models.User).get(1)
    >>> user.name == 'Joe'
    'Joe'
.. _ipython_or_bpython:
pyramid/scripts/pshell.py
@@ -1,4 +1,5 @@
from code import interact
from contextlib import contextmanager
import argparse
import os
import sys
@@ -7,6 +8,7 @@
from pyramid.compat import exec_
from pyramid.util import DottedNameResolver
from pyramid.util import make_contextmanager
from pyramid.paster import bootstrap
from pyramid.settings import aslist
@@ -85,6 +87,7 @@
    preferred_shells = []
    setup = None
    pystartup = os.environ.get('PYTHONSTARTUP')
    resolver = DottedNameResolver(None)
    def __init__(self, argv, quiet=False):
        self.quiet = quiet
@@ -92,7 +95,6 @@
    def pshell_file_config(self, loader, defaults):
        settings = loader.get_settings('pshell', defaults)
        resolver = DottedNameResolver(None)
        self.loaded_objects = {}
        self.object_help = {}
        self.setup = None
@@ -102,7 +104,7 @@
            elif k == 'default_shell':
                self.preferred_shells = [x.lower() for x in aslist(v)]
            else:
                self.loaded_objects[k] = resolver.maybe_resolve(v)
                self.loaded_objects[k] = self.resolver.maybe_resolve(v)
                self.object_help[k] = v
    def out(self, msg): # pragma: no cover
@@ -115,18 +117,36 @@
        if not self.args.config_uri:
            self.out('Requires a config file argument')
            return 2
        config_uri = self.args.config_uri
        config_vars = parse_vars(self.args.config_vars)
        loader = self.get_config_loader(config_uri)
        loader.setup_logging(config_vars)
        self.pshell_file_config(loader, config_vars)
        env = self.bootstrap(config_uri, options=config_vars)
        self.env = self.bootstrap(config_uri, options=config_vars)
        # remove the closer from the env
        self.closer = env.pop('closer')
        self.closer = self.env.pop('closer')
        try:
            if shell is None:
                try:
                    shell = self.make_shell()
                except ValueError as e:
                    self.out(str(e))
                    return 1
            with self.setup_env():
                shell(self.env, self.help)
        finally:
            self.closer()
    @contextmanager
    def setup_env(self):
        # setup help text for default environment
        env = self.env
        env_help = dict(env)
        env_help['app'] = 'The WSGI application.'
        env_help['root'] = 'Root of the default resource tree.'
@@ -134,27 +154,6 @@
        env_help['request'] = 'Active request object.'
        env_help['root_factory'] = (
            'Default root factory used to create `root`.')
        # override use_script with command-line options
        if self.args.setup:
            self.setup = self.args.setup
        if self.setup:
            # store the env before muddling it with the script
            orig_env = env.copy()
            # call the setup callable
            resolver = DottedNameResolver(None)
            setup = resolver.maybe_resolve(self.setup)
            setup(env)
            # remove any objects from default help that were overidden
            for k, v in env.items():
                if k not in orig_env or env[k] != orig_env[k]:
                    if getattr(v, '__doc__', False):
                        env_help[k] = v.__doc__.replace("\n", " ")
                    else:
                        env_help[k] = v
        # load the pshell section of the ini file
        env.update(self.loaded_objects)
@@ -164,36 +163,47 @@
            if k in env_help:
                del env_help[k]
        # generate help text
        help = ''
        if env_help:
            help += 'Environment:'
            for var in sorted(env_help.keys()):
                help += '\n  %-12s %s' % (var, env_help[var])
        # override use_script with command-line options
        if self.args.setup:
            self.setup = self.args.setup
        if self.object_help:
            help += '\n\nCustom Variables:'
            for var in sorted(self.object_help.keys()):
                help += '\n  %-12s %s' % (var, self.object_help[var])
        if self.setup:
            # call the setup callable
            self.setup = self.resolver.maybe_resolve(self.setup)
        if shell is None:
            try:
                shell = self.make_shell()
            except ValueError as e:
                self.out(str(e))
                self.closer()
                return 1
        # store the env before muddling it with the script
        orig_env = env.copy()
        setup_manager = make_contextmanager(self.setup)
        with setup_manager(env):
            # remove any objects from default help that were overidden
            for k, v in env.items():
                if k not in orig_env or v != orig_env[k]:
                    if getattr(v, '__doc__', False):
                        env_help[k] = v.__doc__.replace("\n", " ")
                    else:
                        env_help[k] = v
            del orig_env
        if self.pystartup and os.path.isfile(self.pystartup):
            with open(self.pystartup, 'rb') as fp:
                exec_(fp.read().decode('utf-8'), env)
            if '__builtins__' in env:
                del env['__builtins__']
            # generate help text
            help = ''
            if env_help:
                help += 'Environment:'
                for var in sorted(env_help.keys()):
                    help += '\n  %-12s %s' % (var, env_help[var])
        try:
            shell(env, help)
        finally:
            self.closer()
            if self.object_help:
                help += '\n\nCustom Variables:'
                for var in sorted(self.object_help.keys()):
                    help += '\n  %-12s %s' % (var, self.object_help[var])
            if self.pystartup and os.path.isfile(self.pystartup):
                with open(self.pystartup, 'rb') as fp:
                    exec_(fp.read().decode('utf-8'), env)
                if '__builtins__' in env:
                    del env['__builtins__']
            self.help = help.strip()
            yield
    def show_shells(self):
        shells = self.find_all_shells()
pyramid/tests/test_scripts/dummy.py
@@ -22,11 +22,13 @@
    env = {}
    help = ''
    called = False
    dummy_attr = 1
    def __call__(self, env, help):
        self.env = env
        self.help = help
        self.called = True
        self.env['request'].dummy_attr = self.dummy_attr
class DummyInteractor:
    def __call__(self, banner, local):
pyramid/tests/test_scripts/test_pshell.py
@@ -226,6 +226,33 @@
        self.assertTrue(self.bootstrap.closer.called)
        self.assertTrue(shell.help)
    def test_command_setup_generator(self):
        command = self._makeOne()
        did_resume_after_yield = {}
        def setup(env):
            env['a'] = 1
            env['root'] = 'root override'
            env['none'] = None
            request = env['request']
            yield
            did_resume_after_yield['result'] = True
            self.assertEqual(request.dummy_attr, 1)
        self.loader.settings = {'pshell': {'setup': setup}}
        shell = dummy.DummyShell()
        command.run(shell)
        self.assertEqual(self.bootstrap.a[0], '/foo/bar/myapp.ini#myapp')
        self.assertEqual(shell.env, {
            'app':self.bootstrap.app, 'root':'root override',
            'registry':self.bootstrap.registry,
            'request':self.bootstrap.request,
            'root_factory':self.bootstrap.root_factory,
            'a':1,
            'none': None,
        })
        self.assertTrue(did_resume_after_yield['result'])
        self.assertTrue(self.bootstrap.closer.called)
        self.assertTrue(shell.help)
    def test_command_default_shell_option(self):
        command = self._makeOne()
        ipshell = dummy.DummyShell()
@@ -259,7 +286,7 @@
            'registry':self.bootstrap.registry,
            'request':self.bootstrap.request,
            'root_factory':self.bootstrap.root_factory,
            'a':1, 'm':model,
            'a':1, 'm':'model override',
        })
        self.assertTrue(self.bootstrap.closer.called)
        self.assertTrue(shell.help)
pyramid/tests/test_util.py
@@ -889,3 +889,41 @@
        self.assertTrue(self._callFUT("example.com:8080", "example.com:8080"))
        self.assertFalse(self._callFUT("example.com:8080", "example.com"))
        self.assertFalse(self._callFUT("example.com", "example.com:8080"))
class Test_make_contextmanager(unittest.TestCase):
    def _callFUT(self, *args, **kw):
        from pyramid.util import make_contextmanager
        return make_contextmanager(*args, **kw)
    def test_with_None(self):
        mgr = self._callFUT(None)
        with mgr() as ctx:
            self.assertIsNone(ctx)
    def test_with_generator(self):
        def mygen(ctx):
            yield ctx
        mgr = self._callFUT(mygen)
        with mgr('a') as ctx:
            self.assertEqual(ctx, 'a')
    def test_with_multiple_yield_generator(self):
        def mygen():
            yield 'a'
            yield 'b'
        mgr = self._callFUT(mygen)
        try:
            with mgr() as ctx:
                self.assertEqual(ctx, 'a')
        except RuntimeError:
            pass
        else:  # pragma: no cover
            raise AssertionError('expected raise from multiple yields')
    def test_with_regular_fn(self):
        def mygen():
            return 'a'
        mgr = self._callFUT(mygen)
        with mgr() as ctx:
            self.assertEqual(ctx, 'a')
pyramid/util.py
@@ -1,4 +1,4 @@
import contextlib
from contextlib import contextmanager
import functools
try:
    # py2.7.7+ and py3.3+ have native comparison support
@@ -613,7 +613,7 @@
        )
        raise ConfigurationError(msg % name)
@contextlib.contextmanager
@contextmanager
def hide_attrs(obj, *attrs):
    """
    Temporarily delete object attrs and restore afterward.
@@ -648,3 +648,17 @@
    return (pattern[0] == "." and
            (host.endswith(pattern) or host == pattern[1:]) or
            pattern == host)
def make_contextmanager(fn):
    if inspect.isgeneratorfunction(fn):
        return contextmanager(fn)
    if fn is None:
        fn = lambda *a, **kw: None
    @contextmanager
    @functools.wraps(fn)
    def wrapper(*a, **kw):
        yield fn(*a, **kw)
    return wrapper