diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 2fede71c..f5cb821e 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -77,7 +77,7 @@ jobs:
- run: |
mk python-release owner=vkottler \
- repo=runtimepy version=5.7.5
+ repo=runtimepy version=5.7.7
if: |
matrix.python-version == '3.12'
&& matrix.system == 'ubuntu-latest'
diff --git a/README.md b/README.md
index 709a8b66..9aa7c961 100644
--- a/README.md
+++ b/README.md
@@ -2,11 +2,11 @@
=====================================
generator=datazen
version=3.1.4
- hash=c982a40a3245e84b1ae3b70a17cc6867
+ hash=0bf47d2ff3fb0ed78ed013f4fcbc59e2
=====================================
-->
-# runtimepy ([5.7.5](https://pypi.org/project/runtimepy/))
+# runtimepy ([5.7.7](https://pypi.org/project/runtimepy/))
[![python](https://img.shields.io/pypi/pyversions/runtimepy.svg)](https://pypi.org/project/runtimepy/)
![Build Status](https://github.com/vkottler/runtimepy/workflows/Python%20Package/badge.svg)
diff --git a/local/variables/package.yaml b/local/variables/package.yaml
index f5454cd3..250c64bb 100644
--- a/local/variables/package.yaml
+++ b/local/variables/package.yaml
@@ -1,5 +1,5 @@
---
major: 5
minor: 7
-patch: 5
+patch: 7
entry: runtimepy
diff --git a/pyproject.toml b/pyproject.toml
index 6ab17c30..ac0902bc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta:__legacy__"
[project]
name = "runtimepy"
-version = "5.7.5"
+version = "5.7.7"
description = "A framework for implementing Python services."
readme = "README.md"
requires-python = ">=3.11"
diff --git a/runtimepy/__init__.py b/runtimepy/__init__.py
index 0cd6d472..a2533f18 100644
--- a/runtimepy/__init__.py
+++ b/runtimepy/__init__.py
@@ -1,7 +1,7 @@
# =====================================
# generator=datazen
# version=3.1.4
-# hash=3797b5155c13d8d7422bdbfcb1248cca
+# hash=ed12f307211d1317e3ce6a7c4409b97f
# =====================================
"""
@@ -10,7 +10,7 @@
DESCRIPTION = "A framework for implementing Python services."
PKG_NAME = "runtimepy"
-VERSION = "5.7.5"
+VERSION = "5.7.7"
# runtimepy-specific content.
METRICS_NAME = "metrics"
diff --git a/runtimepy/data/404.html b/runtimepy/data/404.html
new file mode 100644
index 00000000..d7cc1c48
--- /dev/null
+++ b/runtimepy/data/404.html
@@ -0,0 +1,3 @@
+
runtimepy/5.7.6
+ Resource Not Found (404)
(home)
\ No newline at end of file
diff --git a/runtimepy/data/server_base.yaml b/runtimepy/data/server_base.yaml
index c4540271..b0c28b88 100644
--- a/runtimepy/data/server_base.yaml
+++ b/runtimepy/data/server_base.yaml
@@ -13,11 +13,23 @@ structs:
init:
- runtimepy.net.arbiter.housekeeping.init
+config:
+ # Default redirects.
+ http_redirects:
+ "/": "/index.html"
+ "/index.html": "/app.html"
+
+ # Serve these applications by default at these paths.
+ http_app_paths: ["/app.html"]
+
+# Handles config["http_app_prefixes"].
+config_builders:
+ - runtimepy.net.html.arbiter.web_app_paths
+
servers:
- factory: runtimepy_http
kwargs: {port: "$runtimepy_http_server"}
-
- factory: runtimepy_websocket_json
- kwargs: {host: "0.0.0.0", port: "$runtimepy_websocket_json_server"}
+ kwargs: {port: "$runtimepy_websocket_json_server"}
- factory: runtimepy_websocket_data
- kwargs: {host: "0.0.0.0", port: "$runtimepy_websocket_data_server"}
+ kwargs: {port: "$runtimepy_websocket_data_server"}
diff --git a/runtimepy/net/html/arbiter.py b/runtimepy/net/html/arbiter.py
new file mode 100644
index 00000000..0a618575
--- /dev/null
+++ b/runtimepy/net/html/arbiter.py
@@ -0,0 +1,32 @@
+"""
+A module implementing connection-arbiter related utilities.
+"""
+
+# internal
+from runtimepy.net.arbiter.config import ConfigObject
+
+
+def web_app_paths(config: ConfigObject) -> None:
+ """
+ Register boilerplate path handling for additional application-serving URIs.
+ """
+
+ config.setdefault("config", {})
+ redirects = config["config"].setdefault("http_redirects", {})
+ app_paths = config["config"].setdefault("http_app_paths", [])
+
+ for prefix in config["config"].get("http_app_prefixes", []):
+ if not prefix.startswith("/"):
+ prefix = "/" + prefix
+
+ assert not prefix.endswith("/"), prefix
+
+ # Add re-directs.
+ index_path = f"{prefix}/index.html"
+ app_path = f"{prefix}/app.html"
+ redirects.setdefault(prefix, index_path)
+ redirects.setdefault(index_path, app_path)
+
+ # Add app path.
+ if app_path not in app_paths:
+ app_paths.append(app_path)
diff --git a/runtimepy/net/server/__init__.py b/runtimepy/net/server/__init__.py
index 2ce9e778..c9271254 100644
--- a/runtimepy/net/server/__init__.py
+++ b/runtimepy/net/server/__init__.py
@@ -109,6 +109,18 @@ def init(self) -> None:
with favicon.open("rb") as favicon_fd:
type(self).favicon_data = favicon_fd.read()
+ def redirect_to(
+ self,
+ path: str,
+ response: ResponseHeader,
+ status: http.HTTPStatus = http.HTTPStatus.TEMPORARY_REDIRECT,
+ ) -> bytes:
+ """Handle responding with redirection status."""
+
+ response["Location"] = path
+ response.status = status
+ return bytes()
+
async def try_redirect(
self, path: PathMaybeQuery, response: ResponseHeader
) -> Optional[bytes]:
@@ -118,33 +130,39 @@ async def try_redirect(
curr = Path(path[0])
if curr in self.class_redirect_paths:
- response["Location"] = str(self.class_redirect_paths[curr])
- response.status = http.HTTPStatus.TEMPORARY_REDIRECT
-
- # No data payload, but signal to caller that a response is ready.
- result = bytes()
+ result = self.redirect_to(
+ str(self.class_redirect_paths[curr]), response
+ )
return result
- async def render_markdown(
- self, path: Path, response: ResponseHeader, **kwargs
+ def render_markdown(
+ self, content: str, response: ResponseHeader, **kwargs
) -> bytes:
- """Render a markdown file as HTML and return the result."""
+ """Return rendered markdown content."""
document = get_html()
-
- async with aiofiles.open(path, mode="r") as path_fd:
- with IndentedFileWriter.string() as writer:
- writer.write_markdown(await path_fd.read(), **kwargs)
- full_markdown_page(
- document,
- writer.stream.getvalue(), # type: ignore
- )
+ with IndentedFileWriter.string() as writer:
+ writer.write_markdown(content, **kwargs)
+ full_markdown_page(
+ document,
+ writer.stream.getvalue(), # type: ignore
+ )
response["Content-Type"] = f"text/html; charset={DEFAULT_ENCODING}"
return document.encode_str().encode()
+ async def render_markdown_file(
+ self, path: Path, response: ResponseHeader, **kwargs
+ ) -> bytes:
+ """Render a markdown file as HTML and return the result."""
+
+ async with aiofiles.open(path, mode="r") as path_fd:
+ return self.render_markdown(
+ await path_fd.read(), response, **kwargs
+ )
+
async def try_file(
self, path: PathMaybeQuery, response: ResponseHeader
) -> Optional[bytes]:
@@ -157,9 +175,12 @@ async def try_file(
candidate = search.joinpath(path[0][1:])
# Handle markdown sources.
- md_candidate = candidate.with_suffix(".md")
- if md_candidate.is_file():
- return await self.render_markdown(md_candidate, response)
+ if candidate.name:
+ md_candidate = candidate.with_suffix(".md")
+ if md_candidate.is_file():
+ return await self.render_markdown_file(
+ md_candidate, response
+ )
if candidate.is_file():
mime, encoding = mimetypes.guess_type(candidate, strict=False)
@@ -239,6 +260,7 @@ async def get_handler(
request.log(self.logger, False, level=logging.INFO)
result = None
+ populated = False
with StringIO() as stream:
if request.target.origin_form:
@@ -250,7 +272,7 @@ async def get_handler(
return self.favicon_data
# Try serving a file and handling redirects.
- for handler in [self.try_redirect, self.try_file]:
+ for handler in [self.try_file, self.try_redirect]:
result = await handler(
request.target.origin_form, response
)
@@ -266,10 +288,11 @@ async def get_handler(
request_data,
self.json_data,
)
+ populated = True
# Serve the application.
else:
- await html_handler(
+ populated = await html_handler(
type(self).apps,
stream,
request,
@@ -278,6 +301,7 @@ async def get_handler(
default_app=type(self).default_app,
)
- result = stream.getvalue().encode()
+ if populated:
+ result = stream.getvalue().encode()
- return result
+ return result or self.redirect_to("/404.html", response)
diff --git a/runtimepy/net/server/app/__init__.py b/runtimepy/net/server/app/__init__.py
index d8c0d332..589547e0 100644
--- a/runtimepy/net/server/app/__init__.py
+++ b/runtimepy/net/server/app/__init__.py
@@ -69,9 +69,10 @@ async def setup(app: AppInfo) -> int:
)
# Default application (environment tabs).
- web_app = create_app(app, getattr(_import_module(module), method))
- RuntimepyServerConnection.apps["/app.html"] = web_app
- RuntimepyServerConnection.default_app = web_app
+ html_app = create_app(app, getattr(_import_module(module), method))
+ target: str
+ for target in app.config_param("http_app_paths", []):
+ RuntimepyServerConnection.apps[target] = html_app
# Register redirects.
redirects: dict[str, str] = app.config_param("http_redirects", {})
diff --git a/runtimepy/net/server/html.py b/runtimepy/net/server/html.py
index 597335bd..e37a6b53 100644
--- a/runtimepy/net/server/html.py
+++ b/runtimepy/net/server/html.py
@@ -33,7 +33,7 @@ async def html_handler(
response: ResponseHeader,
request_data: Optional[bytes],
default_app: HtmlApp = None,
-) -> None:
+) -> bool:
"""Render an HTML document in response to an HTTP request."""
# Set response headers.
@@ -43,3 +43,5 @@ async def html_handler(
app = apps.get(request.target.path, default_app)
if app is not None:
(await app(get_html(), request, response, request_data)).render(stream)
+
+ return app is not None
diff --git a/runtimepy/net/tcp/connection.py b/runtimepy/net/tcp/connection.py
index 9b4b8740..02e39501 100644
--- a/runtimepy/net/tcp/connection.py
+++ b/runtimepy/net/tcp/connection.py
@@ -219,6 +219,7 @@ async def create_pair(
peer: type[V] = None,
serve_kwargs: dict[str, _Any] = None,
connect_kwargs: dict[str, _Any] = None,
+ host: str = "127.0.0.1",
) -> _AsyncIterator[tuple[V, T]]:
"""Create a connection pair."""
@@ -241,16 +242,22 @@ def callback(conn: V) -> None:
serve_kwargs = {}
server = await stack.enter_async_context(
- peer.serve(callback, port=0, backlog=1, **serve_kwargs)
+ peer.serve(
+ callback,
+ host=host,
+ port=0,
+ backlog=1,
+ **serve_kwargs,
+ )
)
- host = server.sockets[0].getsockname()
-
if connect_kwargs is None:
connect_kwargs = {}
client = await cls.create_connection(
- host="localhost", port=host[1], **connect_kwargs
+ host=host,
+ port=server.sockets[0].getsockname()[1],
+ **connect_kwargs,
)
await cond.acquire()
diff --git a/tests/data/valid/connection_arbiter/runtimepy_http.yaml b/tests/data/valid/connection_arbiter/runtimepy_http.yaml
index 29ae4508..a379e489 100644
--- a/tests/data/valid/connection_arbiter/runtimepy_http.yaml
+++ b/tests/data/valid/connection_arbiter/runtimepy_http.yaml
@@ -19,6 +19,8 @@ config:
foo: bar
xdg_fragment: "wave1,hide-tabs,hide-channels/wave1:sin,cos"
+ http_app_prefixes: [rando_app_prefix]
+
ports:
- {name: tftp_server, type: udp}
diff --git a/tests/data/valid/connection_arbiter/test_ssl.yaml b/tests/data/valid/connection_arbiter/test_ssl.yaml
index 94187d47..59282316 100644
--- a/tests/data/valid/connection_arbiter/test_ssl.yaml
+++ b/tests/data/valid/connection_arbiter/test_ssl.yaml
@@ -13,17 +13,13 @@ servers:
- factory: runtimepy_websocket_json
kwargs:
- host: "0.0.0.0"
port: "$runtimepy_secure_websocket_json_server"
-
certfile: tests/data/valid/certs/test.cert
keyfile: tests/data/valid/certs/test.key
- factory: runtimepy_websocket_data
kwargs:
- host: "0.0.0.0"
port: "$runtimepy_secure_websocket_data_server"
-
certfile: tests/data/valid/certs/test.cert
keyfile: tests/data/valid/certs/test.key
diff --git a/tests/net/server/__init__.py b/tests/net/server/__init__.py
index 2e2d090f..bd6b349e 100644
--- a/tests/net/server/__init__.py
+++ b/tests/net/server/__init__.py
@@ -7,9 +7,11 @@
from typing import Any
# module under test
+from runtimepy.net.arbiter.info import AppInfo
from runtimepy.net.http.header import RequestHeader
from runtimepy.net.server import RuntimepyServerConnection
from runtimepy.net.server.websocket import RuntimepyWebsocketConnection
+from runtimepy.net.tcp.http import HttpConnection
# internal
from tests.resources import resource
@@ -52,8 +54,32 @@ async def runtimepy_websocket_client(
send_ui(client, f"wave{idx}", {"kind": "tab.hidden"})
+async def runtimepy_http_query_peer(app: AppInfo) -> None:
+ """Test querying a peer program's web application."""
+
+ port: int = 0
+
+ # Try to find peer's HTTP server.
+ if "proc1" in app.peers:
+ peer = app.peers["proc1"]
+ await peer.peer_config_event.wait()
+ for port in peer.peer_config["ports"]: # type: ignore
+ if port["name"] == "runtimepy_http_server": # type: ignore
+ port = port["port"] # type: ignore
+ break
+
+ if port:
+ conn = await HttpConnection.create_connection(
+ host="localhost", port=port
+ )
+ async with conn.process_then_disable(stop_sig=app.stop):
+ await conn.request(RequestHeader(target="/app.html"))
+
+
async def runtimepy_http_client_server(
- client: RuntimepyServerConnection, server: RuntimepyServerConnection
+ app: AppInfo,
+ client: RuntimepyServerConnection,
+ server: RuntimepyServerConnection,
) -> None:
"""Test HTTP client and server interactions."""
@@ -66,8 +92,11 @@ async def runtimepy_http_client_server(
# Make requests in parallel.
await asyncio.gather(
*(
+ runtimepy_http_query_peer(app),
# Application.
client.request(RequestHeader(target="/")),
+ client.request(RequestHeader(target="/app.html")),
+ client.request(RequestHeader(target="/app.html")),
client.request(RequestHeader(target="/index.html")),
client.request(RequestHeader(target="/test_json.html")),
client.request(RequestHeader(target="/landing_page.html")),
diff --git a/tests/net/stream/__init__.py b/tests/net/stream/__init__.py
index 02f95b2a..9e58b242 100644
--- a/tests/net/stream/__init__.py
+++ b/tests/net/stream/__init__.py
@@ -64,6 +64,7 @@ async def http_test_loopback(
)
assert await client.request(RequestHeader(target="/index.html?param=true"))
+ assert await client.request(RequestHeader(target="/app.html"))
result = await client.request(RequestHeader(target="google.com"))
assert result[0]["Content-Type"]
@@ -105,7 +106,7 @@ async def runtimepy_http_test(app: AppInfo) -> int:
server = conn
assert server is not None
- await runtimepy_http_client_server(client, server)
+ await runtimepy_http_client_server(app, client, server)
await runtimepy_websocket_client(
app.single(pattern="client", kind=RuntimepyWebsocketConnection)
diff --git a/tests/net/tcp/test_connection.py b/tests/net/tcp/test_connection.py
index 67ae9ef8..6249cdca 100644
--- a/tests/net/tcp/test_connection.py
+++ b/tests/net/tcp/test_connection.py
@@ -218,6 +218,7 @@ async def connect() -> None:
sig,
callback=app,
serving_callback=serve_cb,
+ host="0.0.0.0",
port=0,
manager=manager,
)
diff --git a/tests/net/tcp/test_np_05b.py b/tests/net/tcp/test_np_05b.py
index 913c6fb0..c31f8354 100644
--- a/tests/net/tcp/test_np_05b.py
+++ b/tests/net/tcp/test_np_05b.py
@@ -144,9 +144,12 @@ async def test_conns(client: Np05bConnection, _: MockNp05b) -> None:
# End the test.
stop_sig.set()
- async with MockNp05b.serve(callback=conn_cb, port=0) as server:
+ async with MockNp05b.serve(
+ callback=conn_cb, host="127.0.0.1", port=0
+ ) as server:
+ host = sockname(server.sockets[0])
conn = await Np05bConnection.create_connection(
- host="localhost", port=sockname(server.sockets[0]).port
+ host=host.name, port=host.port
)
conn_srv = await queue.get()