Skip to content

Commit 731ee4b

Browse files
Let nikola serve work together with non-root BASE_URL or SITE_URL. Fixes #3726 (#3804)
* Let nikola serve work together with non-root BASE_URL or SITE_URL. * Backporting a type hint to Python 3.8. * pydocstyle improvement. * Comment cosmetics. * Workaround for Windows \r\n newlines. * Moving the base-path extraction to utils.py. * Fixing imports that were wrongly done by my dev ide.
1 parent 1da7205 commit 731ee4b

File tree

8 files changed

+279
-103
lines changed

8 files changed

+279
-103
lines changed

Diff for: CHANGES.txt

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Bugfixes
1212
* Restore `annotation_helper.tmpl` with dummy content - fix themes still mentioning it
1313
(Issue #3764, #3773)
1414
* Fix compatibility with watchdog 4 (Issue #3766)
15+
* `nikola serve` now works with non-root SITE_URL.
1516

1617
New in v8.3.1
1718
=============

Diff for: nikola/plugins/command/auto/__init__.py

+1-13
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,13 @@
3535
import subprocess
3636
import sys
3737
import typing
38-
import urllib.parse
3938
import webbrowser
4039
from pathlib import Path
4140

4241
import blinker
4342

4443
from nikola.plugin_categories import Command
45-
from nikola.utils import dns_sd, req_missing, get_theme_path, makedirs, pkg_resources_path
44+
from nikola.utils import base_path_from_siteuri, dns_sd, get_theme_path, makedirs, pkg_resources_path, req_missing
4645

4746
try:
4847
import aiohttp
@@ -67,17 +66,6 @@
6766
IDLE_REFRESH_DELAY = 0.05
6867

6968

70-
def base_path_from_siteuri(siteuri: str) -> str:
71-
"""Extract the path part from a URI such as site['SITE_URL'].
72-
73-
The path never ends with a "/". (If only "/" is intended, it is empty.)
74-
"""
75-
path = urllib.parse.urlsplit(siteuri).path
76-
if path.endswith("/"):
77-
path = path[:-1]
78-
return path
79-
80-
8169
class CommandAuto(Command):
8270
"""Automatic rebuilds for Nikola."""
8371

Diff for: nikola/plugins/command/serve.py

+79-21
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,22 @@
2525
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
2626

2727
"""Start test server."""
28-
28+
import atexit
2929
import os
3030
import sys
3131
import re
3232
import signal
3333
import socket
34+
import threading
3435
import webbrowser
3536
from http.server import HTTPServer
3637
from http.server import SimpleHTTPRequestHandler
3738
from io import BytesIO as StringIO
39+
from threading import Thread, current_thread
40+
from typing import Callable, Optional
3841

3942
from nikola.plugin_categories import Command
40-
from nikola.utils import dns_sd
43+
from nikola.utils import base_path_from_siteuri, dns_sd
4144

4245

4346
class IPv6Server(HTTPServer):
@@ -52,7 +55,8 @@ class CommandServe(Command):
5255
name = "serve"
5356
doc_usage = "[options]"
5457
doc_purpose = "start the test webserver"
55-
dns_sd = None
58+
httpd: Optional[HTTPServer] = None
59+
httpd_serving_thread: Optional[Thread] = None
5660

5761
cmd_options = (
5862
{
@@ -98,13 +102,21 @@ class CommandServe(Command):
98102
)
99103

100104
def shutdown(self, signum=None, _frame=None):
101-
"""Shut down the server that is running detached."""
102-
if self.dns_sd:
103-
self.dns_sd.Reset()
105+
"""Shut down the server."""
104106
if os.path.exists(self.serve_pidfile):
105107
os.remove(self.serve_pidfile)
106-
if not self.detached:
107-
self.logger.info("Server is shutting down.")
108+
109+
# Deal with the non-detached state:
110+
if self.httpd is not None and self.httpd_serving_thread is not None and self.httpd_serving_thread != current_thread():
111+
shut_me_down = self.httpd
112+
self.httpd = None
113+
self.httpd_serving_thread = None
114+
self.logger.info("Web server is shutting down.")
115+
shut_me_down.shutdown()
116+
else:
117+
self.logger.debug("No need to shut down the web server.")
118+
119+
# If this was called as a signal handler, shut down the entire application:
108120
if signum:
109121
sys.exit(0)
110122

@@ -127,29 +139,33 @@ def _execute(self, options, args):
127139
ipv6 = False
128140
OurHTTP = HTTPServer
129141

130-
httpd = OurHTTP((options['address'], options['port']),
131-
OurHTTPRequestHandler)
132-
sa = httpd.socket.getsockname()
142+
base_path = base_path_from_siteuri(self.site.config['BASE_URL'])
143+
if base_path == "":
144+
handler_factory = OurHTTPRequestHandler
145+
else:
146+
handler_factory = _create_RequestHandler_removing_basepath(base_path)
147+
self.httpd = OurHTTP((options['address'], options['port']), handler_factory)
148+
149+
sa = self.httpd.socket.getsockname()
133150
if ipv6:
134-
server_url = "http://[{0}]:{1}/".format(*sa)
151+
server_url = "http://[{0}]:{1}/".format(*sa) + base_path
135152
else:
136-
server_url = "http://{0}:{1}/".format(*sa)
153+
server_url = "http://{0}:{1}/".format(*sa) + base_path
137154
self.logger.info("Serving on {0} ...".format(server_url))
138155

139156
if options['browser']:
140157
# Some browsers fail to load 0.0.0.0 (Issue #2755)
141158
if sa[0] == '0.0.0.0':
142-
server_url = "http://127.0.0.1:{1}/".format(*sa)
159+
server_url = "http://127.0.0.1:{1}/".format(*sa) + base_path
143160
self.logger.info("Opening {0} in the default web browser...".format(server_url))
144161
webbrowser.open(server_url)
145162
if options['detach']:
146-
self.detached = True
147163
OurHTTPRequestHandler.quiet = True
148164
try:
149165
pid = os.fork()
150166
if pid == 0:
151167
signal.signal(signal.SIGTERM, self.shutdown)
152-
httpd.serve_forever()
168+
self.httpd.serve_forever()
153169
else:
154170
with open(self.serve_pidfile, 'w') as fh:
155171
fh.write('{0}\n'.format(pid))
@@ -160,11 +176,26 @@ def _execute(self, options, args):
160176
else:
161177
raise
162178
else:
163-
self.detached = False
164179
try:
165-
self.dns_sd = dns_sd(options['port'], (options['ipv6'] or '::' in options['address']))
166-
signal.signal(signal.SIGTERM, self.shutdown)
167-
httpd.serve_forever()
180+
dns_socket_publication = dns_sd(options['port'], (options['ipv6'] or '::' in options['address']))
181+
try:
182+
self.httpd_serving_thread = threading.current_thread()
183+
if threading.main_thread() == self.httpd_serving_thread:
184+
# If we are running as the main thread,
185+
# likely no other threads are running and nothing else will run after us.
186+
# In this special case, we take some responsibility for the application whole
187+
# (not really the job of any single plugin).
188+
# Clean up the socket publication on exit (if we actually had a socket publication):
189+
if dns_socket_publication is not None:
190+
atexit.register(dns_socket_publication.Reset)
191+
# Enable application shutdown via SIGTERM:
192+
signal.signal(signal.SIGTERM, self.shutdown)
193+
self.logger.info("Starting web server.")
194+
self.httpd.serve_forever()
195+
self.logger.info("Web server has shut down.")
196+
finally:
197+
if dns_socket_publication is not None:
198+
dns_socket_publication.Reset()
168199
except KeyboardInterrupt:
169200
self.shutdown()
170201
return 130
@@ -186,7 +217,7 @@ def log_message(self, *args):
186217

187218
# NOTICE: this is a patched version of send_head() to disable all sorts of
188219
# caching. `nikola serve` is a development server, hence caching should
189-
# not happen to have access to the newest resources.
220+
# not happen, instead, we should give access to the newest resources.
190221
#
191222
# The original code was copy-pasted from Python 2.7. Python 3.3 contains
192223
# the same code, missing the binary mode comment.
@@ -205,6 +236,7 @@ def send_head(self):
205236
206237
"""
207238
path = self.translate_path(self.path)
239+
208240
f = None
209241
if os.path.isdir(path):
210242
path_parts = list(self.path.partition('?'))
@@ -277,3 +309,29 @@ def send_head(self):
277309
# end no-cache patch
278310
self.end_headers()
279311
return f
312+
313+
314+
def _omit_basepath_component(base_path_with_slash: str, path: str) -> str:
315+
if path.startswith(base_path_with_slash):
316+
return path[len(base_path_with_slash) - 1:]
317+
elif path == base_path_with_slash[:-1]:
318+
return "/"
319+
else:
320+
# Somewhat dubious. We should not really get asked this, normally.
321+
return path
322+
323+
324+
def _create_RequestHandler_removing_basepath(base_path: str) -> Callable:
325+
"""Create a new subclass of OurHTTPRequestHandler that removes a trailing base path from the path.
326+
327+
Returns that class (used as a factory for objects).
328+
Better return type should be Callable[[...], OurHTTPRequestHandler], but Python 3.8 doesn't understand that.
329+
"""
330+
base_path_with_slash = base_path if base_path.endswith("/") else f"{base_path}/"
331+
332+
class OmitBasepathRequestHandler(OurHTTPRequestHandler):
333+
334+
def translate_path(self, path: str) -> str:
335+
return super().translate_path(_omit_basepath_component(base_path_with_slash, path))
336+
337+
return OmitBasepathRequestHandler

Diff for: nikola/utils.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
import datetime
3131
import hashlib
3232
import io
33+
import urllib
34+
3335
import lxml.html
3436
import operator
3537
import os
@@ -1862,7 +1864,7 @@ def color_hsl_adjust_hex(hexstr, adjust_h=None, adjust_s=None, adjust_l=None):
18621864

18631865

18641866
def dns_sd(port, inet6):
1865-
"""Optimistically publish a HTTP service to the local network over DNS-SD.
1867+
"""Optimistically publish an HTTP service to the local network over DNS-SD.
18661868
18671869
Works only on Linux/FreeBSD. Requires the `avahi` and `dbus` modules (symlinks in virtualenvs)
18681870
"""
@@ -2168,3 +2170,14 @@ def read_from_config(self, site, basename, posts_per_classification_per_language
21682170
args = {'translation_manager': self, 'site': site,
21692171
'posts_per_classification_per_language': posts_per_classification_per_language}
21702172
signal('{}_translations_config'.format(basename.lower())).send(args)
2173+
2174+
2175+
def base_path_from_siteuri(siteuri: str) -> str:
2176+
"""Extract the path part from a URI such as site['SITE_URL'].
2177+
2178+
The path returned doesn't end with a "/". (If only "/" is intended, it is empty.)
2179+
"""
2180+
path = urllib.parse.urlsplit(siteuri).path
2181+
if path.endswith("/"):
2182+
path = path[:-1]
2183+
return path

Diff for: tests/integration/dev_server_test_helper.py

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import pathlib
2+
import socket
3+
from typing import Dict, Any
4+
5+
from ..helper import FakeSite
6+
from nikola.utils import get_logger
7+
8+
SERVER_ADDRESS = "localhost"
9+
TEST_MAX_DURATION = 10 # Watchdog: Give up the test if it did not succeed during this time span.
10+
11+
# Folder that has the fixture file we expect the server to serve:
12+
OUTPUT_FOLDER = pathlib.Path(__file__).parent.parent / "data" / "dev_server_sample_output_folder"
13+
14+
LOGGER = get_logger("test_dev_server")
15+
16+
17+
def find_unused_port() -> int:
18+
"""Ask the OS for a currently unused port number.
19+
20+
(More precisely, a port that can be used for a TCP server servicing SERVER_ADDRESS.)
21+
We use a method here rather than a fixture to minimize side effects of failing tests.
22+
"""
23+
s = socket.socket()
24+
try:
25+
ANY_PORT = 0
26+
s.bind((SERVER_ADDRESS, ANY_PORT))
27+
address, port = s.getsockname()
28+
LOGGER.info("Trying to set up dev server on http://%s:%i/", address, port)
29+
return port
30+
finally:
31+
s.close()
32+
33+
34+
class MyFakeSite(FakeSite):
35+
def __init__(self, config: Dict[str, Any], configuration_filename="conf.py"):
36+
super(MyFakeSite, self).__init__()
37+
self.configured = True
38+
self.debug = True
39+
self.THEMES = []
40+
self._plugin_places = []
41+
self.registered_auto_watched_folders = set()
42+
self.config = config
43+
self.configuration_filename = configuration_filename

Diff for: tests/integration/test_dev_server.py renamed to tests/integration/test_dev_server_auto.py

+7-68
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,13 @@
11
import asyncio
2-
import nikola.plugins.command.auto as auto
3-
from nikola.utils import get_logger
2+
from typing import Optional, Tuple
3+
44
import pytest
5-
import pathlib
65
import requests
7-
import socket
8-
from typing import Optional, Tuple, Any, Dict
9-
10-
from ..helper import FakeSite
11-
12-
SERVER_ADDRESS = "localhost"
13-
TEST_MAX_DURATION = 10 # Watchdog: Give up the test if it did not succeed during this time span.
14-
15-
# Folder that has the fixture file we expect the server to serve:
16-
OUTPUT_FOLDER = pathlib.Path(__file__).parent.parent / "data" / "dev_server_sample_output_folder"
17-
18-
LOGGER = get_logger("test_dev_server")
196

20-
21-
def find_unused_port() -> int:
22-
"""Ask the OS for a currently unused port number.
23-
24-
(More precisely, a port that can be used for a TCP server servicing SERVER_ADDRESS.)
25-
We use a method here rather than a fixture to minimize side effects of failing tests.
26-
"""
27-
s = socket.socket()
28-
try:
29-
ANY_PORT = 0
30-
s.bind((SERVER_ADDRESS, ANY_PORT))
31-
address, port = s.getsockname()
32-
LOGGER.info("Trying to set up dev server on http://%s:%i/", address, port)
33-
return port
34-
finally:
35-
s.close()
36-
37-
38-
class MyFakeSite(FakeSite):
39-
def __init__(self, config: Dict[str, Any], configuration_filename="conf.py"):
40-
super(MyFakeSite, self).__init__()
41-
self.configured = True
42-
self.debug = True
43-
self.THEMES = []
44-
self._plugin_places = []
45-
self.registered_auto_watched_folders = set()
46-
self.config = config
47-
self.configuration_filename = configuration_filename
7+
import nikola.plugins.command.auto as auto
8+
from nikola.utils import base_path_from_siteuri
9+
from .dev_server_test_helper import MyFakeSite, SERVER_ADDRESS, find_unused_port, TEST_MAX_DURATION, LOGGER, \
10+
OUTPUT_FOLDER
4811

4912

5013
def test_serves_root_dir(
@@ -157,7 +120,7 @@ def site_and_base_path(request) -> Tuple[MyFakeSite, str]:
157120
"SITE_URL": request.param,
158121
"OUTPUT_FOLDER": OUTPUT_FOLDER.as_posix(),
159122
}
160-
return MyFakeSite(config), auto.base_path_from_siteuri(request.param)
123+
return MyFakeSite(config), base_path_from_siteuri(request.param)
161124

162125

163126
@pytest.fixture(scope="module")
@@ -170,27 +133,3 @@ def expected_text():
170133
with open(OUTPUT_FOLDER / "index.html", encoding="utf-8") as html_file:
171134
all_html = html_file.read()
172135
return all_html[all_html.find("<body>"):]
173-
174-
175-
@pytest.mark.parametrize(("uri", "expected_basepath"), [
176-
("http://localhost", ""),
177-
("http://local.host", ""),
178-
("http://localhost/", ""),
179-
("http://local.host/", ""),
180-
("http://localhost:123/", ""),
181-
("http://local.host:456/", ""),
182-
("https://localhost", ""),
183-
("https://local.host", ""),
184-
("https://localhost/", ""),
185-
("https://local.host/", ""),
186-
("https://localhost:123/", ""),
187-
("https://local.host:456/", ""),
188-
("http://example.org/blog", "/blog"),
189-
("https://lorem.ipsum/dolet/", "/dolet"),
190-
("http://example.org:124/blog", "/blog"),
191-
("http://example.org:124/Deep/Rab_bit/hol.e/", "/Deep/Rab_bit/hol.e"),
192-
# Would anybody in a sane mind actually do this?
193-
("http://example.org:124/blog?lorem=ipsum&dol=et", "/blog"),
194-
])
195-
def test_basepath(uri: str, expected_basepath: Optional[str]) -> None:
196-
assert expected_basepath == auto.base_path_from_siteuri(uri)

0 commit comments

Comments
 (0)