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

Don't physically copy static files for mkdocs serve #3570

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all 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
10 changes: 8 additions & 2 deletions mkdocs/commands/build.py
Expand Up @@ -246,7 +246,7 @@ def _build_page(
config._current_page = None


def build(config: MkDocsConfig, *, serve_url: str | None = None, dirty: bool = False) -> None:
def build(config: MkDocsConfig, *, serve_url: str | None = None, dirty: bool = False) -> Files:
"""Perform a full site build."""
logger = logging.getLogger('mkdocs')

Expand Down Expand Up @@ -322,7 +322,12 @@ def build(config: MkDocsConfig, *, serve_url: str | None = None, dirty: bool = F
# with lower precedence get written first so that files with higher precedence can overwrite them.

log.debug("Copying static assets.")
files.copy_static_files(dirty=dirty, inclusion=inclusion)
for file in files:
if not file.is_documentation_page() and inclusion(file.inclusion):
if serve_url and file.is_copyless_static_file:
log.debug(f"Skip copying static file: '{file.src_uri}'")
continue
file.copy_file(dirty)

for template in config.theme.static_templates:
_build_theme_template(template, env, files, config, nav)
Expand Down Expand Up @@ -351,6 +356,7 @@ def build(config: MkDocsConfig, *, serve_url: str | None = None, dirty: bool = F
raise Abort(f'Aborted with {msg} in strict mode!')

log.info(f'Documentation built in {time.monotonic() - start:.2f} seconds')
return files

except Exception as e:
# Run `build_error` plugin events.
Expand Down
35 changes: 27 additions & 8 deletions mkdocs/commands/serve.py
@@ -1,15 +1,16 @@
from __future__ import annotations

import logging
import os.path
import shutil
import tempfile
from os.path import isdir, isfile, join
from typing import TYPE_CHECKING
from urllib.parse import urlsplit

from mkdocs.commands.build import build
from mkdocs.config import load_config
from mkdocs.livereload import LiveReloadServer, _serve_url
from mkdocs.structure.files import Files

if TYPE_CHECKING:
from mkdocs.config.defaults import MkDocsConfig
Expand All @@ -35,8 +36,6 @@ def serve(
whenever a file is edited.
"""
# Create a temporary build directory, and set some options to serve it
# PY2 returns a byte string by default. The Unicode prefix ensures a Unicode
# string is returned. And it makes MkDocs temp dirs easier to identify.
site_dir = tempfile.mkdtemp(prefix='mkdocs_')

def get_config():
Expand All @@ -58,22 +57,42 @@ def get_config():
mount_path = urlsplit(config.site_url or '/').path
config.site_url = serve_url = _serve_url(host, port, mount_path)

files: Files = Files(())

def builder(config: MkDocsConfig | None = None):
log.info("Building documentation...")
if config is None:
config = get_config()
config.site_url = serve_url

build(config, serve_url=None if is_clean else serve_url, dirty=is_dirty)
nonlocal files
files = build(config, serve_url=None if is_clean else serve_url, dirty=is_dirty)

def file_hook(path: str) -> str | None:
f = files.get_file_from_path(path)
if f is not None and f.is_copyless_static_file:
return f.abs_src_path
return None

def get_file(path: str) -> str | None:
if new_path := file_hook(path):
return os.path.join(site_dir, new_path)
if os.path.isfile(try_path := os.path.join(site_dir, path)):
return try_path
return None

server = LiveReloadServer(
builder=builder, host=host, port=port, root=site_dir, mount_path=mount_path
builder=builder,
host=host,
port=port,
root=site_dir,
file_hook=file_hook,
mount_path=mount_path,
)

def error_handler(code) -> bytes | None:
if code in (404, 500):
error_page = join(site_dir, f'{code}.html')
if isfile(error_page):
if error_page := get_file(f'{code}.html'):
with open(error_page, 'rb') as f:
return f.read()
return None
Expand Down Expand Up @@ -108,5 +127,5 @@ def error_handler(code) -> bytes | None:
server.shutdown()
finally:
config.plugins.on_shutdown()
if isdir(site_dir):
if os.path.isdir(site_dir):
shutil.rmtree(site_dir)
15 changes: 12 additions & 3 deletions mkdocs/livereload/__init__.py
Expand Up @@ -101,6 +101,8 @@ def __init__(
host: str,
port: int,
root: str,
*,
file_hook: Callable[[str], str | None] = lambda path: None,
mount_path: str = "/",
polling_interval: float = 0.5,
shutdown_delay: float = 0.25,
Expand All @@ -112,6 +114,7 @@ def __init__(
except Exception:
pass
self.root = os.path.abspath(root)
self.file_hook = file_hook
self.mount_path = _normalize_mount_path(mount_path)
self.url = _serve_url(host, port, mount_path)
self.build_delay = 0.1
Expand Down Expand Up @@ -289,7 +292,6 @@ def condition():
rel_file_path += "index.html"
# Prevent directory traversal - normalize the path.
rel_file_path = posixpath.normpath("/" + rel_file_path).lstrip("/")
file_path = os.path.join(self.root, rel_file_path)
elif path == "/":
start_response("302 Found", [("Location", urllib.parse.quote(self.mount_path))])
return []
Expand All @@ -298,13 +300,20 @@ def condition():

# Wait until the ongoing rebuild (if any) finishes, so we're not serving a half-built site.
with self._epoch_cond:
self._epoch_cond.wait_for(lambda: self._visible_epoch == self._wanted_epoch)
file_path = self.file_hook(rel_file_path)
if file_path is None:
file_path = os.path.join(self.root, rel_file_path)

self._epoch_cond.wait_for(lambda: self._visible_epoch == self._wanted_epoch)
epoch = self._visible_epoch

try:
file: BinaryIO = open(file_path, "rb")
except OSError:
if not path.endswith("/") and os.path.isfile(os.path.join(file_path, "index.html")):
if not path.endswith("/") and (
self.file_hook(rel_file_path) is not None
or os.path.isfile(os.path.join(file_path, "index.html"))
):
start_response("302 Found", [("Location", urllib.parse.quote(path) + "/")])
return []
return None # Not found
Expand Down
6 changes: 5 additions & 1 deletion mkdocs/structure/files.py
Expand Up @@ -116,7 +116,7 @@ def copy_static_files(
*,
inclusion: Callable[[InclusionLevel], bool] = InclusionLevel.is_included,
) -> None:
"""Copy static files from source to destination."""
"""Soft-deprecated, do not use."""
for file in self:
if not file.is_documentation_page() and inclusion(file.inclusion):
file.copy_file(dirty)
Expand Down Expand Up @@ -464,6 +464,10 @@ def content_string(self, value: str):
self._content = value
self.abs_src_path = None

@utils.weak_property
def is_copyless_static_file(self) -> bool:
return self.abs_src_path is not None and self.dest_uri == self.src_uri

def copy_file(self, dirty: bool = False) -> None:
"""Copy source file to destination, ensuring parent directories exist."""
if dirty and not self.is_modified():
Expand Down