diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 71370ae6..ee3279e6 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -68,7 +68,7 @@ jobs: - run: | mk python-release owner=vkottler \ - repo=runtimepy version=3.4.1 + repo=runtimepy version=3.5.0 if: | matrix.python-version == '3.11' && matrix.system == 'ubuntu-latest' diff --git a/README.md b/README.md index a3c62a63..1ba9b217 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ ===================================== generator=datazen version=3.1.4 - hash=e249f3854eb9586ba137f0d4cb7089b9 + hash=487a8680ee41c28e805d93708ad0973f ===================================== --> -# runtimepy ([3.4.1](https://pypi.org/project/runtimepy/)) +# runtimepy ([3.5.0](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/configs/package.yaml b/local/configs/package.yaml index 04ccad98..d152fdbe 100644 --- a/local/configs/package.yaml +++ b/local/configs/package.yaml @@ -6,6 +6,7 @@ entry: {{entry}} requirements: - vcorelib>=3.2.0 + - svgen - websockets - "windows-curses; sys_platform == 'win32' and python_version < '3.12'" diff --git a/local/variables/package.yaml b/local/variables/package.yaml index 8e676e17..2c22b4e8 100644 --- a/local/variables/package.yaml +++ b/local/variables/package.yaml @@ -1,5 +1,5 @@ --- major: 3 -minor: 4 -patch: 1 +minor: 5 +patch: 0 entry: runtimepy diff --git a/pyproject.toml b/pyproject.toml index 16fafb5b..21e8c7e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta:__legacy__" [project] name = "runtimepy" -version = "3.4.1" +version = "3.5.0" 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 3c549dee..971fd500 100644 --- a/runtimepy/__init__.py +++ b/runtimepy/__init__.py @@ -1,7 +1,7 @@ # ===================================== # generator=datazen # version=3.1.4 -# hash=4df0a252c47915951eb613d1f294ee92 +# hash=4b3acb30b6d5eb59ef87ed685da69bb3 # ===================================== """ @@ -10,7 +10,7 @@ DESCRIPTION = "A framework for implementing Python services." PKG_NAME = "runtimepy" -VERSION = "3.4.1" +VERSION = "3.5.0" # runtimepy-specific content. METRICS_NAME = "metrics" diff --git a/runtimepy/data/factories.yaml b/runtimepy/data/factories.yaml index 57049f58..c6d42e63 100644 --- a/runtimepy/data/factories.yaml +++ b/runtimepy/data/factories.yaml @@ -18,6 +18,7 @@ factories: # Useful protocols. - {name: runtimepy.net.factories.Http} + - {name: runtimepy.net.factories.RuntimepyHttp} # Useful tasks. - {name: runtimepy.task.trig.Sinusoid} diff --git a/runtimepy/data/favicon.ico b/runtimepy/data/favicon.ico new file mode 100644 index 00000000..53f55e21 Binary files /dev/null and b/runtimepy/data/favicon.ico differ diff --git a/runtimepy/net/factories/__init__.py b/runtimepy/net/factories/__init__.py index d8ce8a73..594a1dd3 100644 --- a/runtimepy/net/factories/__init__.py +++ b/runtimepy/net/factories/__init__.py @@ -11,6 +11,7 @@ ) from runtimepy.net.arbiter.udp import UdpConnectionFactory from runtimepy.net.arbiter.websocket import WebsocketConnectionFactory +from runtimepy.net.server import RuntimepyServerConnection from runtimepy.net.stream import ( EchoTcpMessageConnection, EchoUdpMessageConnection, @@ -137,6 +138,12 @@ class WebsocketJson( class Http(TcpConnectionFactory[HttpConnection]): - """HTTP connection factory..""" + """HTTP connection factory.""" kind = HttpConnection + + +class RuntimepyHttp(TcpConnectionFactory[RuntimepyServerConnection]): + """HTTP connection factory for this package.""" + + kind = RuntimepyServerConnection diff --git a/runtimepy/net/http/request_target.py b/runtimepy/net/http/request_target.py index 3f93e5c7..1153535e 100644 --- a/runtimepy/net/http/request_target.py +++ b/runtimepy/net/http/request_target.py @@ -6,6 +6,8 @@ import http from typing import Optional, Tuple +PathMaybeQuery = Tuple[str, Optional[str]] + class RequestTarget: """A class implementing HTTP's request-target definition.""" @@ -21,7 +23,7 @@ def __init__( self.authority_form: Optional[Tuple[str, int]] = None # Path and optional query. - self.origin_form: Optional[Tuple[str, Optional[str]]] = None + self.origin_form: Optional[PathMaybeQuery] = None self.absolute_form: Optional[str] = None @@ -48,3 +50,10 @@ def __init__( # 3.2.2 absolute-form else: self.absolute_form = request_target_raw + + @property + def path(self) -> str: + """Get the path for this request.""" + + assert self.origin_form is not None + return self.origin_form[0] diff --git a/runtimepy/net/server/__init__.py b/runtimepy/net/server/__init__.py new file mode 100644 index 00000000..da583e84 --- /dev/null +++ b/runtimepy/net/server/__init__.py @@ -0,0 +1,93 @@ +""" +A module implementing a server interface for this package. +""" + +# built-in +from io import StringIO +from typing import Optional + +# third-party +from vcorelib.io import JsonObject +from vcorelib.paths import find_file + +# internal +from runtimepy import PKG_NAME +from runtimepy.net.http.header import RequestHeader +from runtimepy.net.http.response import ResponseHeader +from runtimepy.net.server.html import ( + HtmlApp, + HtmlApps, + default_html_app, + html_handler, +) +from runtimepy.net.server.json import json_handler +from runtimepy.net.tcp.http import HttpConnection + + +class RuntimepyServerConnection(HttpConnection): + """A class implementing a server-connection interface for this package.""" + + # Can register application methods to URL paths. + apps: HtmlApps = {} + default_app: HtmlApp = default_html_app + + # Can load additional data into this dictionary for easy HTTP access. + json_data: JsonObject = {"test": {"a": 1, "b": 2, "c": 3}} + + favicon_data: bytes + + def init(self) -> None: + """Initialize this instance.""" + + super().init() + + # Load favicon if necessary. + if not hasattr(type(self), "favicon_data"): + favicon = find_file("favicon.ico", package=PKG_NAME) + assert favicon is not None + with favicon.open("rb") as favicon_fd: + type(self).favicon_data = favicon_fd.read() + + async def get_handler( + self, + response: ResponseHeader, + request: RequestHeader, + request_data: Optional[bytes], + ) -> Optional[bytes]: + """Sample handler.""" + + result = None + + with StringIO() as stream: + if request.target.origin_form: + path = request.target.path + + # Handle favicon (for browser clients). + if path.startswith("/favicon"): + response["Content-Type"] = "image/x-icon" + return self.favicon_data + + # Handle raw data queries. + if path.startswith("/json"): + json_handler( + stream, + request, + response, + request_data, + self.json_data, + ) + + # Serve the application. + else: + await html_handler( + self.apps, + stream, + request, + response, + request_data, + default_app=type(self).default_app, + ) + + result = stream.getvalue().encode() + + return result diff --git a/runtimepy/net/server/html.py b/runtimepy/net/server/html.py new file mode 100644 index 00000000..289900bb --- /dev/null +++ b/runtimepy/net/server/html.py @@ -0,0 +1,91 @@ +""" +A module implementing HTML interfaces for web applications. +""" + +# built-in +from typing import Awaitable, Callable, Optional, TextIO + +# third-party +from svgen.attribute import attributes +from svgen.element import Element +from vcorelib import DEFAULT_ENCODING + +# internal +from runtimepy.net.http.header import RequestHeader +from runtimepy.net.http.response import ResponseHeader +from runtimepy.net.tcp.http import HttpConnection + +HtmlApp = Callable[ + [Element, Element, RequestHeader, ResponseHeader, Optional[bytes]], + Awaitable[None], +] +HtmlApps = dict[str, HtmlApp] + + +async def default_html_app( + head: Element, + body: Element, + request: RequestHeader, + response: ResponseHeader, + request_data: Optional[bytes], +) -> None: + """A simple 'Hello, world!' application.""" + + del head + del request + del response + del request_data + + body.children.append(Element(tag="div", text="Hello, world!")) + + +# A default 'head' section to use in the HTML document. +HEAD = Element( + tag="head", + children=[ + Element( + tag="meta", + attrib=attributes({"charset": DEFAULT_ENCODING}), + ), + Element( + tag="meta", + attrib=attributes( + { + "name": "viewport", + "content": "width=device-width, initial-scale=1", + } + ), + ), + Element(tag="title", text=HttpConnection.identity), + ], +) + + +async def html_handler( + apps: HtmlApps, + stream: TextIO, + request: RequestHeader, + response: ResponseHeader, + request_data: Optional[bytes], + default_app: HtmlApp = default_html_app, +) -> None: + """Render an HTML document in response to an HTTP request.""" + + # Set response headers. + response["Content-Type"] = f"text/html; charset={DEFAULT_ENCODING}" + + # Create a copy at some point? + head = HEAD + + body = Element(tag="body") + + # Create the application. + await apps.get(request.target.path, default_app)( + head, body, request, response, request_data + ) + + stream.write("\n") + html = Element( + tag="html", attrib=attributes({"lang": "en"}), children=[head, body] + ) + html.encode(stream) diff --git a/runtimepy/net/server/json.py b/runtimepy/net/server/json.py new file mode 100644 index 00000000..60ba56ef --- /dev/null +++ b/runtimepy/net/server/json.py @@ -0,0 +1,66 @@ +""" +A module implementing basic JSON-object response handling. +""" + +# built-in +from typing import Any, Optional, TextIO + +# third-party +from vcorelib import DEFAULT_ENCODING +from vcorelib.io import ARBITER, JsonObject + +# internal +from runtimepy.net.http.header import RequestHeader +from runtimepy.net.http.response import ResponseHeader + + +def json_handler( + stream: TextIO, + request: RequestHeader, + response: ResponseHeader, + request_data: Optional[bytes], + data: JsonObject, +) -> None: + """Create an HTTP response from some JSON object data.""" + + del request_data + + response_type = "json" + response["Content-Type"] = ( + f"application/{response_type}; charset={DEFAULT_ENCODING}" + ) + + error: dict[str, Any] = {"path": {}} + + # Traverse path. + curr_path = [] + for part in request.target.path.split("/")[2:]: + if not part: + continue + + curr_path.append(part) + + # Handle error. + if not isinstance(data, dict): + error["path"]["part"] = part + error["path"]["current"] = ".".join(curr_path) + error["error"] = f"Can't index '{data}' by string key!" + data = error + break + + # Handle 'key not found' error. + if part not in data: + error["path"]["part"] = part + error["path"]["current"] = ".".join(curr_path) + error["error"] = f"Key not found! {data.keys()}" + data = error + break + + data = data[part] # type: ignore + + # Use a convention for indexing data to non-dictionary leaf nodes. + if not isinstance(data, dict): + data = {"__raw__": data} + + ARBITER.encode_stream(response_type, stream, data) + stream.write("\n") diff --git a/runtimepy/net/tcp/http/__init__.py b/runtimepy/net/tcp/http/__init__.py index 180d68d4..3fa659ae 100644 --- a/runtimepy/net/tcp/http/__init__.py +++ b/runtimepy/net/tcp/http/__init__.py @@ -4,6 +4,7 @@ # built-in import asyncio +from copy import copy import http from typing import Awaitable, Callable, Optional, Tuple, Union @@ -28,6 +29,8 @@ ] HttpResponse = Tuple[ResponseHeader, Optional[bytes]] +HttpRequestHandlers = dict[http.HTTPMethod, HttpRequestHandler] + class HttpConnection(_TcpConnection): """A class implementing a basic HTTP interface.""" @@ -38,7 +41,7 @@ class HttpConnection(_TcpConnection): # Handlers registered at the class level so that instances created at # runtime don't need additional initialization. - handlers: dict[http.HTTPMethod, HttpRequestHandler] = {} + handlers: HttpRequestHandlers = {} def init(self) -> None: """Initialize this instance.""" @@ -51,6 +54,17 @@ def init(self) -> None: self.expecting_response = False self.responses: asyncio.Queue[HttpResponse] = asyncio.Queue(maxsize=1) + self.handlers = copy(self.handlers) + self.handlers[http.HTTPMethod.GET] = self.get_handler + + async def get_handler( + self, + response: ResponseHeader, + request: RequestHeader, + request_data: Optional[bytes], + ) -> Optional[bytes]: + """Sample handler.""" + async def _process_request( self, response: ResponseHeader, diff --git a/runtimepy/requirements.txt b/runtimepy/requirements.txt index e0da4a87..6a711424 100644 --- a/runtimepy/requirements.txt +++ b/runtimepy/requirements.txt @@ -1,3 +1,4 @@ vcorelib>=3.2.0 +svgen websockets windows-curses; sys_platform == 'win32' and python_version < '3.12' diff --git a/tests/commands/test_arbiter.py b/tests/commands/test_arbiter.py index 35c142f5..78f960c9 100644 --- a/tests/commands/test_arbiter.py +++ b/tests/commands/test_arbiter.py @@ -21,7 +21,11 @@ def test_arbiter_command_basic(): == 0 ) - for entry in ["basic", "http"]: + apps = [] + apps.append("basic") + apps.append("http") + apps.append("runtimepy_http") + for entry in apps: assert ( runtimepy_main( base + [str(resource("connection_arbiter", f"{entry}.yaml"))] diff --git a/tests/data/valid/connection_arbiter/runtimepy_http.yaml b/tests/data/valid/connection_arbiter/runtimepy_http.yaml new file mode 100644 index 00000000..e674dce6 --- /dev/null +++ b/tests/data/valid/connection_arbiter/runtimepy_http.yaml @@ -0,0 +1,19 @@ +--- +includes: + - package://runtimepy/factories.yaml + +app: tests.net.stream.runtimepy_http_test + +ports: + - {name: runtimepy_http_server, type: tcp} + +clients: + - factory: runtimepy_http + name: runtimepy_http_client + defer: true + kwargs: {host: localhost, port: "$runtimepy_http_server"} + +servers: + - factory: runtimepy_http + kwargs: + port: "$runtimepy_http_server" diff --git a/tests/net/stream/__init__.py b/tests/net/stream/__init__.py index 6b3a8b5a..d945a331 100644 --- a/tests/net/stream/__init__.py +++ b/tests/net/stream/__init__.py @@ -16,6 +16,7 @@ from runtimepy.net.arbiter.info import AppInfo from runtimepy.net.http.header import RequestHeader from runtimepy.net.http.response import ResponseHeader +from runtimepy.net.server import RuntimepyServerConnection from runtimepy.net.stream import StringMessageConnection from runtimepy.net.stream.json import JsonMessage, JsonMessageConnection from runtimepy.net.tcp.http import HttpConnection @@ -70,7 +71,6 @@ async def http_test(app: AppInfo) -> int: conns = list(app.search(kind=HttpConnection)) assert len(conns) == 2 - server = None for conn in conns: if conn is not client: @@ -80,6 +80,42 @@ async def http_test(app: AppInfo) -> int: return await http_test_loopback(client, server) +async def runtimepy_http_test(app: AppInfo) -> int: + """A network application that tests this package's HTTP connection.""" + + client = app.single(pattern="client", kind=RuntimepyServerConnection) + + conns = list(app.search(kind=RuntimepyServerConnection)) + assert len(conns) == 2 + server = None + for conn in conns: + if conn is not client: + server = conn + assert server is not None + + # Make requests in parallel. + await asyncio.gather( + *( + # Application. + client.request(RequestHeader(target="/")), + # favicon.ico. + client.request(RequestHeader(target="/favicon.ico")), + # JSON queries. + client.request(RequestHeader(target="/json")), + client.request(RequestHeader(target="/json//////")), + client.request(RequestHeader(target="/json/a")), + client.request(RequestHeader(target="/json/test")), + client.request(RequestHeader(target="/json/test/a")), + client.request(RequestHeader(target="/json/test/a/b")), + client.request(RequestHeader(target="/json/test/d")), + ) + ) + + del server + + return 0 + + async def stream_test(app: AppInfo) -> int: """A network application that tests string messaging."""