diff --git a/.gitignore b/.gitignore index a903f4d..56c55d6 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,7 @@ __pycache__ # Virtualenv directories [v]env .[v]env + +# Sphinx documentation build artifacts +/docs/_build +.doctrees diff --git a/README.rst b/README.rst index 190b9a1..b940d17 100644 --- a/README.rst +++ b/README.rst @@ -35,7 +35,7 @@ To build a classical Sphinx documentation set, run: sphinx-autobuild docs docs/_build/html -This will start a server at http://127.0.0.1:8000 +This will start a server at http://127.0.0.1:8000 (or automatically find a free port if 8000 is in use) and start watching for changes in the ``docs/`` directory. When a change is detected in ``docs/``, the documentation is rebuilt and any open browser windows are reloaded automatically. @@ -57,7 +57,7 @@ which can seen by running ``sphinx-autobuild --help``: ... autobuild options: - --port PORT port to serve documentation on. 0 means find and use a free port + --port PORT port to serve documentation on (defaults to 8000, or a free port if 8000 is in use) --host HOST hostname to serve documentation on --re-ignore RE_IGNORE regular expression for files to ignore, when watching for changes @@ -101,9 +101,14 @@ Passing ``--open-browser`` will enable this behaviour. Automatically selecting a port ------------------------------ -sphinx-autobuild asks the operating system for a free port number -and use that for its server. -Passing ``--port=0`` will enable this behaviour. +By default, sphinx-autobuild tries to use port 8000. +If port 8000 is already in use, it will automatically find and use a free port instead. + +You can explicitly request a specific port with ``--port N`` (e.g., ``--port 3000``). +If that port is unavailable, sphinx-autobuild will exit with an error. + +To always use an automatically-selected free port (skipping the 8000 default), +pass ``--port 0``. Workflow suggestions diff --git a/sphinx_autobuild/__main__.py b/sphinx_autobuild/__main__.py index b1f058f..ddfcab7 100644 --- a/sphinx_autobuild/__main__.py +++ b/sphinx_autobuild/__main__.py @@ -22,7 +22,12 @@ from sphinx_autobuild.filter import IgnoreFilter from sphinx_autobuild.middleware import JavascriptInjectorMiddleware from sphinx_autobuild.server import RebuildServer -from sphinx_autobuild.utils import find_free_port, open_browser, show_message +from sphinx_autobuild.utils import ( + find_free_port, + is_port_available, + open_browser, + show_message, +) def main(argv=()): @@ -45,17 +50,26 @@ def main(argv=()): serve_dir.mkdir(parents=True, exist_ok=True) host_name = args.host - port_num = args.port or find_free_port() - url_host = f"{host_name}:{port_num}" + + # Resolve port: + # - If user specified --port 0, always find a free port + # - If user specified --port N (N > 0), use that port (may fail if unavailable) + # - If user didn't specify --port, use 8000 (or free port if 8000 is taken) + if args.port == 0: + # Auto-find mode + port_num = find_free_port() + port_explicitly_set = False + elif args.port is not None: + # User specified a specific port + port_num = args.port + port_explicitly_set = True + else: + # Default: try 8000, but allow fallback to free port + port_num = 8000 + port_explicitly_set = False pre_build_commands = list(map(shlex.split, args.pre_build)) post_build_commands = list(map(shlex.split, args.post_build)) - builder = Builder( - build_args, - url_host=url_host, - pre_build_commands=pre_build_commands, - post_build_commands=post_build_commands, - ) watch_dirs = [src_dir] + args.additional_watched_dirs ignore_dirs = [ @@ -80,7 +94,58 @@ def main(argv=()): ] ignore_dirs = list(filter(None, ignore_dirs)) ignore_handler = IgnoreFilter(ignore_dirs, args.re_ignore) - app = _create_app(watch_dirs, ignore_handler, builder, serve_dir, url_host) + + _run_with_port_fallback( + host_name, + port_num, + port_explicitly_set, + args, + watch_dirs, + ignore_handler, + build_args, + pre_build_commands, + post_build_commands, + serve_dir, + ) + + +def _run_with_port_fallback( + host_name, + port_num, + port_explicitly_set, + args, + watch_dirs, + ignore_handler, + build_args, + pre_build_commands, + post_build_commands, + serve_dir, +): + """Run the server with automatic port fallback on binding failure.""" + # Check if the port is available BEFORE doing anything else + if not is_port_available(host_name, port_num): + if port_explicitly_set: + show_message( + f"Error: Cannot bind to {host_name}:{port_num}. " + f"The port is already in use. " + f"Use --port 0 to automatically find a free port." + ) + sys.exit(1) + else: + show_message( + f"Port {port_num} already in use. Attempting to find a free port..." + ) + port_num = find_free_port() + show_message(f"Using port {port_num} instead.") + + url_host = f"{host_name}:{port_num}" + + builder = Builder( + build_args, + url_host=url_host, + pre_build_commands=pre_build_commands, + post_build_commands=post_build_commands, + ) if not args.no_initial_build: show_message("Starting initial build") @@ -90,6 +155,8 @@ def main(argv=()): open_browser(url_host, args.delay) show_message("Waiting to detect changes...") + app = _create_app(watch_dirs, ignore_handler, builder, serve_dir, url_host) + try: uvicorn.run(app, host=host_name, port=port_num, log_level="warning") except KeyboardInterrupt: @@ -182,8 +249,11 @@ def _add_autobuild_arguments(parser): group.add_argument( "--port", type=int, - default=8000, - help="port to serve documentation on. 0 means find and use a free port", + default=None, + help=( + "port to serve documentation on " + "(defaults to 8000, or a free port if 8000 is in use)" + ), ) group.add_argument( "--host", diff --git a/sphinx_autobuild/utils.py b/sphinx_autobuild/utils.py index 16e19fb..4620b73 100644 --- a/sphinx_autobuild/utils.py +++ b/sphinx_autobuild/utils.py @@ -11,6 +11,20 @@ from colorama import Fore, Style +def is_port_available(host: str, port: int) -> bool: + """Check if a port is available for binding. + + Returns True if the port is available, False otherwise. + """ + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind((host, port)) + return True + except OSError: + return False + + def find_free_port(): """Find and return a free port number. diff --git a/tests/test_application.py b/tests/test_application.py index 6919d9a..27dcb86 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1,6 +1,7 @@ """A very basic test that the application works.""" import shutil +import socket from pathlib import Path from starlette.testclient import TestClient @@ -8,6 +9,7 @@ from sphinx_autobuild.__main__ import _create_app from sphinx_autobuild.build import Builder from sphinx_autobuild.filter import IgnoreFilter +from sphinx_autobuild.utils import find_free_port, is_port_available ROOT = Path(__file__).parent.parent @@ -33,3 +35,28 @@ def test_application(tmp_path): response = client.get("/") assert response.status_code == 200 + + +def test_is_port_available(): + """Test that is_port_available correctly detects available and unavailable ports.""" + # A high port number should generally be available + high_port = find_free_port() + assert is_port_available("127.0.0.1", high_port) + + # Bind a port and verify is_port_available detects it as unavailable + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + test_port = find_free_port() + s.bind(("127.0.0.1", test_port)) + s.listen(1) # Put the socket in listening state to actually reserve the port + + try: + # Now the port should not be available + assert not is_port_available("127.0.0.1", test_port) + + # A different high port should still be available + other_port = find_free_port() + assert other_port != test_port + assert is_port_available("127.0.0.1", other_port) + finally: + s.close()