From 82fcf3115ab9c2e1c9fa8d41a90b0ae3eb307080 Mon Sep 17 00:00:00 2001 From: steven-terrana Date: Sun, 7 Nov 2021 04:29:58 -0500 Subject: [PATCH] Support custom directories to watch when running mkdocs serve (#2642) * adds a `watch` property to the `mkdocs.yaml` schema. Accepts a list of directories to watch. * adds a `-w`/`--watch` command line option to `mkdocs serve` that can be passed multiple times * options from `mkdocs.yaml` and CLI flags are combined * the livereload server will also print the directories that it watches * docs updated Co-authored-by: Oleh Prypin --- docs/user-guide/configuration.md | 22 ++++++++++ mkdocs/__main__.py | 7 +++- mkdocs/commands/serve.py | 12 +++++- mkdocs/config/config_options.py | 31 ++++++++++++++ mkdocs/config/defaults.py | 3 ++ mkdocs/livereload/__init__.py | 21 ++++++++++ mkdocs/tests/cli_tests.py | 30 +++++++++----- mkdocs/tests/config/config_options_tests.py | 46 +++++++++++++++++++++ 8 files changed, 159 insertions(+), 13 deletions(-) diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index e1e3d4a5..77c1072d 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -356,6 +356,28 @@ extra: ## Preview controls +## Live Reloading + +### watch + +Determines additional directories to watch when running `mkdocs serve`. +Configuration is a YAML list. + +```yaml +watch: +- directory_a +- directory_b +``` + +Allows a custom default to be set without the need to pass it through the `-w`/`--watch` +option every time the `mkdocs serve` command is called. + +!!! Note + + The paths provided via the configuration file are relative to the configuration file. + + The paths provided via the `-w`/`--watch` CLI parameters are not. + ### use_directory_urls This setting controls the style used for linking to pages within the diff --git a/mkdocs/__main__.py b/mkdocs/__main__.py index 4afd164b..33758c66 100644 --- a/mkdocs/__main__.py +++ b/mkdocs/__main__.py @@ -99,6 +99,8 @@ ignore_version_help = "Ignore check that build is not being deployed with an old watch_theme_help = ("Include the theme in list of files to watch for live reloading. " "Ignored when live reload is not used.") shell_help = "Use the shell when invoking Git." +watch_help = ("A directory or file to watch for live reloading. " + "Can be supplied multiple times.") def add_options(opts): @@ -170,11 +172,12 @@ def cli(): @click.option('--no-livereload', 'livereload', flag_value='no-livereload', help=no_reload_help) @click.option('--dirtyreload', 'livereload', flag_value='dirty', help=dirty_reload_help) @click.option('--watch-theme', help=watch_theme_help, is_flag=True) +@click.option('-w', '--watch', help=watch_help, type=click.Path(exists=True), multiple=True, default=[]) @common_config_options @common_options -def serve_command(dev_addr, livereload, **kwargs): +def serve_command(dev_addr, livereload, watch, **kwargs): """Run the builtin development server""" - serve.serve(dev_addr=dev_addr, livereload=livereload, **kwargs) + serve.serve(dev_addr=dev_addr, livereload=livereload, watch=watch, **kwargs) @cli.command(name="build") diff --git a/mkdocs/commands/serve.py b/mkdocs/commands/serve.py index 4ba7fb4c..28be7fd4 100644 --- a/mkdocs/commands/serve.py +++ b/mkdocs/commands/serve.py @@ -13,7 +13,7 @@ log = logging.getLogger(__name__) def serve(config_file=None, dev_addr=None, strict=None, theme=None, - theme_dir=None, livereload='livereload', watch_theme=False, **kwargs): + theme_dir=None, livereload='livereload', watch_theme=False, watch=[], **kwargs): """ Start the MkDocs development server @@ -41,6 +41,13 @@ def serve(config_file=None, dev_addr=None, strict=None, theme=None, site_dir=site_dir, **kwargs ) + + # combine CLI watch arguments with config file values + if config["watch"] is None: + config["watch"] = watch + else: + config["watch"].extend(watch) + # Override a few config settings after validation config['site_url'] = 'http://{}{}'.format(config['dev_addr'], mount_path(config)) @@ -77,6 +84,9 @@ def serve(config_file=None, dev_addr=None, strict=None, theme=None, # Run `serve` plugin events. server = config['plugins'].run_event('serve', server, config=config, builder=builder) + for item in config['watch']: + server.watch(item) + try: server.serve() except KeyboardInterrupt: diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py index 374d9619..4719a257 100644 --- a/mkdocs/config/config_options.py +++ b/mkdocs/config/config_options.py @@ -356,6 +356,7 @@ class FilesystemObject(Type): """ Base class for options that point to filesystem objects. """ + def __init__(self, exists=False, **kwargs): super().__init__(type_=str, **kwargs) self.exists = exists @@ -406,6 +407,36 @@ class File(FilesystemObject): name = 'file' +class ListOfPaths(OptionallyRequired): + """ + List of Paths Config Option + + A list of file system paths. Raises an error if one of the paths does not exist. + """ + + def __init__(self, default=[], required=False): + self.config_dir = None + super().__init__(default, required) + + def pre_validation(self, config, key_name): + self.config_dir = os.path.dirname(config.config_file_path) if config.config_file_path else None + + def run_validation(self, value): + if not isinstance(value, list): + raise ValidationError(f"Expected a list, got {type(value)}") + if len(value) == 0: + return + paths = [] + for path in value: + if self.config_dir and not os.path.isabs(path): + path = os.path.join(self.config_dir, path) + if not os.path.exists(path): + raise ValidationError(f"The path {path} does not exist.") + path = os.path.abspath(path) + paths.append(path) + return paths + + class SiteDir(Dir): """ SiteDir Config Option diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py index bf048042..b3f6ebca 100644 --- a/mkdocs/config/defaults.py +++ b/mkdocs/config/defaults.py @@ -120,4 +120,7 @@ def get_schema(): # A key value pair should be the string name (as the key) and a dict of config # options (as the value). ('plugins', config_options.Plugins(default=['search'])), + + # a list of extra paths to watch while running `mkdocs serve` + ('watch', config_options.ListOfPaths(default=[])) ) diff --git a/mkdocs/livereload/__init__.py b/mkdocs/livereload/__init__.py index 998022de..deef55ba 100644 --- a/mkdocs/livereload/__init__.py +++ b/mkdocs/livereload/__init__.py @@ -4,6 +4,7 @@ import logging import mimetypes import os import os.path +import pathlib import posixpath import re import socketserver @@ -64,6 +65,8 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe self.serve_thread = threading.Thread(target=lambda: self.serve_forever(shutdown_delay)) self.observer = watchdog.observers.polling.PollingObserver(timeout=polling_interval) + self._watched_paths = {} # Used as an ordered set. + def watch(self, path, func=None, recursive=True): """Add the 'path' to watched paths, call the function and reload when any file changes under it.""" path = os.path.abspath(path) @@ -77,6 +80,9 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe stacklevel=2, ) + if path in self._watched_paths: + return + def callback(event): if event.is_directory: return @@ -90,9 +96,14 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe log.debug(f"Watching '{path}'") self.observer.schedule(handler, path, recursive=recursive) + self._watched_paths[path] = True + def serve(self): self.observer.start() + paths_str = ", ".join(f"'{_try_relativize_path(path)}'" for path in self._watched_paths) + log.info(f"Watching paths for changes: {paths_str}") + log.info(f"Serving on {self.url}") self.serve_thread.start() @@ -267,3 +278,13 @@ class _Handler(wsgiref.simple_server.WSGIRequestHandler): def _timestamp(): return round(time.monotonic() * 1000) + + +def _try_relativize_path(path): + """Make the path relative to current directory if it's under that directory.""" + path = pathlib.Path(path) + try: + path = path.relative_to(os.getcwd()) + except ValueError: + pass + return str(path) diff --git a/mkdocs/tests/cli_tests.py b/mkdocs/tests/cli_tests.py index 31f18cbf..40397d72 100644 --- a/mkdocs/tests/cli_tests.py +++ b/mkdocs/tests/cli_tests.py @@ -29,7 +29,8 @@ class CLITests(unittest.TestCase): strict=None, theme=None, use_directory_urls=None, - watch_theme=False + watch_theme=False, + watch=() ) @mock.patch('mkdocs.commands.serve.serve', autospec=True) @@ -59,7 +60,8 @@ class CLITests(unittest.TestCase): strict=None, theme=None, use_directory_urls=None, - watch_theme=False + watch_theme=False, + watch=() ) @mock.patch('mkdocs.commands.serve.serve', autospec=True) @@ -76,7 +78,8 @@ class CLITests(unittest.TestCase): strict=True, theme=None, use_directory_urls=None, - watch_theme=False + watch_theme=False, + watch=() ) @mock.patch('mkdocs.commands.serve.serve', autospec=True) @@ -93,7 +96,8 @@ class CLITests(unittest.TestCase): strict=None, theme='readthedocs', use_directory_urls=None, - watch_theme=False + watch_theme=False, + watch=() ) @mock.patch('mkdocs.commands.serve.serve', autospec=True) @@ -110,7 +114,8 @@ class CLITests(unittest.TestCase): strict=None, theme=None, use_directory_urls=True, - watch_theme=False + watch_theme=False, + watch=() ) @mock.patch('mkdocs.commands.serve.serve', autospec=True) @@ -127,7 +132,8 @@ class CLITests(unittest.TestCase): strict=None, theme=None, use_directory_urls=False, - watch_theme=False + watch_theme=False, + watch=() ) @mock.patch('mkdocs.commands.serve.serve', autospec=True) @@ -144,7 +150,8 @@ class CLITests(unittest.TestCase): strict=None, theme=None, use_directory_urls=None, - watch_theme=False + watch_theme=False, + watch=() ) @mock.patch('mkdocs.commands.serve.serve', autospec=True) @@ -161,7 +168,8 @@ class CLITests(unittest.TestCase): strict=None, theme=None, use_directory_urls=None, - watch_theme=False + watch_theme=False, + watch=() ) @mock.patch('mkdocs.commands.serve.serve', autospec=True) @@ -178,7 +186,8 @@ class CLITests(unittest.TestCase): strict=None, theme=None, use_directory_urls=None, - watch_theme=False + watch_theme=False, + watch=() ) @mock.patch('mkdocs.commands.serve.serve', autospec=True) @@ -195,7 +204,8 @@ class CLITests(unittest.TestCase): strict=None, theme=None, use_directory_urls=None, - watch_theme=True + watch_theme=True, + watch=() ) @mock.patch('mkdocs.config.load_config', autospec=True) diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py index 2e662a4c..edf3ef63 100644 --- a/mkdocs/tests/config/config_options_tests.py +++ b/mkdocs/tests/config/config_options_tests.py @@ -477,6 +477,52 @@ class DirTest(unittest.TestCase): self.assertEqual(len(warns), 0) +class ListOfPathsTest(unittest.TestCase): + + def test_valid_path(self): + paths = [os.path.dirname(__file__)] + option = config_options.ListOfPaths() + option.validate(paths) + + def test_missing_path(self): + paths = [os.path.join("does", "not", "exist", "i", "hope")] + option = config_options.ListOfPaths() + with self.assertRaises(config_options.ValidationError): + option.validate(paths) + + def test_empty_list(self): + paths = [] + option = config_options.ListOfPaths() + option.validate(paths) + + def test_non_list(self): + paths = os.path.dirname(__file__) + option = config_options.ListOfPaths() + with self.assertRaises(config_options.ValidationError): + option.validate(paths) + + def test_file(self): + paths = [__file__] + option = config_options.ListOfPaths() + option.validate(paths) + + def test_paths_localized_to_config(self): + base_path = os.path.abspath('.') + cfg = Config( + [('watch', config_options.ListOfPaths())], + config_file_path=os.path.join(base_path, 'mkdocs.yml'), + ) + test_config = { + 'watch': ['foo'] + } + cfg.load_dict(test_config) + fails, warns = cfg.validate() + self.assertEqual(len(fails), 0) + self.assertEqual(len(warns), 0) + self.assertIsInstance(cfg['watch'], list) + self.assertEqual(cfg['watch'], [os.path.join(base_path, 'foo')]) + + class SiteDirTest(unittest.TestCase): def validate_config(self, config):