This commit is contained in:
CreaRobotics committed 2026-03-02 11:34:40 +01:00
1 parent 6ccab0347b
commit 954a2fd6d6
18684 files changed
+601012 -190

No files matched your search

@@ -0,0 +1,320 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import logging
import multiprocessing
import os
import posixpath
from collections.abc import Callable
from concurrent.futures import Future, as_completed
from concurrent.futures.process import ProcessPoolExecutor
from logging import Logger
from material.plugins.projects.config import ProjectsConfig
from material.plugins.projects.structure import Project, ProjectJob
from mkdocs.commands.build import build
from mkdocs.config.base import ConfigErrors, ConfigWarnings
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.exceptions import Abort
from mkdocs.livereload import LiveReloadServer
from urllib.parse import urlparse
from .log import (
get_log_for,
get_log_formatter,
get_log_handler,
get_log_level_for
)
from .watcher import ProjectsWatcher
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Projects builder
class ProjectsBuilder:
# Set of projects
projects: set[Project] = set()
# Projects watcher
watcher: ProjectsWatcher | None = None
# Initialize projects builder - compared to our other concurrent plugins,
# this plugin is forced to use a process pool in order to guarantee proper
# isolation, as MkDocs itself is not thread-safe. We also need to recreate
# the process pool on every build, or CTRL-C is broken
def __init__(self, config: MkDocsConfig, plugin: ProjectsConfig):
# Initialize root project
self.root = Project(config.config_file_path, plugin)
self.root.config = config
# Initialize process pool
self.pool = ProcessPoolExecutor
self.pool_jobs: dict[str, Future] = {}
# Build projects
def build(self, serve: bool = False, dirty: bool = False):
self.pool = ProcessPoolExecutor(
self.root.plugin.concurrency,
mp_context = multiprocessing.get_context("spawn")
)
# Determine projects in topological order and prepare for building
built: list[str] = []
queue = [*self.root.jobs()]
for job in queue:
_setup(job.project, self.root, serve)
if serve:
self._link(job.project)
# Schedule projects for building
for job in reversed(queue):
if self._schedule(job, serve, dirty):
queue.remove(job)
# Build loop - iteratively build more projects while there are still
# projects to be built, sticking to the topological order.
while len(built) < len(self.pool_jobs):
for future in as_completed(self.pool_jobs.values()):
slug, errors, warnings = future.result()
if slug in built:
continue
# Mark project as built
built.append(slug)
# Schedule projects for building
for job in reversed(queue):
if self._schedule(job, serve, dirty):
queue.remove(job)
# Print errors and warnings
for project in self.projects:
if project.slug == slug:
_print(get_log_for(project), errors, warnings)
break
# Shutdown process pool
self.pool.shutdown()
if self.watcher:
# Update watched paths
for project in self.projects:
if project.slug not in built:
self.pool_jobs.pop(project.slug, None)
self.watcher.unwatch(project)
else:
self.watcher.watch(project, self.taint)
# Taint a project to schedule it for building
def taint(self, project: Project):
self.pool_jobs.pop(project.slug, None)
# Watch and serve projects
def serve(self, server: LiveReloadServer, is_dirty: bool = False):
self.watcher = ProjectsWatcher(server)
self.watcher.watch(self.root, self.taint)
for project in self.projects:
self.watcher.watch(project, self.taint)
# -------------------------------------------------------------------------
# Create symlink for project if we're serving the site
def _link(self, project: Project):
# Compute path for slug from current project - normalize path,
# as paths computed from slugs or site URLs use forward slashes
path = project.path(self.root)
path = os.path.normpath(path)
# Create symbolic link, if we haven't already
path = os.path.join(self.root.config.site_dir, path)
if not os.path.islink(path):
# Ensure link target exists
target = os.path.realpath(os.path.dirname(path))
if not os.path.exists(target):
os.makedirs(target, exist_ok = True)
# Create symbolic link
os.makedirs(os.path.dirname(path), exist_ok = True)
os.symlink(project.config.site_dir, path)
# Schedule project for building - spawn concurrent job to build the project
# and add a future to the jobs dictionary to link build results to projects
def _schedule(self, job: ProjectJob, serve: bool, dirty: bool):
self.projects.add(job.project)
# Exit early, if project doesn't need to be re built
if job.project.slug in self.pool_jobs:
return True
# Check if dependencies have been built already, and if so, remove
# them from the list of dependencies. If a dependency has failed to
# build, we'll raise an exception, which will be caught by the main
# process, and the entire build will be aborted.
for dependency in [*job.dependencies]:
future = self.pool_jobs[dependency.slug]
if future.running():
continue
# If the dependency has failed to build, we'll raise an exception
# to abort the entire build, as we can't build the project itself
# without the dependency. This will be caught by the main process.
# Otherwise, we'll remove the dependency from the list.
if future.exception():
raise future.exception()
elif future.done():
job.dependencies.remove(dependency)
# If all dependencies of the project have been built, we can build
# the project itself by spawning a concurrent job
if not job.dependencies:
self.pool_jobs[job.project.slug] = self.pool.submit(
_build, job.project, serve, dirty,
get_log_level_for(job.project)
)
# Return whether the project has been scheduled
return not job.dependencies
# -----------------------------------------------------------------------------
# Helper functions
# -----------------------------------------------------------------------------
# Setup project by preparing it for building
def _setup(project: Project, root: Project, serve: bool):
assert project.slug != "."
# Retrieve configuration of top-level project and transform project
transform = root.plugin.projects_config_transform
if isinstance(transform, Callable):
transform(project, root)
# If the top-level project defines a site URL, we need to make sure that the
# site URL of the project is set as well, setting it to the path we derive
# from the slug. This allows to define the URL independent of the entire
# project's directory structure. If the top-level project doesn't define a
# site URL, it might be the case that the author is building a consolidated
# project of several nested projects that are independent, but which should
# be bundled together for distribution. As this is a case that is quite
# common, we're not raising a warning or error.
path = project.path(root)
if root.config.site_url:
# If the project doesn't have a site URL, compute it from the site URL
# of the top-level project and the path derived from the slug
if not project.config.site_url:
project.config.site_url = posixpath.join(
root.config.site_url,
path
)
# If we're serving the site, replace the project's host name with the
# dev server address, so we can serve nested projects as well
if serve:
url = urlparse(project.config.site_url)
url = url._replace(
scheme = "http",
netloc = str(root.config.dev_addr)
)
# Update site URL with dev server address
project.config.site_url = url.geturl()
# If we're building the site, the project's output must be written to the
# site directory of the top-level project, so we can serve it from there
if not serve:
project.config.site_dir = os.path.join(
root.config.site_dir,
os.path.normpath(path)
)
# If we're serving the site, we must fall back to symbolic links, as MkDocs
# will empty the entire site directory every time it performs a build
else:
project.config.site_dir = os.path.join(
os.path.dirname(project.config.config_file_path),
project.config.site_dir
)
# Build project - note that regardless of whether MkDocs was started in build
# or serve mode, projects must always be built, as they're served by the root
def _build(project: Project, serve: bool, dirty: bool, level = logging.WARN):
config = project.config
# Change working directory to project root - this is necessary, or relative
# paths used in extensions and plugins will be resolved incorrectly
os.chdir(os.path.dirname(config.config_file_path))
# Validate configuration
errors, warnings = config.validate()
if not errors:
# Retrieve and configure MkDocs' logger
log = logging.getLogger("mkdocs")
log.setLevel(level)
# Hack: there seems to be an inconsistency between operating systems,
# and it's yet unclear where this is coming from - on macOS, the MkDocs
# default logger has no designated handler registered, but on Linux it
# does. If there's no handler, we need to create one. If there is, we
# must only set the formatter, as otherwise we'll end up with the same
# message printed on two log handlers - see https://t.ly/q7UEq
handler = get_log_handler(project)
if not log.hasHandlers():
log.addHandler(handler)
else:
for handler in log.handlers:
handler.setFormatter(get_log_formatter(project))
# Build project and dispatch startup and shutdown plugin events - note
# that we must pass the correct command to the event handler, but run
# the build command anyway, because otherwise some plugins will not
# run in serve mode.
command = "serve" if serve else "build"
config.plugins.run_event("startup", command = command, dirty = dirty)
try:
build(config, dirty = dirty)
finally:
config.plugins.run_event("shutdown")
log.removeHandler(handler)
# Return slug, errors and warnings
return project.slug, errors, warnings
# Print errors and warnings resulting from building a project
def _print(log: Logger, errors: ConfigErrors, warnings: ConfigWarnings):
# Print warnings
for value, message in warnings:
log.warning(f"Config value '{value}': {message}")
# Print errors
for value, message in errors:
log.error(f"Config value '{value}': {message}")
# Abort if there were errors
if errors:
raise Abort(f"Aborted with {len(errors)} configuration errors")
@@ -0,0 +1,100 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import logging
from click import style
from logging import Filter
from material.plugins.projects.structure import Project
from mkdocs.__main__ import ColorFormatter
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Dirty build warning filter
class ProjectsFilter(Filter):
# Filter log messages
def filter(self, record):
message = record.getMessage()
return not message.startswith("A 'dirty' build")
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# Retrieve logger for project
def get_log_for(project: Project):
log = logging.getLogger("".join(["mkdocs.material.projects", project.slug]))
# Ensure logger does not propagate messags to parent logger, or messages
# will be printed multiple times, and attach handler with color formatter
log.propagate = False
if not log.hasHandlers():
log.addHandler(get_log_handler(project))
log.setLevel(get_log_level_for(project))
# Return logger
return log
# Retrieve log level for project
def get_log_level_for(project: Project):
level = logging.INFO
# Determine log level as set in MkDocs - if the build is started with the
# `--quiet` flag, the log level is set to `ERROR` to suppress all messages,
# except for errors. If it's started with `--verbose`, MkDocs sets the log
# level to `DEBUG`, the most verbose of all log levels.
log = logging.getLogger("mkdocs")
for handler in log.handlers:
level = handler.level
break
# Determine if MkDocs was invoked with the `--quiet` flag and the log level
# as configured in the plugin configuration. When `--quiet` is set, or the
# projects plugin configuration disables logging, ignore the configured log
# level and set it to `ERROR` to suppress all messages.
quiet = level == logging.ERROR
level = project.plugin.log_level.upper()
if quiet or not project.plugin.log:
level = logging.ERROR
# Retun log level
return level
# -----------------------------------------------------------------------------
# Retrieve log handler for project
def get_log_handler(project: Project):
handler = logging.StreamHandler()
handler.setFormatter(get_log_formatter(project))
# Add filter to suppress dirty build warning, or we'll get as many of those
# as projects are built - one warning is surely enough, KTHXBYE
handler.addFilter(ProjectsFilter())
return handler
# Retrieve log formatter for project
def get_log_formatter(project: Project):
prefix = style(f"project://{project.slug}", underline = True)
return ColorFormatter(f"[{prefix}] %(message)s")
@@ -0,0 +1,98 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import os
from collections.abc import Callable
from material.plugins.projects.structure import Project
from mkdocs.livereload import LiveReloadServer
from .handler import ProjectChanged, ProjectAddedOrRemoved
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Projects watcher
class ProjectsWatcher:
# Set of watched paths
watched: set[str] = set()
# Initialize projects watcher
def __init__(self, server: LiveReloadServer):
self.server = server
# Watch project and invoke function on change
def watch(self, project: Project, fn: Callable):
self._on_project_changed(project, fn)
self._on_project_added_or_removed(project, fn)
# Stop watching project
def unwatch(self, project: Project):
# Traverse all watched paths
root = os.path.dirname(project.config.config_file_path)
for path in [*self.watched]:
# Remove path from watched paths
if path.startswith(root):
self.server.unwatch(path)
self.watched.remove(path)
# -------------------------------------------------------------------------
# Register event handler for project changes
def _on_project_changed(self, project: Project, fn: Callable):
# Resolve project root and docs directory
root = os.path.dirname(project.config.config_file_path)
docs = os.path.join(root, project.config.docs_dir)
# Collect all paths to watch
paths = set([docs, project.config.config_file_path])
for path in project.config.watch:
paths.add(os.path.join(root, path))
# Register event handler for unwatched paths
handler = ProjectChanged(project, fn)
for path in paths - self.watched:
self.server.watch(path)
# Add path and its descendents to watched paths
self.server.observer.schedule(handler, path, recursive = True)
self.watched.add(path)
# Register event handler for project additions and removals
def _on_project_added_or_removed(self, project: Project, fn: Callable):
# Resolve project root and path to projects directory
root = os.path.dirname(project.config.config_file_path)
path = os.path.join(root, project.plugin.projects_dir)
# Register event handler for unwatched paths
handler = ProjectAddedOrRemoved(project, fn)
if path not in self.watched and os.path.isdir(path):
# Add path to watched paths
self.server.observer.schedule(handler, path)
self.watched.add(path)
@@ -0,0 +1,105 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import logging
import os
from collections.abc import Callable
from material.plugins.projects.structure import Project
from watchdog.events import FileSystemEvent, FileSystemEventHandler
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Project changed
class ProjectChanged(FileSystemEventHandler):
# Initialize event handler
def __init__(self, project: Project, handler: Callable):
self.project = project
self.handler = handler
# Handle file event
def on_any_event(self, event: FileSystemEvent):
self._handle(event)
# -------------------------------------------------------------------------
# Invoke file event handler
def _handle(self, event: FileSystemEvent):
config = self.project.config
# Resolve path to docs directory
base = os.path.dirname(config.config_file_path)
docs = os.path.join(base, config.docs_dir)
# Resolve project root and path to changed file
root = os.path.relpath(base)
path = os.path.relpath(event.src_path, root)
# Check if mkdocs.yml or docs directory was deleted
if event.src_path in [docs, config.config_file_path]:
if event.event_type == "deleted":
return
# Invoke handler and print message that we're scheduling a build
log.info(f"Schedule build due to '{path}' in '{root}'")
self.handler(self.project)
# -----------------------------------------------------------------------------
# Project added or removed
class ProjectAddedOrRemoved(FileSystemEventHandler):
# Initialize event handler
def __init__(self, project: Project, handler: Callable):
self.project = project
self.handler = handler
# Handle file creation event
def on_created(self, event: FileSystemEvent):
self._handle(event)
# Handle file deletion event
def on_deleted(self, event: FileSystemEvent):
self._handle(event)
# ------------------------------------------------------------------------
# Invoke file event handler
def _handle(self, event: FileSystemEvent):
config = self.project.config
# Touch mkdocs.yml to trigger rebuild
if os.path.isfile(config.config_file_path):
os.utime(config.config_file_path, None)
# Invoke handler
self.handler(self.project)
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs")