diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index d7da01eaa6..34b8e8cfae 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -355,6 +355,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 4afd164b14..33758c661e 100644 --- a/mkdocs/__main__.py +++ b/mkdocs/__main__.py @@ -99,6 +99,8 @@ def __init__(self, log_name='mkdocs', level=logging.INFO): 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 4ba7fb4cc9..28be7fd476 100644 --- a/mkdocs/commands/serve.py +++ b/mkdocs/commands/serve.py @@ -13,7 +13,7 @@ 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 builder(): 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 error_handler(code): # 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 374d9619bb..4719a257ed 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 bf04804282..b3f6ebca02 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 998022de7e..deef55bafd 100644 --- a/mkdocs/livereload/__init__.py +++ b/mkdocs/livereload/__init__.py @@ -4,6 +4,7 @@ import mimetypes import os import os.path +import pathlib import posixpath import re import socketserver @@ -64,6 +65,8 @@ def __init__( 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 @@ def watch(self, path, func=None, recursive=True): stacklevel=2, ) + if path in self._watched_paths: + return + def callback(event): if event.is_directory: return @@ -90,9 +96,14 @@ def callback(event): 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 @@ def log_message(self, format, *args): 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 31f18cbfc1..40397d72f9 100644 --- a/mkdocs/tests/cli_tests.py +++ b/mkdocs/tests/cli_tests.py @@ -29,7 +29,8 @@ def test_serve_default(self, mock_serve): 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 @@ def test_serve_dev_addr(self, mock_serve): 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 @@ def test_serve_strict(self, mock_serve): 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 @@ def test_serve_theme(self, mock_serve): 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 @@ def test_serve_use_directory_urls(self, mock_serve): 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 @@ def test_serve_no_directory_urls(self, mock_serve): 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 @@ def test_serve_livereload(self, mock_serve): 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 @@ def test_serve_no_livereload(self, mock_serve): 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 @@ def test_serve_dirtyreload(self, mock_serve): 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 @@ def test_serve_watch_theme(self, mock_serve): 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 64996aee9a..ed9a142c72 100644 --- a/mkdocs/tests/config/config_options_tests.py +++ b/mkdocs/tests/config/config_options_tests.py @@ -494,6 +494,52 @@ def test_dir_is_config_dir_fails(self): 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):