Skip to content

Commit

Permalink
3.5.0: HTTP server implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
vkottler committed Feb 18, 2024
1 parent 98f26ab commit 671052c
Show file tree
Hide file tree
Showing 18 changed files with 355 additions and 13 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions local/configs/package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ entry: {{entry}}

requirements:
- vcorelib>=3.2.0
- svgen
- websockets
- "windows-curses; sys_platform == 'win32' and python_version < '3.12'"

Expand Down
4 changes: 2 additions & 2 deletions local/variables/package.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
major: 3
minor: 4
patch: 1
minor: 5
patch: 0
entry: runtimepy
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions runtimepy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# =====================================
# generator=datazen
# version=3.1.4
# hash=4df0a252c47915951eb613d1f294ee92
# hash=4b3acb30b6d5eb59ef87ed685da69bb3
# =====================================

"""
Expand All @@ -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"
1 change: 1 addition & 0 deletions runtimepy/data/factories.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ factories:

# Useful protocols.
- {name: runtimepy.net.factories.Http}
- {name: runtimepy.net.factories.RuntimepyHttp}

# Useful tasks.
- {name: runtimepy.task.trig.Sinusoid}
Expand Down
Binary file added runtimepy/data/favicon.ico
Binary file not shown.
9 changes: 8 additions & 1 deletion runtimepy/net/factories/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
11 changes: 10 additions & 1 deletion runtimepy/net/http/request_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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

Expand All @@ -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]
93 changes: 93 additions & 0 deletions runtimepy/net/server/__init__.py
Original file line number Diff line number Diff line change
@@ -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
91 changes: 91 additions & 0 deletions runtimepy/net/server/html.py
Original file line number Diff line number Diff line change
@@ -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("<!DOCTYPE html>\n")
html = Element(
tag="html", attrib=attributes({"lang": "en"}), children=[head, body]
)
html.encode(stream)
66 changes: 66 additions & 0 deletions runtimepy/net/server/json.py
Original file line number Diff line number Diff line change
@@ -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")
Loading

0 comments on commit 671052c

Please sign in to comment.