Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support custom directories to watch when running mkdocs serve #2642

Merged
merged 18 commits into from Nov 7, 2021
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/user-guide/configuration.md
Expand Up @@ -355,6 +355,22 @@ 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.

### use_directory_urls

This setting controls the style used for linking to pages within the
Expand Down
7 changes: 5 additions & 2 deletions mkdocs/__main__.py
Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Expand Down
12 changes: 11 additions & 1 deletion mkdocs/commands/serve.py
Expand Up @@ -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

Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions mkdocs/config/config_options.py
Expand Up @@ -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
Expand Down Expand Up @@ -406,6 +407,37 @@ 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)
assert isinstance(path, str)
steven-terrana marked this conversation as resolved.
Show resolved Hide resolved
paths.append(path)
return paths


class SiteDir(Dir):
"""
SiteDir Config Option
Expand Down
3 changes: 3 additions & 0 deletions mkdocs/config/defaults.py
Expand Up @@ -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=[]))
)
21 changes: 21 additions & 0 deletions mkdocs/livereload/__init__.py
Expand Up @@ -4,6 +4,7 @@
import mimetypes
import os
import os.path
import pathlib
import posixpath
import re
import socketserver
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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()

Expand Down Expand Up @@ -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)
30 changes: 20 additions & 10 deletions mkdocs/tests/cli_tests.py
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
44 changes: 44 additions & 0 deletions mkdocs/tests/config/config_options_tests.py
Expand Up @@ -494,6 +494,50 @@ 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("doesnt", "exist", "i", "hope") ]
option = config_options.ListOfPaths()
self.assertRaises(config_options.ValidationError, option.validate, paths)
steven-terrana marked this conversation as resolved.
Show resolved Hide resolved

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()
self.assertRaises(config_options.ValidationError, option.validate, paths)
steven-terrana marked this conversation as resolved.
Show resolved Hide resolved

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):
Expand Down