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 <oleh@pryp.in>
This commit is contained in:
steven-terrana
2021-11-07 04:29:58 -05:00
committed by GitHub
parent fa52d3ae71
commit 82fcf3115a
8 changed files with 159 additions and 13 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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:

View File

@@ -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

View File

@@ -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=[]))
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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):