From 4421a7a532d5dcb89a035fa17ee4322d0bb44ad9 Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Thu, 14 Sep 2023 02:59:35 -0500 Subject: [PATCH 1/3] 2.8.0 - Channel environment command processor --- .github/workflows/python-package.yml | 4 +- README.md | 4 +- config | 2 +- local/variables/package.yaml | 4 +- pyproject.toml | 2 +- runtimepy/__init__.py | 4 +- runtimepy/channel/environment/base.py | 36 +++-- .../channel/environment/command/__init__.py | 143 ++++++++++++++++++ .../channel/environment/command/parser.py | 58 +++++++ .../channel/environment/command/result.py | 30 ++++ runtimepy/net/connection.py | 2 + runtimepy/task/basic/periodic.py | 2 + tests/channel/environment/test_command.py | 53 +++++++ 13 files changed, 323 insertions(+), 21 deletions(-) create mode 100644 runtimepy/channel/environment/command/__init__.py create mode 100644 runtimepy/channel/environment/command/parser.py create mode 100644 runtimepy/channel/environment/command/result.py create mode 100644 tests/channel/environment/test_command.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index e06063b5..620cda1b 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -37,7 +37,7 @@ jobs: python-version: ${{matrix.python-version}} cache: pip - - run: pip${{matrix.python-version}} install vmklib>=1.8.0 + - run: pip${{matrix.python-version}} install vmklib - run: mk python-sa-types @@ -68,7 +68,7 @@ jobs: - run: | mk python-release owner=vkottler \ - repo=runtimepy version=2.7.1 + repo=runtimepy version=2.8.0 if: | matrix.python-version == '3.11' && matrix.system == 'ubuntu-latest' diff --git a/README.md b/README.md index 9cd02b19..ac453731 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ ===================================== generator=datazen version=3.1.3 - hash=a72da168f214fb625163824388549911 + hash=ef2c8726c6c740e33ce788f7ce0be366 ===================================== --> -# runtimepy ([2.7.1](https://pypi.org/project/runtimepy/)) +# runtimepy ([2.8.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/config b/config index b9db1e3b..5b7a6b32 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit b9db1e3b57715adff8895652a1d4bcb88962626d +Subproject commit 5b7a6b32ef2a2e03dc91d8c5dbd779427d906389 diff --git a/local/variables/package.yaml b/local/variables/package.yaml index da79deb3..59837002 100644 --- a/local/variables/package.yaml +++ b/local/variables/package.yaml @@ -1,5 +1,5 @@ --- major: 2 -minor: 7 -patch: 1 +minor: 8 +patch: 0 entry: runtimepy diff --git a/pyproject.toml b/pyproject.toml index 47021040..24ca8905 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta:__legacy__" [project] name = "runtimepy" -version = "2.7.1" +version = "2.8.0" description = "A framework for implementing Python services." readme = "README.md" requires-python = ">=3.10" diff --git a/runtimepy/__init__.py b/runtimepy/__init__.py index 14ffd678..19ee5e59 100644 --- a/runtimepy/__init__.py +++ b/runtimepy/__init__.py @@ -1,7 +1,7 @@ # ===================================== # generator=datazen # version=3.1.3 -# hash=962f3227b7f0033ff39fbfa1a78060f5 +# hash=6ef03f741188d3cc145268db1edd0d8d # ===================================== """ @@ -10,7 +10,7 @@ DESCRIPTION = "A framework for implementing Python services." PKG_NAME = "runtimepy" -VERSION = "2.7.1" +VERSION = "2.8.0" # runtimepy-specific content. METRICS_NAME = "metrics" diff --git a/runtimepy/channel/environment/base.py b/runtimepy/channel/environment/base.py index 739171e4..e4279f88 100644 --- a/runtimepy/channel/environment/base.py +++ b/runtimepy/channel/environment/base.py @@ -106,21 +106,35 @@ def set(self, key: _RegistryKey, value: ChannelValue) -> None: if isinstance(value, str): # Ensure that the channel has an associated enumeration. if enum is None: - raise ValueError( - ( - f"Can't assign '{value}' to channel " - f"'{self.channels.names.name(key)}'!" + resolved = False + + is_int = chan.raw.kind.is_integer + + if is_int or chan.raw.kind.is_float: + kind = int if is_int else float + try: + value = kind(value) + resolved = True + except ValueError: + pass + + if not resolved: + raise ValueError( + ( + f"Can't assign '{value}' to channel " + f"'{self.channels.names.name(key)}'!" + ) ) - ) - value = ( - enum.get_bool(value) - if chan.type.is_boolean - else enum.get_int(value) - ) + else: + value = ( + enum.get_bool(value) + if chan.type.is_boolean + else enum.get_int(value) + ) # Assign the value to the channel. - chan.raw.value = value + chan.raw.value = value # type: ignore def apply(self, values: ValueMap) -> None: """Apply a map of values to the environment.""" diff --git a/runtimepy/channel/environment/command/__init__.py b/runtimepy/channel/environment/command/__init__.py new file mode 100644 index 00000000..95246083 --- /dev/null +++ b/runtimepy/channel/environment/command/__init__.py @@ -0,0 +1,143 @@ +""" +A module implementing UI command processing. +""" + +# built-in +from argparse import Namespace +from typing import Any, Optional, cast + +# third-party +from vcorelib.logging import LoggerType + +# internal +from runtimepy.channel import AnyChannel +from runtimepy.channel.environment import ChannelEnvironment +from runtimepy.channel.environment.command.parser import ( + ChannelCommand, + CommandParser, +) +from runtimepy.channel.environment.command.result import SUCCESS, CommandResult +from runtimepy.mixins.environment import ChannelEnvironmentMixin +from runtimepy.primitives.bool import Bool + + +class ChannelCommandProcessor(ChannelEnvironmentMixin): + """A command processing interface for channel environments.""" + + def __init__( + self, env: ChannelEnvironment, logger: LoggerType, **kwargs + ) -> None: + """Initialize this instance.""" + + super().__init__(env=env, **kwargs) + self.logger = logger + + self.parser_data: dict[str, Any] = {} + self.parser = CommandParser() + self.parser.data = self.parser_data + + self.parser.initialize() + + def get_suggestion(self, value: str) -> Optional[str]: + """Get an input suggestion.""" + + result = None + + args = self.parse(value) + if args is not None: + result = self.env.namespace_suggest(args.channel, delta=False) + if result is not None: + result = args.command + " " + result + + return result + + def do_set(self, args: Namespace) -> CommandResult: + """Attempt to set a channel value.""" + + result = SUCCESS + + if not args.extra: + return CommandResult(False, "No value specified.") + + try: + self.env.set(args.channel, args.extra[0]) + except (ValueError, KeyError) as exc: + result = CommandResult(False, str(exc)) + + return result + + def do_toggle(self, args: Namespace, channel: AnyChannel) -> CommandResult: + """Attempt to toggle a channel.""" + + if args.command == ChannelCommand.TOGGLE: + if not channel.type.is_boolean: + return CommandResult( + False, + ( + f"Channel '{args.channel}' is " + f"{channel.type}, not boolean." + ), + ) + + cast(Bool, channel.raw).toggle() + + return SUCCESS + + def handle_command(self, args: Namespace) -> CommandResult: + """Handle a command from parsed arguments.""" + + result = SUCCESS + + chan = self.env.get(args.channel) + if chan is None: + return CommandResult(False, f"No channel '{args.channel}'.") + + channel, enum = chan + del enum + + # Check if channel is commandable (or if a -f/--force flag is set?). + if not channel.commandable and not args.force: + return CommandResult( + False, + ( + f"Channel '{args.channel}' not " + "commandable! (use -f/--force to bypass if necessary)" + ), + ) + + if args.command == ChannelCommand.TOGGLE: + result = self.do_toggle(args, channel) + elif args.command == ChannelCommand.SET: + result = self.do_set(args) + + return result + + def parse(self, value: str) -> Optional[Namespace]: + """Attempt to parse arguments.""" + + self.parser_data["error_message"] = None + args = self.parser.parse_args(value.split()) + return args if not self.parser_data["error_message"] else None + + def command(self, value: str) -> CommandResult: + """Process a command.""" + + args = self.parse(value) + success = args is not None + + if not args or "help" in value: + self.logger.info(self.parser.format_help()) + + reason = None + if not success: + reason = self.parser_data["error_message"] + if "help" not in value: + self.logger.info("Try 'help'.") + + result = CommandResult(success, reason) + + if success: + assert args is not None + result = self.handle_command(args) + + return result diff --git a/runtimepy/channel/environment/command/parser.py b/runtimepy/channel/environment/command/parser.py new file mode 100644 index 00000000..dd846364 --- /dev/null +++ b/runtimepy/channel/environment/command/parser.py @@ -0,0 +1,58 @@ +""" +A module implementing a channel-environment command argument parser wrapper. +""" + +# built-in +from argparse import ArgumentParser +from enum import StrEnum +from typing import Any + + +class ChannelCommand(StrEnum): + """An enumeration for all channel command options.""" + + SET = "set" + TOGGLE = "toggle" + + +class CommandParser(ArgumentParser): + """An argument parser wrapper.""" + + data: dict[str, Any] + + def error(self, message: str): + """Pass error message to error handling.""" + self.data["error_message"] = message + + def exit(self, status: int = 0, message: str = None): + """Override exit behavior.""" + + del status + + if message: + curr = self.data.get("error_message") + curr = curr + f" [{message}]" if curr else message + self.data["error_message"] = curr + + def initialize(self) -> None: + """Initialize this command parser.""" + + self.add_argument( + "command", + type=ChannelCommand, + choices=set(ChannelCommand), + help="command to run", + ) + + self.add_argument( + "-f", + "--force", + action="store_true", + help="operate on a channel even if it's not commandable", + ) + + self.add_argument( + "channel", type=str, help="channel to perform action on" + ) + + self.add_argument("extra", nargs="*") diff --git a/runtimepy/channel/environment/command/result.py b/runtimepy/channel/environment/command/result.py new file mode 100644 index 00000000..81a6a264 --- /dev/null +++ b/runtimepy/channel/environment/command/result.py @@ -0,0 +1,30 @@ +""" +A module implementing a command result interface. +""" + +# built-in +from typing import NamedTuple, Optional + + +class CommandResult(NamedTuple): + """A container for command result data.""" + + success: bool + reason: Optional[str] = None + + def __bool__(self) -> bool: + """Evaluate this instance as a boolean.""" + return self.success + + def __str__(self) -> str: + """Get this command result as a string.""" + + message = "(success)" if self.success else "(failure)" + + if self.reason: + message += " " + self.reason + + return message + + +SUCCESS = CommandResult(True) diff --git a/runtimepy/net/connection.py b/runtimepy/net/connection.py index 652be79c..27f2efbc 100644 --- a/runtimepy/net/connection.py +++ b/runtimepy/net/connection.py @@ -17,6 +17,7 @@ # internal from runtimepy.channel.environment import ChannelEnvironment +from runtimepy.channel.environment.command import ChannelCommandProcessor from runtimepy.metrics import ConnectionMetrics from runtimepy.mixins.environment import ChannelEnvironmentMixin from runtimepy.primitives import Bool @@ -63,6 +64,7 @@ def __init__( self.metrics = ConnectionMetrics() ChannelEnvironmentMixin.__init__(self, env=env) + self.command = ChannelCommandProcessor(self.env, self.logger) if add_metrics: self.register_connection_metrics(self.metrics) diff --git a/runtimepy/task/basic/periodic.py b/runtimepy/task/basic/periodic.py index 95c90ac6..d7e5a775 100644 --- a/runtimepy/task/basic/periodic.py +++ b/runtimepy/task/basic/periodic.py @@ -21,6 +21,7 @@ # internal from runtimepy.channel.environment import ChannelEnvironment +from runtimepy.channel.environment.command import ChannelCommandProcessor from runtimepy.metrics import PeriodicTaskMetrics from runtimepy.mixins.environment import ChannelEnvironmentMixin from runtimepy.primitives import Bool as _Bool @@ -56,6 +57,7 @@ def __init__( self.metrics = metrics ChannelEnvironmentMixin.__init__(self, env=env) + self.command = ChannelCommandProcessor(self.env, self.logger) self.register_task_metrics(self.metrics) # State. diff --git a/tests/channel/environment/test_command.py b/tests/channel/environment/test_command.py new file mode 100644 index 00000000..bca8353a --- /dev/null +++ b/tests/channel/environment/test_command.py @@ -0,0 +1,53 @@ +""" +Test the 'channel.environment.command' module. +""" + +# built-in +from logging import getLogger +from math import isclose + +# module under test +from runtimepy.channel.environment import ChannelEnvironment +from runtimepy.channel.environment.command import ChannelCommandProcessor + + +def test_channel_command_processor_basic(): + """Test basic interactions with the channel-command processor.""" + + env = ChannelEnvironment() + + # Add some channels. + env.int_channel("int1", commandable=True) + env.bool_channel("bool1") + env.float_channel("float1") + + processor = ChannelCommandProcessor(env, getLogger(__name__)) + + processor.parser.exit(message="null") + + assert processor.get_suggestion("set in") is not None + assert processor.get_suggestion("set not_a_channel") is None + + result = processor.command("asdf") + assert not result + assert str(result) + assert not processor.command("help") + + assert not env.value("bool1") + assert not processor.command("toggle bool1") + assert processor.command("toggle -f bool1") + assert not processor.command("toggle int1") + assert not processor.command("toggle asdf") + assert env.value("bool1") + + assert env.value("int1") == 0 + assert not processor.command("set int1") + assert processor.command("set int1 42") + assert not processor.command("set int1 asdf") + assert env.value("int1") == 42 + + assert env.value("float1") == 0 + assert processor.command("set float1 42 -f") + assert isclose(env.value("float1"), 42) # type: ignore + assert processor.command("set float1 -101.5 -f") + assert isclose(env.value("float1"), -101.5) # type: ignore From 7b1fd1e58dc005b23412419769c8fa2a5e01f86c Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Thu, 14 Sep 2023 03:02:52 -0500 Subject: [PATCH 2/3] Remove Python 3.10 from CI --- .github/workflows/python-package.yml | 1 - README.md | 3 +-- local/configs/python.yaml | 2 +- pyproject.toml | 3 +-- setup.py | 3 +-- 5 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 620cda1b..8f7dd85c 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -18,7 +18,6 @@ jobs: strategy: matrix: python-version: - - "3.10" - "3.11" system: - ubuntu-latest diff --git a/README.md b/README.md index ac453731..2d0fc76d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ===================================== generator=datazen version=3.1.3 - hash=ef2c8726c6c740e33ce788f7ce0be366 + hash=86fa83db6bac485c345afa670f2b159d ===================================== --> @@ -29,7 +29,6 @@ This package is tested with the following Python minor versions: -* [`python3.10`](https://docs.python.org/3.10/) * [`python3.11`](https://docs.python.org/3.11/) ## Platform Support diff --git a/local/configs/python.yaml b/local/configs/python.yaml index 002530ff..924bf918 100644 --- a/local/configs/python.yaml +++ b/local/configs/python.yaml @@ -3,7 +3,7 @@ author_info: name: Vaughn Kottler email: vaughnkottler@gmail.com username: vkottler -versions: ["3.10", "3.11"] +versions: ["3.11"] systems: - macos-latest diff --git a/pyproject.toml b/pyproject.toml index 24ca8905..72d86d6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "runtimepy" version = "2.8.0" description = "A framework for implementing Python services." readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [ {name = "Vaughn Kottler", email = "vaughnkottler@gmail.com"} ] @@ -15,7 +15,6 @@ maintainers = [ {name = "Vaughn Kottler", email = "vaughnkottler@gmail.com"} ] classifiers = [ - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Operating System :: Microsoft :: Windows", "Operating System :: MacOS", diff --git a/setup.py b/setup.py index 81b7a6f1..6cc96dcb 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ # ===================================== # generator=datazen # version=3.1.3 -# hash=1990ecbc2f02a811658460c1772dbb95 +# hash=38abf923add5549927838813cd220cfa # ===================================== """ @@ -28,7 +28,6 @@ "version": VERSION, "description": DESCRIPTION, "versions": [ - "3.10", "3.11", ], } From 07122a46dc70879a11fdd3f24de89f8ffe02942f Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Thu, 14 Sep 2023 03:10:55 -0500 Subject: [PATCH 3/3] Add pausing to periodic tasks --- runtimepy/task/basic/periodic.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/runtimepy/task/basic/periodic.py b/runtimepy/task/basic/periodic.py index d7e5a775..aa9e5a99 100644 --- a/runtimepy/task/basic/periodic.py +++ b/runtimepy/task/basic/periodic.py @@ -51,6 +51,7 @@ def __init__( # Setup runtime state. self._enabled = _Bool() + self._paused = _Bool() if metrics is None: metrics = PeriodicTaskMetrics.create() @@ -61,8 +62,8 @@ def __init__( self.register_task_metrics(self.metrics) # State. - self.env.channel("enabled", self._enabled) - self.env.channel("period", self.period_s) + self.env.channel("paused", self._paused, commandable=True) + self.env.channel("period_s", self.period_s, commandable=True) self._init_state() self._dispatch_rate = _RateTracker(depth=average_depth) @@ -118,12 +119,14 @@ async def run( iter_time = _Double() while self._enabled: - with self.metrics.measure( - eloop, self._dispatch_rate, self._dispatch_time, iter_time - ): - self._enabled.raw.value = await _asyncio.shield( - self.dispatch() - ) + # When paused, don't run the iteration itself. + if not self._paused: + with self.metrics.measure( + eloop, self._dispatch_rate, self._dispatch_time, iter_time + ): + self._enabled.raw.value = await _asyncio.shield( + self.dispatch() + ) # Check this synchronously. This may not be suitable for tasks # with long periods.