Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ __pycache__
# Virtualenv directories
[v]env
.[v]env

# Sphinx documentation build artifacts
/docs/_build
.doctrees
15 changes: 10 additions & 5 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
94 changes: 82 additions & 12 deletions sphinx_autobuild/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=()):
Expand All @@ -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 = [
Expand All @@ -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")
Expand All @@ -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:
Expand Down Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions sphinx_autobuild/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
27 changes: 27 additions & 0 deletions tests/test_application.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""A very basic test that the application works."""

import shutil
import socket
from pathlib import Path

from starlette.testclient import TestClient

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

Expand All @@ -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()