mirror of
https://github.com/mkdocs/mkdocs.git
synced 2026-04-12 07:06:28 +07:00
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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=[]))
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user