Repository: Pylons/pyramid

Issue:
support extending pserve to monitor arbitrary files via a custom ini section
Currently the only way to extend `pserve` to monitor extra files for restarts is via monkey patching the code at runtime.

Similar to `pshell`'s `[pshell]` section I propose we add a new `[pserve]` ini section with a `watch_files` key supporting new-line separated patterns.

```
[pserve]
watch_files =
    foo.ini 
    something else with spaces in the name.png
    a glob pattern/**/*.png
```



Discussion:
Glob support requires updating hupper. See https://github.com/Pylons/hupper/issues/5.


Resolving patch:
diff --git a/pyramid/scripts/pserve.py b/pyramid/scripts/pserve.py
--- a/pyramid/scripts/pserve.py
+++ b/pyramid/scripts/pserve.py
@@ -28,9 +28,12 @@
 )
 
 from pyramid.compat import PY2
+from pyramid.compat import configparser
 
 from pyramid.scripts.common import parse_vars
 from pyramid.scripts.common import setup_logging
+from pyramid.path import AssetResolver
+from pyramid.settings import aslist
 
 def main(argv=sys.argv, quiet=False):
     command = PServeCommand(argv, quiet=quiet)
@@ -97,12 +100,17 @@ class PServeCommand(object):
         dest='verbose',
         help="Suppress verbose output")
 
+    ConfigParser = configparser.ConfigParser  # testing
+    loadapp = staticmethod(loadapp)  # testing
+    loadserver = staticmethod(loadserver)  # testing
+
     _scheme_re = re.compile(r'^[a-z][a-z]+:', re.I)
 
     def __init__(self, argv, quiet=False):
         self.options, self.args = self.parser.parse_args(argv[1:])
         if quiet:
             self.options.verbose = 0
+        self.watch_files = []
 
     def out(self, msg): # pragma: no cover
         if self.options.verbose > 0:
@@ -112,6 +120,32 @@ def get_options(self):
         restvars = self.args[1:]
         return parse_vars(restvars)
 
+    def pserve_file_config(self, filename, global_conf=None):
+        here = os.path.abspath(os.path.dirname(filename))
+        defaults = {}
+        if global_conf:
+            defaults.update(global_conf)
+        defaults['here'] = here
+
+        config = self.ConfigParser(defaults=defaults)
+        config.optionxform = str
+        config.read(filename)
+        try:
+            items = dict(config.items('pserve'))
+        except configparser.NoSectionError:
+            return
+
+        watch_files = aslist(items.get('watch_files', ''), flatten=False)
+
+        # track file paths relative to the ini file
+        resolver = AssetResolver(package=None)
+        for file in watch_files:
+            if ':' in file:
+                file = resolver.resolve(file).abspath()
+            elif not os.path.isabs(file):
+                file = os.path.join(here, file)
+            self.watch_files.append(os.path.abspath(file))
+
     def run(self):  # pragma: no cover
         if not self.args:
             self.out('You must give a config file')
@@ -121,8 +155,12 @@ def run(self):  # pragma: no cover
         vars = self.get_options()
         app_name = self.options.app_name
 
+        base = os.getcwd()
         if not self._scheme_re.search(app_spec):
+            config_path = os.path.join(base, app_spec)
             app_spec = 'config:' + app_spec
+        else:
+            config_path = None
         server_name = self.options.server_name
         if self.options.server:
             server_spec = 'egg:pyramid'
@@ -130,7 +168,6 @@ def run(self):  # pragma: no cover
             server_name = self.options.server
         else:
             server_spec = app_spec
-        base = os.getcwd()
 
         # do not open the browser on each reload so check hupper first
         if self.options.browser and not hupper.is_active():
@@ -155,22 +192,17 @@ def open_browser():
             )
             return 0
 
+        if config_path:
+            setup_logging(config_path, global_conf=vars)
+            self.pserve_file_config(config_path, global_conf=vars)
+            self.watch_files.append(config_path)
+
         if hupper.is_active():
             reloader = hupper.get_reloader()
-            if app_spec.startswith('config:'):
-                reloader.watch_files([app_spec[len('config:'):]])
-
-        log_fn = app_spec
-        if log_fn.startswith('config:'):
-            log_fn = app_spec[len('config:'):]
-        elif log_fn.startswith('egg:'):
-            log_fn = None
-        if log_fn:
-            log_fn = os.path.join(base, log_fn)
-            setup_logging(log_fn, global_conf=vars)
+            reloader.watch_files(self.watch_files)
 
-        server = self.loadserver(server_spec, name=server_name,
-                                 relative_to=base, global_conf=vars)
+        server = self.loadserver(
+            server_spec, name=server_name, relative_to=base, global_conf=vars)
 
         app = self.loadapp(
             app_spec, name=app_name, relative_to=base, global_conf=vars)
@@ -182,26 +214,16 @@ def open_browser():
                 msg = 'Starting server.'
             self.out(msg)
 
-        def serve():
-            try:
-                server(app)
-            except (SystemExit, KeyboardInterrupt) as e:
-                if self.options.verbose > 1:
-                    raise
-                if str(e):
-                    msg = ' ' + str(e)
-                else:
-                    msg = ''
-                self.out('Exiting%s (-v to see traceback)' % msg)
-
-        serve()
-
-    def loadapp(self, app_spec, name, relative_to, **kw): # pragma: no cover
-        return loadapp(app_spec, name=name, relative_to=relative_to, **kw)
-
-    def loadserver(self, server_spec, name, relative_to, **kw):# pragma:no cover
-        return loadserver(
-            server_spec, name=name, relative_to=relative_to, **kw)
+        try:
+            server(app)
+        except (SystemExit, KeyboardInterrupt) as e:
+            if self.options.verbose > 1:
+                raise
+            if str(e):
+                msg = ' ' + str(e)
+            else:
+                msg = ''
+            self.out('Exiting%s (-v to see traceback)' % msg)
 
 # For paste.deploy server instantiation (egg:pyramid#wsgiref)
 def wsgiref_server_runner(wsgi_app, global_conf, **kw): # pragma: no cover


Test patch:
diff --git a/pyramid/tests/test_scripts/dummy.py b/pyramid/tests/test_scripts/dummy.py
--- a/pyramid/tests/test_scripts/dummy.py
+++ b/pyramid/tests/test_scripts/dummy.py
@@ -82,8 +82,9 @@ def __init__(self, *views, **attrs):
         self.__request_attrs__ = attrs
 
 class DummyConfigParser(object):
-    def __init__(self, result):
+    def __init__(self, result, defaults=None):
         self.result = result
+        self.defaults = defaults
 
     def read(self, filename):
         self.filename = filename
@@ -98,8 +99,9 @@ def items(self, section):
 class DummyConfigParserFactory(object):
     items = None
 
-    def __call__(self):
-        self.parser = DummyConfigParser(self.items)
+    def __call__(self, defaults=None):
+        self.defaults = defaults
+        self.parser = DummyConfigParser(self.items, defaults)
         return self.parser
 
 class DummyCloser(object):
diff --git a/pyramid/tests/test_scripts/test_pserve.py b/pyramid/tests/test_scripts/test_pserve.py
--- a/pyramid/tests/test_scripts/test_pserve.py
+++ b/pyramid/tests/test_scripts/test_pserve.py
@@ -1,9 +1,14 @@
+import os
 import unittest
+from pyramid.tests.test_scripts import dummy
+
+here = os.path.abspath(os.path.dirname(__file__))
 
 class TestPServeCommand(unittest.TestCase):
     def setUp(self):
         from pyramid.compat import NativeIO
         self.out_ = NativeIO()
+        self.config_factory = dummy.DummyConfigParserFactory()
 
     def out(self, msg):
         self.out_.write(msg)
@@ -11,7 +16,6 @@ def out(self, msg):
     def _get_server(*args, **kwargs):
         def server(app):
             return ''
-
         return server
 
     def _getTargetClass(self):
@@ -23,6 +27,7 @@ def _makeOne(self, *args):
         effargs.extend(args)
         cmd = self._getTargetClass()(effargs)
         cmd.out = self.out
+        cmd.ConfigParser = self.config_factory
         return cmd
 
     def test_run_no_args(self):
@@ -38,20 +43,15 @@ def test_get_options_no_command(self):
         self.assertEqual(result, {'a': '1', 'b': '2'})
 
     def test_parse_vars_good(self):
-        from pyramid.tests.test_scripts.dummy import DummyApp
-
         inst = self._makeOne('development.ini', 'a=1', 'b=2')
         inst.loadserver = self._get_server
 
-
-        app = DummyApp()
-
+        app = dummy.DummyApp()
         def get_app(*args, **kwargs):
             app.global_conf = kwargs.get('global_conf', None)
-
         inst.loadapp = get_app
-        inst.run()
 
+        inst.run()
         self.assertEqual(app.global_conf, {'a': '1', 'b': '2'})
 
     def test_parse_vars_bad(self):
@@ -59,6 +59,23 @@ def test_parse_vars_bad(self):
         inst.loadserver = self._get_server
         self.assertRaises(ValueError, inst.run)
 
+    def test_config_file_finds_watch_files(self):
+        inst = self._makeOne('development.ini')
+        self.config_factory.items = [(
+            'watch_files',
+            'foo\n/baz\npyramid.tests.test_scripts:*.py',
+        )]
+        inst.pserve_file_config('/base/path.ini', global_conf={'a': '1'})
+        self.assertEqual(self.config_factory.defaults, {
+            'a': '1',
+            'here': os.path.abspath('/base'),
+        })
+        self.assertEqual(inst.watch_files, [
+            os.path.abspath('/base/foo'),
+            os.path.abspath('/baz'),
+            os.path.abspath(os.path.join(here, '*.py')),
+        ])
+
 class Test_main(unittest.TestCase):
     def _callFUT(self, argv):
         from pyramid.scripts.pserve import main