Repository: mkdocs/mkdocs

Issue:
refactor: only pass server url to build function
Small refactor, dont pass the whole Live server instance to the build function.
Right now, the builder method knows about the live server and the live server knows about the build method.
(see https://github.com/mkdocs/mkdocs/compare/master...phil65:mkdocs:untangle_build_and_serve?expand=1#diff-6ce1f9285defedc650e776dc5832321bc5297c681ac941dac31982ee237c7589L67 and https://github.com/mkdocs/mkdocs/compare/master...phil65:mkdocs:untangle_build_and_serve?expand=1#diff-6ce1f9285defedc650e776dc5832321bc5297c681ac941dac31982ee237c7589L70
This untangles this so that the build method only needs the server url.


Discussion:

Resolving patch:
diff --git a/mkdocs/commands/build.py b/mkdocs/commands/build.py
--- a/mkdocs/commands/build.py
+++ b/mkdocs/commands/build.py
@@ -22,8 +22,6 @@
 if TYPE_CHECKING:
     from mkdocs.config.defaults import MkDocsConfig
 
-if TYPE_CHECKING:
-    from mkdocs.livereload import LiveReloadServer
 
 log = logging.getLogger(__name__)
 
@@ -247,9 +245,7 @@ def _build_page(
         config._current_page = None
 
 
-def build(
-    config: MkDocsConfig, live_server: LiveReloadServer | None = None, dirty: bool = False
-) -> None:
+def build(config: MkDocsConfig, *, serve_url: str | None = None, dirty: bool = False) -> None:
     """Perform a full site build."""
     logger = logging.getLogger('mkdocs')
 
@@ -259,7 +255,7 @@ def build(
     if config.strict:
         logging.getLogger('mkdocs').addHandler(warning_counter)
 
-    inclusion = InclusionLevel.all if live_server else InclusionLevel.is_included
+    inclusion = InclusionLevel.all if serve_url else InclusionLevel.is_included
 
     try:
         start = time.monotonic()
@@ -280,7 +276,7 @@ def build(
                 " links within your site. This option is designed for site development purposes only."
             )
 
-        if not live_server:  # pragma: no cover
+        if not serve_url:  # pragma: no cover
             log.info(f"Building documentation to directory: {config.site_dir}")
             if dirty and site_directory_contains_stale_files(config.site_dir):
                 log.info("The directory contains stale files. Use --clean to remove them.")
@@ -306,8 +302,8 @@ def build(
         for file in files.documentation_pages(inclusion=inclusion):
             log.debug(f"Reading: {file.src_uri}")
             if file.page is None and file.inclusion.is_not_in_nav():
-                if live_server and file.inclusion.is_excluded():
-                    excluded.append(urljoin(live_server.url, file.url))
+                if serve_url and file.inclusion.is_excluded():
+                    excluded.append(urljoin(serve_url, file.url))
                 Page(None, file, config)
             assert file.page is not None
             _populate_page(file.page, config, files, dirty)
diff --git a/mkdocs/commands/serve.py b/mkdocs/commands/serve.py
--- a/mkdocs/commands/serve.py
+++ b/mkdocs/commands/serve.py
@@ -9,7 +9,7 @@
 
 from mkdocs.commands.build import build
 from mkdocs.config import load_config
-from mkdocs.livereload import LiveReloadServer
+from mkdocs.livereload import LiveReloadServer, _serve_url
 
 if TYPE_CHECKING:
     from mkdocs.config.defaults import MkDocsConfig
@@ -37,9 +37,6 @@ def serve(
     # string is returned. And it makes MkDocs temp dirs easier to identify.
     site_dir = tempfile.mkdtemp(prefix='mkdocs_')
 
-    def mount_path(config: MkDocsConfig):
-        return urlsplit(config.site_url or '/').path
-
     def get_config():
         config = load_config(
             config_file=config_file,
@@ -47,7 +44,6 @@ def get_config():
             **kwargs,
         )
         config.watch.extend(watch)
-        config.site_url = f'http://{config.dev_addr}{mount_path(config)}'
         return config
 
     is_clean = build_type == 'clean'
@@ -56,16 +52,20 @@ def get_config():
     config = get_config()
     config.plugins.on_startup(command=('build' if is_clean else 'serve'), dirty=is_dirty)
 
+    host, port = config.dev_addr
+    mount_path = urlsplit(config.site_url or '/').path
+    config.site_url = serve_url = _serve_url(host, port, mount_path)
+
     def builder(config: MkDocsConfig | None = None):
         log.info("Building documentation...")
         if config is None:
             config = get_config()
+            config.site_url = serve_url
 
-        build(config, live_server=None if is_clean else server, dirty=is_dirty)
+        build(config, serve_url=None if is_clean else serve_url, dirty=is_dirty)
 
-    host, port = config.dev_addr
     server = LiveReloadServer(
-        builder=builder, host=host, port=port, root=site_dir, mount_path=mount_path(config)
+        builder=builder, host=host, port=port, root=site_dir, mount_path=mount_path
     )
 
     def error_handler(code) -> bytes | None:
diff --git a/mkdocs/livereload/__init__.py b/mkdocs/livereload/__init__.py
--- a/mkdocs/livereload/__init__.py
+++ b/mkdocs/livereload/__init__.py
@@ -81,6 +81,15 @@ def process(self, msg: str, kwargs: dict) -> tuple[str, dict]:  # type: ignore[o
 log = _LoggerAdapter(logging.getLogger(__name__), {})
 
 
+def _normalize_mount_path(mount_path: str) -> str:
+    """Ensure the mount path starts and ends with a slash."""
+    return ("/" + mount_path.lstrip("/")).rstrip("/") + "/"
+
+
+def _serve_url(host: str, port: int, path: str) -> str:
+    return f"http://{host}:{port}{_normalize_mount_path(path)}"
+
+
 class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGIServer):
     daemon_threads = True
     poll_response_timeout = 60
@@ -96,16 +105,14 @@ def __init__(
         shutdown_delay: float = 0.25,
     ) -> None:
         self.builder = builder
-        self.server_name = host
-        self.server_port = port
         try:
             if isinstance(ipaddress.ip_address(host), ipaddress.IPv6Address):
                 self.address_family = socket.AF_INET6
         except Exception:
             pass
         self.root = os.path.abspath(root)
-        self.mount_path = ("/" + mount_path.lstrip("/")).rstrip("/") + "/"
-        self.url = f"http://{self.server_name}:{self.server_port}{self.mount_path}"
+        self.mount_path = _normalize_mount_path(mount_path)
+        self.url = _serve_url(host, port, mount_path)
         self.build_delay = 0.1
         self.shutdown_delay = shutdown_delay
         # To allow custom error pages.


Test patch:
diff --git a/mkdocs/tests/build_tests.py b/mkdocs/tests/build_tests.py
--- a/mkdocs/tests/build_tests.py
+++ b/mkdocs/tests/build_tests.py
@@ -16,7 +16,6 @@
 from mkdocs.commands import build
 from mkdocs.config import base
 from mkdocs.exceptions import PluginError
-from mkdocs.livereload import LiveReloadServer
 from mkdocs.structure.files import File, Files
 from mkdocs.structure.nav import get_navigation
 from mkdocs.structure.pages import Page
@@ -36,13 +35,6 @@ def build_page(title, path, config, md_src=''):
     return page, files
 
 
-def testing_server(root, builder=lambda: None, mount_path="/"):
-    with mock.patch("socket.socket"):
-        return LiveReloadServer(
-            builder, host="localhost", port=123, root=root, mount_path=mount_path
-        )
-
-
 class BuildTests(PathAssertionMixin, unittest.TestCase):
     def _get_env_with_null_translations(self, config):
         env = config.theme.get_env()
@@ -596,7 +588,7 @@ def test_exclude_pages_with_invalid_links(self, site_dir, docs_dir):
             exclude_docs='ba*.md',
         )
 
-        with self.subTest(live_server=None):
+        with self.subTest(serve_url=None):
             expected_logs = '''
                 INFO:Doc file 'test/foo.md' contains a link to 'test/bar.md' which is excluded from the built site.
             '''
@@ -606,8 +598,8 @@ def test_exclude_pages_with_invalid_links(self, site_dir, docs_dir):
             self.assertPathNotExists(site_dir, 'test', 'baz.html')
             self.assertPathNotExists(site_dir, '.zoo.html')
 
-        server = testing_server(site_dir, mount_path='/documentation/')
-        with self.subTest(live_server=server):
+        serve_url = 'http://localhost:123/documentation/'
+        with self.subTest(serve_url=serve_url):
             expected_logs = '''
                 INFO:Doc file 'test/bar.md' contains a relative link 'nonexistent.md', but the target 'test/nonexistent.md' is not found among documentation files.
                 INFO:Doc file 'test/foo.md' contains a link to 'test/bar.md' which is excluded from the built site.
@@ -617,7 +609,7 @@ def test_exclude_pages_with_invalid_links(self, site_dir, docs_dir):
                   - http://localhost:123/documentation/test/baz.html
             '''
             with self._assert_build_logs(expected_logs):
-                build.build(cfg, live_server=server)
+                build.build(cfg, serve_url=serve_url)
 
             foo_path = Path(site_dir, 'test', 'foo.html')
             self.assertTrue(foo_path.is_file())
@@ -639,13 +631,13 @@ def test_exclude_pages_with_invalid_links(self, site_dir, docs_dir):
     def test_conflicting_readme_and_index(self, site_dir, docs_dir):
         cfg = load_config(docs_dir=docs_dir, site_dir=site_dir, use_directory_urls=False)
 
-        for server in None, testing_server(site_dir):
-            with self.subTest(live_server=server):
+        for serve_url in None, 'http://localhost:123/':
+            with self.subTest(serve_url=serve_url):
                 expected_logs = '''
                     WARNING:Excluding 'foo/README.md' from the site because it conflicts with 'foo/index.md'.
                 '''
                 with self._assert_build_logs(expected_logs):
-                    build.build(cfg, live_server=server)
+                    build.build(cfg, serve_url=serve_url)
 
                 index_path = Path(site_dir, 'foo', 'index.html')
                 self.assertPathIsFile(index_path)
@@ -663,10 +655,10 @@ def test_exclude_readme_and_index(self, site_dir, docs_dir):
             docs_dir=docs_dir, site_dir=site_dir, use_directory_urls=False, exclude_docs='index.md'
         )
 
-        for server in None, testing_server(site_dir):
-            with self.subTest(live_server=server):
+        for serve_url in None, 'http://localhost:123/':
+            with self.subTest(serve_url=serve_url):
                 with self._assert_build_logs(''):
-                    build.build(cfg, live_server=server)
+                    build.build(cfg, serve_url=serve_url)
 
                 index_path = Path(site_dir, 'foo', 'index.html')
                 self.assertPathIsFile(index_path)
@@ -693,9 +685,9 @@ def on_files_2(files: Files, config: MkDocsConfig) -> None:
             assert f is not None
             config.nav = Path(f.abs_src_path).read_text().splitlines()
 
-        for server in None, testing_server(site_dir):
+        for serve_url in None, 'http://localhost:123/':
             for exclude in 'full', 'nav', None:
-                with self.subTest(live_server=server, exclude=exclude):
+                with self.subTest(serve_url=serve_url, exclude=exclude):
                     cfg = load_config(
                         docs_dir=docs_dir,
                         site_dir=site_dir,
@@ -711,13 +703,13 @@ def on_files_2(files: Files, config: MkDocsConfig) -> None:
                             INFO:The following pages exist in the docs directory, but are not included in the "nav" configuration:
                               - SUMMARY.md
                         '''
-                    if exclude == 'full' and server:
+                    if exclude == 'full' and serve_url:
                         expected_logs = '''
                             INFO:The following pages are being built only for the preview but will be excluded from `mkdocs build` per `exclude_docs`:
                               - http://localhost:123/SUMMARY.html
                         '''
                     with self._assert_build_logs(expected_logs):
-                        build.build(cfg, live_server=server)
+                        build.build(cfg, serve_url=serve_url)
 
                     foo_path = Path(site_dir, 'foo.html')
                     self.assertPathIsFile(foo_path)
@@ -727,7 +719,7 @@ def on_files_2(files: Files, config: MkDocsConfig) -> None:
                     )
 
                     summary_path = Path(site_dir, 'SUMMARY.html')
-                    if exclude == 'full' and not server:
+                    if exclude == 'full' and not serve_url:
                         self.assertPathNotExists(summary_path)
                     else:
                         self.assertPathExists(summary_path)
diff --git a/mkdocs/tests/livereload_tests.py b/mkdocs/tests/livereload_tests.py
--- a/mkdocs/tests/livereload_tests.py
+++ b/mkdocs/tests/livereload_tests.py
@@ -39,6 +39,8 @@ def testing_server(root, builder=lambda: None, mount_path="/"):
             mount_path=mount_path,
             polling_interval=0.2,
         )
+        server.server_name = "localhost"
+        server.server_port = 0
         server.setup_environ()
     server.observer.start()
     thread = threading.Thread(target=server._build_loop, daemon=True)