diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index aba12403..9dd7b964 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.6.3 + repo=runtimepy version=5.6.4 if: | matrix.python-version == '3.12' && matrix.system == 'ubuntu-latest' diff --git a/.pylintrc b/.pylintrc index 721b9568..ac4cdc61 100644 --- a/.pylintrc +++ b/.pylintrc @@ -2,7 +2,7 @@ max-args=10 max-positional-arguments=10 max-attributes=15 -max-parents=13 +max-parents=14 max-public-methods=22 max-branches=13 diff --git a/README.md b/README.md index b72f04cf..3aef67d1 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ ===================================== generator=datazen version=3.1.4 - hash=ac4d0a4097094aeb4e3f43c5f1128115 + hash=41c5a2511c80b2cbbaee668bb6a75bd2 ===================================== --> -# runtimepy ([5.6.3](https://pypi.org/project/runtimepy/)) +# runtimepy ([5.6.4](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/config b/config index 8e16a46c..170d4643 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 8e16a46cd7bf6fad5b3f8325566127f733ea7091 +Subproject commit 170d4643385923ce4ab235aa6fc0c742a9807213 diff --git a/local/configs/package.yaml b/local/configs/package.yaml index 0e9d2d29..d05eecfd 100644 --- a/local/configs/package.yaml +++ b/local/configs/package.yaml @@ -7,7 +7,8 @@ entry: {{entry}} time_command: true requirements: - - vcorelib>=3.3.1 + - aiofiles + - vcorelib>=3.4.2 - svgen>=0.6.8 - websockets - psutil @@ -39,5 +40,9 @@ init_local: | METRICS_NAME = "metrics" DEFAULT_EXT = "yaml" +mypy_local: | + [mypy-aiofiles.*] + ignore_missing_imports = True + ci_local: - "- run: mk python-editable" diff --git a/local/configs/python.yaml b/local/configs/python.yaml index c7712f05..b7fd071c 100644 --- a/local/configs/python.yaml +++ b/local/configs/python.yaml @@ -1,7 +1,7 @@ --- author_info: name: Vaughn Kottler - email: vaughnkottler@gmail.com + email: vaughn@libre-embedded.com username: vkottler versions: ["3.11", "3.12"] diff --git a/local/variables/package.yaml b/local/variables/package.yaml index 1aebc0ca..93bcb3fa 100644 --- a/local/variables/package.yaml +++ b/local/variables/package.yaml @@ -1,5 +1,5 @@ --- major: 5 minor: 6 -patch: 3 +patch: 4 entry: runtimepy diff --git a/mypy.ini b/mypy.ini index e23c0ead..5236fb12 100644 --- a/mypy.ini +++ b/mypy.ini @@ -13,3 +13,7 @@ warn_unused_ignores = False strict = False disallow_any_generics = False strict_equality = False + +# runtimepy-specific configurations. +[mypy-aiofiles.*] +ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml index 6253bda3..6c585ec8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta:__legacy__" [project] name = "runtimepy" -version = "5.6.3" +version = "5.6.4" description = "A framework for implementing Python services." readme = "README.md" requires-python = ">=3.11" authors = [ - {name = "Vaughn Kottler", email = "vaughnkottler@gmail.com"} + {name = "Vaughn Kottler", email = "vaughn@libre-embedded.com"} ] maintainers = [ - {name = "Vaughn Kottler", email = "vaughnkottler@gmail.com"} + {name = "Vaughn Kottler", email = "vaughn@libre-embedded.com"} ] classifiers = [ "Programming Language :: Python :: 3.11", diff --git a/runtimepy/__init__.py b/runtimepy/__init__.py index 640558bd..cbe432d2 100644 --- a/runtimepy/__init__.py +++ b/runtimepy/__init__.py @@ -1,7 +1,7 @@ # ===================================== # generator=datazen # version=3.1.4 -# hash=9547492b6c819241db9f6c010450b6ce +# hash=beefe82269955725f177c01474f7cea1 # ===================================== """ @@ -10,7 +10,7 @@ DESCRIPTION = "A framework for implementing Python services." PKG_NAME = "runtimepy" -VERSION = "5.6.3" +VERSION = "5.6.4" # runtimepy-specific content. METRICS_NAME = "metrics" diff --git a/runtimepy/channel/environment/command/__init__.py b/runtimepy/channel/environment/command/__init__.py index 95832e2e..783bc14d 100644 --- a/runtimepy/channel/environment/command/__init__.py +++ b/runtimepy/channel/environment/command/__init__.py @@ -15,6 +15,7 @@ from vcorelib.io import ARBITER, JsonObject from vcorelib.logging import DEFAULT_TIME_FORMAT, LoggerMixin from vcorelib.math import default_time_ns, nano_str +from vcorelib.names import name_search # internal from runtimepy.channel.environment import ChannelEnvironment @@ -26,7 +27,6 @@ ) from runtimepy.channel.registry import ParsedEvent from runtimepy.mapping import DEFAULT_PATTERN -from runtimepy.util import name_search # Declared so we re-export FieldOrChannel after moving where it's declared. __all__ = [ diff --git a/runtimepy/channel/environment/telemetry.py b/runtimepy/channel/environment/telemetry.py index 3f81e922..1c040a18 100644 --- a/runtimepy/channel/environment/telemetry.py +++ b/runtimepy/channel/environment/telemetry.py @@ -6,6 +6,9 @@ from contextlib import ExitStack, contextmanager from typing import BinaryIO, Iterator, Optional, cast +# third-party +from vcorelib.names import name_search + # internal from runtimepy.channel.environment.base import ( BaseChannelEnvironment as _BaseChannelEnvironment, @@ -14,7 +17,6 @@ from runtimepy.channel.registry import ParsedEvent from runtimepy.mapping import DEFAULT_PATTERN from runtimepy.metrics.channel import ChannelMetrics -from runtimepy.util import name_search class TelemetryChannelEnvironment(_BaseChannelEnvironment): diff --git a/runtimepy/data/css/bootstrap_extra.css b/runtimepy/data/css/bootstrap_extra.css index df7edae5..f66eb527 100644 --- a/runtimepy/data/css/bootstrap_extra.css +++ b/runtimepy/data/css/bootstrap_extra.css @@ -42,6 +42,7 @@ .table-container { overflow-x: scroll; + min-height: fit-content; } .channel-column { diff --git a/runtimepy/data/css/main.css b/runtimepy/data/css/main.css index f3503d26..f6458923 100644 --- a/runtimepy/data/css/main.css +++ b/runtimepy/data/css/main.css @@ -17,6 +17,10 @@ body > :first-child { height: 100%; } +#runtimepy-tabs { + width: min-content; +} + #runtimepy-splash { position: fixed; top: 0; diff --git a/runtimepy/data/dummy_load.yaml b/runtimepy/data/dummy_load.yaml index af7c38e0..11465c32 100644 --- a/runtimepy/data/dummy_load.yaml +++ b/runtimepy/data/dummy_load.yaml @@ -9,7 +9,19 @@ tasks: # Sinusoids. - {name: wave1, factory: sinusoid, period_s: 0.01} - {name: wave2, factory: sinusoid, period_s: 0.02} - - {name: wave3, factory: sinusoid, period_s: 0.03} + - name: wave3 + factory: sinusoid + period_s: 0.03 + markdown: | + # Markdown for wave3 + + * list element 1 + * list element 2 + * list element 3 + + To be continued. + + `peace` # Drive interactions with runtime entities that won't otherwise be polled. - {name: app, factory: SampleApp, period_s: 0.25} @@ -21,6 +33,11 @@ clients: defer: true kwargs: remote_addr: [localhost, "$udp_json"] + markdown: | + # `udp_json_client` + + Connects to `udp_json_server`. + - factory: udp_json name: udp_json_server kwargs: @@ -36,8 +53,19 @@ structs: b: 2 c: 3 + markdown: | + # Docs for `example.struct1` + + Should be shown for peer process as well? + - name: struct2 factory: sample_struct + config: + markdown: | + # `struct2` + + One of the structs of all time. + - name: struct3 factory: sample_struct @@ -46,6 +74,11 @@ processes: - name: proc1 factory: sample_peer + markdown: | + # Markdown for `proc1` + + A process that + # The peer itself runs an arbiter process. config: includes: diff --git a/runtimepy/data/md/Connection.md b/runtimepy/data/md/Connection.md new file mode 100644 index 00000000..c141711c --- /dev/null +++ b/runtimepy/data/md/Connection.md @@ -0,0 +1,3 @@ +# Connections + +To be continued. diff --git a/runtimepy/data/md/PeriodicTask.md b/runtimepy/data/md/PeriodicTask.md new file mode 100644 index 00000000..256f620f --- /dev/null +++ b/runtimepy/data/md/PeriodicTask.md @@ -0,0 +1,3 @@ +# Periodic Tasks + +To be continued. diff --git a/runtimepy/data/md/RuntimeStruct.md b/runtimepy/data/md/RuntimeStruct.md new file mode 100644 index 00000000..5df24582 --- /dev/null +++ b/runtimepy/data/md/RuntimeStruct.md @@ -0,0 +1,3 @@ +# Runtime Structures + +To be continued. diff --git a/runtimepy/data/md/RuntimepyPeer.md b/runtimepy/data/md/RuntimepyPeer.md new file mode 100644 index 00000000..f327ff85 --- /dev/null +++ b/runtimepy/data/md/RuntimepyPeer.md @@ -0,0 +1,3 @@ +# Peer Programs + +To be continued. diff --git a/runtimepy/data/md/SinusoidTask.md b/runtimepy/data/md/SinusoidTask.md new file mode 100644 index 00000000..9ef6f52f --- /dev/null +++ b/runtimepy/data/md/SinusoidTask.md @@ -0,0 +1,20 @@ +# Sinusoid Tasks + +## Intent + +These tasks compute +[`math.sin`](https://docs.python.org/3/library/math.html#math.sin) and +[`math.cos`](https://docs.python.org/3/library/math.html#math.cos) on every +iteration, which advances one "step" per dispatch where the "step angle" +advanced depends on the `steps` control. + +Once the current "step angle" is computed, controls `amplitude` and phase +angles are considered for final `sin` and `cos` channel values. + +## Discussion + +* Is it worth adding a `tan` channel that computes +[`math.tan`](https://docs.python.org/3/library/math.html#math.tan) and/or other +trigonometric functions? +* Is it worth adding a "number of steps" control, such that one task iteration +could advance multiple waveform steps? diff --git a/runtimepy/data/schemas/ClientConnectionConfig.yaml b/runtimepy/data/schemas/ClientConnectionConfig.yaml index 24aba16d..7068226e 100644 --- a/runtimepy/data/schemas/ClientConnectionConfig.yaml +++ b/runtimepy/data/schemas/ClientConnectionConfig.yaml @@ -2,6 +2,7 @@ includes: - has_factory.yaml - has_name.yaml + - has_markdown.yaml properties: defer: diff --git a/runtimepy/data/schemas/PeerProcessConfig.yaml b/runtimepy/data/schemas/PeerProcessConfig.yaml index 3e251855..22c76727 100644 --- a/runtimepy/data/schemas/PeerProcessConfig.yaml +++ b/runtimepy/data/schemas/PeerProcessConfig.yaml @@ -3,6 +3,7 @@ includes: - has_factory.yaml - has_name.yaml - has_config.yaml + - has_markdown.yaml required: [program] diff --git a/runtimepy/data/schemas/TaskConfig.yaml b/runtimepy/data/schemas/TaskConfig.yaml index dba5f96b..ec1aa835 100644 --- a/runtimepy/data/schemas/TaskConfig.yaml +++ b/runtimepy/data/schemas/TaskConfig.yaml @@ -2,6 +2,7 @@ includes: - has_factory.yaml - has_name.yaml + - has_markdown.yaml properties: period_s: diff --git a/runtimepy/data/schemas/has_markdown.yaml b/runtimepy/data/schemas/has_markdown.yaml new file mode 100644 index 00000000..a015dff4 --- /dev/null +++ b/runtimepy/data/schemas/has_markdown.yaml @@ -0,0 +1,4 @@ +--- +properties: + markdown: + type: string diff --git a/runtimepy/mapping.py b/runtimepy/mapping.py index 638fdcbb..34a20bbc 100644 --- a/runtimepy/mapping.py +++ b/runtimepy/mapping.py @@ -13,10 +13,10 @@ # third-party from vcorelib.logging import LoggerMixin +from vcorelib.names import name_search # internal from runtimepy.mixins.regex import RegexMixin as _RegexMixin -from runtimepy.util import name_search # This determines types that are valid as keys. T = _TypeVar("T", int, bool) diff --git a/runtimepy/message/interface.py b/runtimepy/message/interface.py index 95f2f112..f7f723db 100644 --- a/runtimepy/message/interface.py +++ b/runtimepy/message/interface.py @@ -11,7 +11,7 @@ # third-party from vcorelib.dict.codec import JsonCodec -from vcorelib.logging import LoggerType +from vcorelib.logging import ListLogger, LoggerType from vcorelib.target.resolver import TargetResolver # internal @@ -39,7 +39,7 @@ T, TypedHandler, ) -from runtimepy.util import Identifier, ListLogger +from runtimepy.util import Identifier class JsonMessageInterface: diff --git a/runtimepy/mixins/environment.py b/runtimepy/mixins/environment.py index 3c75e328..929989cd 100644 --- a/runtimepy/mixins/environment.py +++ b/runtimepy/mixins/environment.py @@ -30,11 +30,14 @@ def __hash__(self) -> int: return id(self.env) def register_task_metrics( - self, metrics: PeriodicTaskMetrics, namespace: str = METRICS_NAME + self, + metrics: PeriodicTaskMetrics, + *names: str, + namespace: str = METRICS_NAME, ) -> None: """Register periodic task metrics.""" - with self.env.names_pushed(namespace): + with self.env.names_pushed(namespace, *names): self.env.channel( "dispatches", metrics.dispatches, @@ -104,11 +107,14 @@ def register_channel_metrics( ) def register_connection_metrics( - self, metrics: ConnectionMetrics, namespace: str = METRICS_NAME + self, + metrics: ConnectionMetrics, + *names: str, + namespace: str = METRICS_NAME, ) -> None: """Register connection metrics.""" - with self.env.names_pushed(namespace): + with self.env.names_pushed(namespace, *names): for name, direction, verb in [ ("tx", metrics.tx, "transmitted"), ("rx", metrics.rx, "received"), diff --git a/runtimepy/mixins/logging.py b/runtimepy/mixins/logging.py index 8919525f..3179e800 100644 --- a/runtimepy/mixins/logging.py +++ b/runtimepy/mixins/logging.py @@ -3,10 +3,15 @@ """ # built-in +from contextlib import AsyncExitStack +import io import logging +from typing import Any, Iterable # third-party -from vcorelib.logging import LoggerMixin +import aiofiles +from vcorelib.logging import LoggerMixin, LoggerType +from vcorelib.paths import Pathlike, normalize # internal from runtimepy.channel.environment import ChannelEnvironment @@ -23,6 +28,9 @@ class LogLevel(RuntimeIntEnum): CRITICAL = logging.CRITICAL +LogLevellike = LogLevel | int | str + + class LoggerMixinLevelControl(LoggerMixin): """A logger mixin that exposes a runtime-controllable level.""" @@ -55,3 +63,48 @@ def setup_level_channel( env.set(name, initial) del chan + + +LogPaths = Iterable[tuple[LogLevellike, Pathlike]] + + +class LogCaptureMixin: + """A simple async file-reading interface.""" + + logger: LoggerType + + # Open aiofiles handles. + streams: list[tuple[int, Any]] + + ext_log_extra = {"external": True} + + async def init_log_capture( + self, stack: AsyncExitStack, log_paths: LogPaths + ) -> None: + """Initialize this task with application information.""" + + self.streams = [ + ( + LogLevel.normalize(level), + await stack.enter_async_context(aiofiles.open(path, mode="r")), + ) + for level, path in log_paths + if normalize(path).is_file() + ] + + # Don't handle any kind of backhaul. + for stream in self.streams: + await stream[1].seek(0, io.SEEK_END) + + def log_line(self, level: int, data: str) -> None: + """Log a line for output.""" + self.logger.log(level, data, extra=self.ext_log_extra) + + async def dispatch_log_capture(self) -> None: + """Get the next line from this log stream.""" + + for level, stream in self.streams: + line = (await stream.readline()).rstrip() + while line: + self.log_line(level, line) + line = (await stream.readline()).rstrip() diff --git a/runtimepy/net/arbiter/base.py b/runtimepy/net/arbiter/base.py index e5e2ab49..101a0621 100644 --- a/runtimepy/net/arbiter/base.py +++ b/runtimepy/net/arbiter/base.py @@ -48,7 +48,9 @@ from runtimepy.tui.mixin import CursesWindow, TuiMixin ServerTask = _Awaitable[None] -RuntimeProcessTask = tuple[type[_RuntimepyPeer], str, _JsonObject, str] +RuntimeProcessTask = tuple[ + type[_RuntimepyPeer], str, _JsonObject, str, Optional[str] +] async def init_only(app: AppInfo) -> int: @@ -226,9 +228,17 @@ async def _build_structs(self, info: AppInfo) -> None: async def _start_processes(self, stack: _AsyncExitStack) -> None: """Start processes.""" - for name, (peer, name, config, import_str) in self._peers.items(): + for name, ( + peer, + name, + config, + import_str, + markdown, + ) in self._peers.items(): self._runtime_peers[name] = await stack.enter_async_context( - peer.running_program(name, config, import_str) + peer.running_program( + name, config, import_str, markdown=markdown + ) ) self.logger.info("Started process '%s'.", name) diff --git a/runtimepy/net/arbiter/config/__init__.py b/runtimepy/net/arbiter/config/__init__.py index 43050323..166c0b67 100644 --- a/runtimepy/net/arbiter/config/__init__.py +++ b/runtimepy/net/arbiter/config/__init__.py @@ -17,6 +17,7 @@ from vcorelib.io import ARBITER as _ARBITER from vcorelib.io.types import JsonObject as _JsonObject from vcorelib.logging import LoggerMixin as _LoggerMixin +from vcorelib.names import import_str_and_item from vcorelib.paths import Pathlike as _Pathlike from vcorelib.paths import find_file from vcorelib.paths import normalize as _normalize @@ -29,7 +30,6 @@ ImportConnectionArbiter as _ImportConnectionArbiter, ) from runtimepy.net.arbiter.imports.util import get_apps -from runtimepy.util import import_str_and_item ConfigObject = dict[str, _Any] ConfigBuilder = _Callable[[ConfigObject], None] @@ -168,6 +168,7 @@ async def process_config( kwargs = dict_resolve_env_vars( client.get("kwargs", {}), env=config.ports # type: ignore ) + kwargs.setdefault("markdown", client.get("markdown")) assert await self.factory_client( factory, @@ -201,6 +202,7 @@ async def process_config( name, period_s=task["period_s"], average_depth=task["average_depth"], + markdown=task.get("markdown"), ), f"Couldn't register task '{name}' ({factory})!" # Register structs. @@ -216,7 +218,9 @@ async def process_config( name = process["name"] factory = process["factory"] assert self.factory_process( - factory, name, process.get("config", {}), process["program"] + factory, + name, + process, ), f"Couldn't register process '{name}' ({factory})!" # Load initialization methods. diff --git a/runtimepy/net/arbiter/imports/__init__.py b/runtimepy/net/arbiter/imports/__init__.py index 228e36b0..b2da4562 100644 --- a/runtimepy/net/arbiter/imports/__init__.py +++ b/runtimepy/net/arbiter/imports/__init__.py @@ -8,7 +8,7 @@ # third-party from vcorelib.io.types import JsonObject as _JsonObject -from vcorelib.names import to_snake +from vcorelib.names import import_str_and_item, to_snake # internal from runtimepy.net.arbiter.factory import ( @@ -23,7 +23,6 @@ from runtimepy.net.arbiter.info import RuntimeStruct as _RuntimeStruct from runtimepy.net.arbiter.task import TaskFactory as _TaskFactory from runtimepy.subprocess.peer import RuntimepyPeer as _RuntimepyPeer -from runtimepy.util import import_str_and_item class ImportConnectionArbiter( @@ -111,7 +110,7 @@ def factory_struct( return result def factory_process( - self, factory: str, name: str, config: _JsonObject, program: str + self, factory: str, name: str, top_level: _JsonObject ) -> bool: """Register a runtime process.""" @@ -121,8 +120,9 @@ def factory_process( self._peers[name] = ( self._peer_factories[factory], name, - config, - program, + top_level.get("config", {}), # type: ignore + str(top_level["program"]), + top_level.get("markdown"), ) result = True diff --git a/runtimepy/net/arbiter/imports/util.py b/runtimepy/net/arbiter/imports/util.py index b4a78dbd..1dffd9cc 100644 --- a/runtimepy/net/arbiter/imports/util.py +++ b/runtimepy/net/arbiter/imports/util.py @@ -5,10 +5,12 @@ # built-in from importlib import import_module as _import_module +# third-party +from vcorelib.names import import_str_and_item + # internal from runtimepy.net.arbiter.config.codec import ConfigApps from runtimepy.net.arbiter.info import ArbiterApps -from runtimepy.util import import_str_and_item def get_apps( diff --git a/runtimepy/net/connection.py b/runtimepy/net/connection.py index 0d8f7690..8b8c64f2 100644 --- a/runtimepy/net/connection.py +++ b/runtimepy/net/connection.py @@ -13,9 +13,11 @@ # third-party from vcorelib.asyncio import log_exceptions as _log_exceptions +from vcorelib.io import MarkdownMixin from vcorelib.logging import LoggerType as _LoggerType # internal +from runtimepy import PKG_NAME from runtimepy.channel.environment import ChannelEnvironment from runtimepy.channel.environment.command.processor import ( ChannelCommandProcessor, @@ -30,7 +32,9 @@ BinaryMessage = _Union[bytes, bytearray, memoryview] -class Connection(LoggerMixinLevelControl, ChannelEnvironmentMixin, _ABC): +class Connection( + LoggerMixinLevelControl, ChannelEnvironmentMixin, MarkdownMixin, _ABC +): """A connection interface.""" uses_text_tx_queue = True @@ -46,9 +50,11 @@ def __init__( logger: _LoggerType, env: ChannelEnvironment = None, add_metrics: bool = True, + markdown: str = None, ) -> None: """Initialize this connection.""" + self.set_markdown(markdown=markdown, package=PKG_NAME) LoggerMixinLevelControl.__init__(self, logger=logger) # A queue for out-going text messages. Connections that don't use diff --git a/runtimepy/net/http/common.py b/runtimepy/net/http/common.py index c1a8676a..d6450600 100644 --- a/runtimepy/net/http/common.py +++ b/runtimepy/net/http/common.py @@ -9,6 +9,7 @@ # third-party from vcorelib import DEFAULT_ENCODING +from vcorelib.logging import LoggerType HTTPMethodlike = Union[str, http.HTTPMethod] HEADER_LINESEP = "\r\n" @@ -62,6 +63,10 @@ def write_field_lines(self, stream: TextIO) -> None: def from_lines(self, lines: list[str]) -> None: """Update this request from line data.""" + @abstractmethod + def log(self, logger: LoggerType, out: bool, **kwargs) -> None: + """Log information about this response header.""" + @abstractmethod def __str__(self) -> str: """Get this response as a string.""" diff --git a/runtimepy/net/http/header.py b/runtimepy/net/http/header.py index afeaf016..6639f8c6 100644 --- a/runtimepy/net/http/header.py +++ b/runtimepy/net/http/header.py @@ -55,7 +55,11 @@ def from_lines(self, lines: list[str]) -> None: HeadersMixin.__init__(self, lines[1:]) def log( - self, logger: LoggerType, out: bool, level: int = logging.DEBUG + self, + logger: LoggerType, + out: bool, + level: int = logging.DEBUG, + **_, ) -> None: """Log information about this request header.""" diff --git a/runtimepy/net/http/response.py b/runtimepy/net/http/response.py index 355e61c7..77fda5b4 100644 --- a/runtimepy/net/http/response.py +++ b/runtimepy/net/http/response.py @@ -72,7 +72,7 @@ def __str__(self) -> str: return stream.getvalue() - def log(self, logger: LoggerType, out: bool) -> None: + def log(self, logger: LoggerType, out: bool, **_) -> None: """Log information about this response header.""" level = logging.INFO if (200 <= self.status <= 299) else logging.ERROR diff --git a/runtimepy/net/server/app/__init__.py b/runtimepy/net/server/app/__init__.py index fed63507..db0e7dc3 100644 --- a/runtimepy/net/server/app/__init__.py +++ b/runtimepy/net/server/app/__init__.py @@ -7,6 +7,9 @@ from importlib import import_module as _import_module from typing import Any +# third-party +from vcorelib.names import import_str_and_item + # internal from runtimepy.net.arbiter.info import AppInfo from runtimepy.net.server import RuntimepyServerConnection @@ -17,7 +20,6 @@ ) from runtimepy.net.server.app.landing_page import landing_page from runtimepy.subprocess import spawn_exec -from runtimepy.util import import_str_and_item async def launch_browser(app: AppInfo) -> None: diff --git a/runtimepy/net/server/app/bootstrap/elements.py b/runtimepy/net/server/app/bootstrap/elements.py index c607d711..5d99f86e 100644 --- a/runtimepy/net/server/app/bootstrap/elements.py +++ b/runtimepy/net/server/app/bootstrap/elements.py @@ -3,17 +3,19 @@ """ # built-in +from io import StringIO from typing import Optional # third-party from svgen.element import Element from svgen.element.html import div +from vcorelib.io.file_writer import IndentedFileWriter # internal from runtimepy.net.server.app.bootstrap import icon_str TEXT = "font-monospace" -BOOTSTRAP_BUTTON = f"rounded-0 {TEXT} button-bodge" +BOOTSTRAP_BUTTON = f"rounded-0 {TEXT} button-bodge text-nowrap" def flex(kind: str = "row", **kwargs) -> Element: @@ -161,3 +163,38 @@ def slider( # div(tag="option", value=start + (idx * step), parent=markers) return elem + + +def centered_markdown( + parent: Element, markdown: str, *container_classes: str +) -> None: + """Add centered markdown.""" + + container = div(parent=parent) + container.add_class( + "flex-grow-1", + "d-flex", + "flex-column", + "justify-content-between", + *container_classes, + ) + + div(parent=container) + + horiz_container = div(parent=container) + horiz_container.add_class("d-flex", "flex-row", "justify-content-between") + + div(parent=horiz_container) + + with StringIO() as stream: + writer = IndentedFileWriter(stream) + writer.write_markdown(markdown) + div( + text=stream.getvalue(), + parent=horiz_container, + class_str="text-light p-3 pb-0", + ) + + div(parent=horiz_container) + + div(parent=container) diff --git a/runtimepy/net/server/app/env/__init__.py b/runtimepy/net/server/app/env/__init__.py index bc6ac824..dd581849 100644 --- a/runtimepy/net/server/app/env/__init__.py +++ b/runtimepy/net/server/app/env/__init__.py @@ -8,53 +8,75 @@ # internal from runtimepy import PKG_NAME from runtimepy.net.arbiter.info import AppInfo -from runtimepy.net.server.app.bootstrap.elements import input_box +from runtimepy.net.server.app.bootstrap.elements import ( + centered_markdown, + input_box, +) from runtimepy.net.server.app.bootstrap.tabs import TabbedContent from runtimepy.net.server.app.env.modal import Modal from runtimepy.net.server.app.env.settings import plot_settings from runtimepy.net.server.app.env.tab import ChannelEnvironmentTab -from runtimepy.net.server.app.placeholder import dummy_tabs, under_construction +from runtimepy.net.server.app.placeholder import dummy_tabs from runtimepy.net.server.app.sound import SoundTab -def channel_environments(app: AppInfo, tabs: TabbedContent) -> None: - """Populate application elements.""" - - # Remove tab-content scrolling. - tabs.set_scroll(False) - - # Tab name filter. - input_box(tabs.tabs, label="tab", description="Tab name filter.") +def populate_tabs(app: AppInfo, tabs: TabbedContent) -> None: + """Populate tab contents.""" # Connection tabs. for name, conn in app.connections.items(): ChannelEnvironmentTab( - name, conn.command, app, tabs, icon="ethernet" + name, + conn.command, + app, + tabs, + icon="ethernet", + markdown=conn.markdown, ).entry() # Task tabs. for name, task in app.tasks.items(): ChannelEnvironmentTab( - name, task.command, app, tabs, icon="arrow-repeat" + name, + task.command, + app, + tabs, + icon="arrow-repeat", + markdown=task.markdown, ).entry() # Struct tabs. for struct in app.structs.values(): ChannelEnvironmentTab( - struct.name, struct.command, app, tabs, icon="bucket" + struct.name, + struct.command, + app, + tabs, + icon="bucket", + markdown=struct.markdown, ).entry() # Subprocess tabs. for peer in app.peers.values(): # Host side. ChannelEnvironmentTab( - peer.struct.name, peer.struct.command, app, tabs, icon="cpu-fill" + peer.struct.name, + peer.struct.command, + app, + tabs, + icon="cpu-fill", + markdown=peer.markdown, ).entry() # Remote side. assert peer.peer is not None ChannelEnvironmentTab( - peer.peer_name, peer.peer, app, tabs, icon="cpu" + peer.peer_name, + peer.peer, + app, + tabs, + icon="cpu", + markdown=peer.struct.markdown, ).entry() # If we are a peer program, load environments. @@ -69,14 +91,40 @@ def channel_environments(app: AppInfo, tabs: TabbedContent) -> None: app, tabs, icon="cpu-fill", + markdown=PROGRAM.struct.markdown, ).entry() # Remote side. assert PROGRAM.peer is not None ChannelEnvironmentTab( - PROGRAM.peer_name, PROGRAM.peer, app, tabs, icon="cpu" + PROGRAM.peer_name, + PROGRAM.peer, + app, + tabs, + icon="cpu", + markdown=PROGRAM.markdown, ).entry() + +def channel_environments(app: AppInfo, tabs: TabbedContent) -> None: + """Populate application elements.""" + + # Remove tab-content scrolling. + tabs.set_scroll(False) + + # Tab name filter. + input_box(tabs.tabs, label="tab", description="Tab name filter.") + + centered_markdown( + tabs.tabs, + app.config_param("top_markdown", "configure `top_markdown`"), + "border-start", + "border-bottom", + "border-end", + ) + + populate_tabs(app, tabs) + # Toggle channel-table button. tabs.add_button( "Toggle channel table", @@ -99,9 +147,11 @@ def channel_environments(app: AppInfo, tabs: TabbedContent) -> None: Modal(tabs) Modal(tabs, name="diagnostics", icon="activity") - # Placeholder for using space at the bottom of the tab list. - under_construction( - tabs.tabs, note="unused space", class_str="border-start border-end" + centered_markdown( + tabs.tabs, + app.config_param("bottom_markdown", "configure `bottom_markdown`"), + "border-start", + "border-end", ) # Add splash screen element. diff --git a/runtimepy/net/server/app/env/tab/base.py b/runtimepy/net/server/app/env/tab/base.py index 5728fb54..833ba794 100644 --- a/runtimepy/net/server/app/env/tab/base.py +++ b/runtimepy/net/server/app/env/tab/base.py @@ -3,10 +3,12 @@ """ # third-party +from vcorelib.io import MarkdownMixin from vcorelib.logging import LoggerMixin from vcorelib.math import RateLimiter # internal +from runtimepy import PKG_NAME from runtimepy.channel.environment.command.processor import ( ChannelCommandProcessor, ) @@ -15,7 +17,7 @@ from runtimepy.net.server.app.tab import Tab -class ChannelEnvironmentTabBase(Tab, LoggerMixin): +class ChannelEnvironmentTabBase(Tab, LoggerMixin, MarkdownMixin): """A channel-environment tab interface.""" def __init__( @@ -25,10 +27,12 @@ def __init__( app: AppInfo, tabs: TabbedContent, icon: str = "alarm", + markdown: str = None, ) -> None: """Initialize this instance.""" self.command = command + self.set_markdown(markdown=markdown, package=PKG_NAME) super().__init__(name, app, tabs, source="env", icon=icon) # Logging. diff --git a/runtimepy/net/server/app/env/tab/html.py b/runtimepy/net/server/app/env/tab/html.py index 5b495a50..2316c522 100644 --- a/runtimepy/net/server/app/env/tab/html.py +++ b/runtimepy/net/server/app/env/tab/html.py @@ -14,6 +14,7 @@ from runtimepy.enum import RuntimeEnum from runtimepy.net.server.app.bootstrap.elements import ( TEXT, + centered_markdown, flex, input_box, set_tooltip, @@ -26,7 +27,6 @@ channel_table_header, plot_checkbox, ) -from runtimepy.net.server.app.placeholder import under_construction def channel_color_button(parent: Element, name: str) -> Element: @@ -246,12 +246,12 @@ def compose(self, parent: Element) -> None: self.channel_table(vert_container) - # Possible empty space that could eventually be used (scenario: channel - # table doesn't take up full vertical space, few channels). - under_construction( + centered_markdown( vert_container, - class_str="border-start border-top border-end", - note="unused space", + self.markdown, + "border-start", + "border-top", + "border-end", ) # Divider. diff --git a/runtimepy/net/server/struct/__init__.py b/runtimepy/net/server/struct/__init__.py index 5bc4b04a..8783f510 100644 --- a/runtimepy/net/server/struct/__init__.py +++ b/runtimepy/net/server/struct/__init__.py @@ -43,8 +43,7 @@ def init_env(self) -> None: # JSON-messaging interface metrics. self.json_metrics = ConnectionMetrics() - with self.env.names_pushed("json"): - self.register_connection_metrics(self.json_metrics) + self.register_connection_metrics(self.json_metrics, "json") # System metrics. self.use_psutil = self.config.get("psutil", True) # type: ignore diff --git a/runtimepy/net/server/websocket/state.py b/runtimepy/net/server/websocket/state.py index c1c92359..b0f53949 100644 --- a/runtimepy/net/server/websocket/state.py +++ b/runtimepy/net/server/websocket/state.py @@ -7,11 +7,13 @@ from dataclasses import dataclass import logging +# third-party +from vcorelib.logging import ListLogger + # internal from runtimepy.channel.environment.base import ValueMap from runtimepy.message import JsonMessage from runtimepy.primitives import AnyPrimitive -from runtimepy.util import ListLogger # (value, nanosecond timestamp) Point = tuple[str | int | float | bool, int] diff --git a/runtimepy/net/tcp/connection.py b/runtimepy/net/tcp/connection.py index 620fe1f1..997f9405 100644 --- a/runtimepy/net/tcp/connection.py +++ b/runtimepy/net/tcp/connection.py @@ -51,7 +51,12 @@ class TcpConnection(_Connection, _TransportMixin): log_alias = "TCP" log_prefix = "" - def __init__(self, transport: _Transport, protocol: QueueProtocol) -> None: + def __init__( + self, + transport: _Transport, + protocol: QueueProtocol, + **kwargs, + ) -> None: """Initialize this TCP connection.""" _TransportMixin.__init__(self, transport) @@ -60,7 +65,9 @@ def __init__(self, transport: _Transport, protocol: QueueProtocol) -> None: self._transport: _Transport = transport self._set_protocol(protocol) - super().__init__(_getLogger(self.logger_name(f"{self.log_alias} "))) + super().__init__( + _getLogger(self.logger_name(f"{self.log_alias} ")), **kwargs + ) # Store connection-instantiation arguments. self._conn_kwargs: dict[str, _Any] = {} @@ -121,14 +128,17 @@ def callback(transport_protocol: TcpTransportProtocol) -> None: @classmethod async def create_connection( - cls: type[T], backoff: ExponentialBackoff = None, **kwargs + cls: type[T], + backoff: ExponentialBackoff = None, + markdown: str = None, + **kwargs, ) -> T: """Create a TCP connection.""" transport, protocol = await tcp_transport_protocol_backoff( backoff=backoff, **kwargs ) - inst = cls(transport, protocol) + inst = cls(transport, protocol, markdown=markdown) # Is there a better way to do this? We can't restart a server's side # of a connection (seems okay). diff --git a/runtimepy/net/tcp/http/__init__.py b/runtimepy/net/tcp/http/__init__.py index 83e164a5..5351c078 100644 --- a/runtimepy/net/tcp/http/__init__.py +++ b/runtimepy/net/tcp/http/__init__.py @@ -7,7 +7,7 @@ from copy import copy import http from json import loads -from typing import Any, Awaitable, Callable, Optional, Tuple, Union +from typing import Any, Awaitable, Callable, Optional, Tuple, Union, cast # third-party from vcorelib import DEFAULT_ENCODING @@ -174,11 +174,9 @@ def _send( async def process_binary(self, data: bytes) -> bool: """Process a binary frame.""" - kind = RequestHeader if not self.expecting_response else ResponseHeader - - for header, payload in self.processor.ingest( # type: ignore + for header, payload in self.processor.ingest( data, - kind, # type: ignore + RequestHeader if not self.expecting_response else ResponseHeader, ): header.log(self.logger, False) @@ -187,11 +185,15 @@ async def process_binary(self, data: bytes) -> bool: response = ResponseHeader() self._send( response, - await self._process_request(response, header, payload), + await self._process_request( + response, cast(RequestHeader, header), payload + ), ) # Process the response to a pending request. else: - await self.responses.put((header, payload)) + await self.responses.put( + (cast(ResponseHeader, header), payload) + ) return True diff --git a/runtimepy/net/udp/connection.py b/runtimepy/net/udp/connection.py index 6a7d0d0f..cfa4f8fc 100644 --- a/runtimepy/net/udp/connection.py +++ b/runtimepy/net/udp/connection.py @@ -44,7 +44,10 @@ class UdpConnection(_Connection, _TransportMixin): log_alias = "UDP" def __init__( - self, transport: _DatagramTransport, protocol: UdpQueueProtocol + self, + transport: _DatagramTransport, + protocol: UdpQueueProtocol, + **kwargs, ) -> None: """Initialize this UDP connection.""" @@ -55,7 +58,9 @@ def __init__( # Re-assign with updated type information. self._transport: _DatagramTransport = transport - super().__init__(_getLogger(self.logger_name(f"{self.log_alias} "))) + super().__init__( + _getLogger(self.logger_name(f"{self.log_alias} ")), **kwargs + ) self._set_protocol(protocol) # Store connection-instantiation arguments. @@ -127,7 +132,9 @@ def callback(transport_protocol: UdpTransportProtocol) -> None: should_connect: bool = True @classmethod - async def create_connection(cls: type[T], **kwargs) -> T: + async def create_connection( + cls: type[T], markdown: str = None, **kwargs + ) -> T: """Create a UDP connection.""" LOG.debug("kwargs: %s", kwargs) @@ -151,7 +158,7 @@ async def create_connection(cls: type[T], **kwargs) -> T: # Create the underlying connection. transport, protocol = await udp_transport_protocol_backoff(**kwargs) - conn = cls(transport, protocol) + conn = cls(transport, protocol, markdown=markdown) conn._conn_kwargs = {**kwargs} # Set the remote address manually if necessary. diff --git a/runtimepy/net/udp/tftp/__init__.py b/runtimepy/net/udp/tftp/__init__.py index 1491655e..dbf4cbae 100644 --- a/runtimepy/net/udp/tftp/__init__.py +++ b/runtimepy/net/udp/tftp/__init__.py @@ -11,7 +11,7 @@ # third-party from vcorelib.asyncio.poll import repeat_until -from vcorelib.paths.context import tempfile +from vcorelib.paths.context import PossiblePath, as_path, tempfile from vcorelib.paths.hashing import file_md5_hex from vcorelib.paths.info import FileInfo @@ -25,7 +25,6 @@ ) from runtimepy.net.udp.tftp.enums import DEFAULT_MODE from runtimepy.net.util import IpHostTuplelike -from runtimepy.util import PossiblePath, as_path class TftpConnection(BaseTftpConnection): diff --git a/runtimepy/net/websocket/connection.py b/runtimepy/net/websocket/connection.py index fc44256f..c14bb7ee 100644 --- a/runtimepy/net/websocket/connection.py +++ b/runtimepy/net/websocket/connection.py @@ -51,11 +51,12 @@ class WebsocketConnection(Connection): def __init__( self, protocol: _Union[_WebSocketClientProtocol, _WebSocketServerProtocol], + **kwargs, ) -> None: """Initialize this connection.""" self.protocol = protocol - super().__init__(self.protocol.logger) + super().__init__(self.protocol.logger, **kwargs) async def _handle_connection_closed( self, task: _Awaitable[V] @@ -92,7 +93,9 @@ async def close(self) -> None: await self.protocol.close() @classmethod - async def create_connection(cls: type[T], uri: str, **kwargs) -> T: + async def create_connection( + cls: type[T], uri: str, markdown: str = None, **kwargs + ) -> T: """Connect a client to an endpoint.""" kwargs.setdefault("use_ssl", uri.startswith("wss")) @@ -100,11 +103,13 @@ async def create_connection(cls: type[T], uri: str, **kwargs) -> T: protocol = await getattr(websockets, "connect")( uri, **handle_possible_ssl(**kwargs) ) - return cls(protocol) + return cls(protocol, markdown=markdown) @classmethod @_asynccontextmanager - async def client(cls: type[T], uri: str, **kwargs) -> _AsyncIterator[T]: + async def client( + cls: type[T], uri: str, markdown: str = None, **kwargs + ) -> _AsyncIterator[T]: """A wrapper for connecting a client.""" kwargs.setdefault("use_ssl", uri.startswith("wss")) @@ -112,7 +117,7 @@ async def client(cls: type[T], uri: str, **kwargs) -> _AsyncIterator[T]: async with getattr(websockets, "connect")( uri, **handle_possible_ssl(**kwargs) ) as protocol: - yield cls(protocol) + yield cls(protocol, markdown=markdown) @classmethod def server_handler( diff --git a/runtimepy/requirements.txt b/runtimepy/requirements.txt index 10383f7e..567efbb5 100644 --- a/runtimepy/requirements.txt +++ b/runtimepy/requirements.txt @@ -1,4 +1,5 @@ -vcorelib>=3.3.1 +aiofiles +vcorelib>=3.4.1 svgen>=0.6.8 websockets psutil diff --git a/runtimepy/struct/__init__.py b/runtimepy/struct/__init__.py index 56115cd2..2403f13b 100644 --- a/runtimepy/struct/__init__.py +++ b/runtimepy/struct/__init__.py @@ -6,9 +6,11 @@ from logging import getLogger as _getLogger # third-party +from vcorelib.io import MarkdownMixin from vcorelib.io.types import JsonObject as _JsonObject # internal +from runtimepy import PKG_NAME from runtimepy.channel.environment.command.processor import ( ChannelCommandProcessor, ) @@ -16,15 +18,23 @@ from runtimepy.mixins.logging import LoggerMixinLevelControl -class RuntimeStructBase(LoggerMixinLevelControl, ChannelEnvironmentMixin): +class RuntimeStructBase( + LoggerMixinLevelControl, ChannelEnvironmentMixin, MarkdownMixin +): """A base runtime structure.""" log_level_channel: bool = True - def __init__(self, name: str, config: _JsonObject) -> None: + # Unclear why this is/was necessary (mypy bug?) + markdown: str + + def __init__( + self, name: str, config: _JsonObject, markdown: str = None + ) -> None: """Initialize this instance.""" self.name = name + self.set_markdown(config=config, markdown=markdown, package=PKG_NAME) LoggerMixinLevelControl.__init__(self, logger=_getLogger(self.name)) ChannelEnvironmentMixin.__init__(self) if self.log_level_channel: diff --git a/runtimepy/subprocess/interface.py b/runtimepy/subprocess/interface.py index 19e42cae..4a6dc212 100644 --- a/runtimepy/subprocess/interface.py +++ b/runtimepy/subprocess/interface.py @@ -12,11 +12,12 @@ from typing import Optional # third-party +from vcorelib.io import MarkdownMixin from vcorelib.io.types import JsonObject from vcorelib.math import RateLimiter # internal -from runtimepy import METRICS_NAME +from runtimepy import METRICS_NAME, PKG_NAME from runtimepy.channel.environment import ChannelEnvironment from runtimepy.channel.environment.base import FieldOrChannel from runtimepy.channel.environment.command import register_env @@ -34,7 +35,7 @@ class RuntimepyPeerInterface( - JsonMessageInterface, AsyncCommandProcessingMixin + JsonMessageInterface, AsyncCommandProcessingMixin, MarkdownMixin ): """A class implementing an interface for messaging peer subprocesses.""" @@ -42,13 +43,25 @@ class RuntimepyPeerInterface( struct_type: type[RuntimeStruct] = SampleStruct - def __init__(self, name: str, config: JsonObject) -> None: + # Unclear why this is/was necessary (mypy bug?) + markdown: str + + def __init__( + self, name: str, config: JsonObject, markdown: str = None + ) -> None: """Initialize this instance.""" + self.set_markdown(markdown=markdown, package=PKG_NAME) + self.processor = MessageProcessor() self.basename = name - self.struct = self.struct_type(self.basename + HOST_SUFFIX, config) + + self.struct = self.struct_type( + self.basename + HOST_SUFFIX, + config, + markdown=config.get("config", {}).get("markdown"), # type: ignore + ) self.peer: Optional[RemoteCommandProcessor] = None self.peer_config: Optional[JsonMessage] = None diff --git a/runtimepy/subprocess/peer.py b/runtimepy/subprocess/peer.py index 1811945a..68930c59 100644 --- a/runtimepy/subprocess/peer.py +++ b/runtimepy/subprocess/peer.py @@ -19,13 +19,13 @@ from vcorelib.io import ARBITER, DEFAULT_INCLUDES_KEY from vcorelib.io.file_writer import IndentedFileWriter from vcorelib.io.types import JsonObject +from vcorelib.names import import_str_and_item from vcorelib.paths.context import tempfile # internal from runtimepy.subprocess import spawn_exec, spawn_shell from runtimepy.subprocess.interface import RuntimepyPeerInterface from runtimepy.subprocess.protocol import RuntimepySubprocessProtocol -from runtimepy.util import import_str_and_item T = TypeVar("T", bound="RuntimepyPeer") @@ -38,10 +38,11 @@ def __init__( protocol: RuntimepySubprocessProtocol, name: str, config: JsonObject, + markdown: str = None, ) -> None: """Initialize this instance.""" - super().__init__(name, config) + super().__init__(name, config, markdown=markdown) self.protocol = protocol # Offset message identifiers. @@ -89,14 +90,20 @@ async def _context(self: T) -> AsyncIterator[T]: @classmethod @asynccontextmanager async def shell( - cls: Type[T], name: str, config: JsonObject, cmd: str + cls: Type[T], + name: str, + config: JsonObject, + cmd: str, + markdown: str = None, ) -> AsyncIterator[T]: """Create an instance from a shell command.""" async with spawn_shell( cmd, stdout=asyncio.Queue(), stderr=asyncio.Queue() ) as proto: - async with cls(proto, name, config)._context() as inst: + async with cls( + proto, name, config, markdown=markdown + )._context() as inst: yield inst async def main(self) -> None: @@ -105,14 +112,21 @@ async def main(self) -> None: @classmethod @asynccontextmanager async def exec( - cls: Type[T], name: str, config: JsonObject, *args, **kwargs + cls: Type[T], + name: str, + config: JsonObject, + *args, + markdown: str = None, + **kwargs, ) -> AsyncIterator[T]: """Create an instance from comand-line arguments.""" async with spawn_exec( *args, stdout=asyncio.Queue(), stderr=asyncio.Queue(), **kwargs ) as proto: - async with cls(proto, name, config)._context() as inst: + async with cls( + proto, name, config, markdown=markdown + )._context() as inst: yield inst @classmethod diff --git a/runtimepy/task/basic/periodic.py b/runtimepy/task/basic/periodic.py index d98895e6..0db16670 100644 --- a/runtimepy/task/basic/periodic.py +++ b/runtimepy/task/basic/periodic.py @@ -13,12 +13,14 @@ from typing import Optional as _Optional # third-party +from vcorelib.io import MarkdownMixin from vcorelib.math import DEFAULT_DEPTH as _DEFAULT_DEPTH from vcorelib.math import MovingAverage as _MovingAverage from vcorelib.math import RateTracker as _RateTracker from vcorelib.math import rate_str as _rate_str # internal +from runtimepy import PKG_NAME from runtimepy.channel.environment import ChannelEnvironment from runtimepy.channel.environment.command.processor import ( ChannelCommandProcessor, @@ -32,7 +34,9 @@ from runtimepy.ui.controls import Controlslike -class PeriodicTask(LoggerMixinLevelControl, ChannelEnvironmentMixin, _ABC): +class PeriodicTask( + LoggerMixinLevelControl, ChannelEnvironmentMixin, MarkdownMixin, _ABC +): """A class implementing a simple periodic-task interface.""" auto_finalize = True @@ -45,10 +49,12 @@ def __init__( period_s: float = 1.0, env: ChannelEnvironment = None, period_controls: Controlslike = "period", + markdown: str = None, ) -> None: """Initialize this task.""" self.name = name + self.set_markdown(markdown=markdown, package=PKG_NAME) LoggerMixinLevelControl.__init__(self, logger=_getLogger(self.name)) self._task: _Optional[_asyncio.Task[None]] = None diff --git a/runtimepy/util.py b/runtimepy/util.py index b36b1701..094d0bd7 100644 --- a/runtimepy/util.py +++ b/runtimepy/util.py @@ -2,87 +2,6 @@ A module implementing package utilities. """ -# built-in -import logging -import re -from typing import Iterable, Iterator - -# third-party -from vcorelib.logging import DEFAULT_TIME_FORMAT -from vcorelib.paths.context import PossiblePath, as_path - -# Continue exporting some migrated things. -__all__ = [ - "ListLogger", - "as_path", - "import_str_and_item", - "name_search", - "Identifier", - "PossiblePath", -] - - -class ListLogger(logging.Handler): - """An interface facilitating sending log messages to browser tabs.""" - - log_messages: list[logging.LogRecord] - - def drain(self) -> list[logging.LogRecord]: - """Drain messages.""" - - result = self.log_messages - self.log_messages = [] - return result - - def drain_str(self) -> list[str]: - """Drain formatted messages.""" - - return [self.format(x) for x in self.drain()] - - def __bool__(self) -> bool: - """Evaluate this instance as boolean.""" - return bool(self.log_messages) - - def emit(self, record: logging.LogRecord) -> None: - """Send the log message.""" - - self.log_messages.append(record) - - @staticmethod - def create() -> "ListLogger": - """Create an instance of this handler.""" - - logger = ListLogger() - logger.log_messages = [] - logger.setFormatter(logging.Formatter(DEFAULT_TIME_FORMAT)) - - return logger - - -def import_str_and_item(module_path: str) -> tuple[str, str]: - """ - Treat the last entry in a '.' delimited string as the item to import from - the module in the string preceding it. - """ - - parts = module_path.split(".") - assert len(parts) > 1, module_path - - item = parts.pop() - return ".".join(parts), item - - -def name_search( - names: Iterable[str], pattern: str, exact: bool = False -) -> Iterator[str]: - """A simple name searching method.""" - - compiled = re.compile(pattern) - for name in names: - if compiled.search(name) is not None: - if not exact or name == pattern: - yield name - class Identifier: """A simple message indentifier interface.""" diff --git a/setup.py b/setup.py index 5463aaf1..e398aec3 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ # ===================================== # generator=datazen # version=3.1.4 -# hash=23727a13b6d02be1ea4a841537c013c3 +# hash=183f8b361ba2aa54cf7e87c823ff45fa # ===================================== """ @@ -19,7 +19,7 @@ author_info = { "name": "Vaughn Kottler", - "email": "vaughnkottler@gmail.com", + "email": "vaughn@libre-embedded.com", "username": "vkottler", } pkg_info = { diff --git a/tasks/default.yaml b/tasks/default.yaml index 576fcfbd..ef45f32d 100644 --- a/tasks/default.yaml +++ b/tasks/default.yaml @@ -3,5 +3,16 @@ includes_left: - package://runtimepy/server.yaml - package://runtimepy/server_dev.yaml +config: + top_markdown: | + # Top Markdown + + Arbitrary configuration (testing). + + bottom_markdown: | + # Bottom Markdown + + Arbitrary configuration (testing). + app: - runtimepy.net.apps.wait_for_stop diff --git a/tasks/dev.yaml b/tasks/dev.yaml index c53e76e6..7ed0bd28 100644 --- a/tasks/dev.yaml +++ b/tasks/dev.yaml @@ -4,6 +4,11 @@ includes: - dev_no_wait.yaml - ../tests/data/valid/connection_arbiter/test_ssl.yaml +factories: + - {name: tasks.tlm.LogCapture} +tasks: + - {name: root_log, factory: log_capture, period_s: 0.1} + port_overrides: runtimepy_https_server: 8443 diff --git a/tasks/tlm.py b/tasks/tlm.py index 34cca957..fc62b01d 100644 --- a/tasks/tlm.py +++ b/tasks/tlm.py @@ -4,9 +4,14 @@ # built-in import asyncio +import os +from pathlib import Path # internal +from runtimepy.mixins.logging import LogCaptureMixin from runtimepy.net.arbiter import AppInfo +from runtimepy.net.arbiter.task import ArbiterTask as _ArbiterTask +from runtimepy.net.arbiter.task import TaskFactory as _TaskFactory async def sample_app(app: AppInfo) -> int: @@ -19,3 +24,36 @@ async def sample_app(app: AppInfo) -> int: await asyncio.sleep(0.01) return 0 + + +class LogCaptureTask(_ArbiterTask, LogCaptureMixin): + """ + A task that captures all log messages emitted by this program instance. + """ + + auto_finalize = True + + async def init(self, app: AppInfo) -> None: + """Initialize this task with application information.""" + + await super().init(app) + + # See above comment, we can probably keep the mixin class but delete + # this one - unless we want a separate "Linux" task to run / handle + # this (we might want to increase the housekeeping task rate to reduce + # async command latency + connection processing?). + await self.init_log_capture( + app.stack, [("info", Path(os.sep, "var", "log", "syslog"))] + ) + + async def dispatch(self) -> bool: + """Dispatch an iteration of this task.""" + + await self.dispatch_log_capture() + return True + + +class LogCapture(_TaskFactory[LogCaptureTask]): + """A factory for the syslog capture task.""" + + kind = LogCaptureTask diff --git a/tests/mixins/__init__.py b/tests/mixins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/mixins/test_logging.py b/tests/mixins/test_logging.py new file mode 100644 index 00000000..4e0e7aa3 --- /dev/null +++ b/tests/mixins/test_logging.py @@ -0,0 +1,39 @@ +""" +Test the 'mixins.logging' module. +""" + +# built-in +from contextlib import AsyncExitStack + +# third-party +from pytest import mark +from vcorelib.logging import LoggerMixin +from vcorelib.paths.context import tempfile + +# module under test +from runtimepy.mixins.logging import LogCaptureMixin + + +class SampleLogger(LoggerMixin, LogCaptureMixin): + """A sample class.""" + + +@mark.asyncio +async def test_log_capture_mixin_basic(): + """Test basic interactions with a log capture.""" + + inst = SampleLogger() + + async with AsyncExitStack() as stack: + path = stack.enter_context(tempfile()) + writer = stack.enter_context(path.open("w")) + + await inst.init_log_capture(stack, [("info", path)]) + await inst.dispatch_log_capture() + + for _ in range(10): + writer.write("Hello, world!\n") + writer.flush() + await inst.dispatch_log_capture() + + await inst.dispatch_log_capture() diff --git a/tests/net/server/app/test_elements.py b/tests/net/server/app/test_elements.py index 23ee841a..93d20996 100644 --- a/tests/net/server/app/test_elements.py +++ b/tests/net/server/app/test_elements.py @@ -2,8 +2,12 @@ Test the 'net.server.app.elements' module. """ +# third-party +from svgen.element.html import div + # module under test from runtimepy.net.server.app.elements import kind +from runtimepy.net.server.app.placeholder import under_construction from runtimepy.net.server.app.pyodide import add_pyodide_js @@ -14,3 +18,5 @@ def test_html_kind_basic(): assert elem assert add_pyodide_js(elem) + + assert under_construction(div(), note="hello")