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 +

logo Resource Not Found (404) logo

(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()