From 3f130937bcce073e9933e28c332e59c559025e07 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Mon, 21 Nov 2016 00:14:26 -0600 Subject: support a [pserve] config section with a list of files to watch fixes #2732 --- docs/narr/project.rst | 20 +++++++++++ pyramid/scripts/pserve.py | 58 ++++++++++++++++++++----------- pyramid/tests/test_scripts/test_pserve.py | 24 ++++++++----- 3 files changed, 73 insertions(+), 29 deletions(-) diff --git a/docs/narr/project.rst b/docs/narr/project.rst index 6c42881f4..b4ad6948e 100644 --- a/docs/narr/project.rst +++ b/docs/narr/project.rst @@ -1065,3 +1065,23 @@ hard drive, you should install the `watchdog ` package in development. ``hupper`` will automatically use ``watchdog`` to more efficiently poll the filesystem. + +Monitoring Custom Files +~~~~~~~~~~~~~~~~~~~~~~~ + +By default, ``pserve --reload`` will monitor all imported Python code +(everything in ``sys.modules``) as well as the config file passed to +``pserve`` (e.g. ``development.ini``). You can instruct ``pserve`` to watch +other files for changes as well by defining a ``[pserve]`` section in your +configuration file. For example, let's say your application loads the +``favicon.ico`` file at startup and stores it in memory to efficiently +serve it many times. When you change it you want ``pserve`` to restart: + +.. code-block:: ini + + [pserve] + watch_files = + myapp/static/favicon.ico + +Paths are relative to the configuration file and are passed to ``hupper`` +to watch. Currently it does not support globs but this may change. diff --git a/pyramid/scripts/pserve.py b/pyramid/scripts/pserve.py index 969bc07f1..c67dc86ba 100644 --- a/pyramid/scripts/pserve.py +++ b/pyramid/scripts/pserve.py @@ -28,9 +28,11 @@ from paste.deploy.loadwsgi import ( ) 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.settings import aslist def main(argv=sys.argv, quiet=False): command = PServeCommand(argv, quiet=quiet) @@ -97,12 +99,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 +119,24 @@ class PServeCommand(object): restvars = self.args[1:] return parse_vars(restvars) + def pserve_file_config(self, filename): + config = self.ConfigParser() + 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 + basedir = os.path.dirname(filename) + for file in watch_files: + if not os.path.isabs(file): + file = os.path.join(basedir, file) + self.watch_files.append(os.path.normpath(file)) + def run(self): # pragma: no cover if not self.args: self.out('You must give a config file') @@ -121,8 +146,12 @@ class PServeCommand(object): 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 +159,6 @@ class PServeCommand(object): 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 +183,17 @@ class PServeCommand(object): ) return 0 + if config_path: + setup_logging(config_path, global_conf=vars) + self.watch_files.append(config_path) + self.pserve_file_config(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) @@ -196,13 +219,6 @@ class PServeCommand(object): 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) - # For paste.deploy server instantiation (egg:pyramid#wsgiref) def wsgiref_server_runner(wsgi_app, global_conf, **kw): # pragma: no cover from wsgiref.simple_server import make_server diff --git a/pyramid/tests/test_scripts/test_pserve.py b/pyramid/tests/test_scripts/test_pserve.py index e84de92d4..cf9426943 100644 --- a/pyramid/tests/test_scripts/test_pserve.py +++ b/pyramid/tests/test_scripts/test_pserve.py @@ -1,9 +1,12 @@ +import os import unittest +from pyramid.tests.test_scripts import dummy 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 +14,6 @@ class TestPServeCommand(unittest.TestCase): def _get_server(*args, **kwargs): def server(app): return '' - return server def _getTargetClass(self): @@ -23,6 +25,7 @@ class TestPServeCommand(unittest.TestCase): 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 +41,15 @@ class TestPServeCommand(unittest.TestCase): 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 +57,16 @@ class TestPServeCommand(unittest.TestCase): 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\nbar\n/baz')] + inst.pserve_file_config('/base/path.ini') + self.assertEqual(inst.watch_files, [ + os.path.normpath('/base/foo'), + os.path.normpath('/base/bar'), + os.path.normpath('/baz'), + ]) + class Test_main(unittest.TestCase): def _callFUT(self, argv): from pyramid.scripts.pserve import main -- cgit v1.2.3 From abfb2f61458b111008fd1d9de1553380715889a3 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Mon, 21 Nov 2016 01:01:47 -0600 Subject: add changelog for #2827 --- CHANGES.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.txt b/CHANGES.txt index dac61678d..0686e1ad1 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -116,6 +116,10 @@ Features See https://github.com/Pylons/pyramid/pull/2805 +- A new ``[pserve]`` section is supported in your config files with a + ``watch_files`` key that can configure ``pserve --reload`` to monitor custom + file paths. See https://github.com/Pylons/pyramid/pull/2827 + Bug Fixes --------- -- cgit v1.2.3 From 9cab0e7cc24dc95e357db026cead2507111ee93a Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Mon, 21 Nov 2016 21:31:41 -0600 Subject: support asset specs in watch_files and variable interpolation --- pyramid/scripts/pserve.py | 46 +++++++++++++++++-------------- pyramid/tests/test_scripts/dummy.py | 8 ++++-- pyramid/tests/test_scripts/test_pserve.py | 19 +++++++++---- 3 files changed, 45 insertions(+), 28 deletions(-) diff --git a/pyramid/scripts/pserve.py b/pyramid/scripts/pserve.py index c67dc86ba..b8776d44f 100644 --- a/pyramid/scripts/pserve.py +++ b/pyramid/scripts/pserve.py @@ -32,6 +32,7 @@ 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): @@ -119,8 +120,14 @@ class PServeCommand(object): restvars = self.args[1:] return parse_vars(restvars) - def pserve_file_config(self, filename): - config = self.ConfigParser() + 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: @@ -131,11 +138,13 @@ class PServeCommand(object): watch_files = aslist(items.get('watch_files', ''), flatten=False) # track file paths relative to the ini file - basedir = os.path.dirname(filename) + resolver = AssetResolver(package=None) for file in watch_files: - if not os.path.isabs(file): - file = os.path.join(basedir, file) - self.watch_files.append(os.path.normpath(file)) + 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: @@ -185,8 +194,8 @@ class PServeCommand(object): 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) - self.pserve_file_config(config_path) if hupper.is_active(): reloader = hupper.get_reloader() @@ -205,19 +214,16 @@ class PServeCommand(object): 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() + 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 diff --git a/pyramid/tests/test_scripts/dummy.py b/pyramid/tests/test_scripts/dummy.py index f3aa20e7c..ced09d0b0 100644 --- a/pyramid/tests/test_scripts/dummy.py +++ b/pyramid/tests/test_scripts/dummy.py @@ -82,8 +82,9 @@ class DummyMultiView(object): 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 @@ class DummyConfigParser(object): 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 index cf9426943..18f7c8c2f 100644 --- a/pyramid/tests/test_scripts/test_pserve.py +++ b/pyramid/tests/test_scripts/test_pserve.py @@ -2,6 +2,8 @@ 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 @@ -59,12 +61,19 @@ class TestPServeCommand(unittest.TestCase): def test_config_file_finds_watch_files(self): inst = self._makeOne('development.ini') - self.config_factory.items = [('watch_files', 'foo\nbar\n/baz')] - inst.pserve_file_config('/base/path.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.normpath('/base/foo'), - os.path.normpath('/base/bar'), - os.path.normpath('/baz'), + os.path.abspath('/base/foo'), + os.path.abspath('/baz'), + os.path.abspath(os.path.join(here, '*.py')), ]) class Test_main(unittest.TestCase): -- cgit v1.2.3 From ff0da73a8922b5e82c676715078c7b9e60a6a1da Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Mon, 21 Nov 2016 22:42:55 -0600 Subject: document globbing --- docs/narr/project.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/narr/project.rst b/docs/narr/project.rst index b4ad6948e..77c637571 100644 --- a/docs/narr/project.rst +++ b/docs/narr/project.rst @@ -1083,5 +1083,7 @@ serve it many times. When you change it you want ``pserve`` to restart: watch_files = myapp/static/favicon.ico -Paths are relative to the configuration file and are passed to ``hupper`` -to watch. Currently it does not support globs but this may change. +Paths may be absolute or relative to the configuration file. They may also +be an :term:`asset specification`. These paths are passed to ``hupper`` which +has some basic support for globbing. Acceptable glob patterns depend on the +version of Python being used. -- cgit v1.2.3