je-action-core 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. je_action_core-0.0.1/LICENSE +21 -0
  2. je_action_core-0.0.1/PKG-INFO +145 -0
  3. je_action_core-0.0.1/README.md +125 -0
  4. je_action_core-0.0.1/je_action_core/__init__.py +73 -0
  5. je_action_core-0.0.1/je_action_core/action_list.py +173 -0
  6. je_action_core-0.0.1/je_action_core/builtins_policy.py +18 -0
  7. je_action_core-0.0.1/je_action_core/callback.py +123 -0
  8. je_action_core-0.0.1/je_action_core/exceptions.py +25 -0
  9. je_action_core-0.0.1/je_action_core/executor.py +108 -0
  10. je_action_core-0.0.1/je_action_core/file_listing.py +19 -0
  11. je_action_core-0.0.1/je_action_core/json_io.py +108 -0
  12. je_action_core-0.0.1/je_action_core/package_manager.py +208 -0
  13. je_action_core-0.0.1/je_action_core/py.typed +0 -0
  14. je_action_core-0.0.1/je_action_core/registry.py +105 -0
  15. je_action_core-0.0.1/je_action_core/reporting.py +65 -0
  16. je_action_core-0.0.1/je_action_core/socket_server.py +111 -0
  17. je_action_core-0.0.1/je_action_core.egg-info/PKG-INFO +145 -0
  18. je_action_core-0.0.1/je_action_core.egg-info/SOURCES.txt +31 -0
  19. je_action_core-0.0.1/je_action_core.egg-info/dependency_links.txt +1 -0
  20. je_action_core-0.0.1/je_action_core.egg-info/top_level.txt +1 -0
  21. je_action_core-0.0.1/pyproject.toml +38 -0
  22. je_action_core-0.0.1/setup.cfg +4 -0
  23. je_action_core-0.0.1/test/test_action_list.py +117 -0
  24. je_action_core-0.0.1/test/test_callback.py +78 -0
  25. je_action_core-0.0.1/test/test_executor.py +145 -0
  26. je_action_core-0.0.1/test/test_file_listing_and_builtins.py +31 -0
  27. je_action_core-0.0.1/test/test_json_io.py +65 -0
  28. je_action_core-0.0.1/test/test_package_manager.py +135 -0
  29. je_action_core-0.0.1/test/test_public_api.py +13 -0
  30. je_action_core-0.0.1/test/test_registry.py +64 -0
  31. je_action_core-0.0.1/test/test_socket_server.py +93 -0
  32. je_action_core-0.0.1/test/test_workflow_actions.py +124 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 JE-Chen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: je_action_core
3
+ Version: 0.0.1
4
+ Summary: The keyword-driven action executor shared by APITestka, LoadDensity, MailThunder and FileAutomation
5
+ Author-email: JE-Chen <jechenmailman@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Code, https://github.com/Integration-Automation/ActionCore
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Other Environment
15
+ Classifier: Operating System :: OS Independent
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # ActionCore
22
+
23
+ **English** | [繁體中文](README/README_zh-TW.md) | [简体中文](README/README_zh-CN.md)
24
+
25
+ `je_action_core` is the keyword-driven action executor shared by
26
+ [APITestka](https://github.com/Integration-Automation/APITestka), [LoadDensity](https://github.com/Integration-Automation/LoadDensity),
27
+ [MailThunder](https://github.com/Integration-Automation/MailThunder) and [FileAutomation](https://github.com/Integration-Automation/FileAutomation).
28
+ Each of them carried its own copy of the same executor, package manager, callback executor, action-file reader
29
+ and socket server. This package replaces those copies: a project configures it with its own command prefix,
30
+ document key, exceptions and messages.
31
+
32
+ It has no dependencies and supports Python 3.10 to 3.14.
33
+
34
+ ## Contents
35
+
36
+ - [Installation](#installation)
37
+ - [Actions](#actions)
38
+ - [Quick start](#quick-start)
39
+ - [The pieces](#the-pieces)
40
+ - [Package gate](#package-gate)
41
+ - [Socket server protocol](#socket-server-protocol)
42
+ - [Who uses it](#who-uses-it)
43
+ - [Development](#development)
44
+ - [License](#license)
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ pip install je_action_core
50
+ ```
51
+
52
+ A framework that is built on it installs it as a dependency.
53
+
54
+ ## Actions
55
+
56
+ An action list is a JSON list of actions, or a document that keeps the list under one key
57
+ (`{"api_testka": [...]}`). Each action takes one of three forms:
58
+
59
+ | Action | Call |
60
+ |---|---|
61
+ | `["name"]` | `command()` |
62
+ | `["name", {"a": 1}]` | `command(a=1)` |
63
+ | `["name", [1, 2]]` | `command(1, 2)` |
64
+
65
+ Running a list returns one record per action: the command's return value, or `repr(error)` when it raised. One
66
+ failing action does not stop the rest.
67
+
68
+ ## Quick start
69
+
70
+ ```python
71
+ from je_action_core import (
72
+ ActionExecutor, ActionListRules, CommandPolicy, CommandRegistry, ExecutorSettings, LoggingReporter,
73
+ )
74
+ import logging
75
+
76
+ registry = CommandRegistry({"MY_add": lambda a, b: a + b}, policy=CommandPolicy.FUNCTIONS_ONLY)
77
+ executor = ActionExecutor(
78
+ ExecutorSettings(rules=ActionListRules("my_tool"), reporter=LoggingReporter(logging.getLogger("my_tool"))),
79
+ registry,
80
+ )
81
+
82
+ executor.execute_action({"my_tool": [["MY_add", [1, 2]], ["MY_add", {"a": 3, "b": 4}]]})
83
+ # {"execute: ['MY_add', [1, 2]]": 3, "execute: ['MY_add', {'a': 3, 'b': 4}]": 7}
84
+ ```
85
+
86
+ ## The pieces
87
+
88
+ | Module | What it gives you | Settings |
89
+ |---|---|---|
90
+ | `registry` | `CommandRegistry`: commands by name, `event_dict` as the live mapping | `CommandPolicy.FUNCTIONS_ONLY` or `ANY_CALLABLE` for caller-added commands; the exception for a refused one |
91
+ | `action_list` | `ActionListRules` (where the list is), `LegacyActionParser` and `StrictActionParser` | document key and legacy keys (with a `DeprecationWarning`); empty list raises or returns `{}`; error messages |
92
+ | `executor` | `ActionExecutor`: `execute_action`, `execute_files`, `add_command_to_executor` | `ExecutorSettings`: rules, parser, reporter, file reader, record key (`execute: …` or `execute[i]: …`), action rewrite |
93
+ | `reporting` | `LoggingReporter`, `PrintReporter`, or your own `ExecutionReporter` | where events, failures and records go |
94
+ | `package_manager` | `PackageManager`: load an installed package's members as commands, behind the [package gate](#package-gate) | member naming (`<package>_<member>` or bare), predicates, name check, errors to log |
95
+ | `callback` | `CallbackFunctionExecutor`: run a trigger, then a callback | legacy or strict checking; raise or log and return `None` |
96
+ | `json_io` | `ActionJsonFile`, `read_action_json`, `write_action_json`: UTF-8, locked, non-ASCII kept | exception class and message templates |
97
+ | `file_listing` | `get_dir_files_as_list`: action files under a directory | — |
98
+ | `socket_server` | `start_action_socket_server`: the plain TCP action server | executor, payload check, errors answered, oversize policy |
99
+ | `builtins_policy` | `SAFE_BUILTINS`: the builtins an action list may call | — |
100
+
101
+ `__all__` in `je_action_core/__init__.py` is the supported import surface.
102
+
103
+ ## Package gate
104
+
105
+ `PackageManager.add_package_to_executor` imports a package and registers its members as commands. An action list
106
+ that can name `os` or `subprocess` could therefore run anything. The host program decides what may load:
107
+
108
+ ```python
109
+ package_manager.allow_packages("my_helpers") # these, and their submodules
110
+ package_manager.set_allow_arbitrary_packages(False) # refuse everything else before importing it
111
+ ```
112
+
113
+ Neither switch should ever be exposed as an action command, so an action list cannot open its own gate. A
114
+ refused package raises the project's `refused` exception, which the executor records as that action's result.
115
+ Until the host calls either switch, any package still loads but raises a `DeprecationWarning`. A project can
116
+ turn the gate off (`PackageGate.OFF`) while it moves to it.
117
+
118
+ ## Socket server protocol
119
+
120
+ `start_action_socket_server(host, port, settings)` serves on a daemon thread. A client sends one JSON action
121
+ document per connection. The server replies with each record value on its own line, then
122
+ `Return_Data_Over_JE`. A failure replies with the error text and the same marker. `quit_server` stops the server
123
+ and sets `close_flag`. The server has no authentication, so bind it only to a trust## Who uses it
124
+
125
+ APITestka (`AT_`, port 9939), LoadDensity (`LD_`), MailThunder (`MT_`, port 9942) and FileAutomation (`FA_`) are
126
+ the projects this package is for. `architecture.md` §6 lists which of them have moved to it and which pieces and
127
+ settings each one uses. LoadDensity and FileAutomation keep their own socket servers: a gevent server with
128
+ framing, a token and TLS, and a server with authentication and an ACL.
129
+
130
+ n and an ACL.
131
+
132
+ ## Development
133
+
134
+ ```bash
135
+ pip install -e .
136
+ pip install pytest
137
+ python -m pytest test/
138
+ ```
139
+
140
+ `architecture.md` describes the layers and the contracts with the four projects. Outstanding work is in
141
+ `progress.md`, and finished work is recorded in `docs/updates/`.
142
+
143
+ ## License
144
+
145
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,125 @@
1
+ # ActionCore
2
+
3
+ **English** | [繁體中文](README/README_zh-TW.md) | [简体中文](README/README_zh-CN.md)
4
+
5
+ `je_action_core` is the keyword-driven action executor shared by
6
+ [APITestka](https://github.com/Integration-Automation/APITestka), [LoadDensity](https://github.com/Integration-Automation/LoadDensity),
7
+ [MailThunder](https://github.com/Integration-Automation/MailThunder) and [FileAutomation](https://github.com/Integration-Automation/FileAutomation).
8
+ Each of them carried its own copy of the same executor, package manager, callback executor, action-file reader
9
+ and socket server. This package replaces those copies: a project configures it with its own command prefix,
10
+ document key, exceptions and messages.
11
+
12
+ It has no dependencies and supports Python 3.10 to 3.14.
13
+
14
+ ## Contents
15
+
16
+ - [Installation](#installation)
17
+ - [Actions](#actions)
18
+ - [Quick start](#quick-start)
19
+ - [The pieces](#the-pieces)
20
+ - [Package gate](#package-gate)
21
+ - [Socket server protocol](#socket-server-protocol)
22
+ - [Who uses it](#who-uses-it)
23
+ - [Development](#development)
24
+ - [License](#license)
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install je_action_core
30
+ ```
31
+
32
+ A framework that is built on it installs it as a dependency.
33
+
34
+ ## Actions
35
+
36
+ An action list is a JSON list of actions, or a document that keeps the list under one key
37
+ (`{"api_testka": [...]}`). Each action takes one of three forms:
38
+
39
+ | Action | Call |
40
+ |---|---|
41
+ | `["name"]` | `command()` |
42
+ | `["name", {"a": 1}]` | `command(a=1)` |
43
+ | `["name", [1, 2]]` | `command(1, 2)` |
44
+
45
+ Running a list returns one record per action: the command's return value, or `repr(error)` when it raised. One
46
+ failing action does not stop the rest.
47
+
48
+ ## Quick start
49
+
50
+ ```python
51
+ from je_action_core import (
52
+ ActionExecutor, ActionListRules, CommandPolicy, CommandRegistry, ExecutorSettings, LoggingReporter,
53
+ )
54
+ import logging
55
+
56
+ registry = CommandRegistry({"MY_add": lambda a, b: a + b}, policy=CommandPolicy.FUNCTIONS_ONLY)
57
+ executor = ActionExecutor(
58
+ ExecutorSettings(rules=ActionListRules("my_tool"), reporter=LoggingReporter(logging.getLogger("my_tool"))),
59
+ registry,
60
+ )
61
+
62
+ executor.execute_action({"my_tool": [["MY_add", [1, 2]], ["MY_add", {"a": 3, "b": 4}]]})
63
+ # {"execute: ['MY_add', [1, 2]]": 3, "execute: ['MY_add', {'a': 3, 'b': 4}]": 7}
64
+ ```
65
+
66
+ ## The pieces
67
+
68
+ | Module | What it gives you | Settings |
69
+ |---|---|---|
70
+ | `registry` | `CommandRegistry`: commands by name, `event_dict` as the live mapping | `CommandPolicy.FUNCTIONS_ONLY` or `ANY_CALLABLE` for caller-added commands; the exception for a refused one |
71
+ | `action_list` | `ActionListRules` (where the list is), `LegacyActionParser` and `StrictActionParser` | document key and legacy keys (with a `DeprecationWarning`); empty list raises or returns `{}`; error messages |
72
+ | `executor` | `ActionExecutor`: `execute_action`, `execute_files`, `add_command_to_executor` | `ExecutorSettings`: rules, parser, reporter, file reader, record key (`execute: …` or `execute[i]: …`), action rewrite |
73
+ | `reporting` | `LoggingReporter`, `PrintReporter`, or your own `ExecutionReporter` | where events, failures and records go |
74
+ | `package_manager` | `PackageManager`: load an installed package's members as commands, behind the [package gate](#package-gate) | member naming (`<package>_<member>` or bare), predicates, name check, errors to log |
75
+ | `callback` | `CallbackFunctionExecutor`: run a trigger, then a callback | legacy or strict checking; raise or log and return `None` |
76
+ | `json_io` | `ActionJsonFile`, `read_action_json`, `write_action_json`: UTF-8, locked, non-ASCII kept | exception class and message templates |
77
+ | `file_listing` | `get_dir_files_as_list`: action files under a directory | — |
78
+ | `socket_server` | `start_action_socket_server`: the plain TCP action server | executor, payload check, errors answered, oversize policy |
79
+ | `builtins_policy` | `SAFE_BUILTINS`: the builtins an action list may call | — |
80
+
81
+ `__all__` in `je_action_core/__init__.py` is the supported import surface.
82
+
83
+ ## Package gate
84
+
85
+ `PackageManager.add_package_to_executor` imports a package and registers its members as commands. An action list
86
+ that can name `os` or `subprocess` could therefore run anything. The host program decides what may load:
87
+
88
+ ```python
89
+ package_manager.allow_packages("my_helpers") # these, and their submodules
90
+ package_manager.set_allow_arbitrary_packages(False) # refuse everything else before importing it
91
+ ```
92
+
93
+ Neither switch should ever be exposed as an action command, so an action list cannot open its own gate. A
94
+ refused package raises the project's `refused` exception, which the executor records as that action's result.
95
+ Until the host calls either switch, any package still loads but raises a `DeprecationWarning`. A project can
96
+ turn the gate off (`PackageGate.OFF`) while it moves to it.
97
+
98
+ ## Socket server protocol
99
+
100
+ `start_action_socket_server(host, port, settings)` serves on a daemon thread. A client sends one JSON action
101
+ document per connection. The server replies with each record value on its own line, then
102
+ `Return_Data_Over_JE`. A failure replies with the error text and the same marker. `quit_server` stops the server
103
+ and sets `close_flag`. The server has no authentication, so bind it only to a trust## Who uses it
104
+
105
+ APITestka (`AT_`, port 9939), LoadDensity (`LD_`), MailThunder (`MT_`, port 9942) and FileAutomation (`FA_`) are
106
+ the projects this package is for. `architecture.md` §6 lists which of them have moved to it and which pieces and
107
+ settings each one uses. LoadDensity and FileAutomation keep their own socket servers: a gevent server with
108
+ framing, a token and TLS, and a server with authentication and an ACL.
109
+
110
+ n and an ACL.
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ pip install -e .
116
+ pip install pytest
117
+ python -m pytest test/
118
+ ```
119
+
120
+ `architecture.md` describes the layers and the contracts with the four projects. Outstanding work is in
121
+ `progress.md`, and finished work is recorded in `docs/updates/`.
122
+
123
+ ## License
124
+
125
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,73 @@
1
+ """
2
+ je_action_core: the keyword-driven action executor shared by APITestka, LoadDensity, MailThunder and
3
+ FileAutomation.
4
+
5
+ Each project configures these pieces with its own command prefix, document key, exceptions and messages;
6
+ ``__all__`` is the supported import surface.
7
+ """
8
+ from je_action_core.action_list import (
9
+ ActionListRules,
10
+ ActionParser,
11
+ BoundAction,
12
+ EmptyListPolicy,
13
+ LegacyActionParser,
14
+ ParsedAction,
15
+ StrictActionParser,
16
+ )
17
+ from je_action_core.builtins_policy import SAFE_BUILTINS, safe_builtin_commands
18
+ from je_action_core.callback import CallbackErrorPolicy, CallbackFunctionExecutor, CallbackSettings, CallbackStyle
19
+ from je_action_core.exceptions import (
20
+ ActionCoreException,
21
+ ActionExecuteException,
22
+ ActionJsonException,
23
+ AddCommandException,
24
+ CallbackExecutorException,
25
+ PackageNotAllowedException,
26
+ )
27
+ from je_action_core.executor import ActionExecutor, ExecutorSettings, indexed_record_key, plain_record_key
28
+ from je_action_core.file_listing import get_dir_files_as_list
29
+ from je_action_core.json_io import (
30
+ ActionJsonFile,
31
+ JsonFileMessages,
32
+ JsonFileSettings,
33
+ read_action_json,
34
+ write_action_json,
35
+ )
36
+ from je_action_core.package_manager import (
37
+ MemberNaming,
38
+ PackageGate,
39
+ PackageManager,
40
+ PackageManagerSettings,
41
+ is_identifier_path,
42
+ is_module_name,
43
+ )
44
+ from je_action_core.registry import Command, CommandPolicy, CommandRegistry
45
+ from je_action_core.reporting import ExecutionReporter, LoggingReporter, PrintReporter
46
+ from je_action_core.socket_server import (
47
+ END_MARKER,
48
+ MAX_PAYLOAD_BYTES,
49
+ QUIT_COMMAND,
50
+ ActionRequestHandler,
51
+ ActionTCPServer,
52
+ OversizePolicy,
53
+ SocketServerSettings,
54
+ start_action_socket_server,
55
+ )
56
+
57
+ __all__ = [
58
+ "ActionListRules", "ActionParser", "BoundAction", "EmptyListPolicy", "LegacyActionParser", "ParsedAction",
59
+ "StrictActionParser",
60
+ "SAFE_BUILTINS", "safe_builtin_commands",
61
+ "CallbackErrorPolicy", "CallbackFunctionExecutor", "CallbackSettings", "CallbackStyle",
62
+ "ActionCoreException", "ActionExecuteException", "ActionJsonException", "AddCommandException",
63
+ "CallbackExecutorException", "PackageNotAllowedException",
64
+ "ActionExecutor", "ExecutorSettings", "indexed_record_key", "plain_record_key",
65
+ "get_dir_files_as_list",
66
+ "ActionJsonFile", "JsonFileMessages", "JsonFileSettings", "read_action_json", "write_action_json",
67
+ "MemberNaming", "PackageGate", "PackageManager", "PackageManagerSettings", "is_identifier_path",
68
+ "is_module_name",
69
+ "Command", "CommandPolicy", "CommandRegistry",
70
+ "ExecutionReporter", "LoggingReporter", "PrintReporter",
71
+ "END_MARKER", "MAX_PAYLOAD_BYTES", "QUIT_COMMAND", "ActionRequestHandler", "ActionTCPServer",
72
+ "OversizePolicy", "SocketServerSettings", "start_action_socket_server",
73
+ ]
@@ -0,0 +1,173 @@
1
+ """
2
+ Action documents and single actions.
3
+
4
+ An action document is a list of actions, or a mapping that keeps the list under one key (``{"api_testka": [...]}``).
5
+ An action is ``[name]``, ``[name, {kwargs}]`` or ``[name, [args]]``. :class:`ActionListRules` finds the list;
6
+ a parser binds one action to its command.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import warnings
11
+ from dataclasses import dataclass
12
+ from enum import Enum
13
+ from types import MappingProxyType
14
+ from typing import Any, Callable, Iterable, Mapping, NamedTuple, Optional, Protocol, Tuple, Type
15
+
16
+ from je_action_core.exceptions import ActionExecuteException
17
+
18
+ Resolver = Callable[[Any], Optional[Callable[..., Any]]]
19
+ _NAME_ONLY: int = 1
20
+ _NAME_AND_PAYLOAD: int = 2
21
+ # warnings.warn -> from_document -> extract -> ActionExecutor.execute_action -> the caller of execute_action
22
+ _LEGACY_KEY_STACKLEVEL: int = 4
23
+
24
+
25
+ class EmptyListPolicy(Enum):
26
+ """What an executor does with an empty action list, or with something that is not a list."""
27
+
28
+ RAISE = "raise"
29
+ RETURN_EMPTY = "return_empty" # run nothing and return an empty record
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ActionListRules:
34
+ """
35
+ Where a document keeps its action list and what is wrong with a bad one.
36
+
37
+ The messages are ``str.format`` templates: ``missing_message`` gets ``key``, ``not_list_message`` gets
38
+ ``type`` (the type name of what was found).
39
+ """
40
+
41
+ key: str
42
+ legacy_keys: Tuple[str, ...] = ()
43
+ error: Type[Exception] = ActionExecuteException
44
+ missing_message: str = "action document has no {key!r} list"
45
+ not_list_message: str = "action list must be a list, got {type}"
46
+ empty_message: str = "action list is empty"
47
+ empty: EmptyListPolicy = EmptyListPolicy.RAISE
48
+
49
+ def extract(self, action_list: Any) -> Optional[list]:
50
+ """
51
+ Return the actions to run, or ``None`` when :attr:`empty` says to run nothing.
52
+
53
+ :raises error: the document has no list under :attr:`key` (or a legacy key), or, under
54
+ :attr:`EmptyListPolicy.RAISE`, the list is not a list or is empty.
55
+ """
56
+ actions = action_list
57
+ if isinstance(action_list, Mapping):
58
+ actions = self.from_document(action_list, stacklevel=_LEGACY_KEY_STACKLEVEL)
59
+ if actions is None:
60
+ raise self.error(self.missing_message.format(key=self.key))
61
+ if isinstance(actions, list) and actions:
62
+ return actions
63
+ if self.empty is EmptyListPolicy.RETURN_EMPTY:
64
+ return None
65
+ if not isinstance(actions, list):
66
+ raise self.error(self.not_list_message.format(type=type(actions).__name__))
67
+ raise self.error(self.empty_message)
68
+
69
+ def from_document(self, document: Mapping[str, Any], stacklevel: int = 2) -> Any:
70
+ """
71
+ The value under :attr:`key`, else under the first legacy key present (with a ``DeprecationWarning``
72
+ attributed ``stacklevel`` frames up), else ``None``. The value is returned as found, unchecked.
73
+ """
74
+ if self.key in document:
75
+ return document[self.key]
76
+ for legacy_key in self.legacy_keys:
77
+ if legacy_key in document:
78
+ warnings.warn(f'the "{legacy_key}" key is deprecated; use "{self.key}"', DeprecationWarning,
79
+ stacklevel=stacklevel)
80
+ return document[legacy_key]
81
+ return None
82
+
83
+
84
+ class BoundAction(NamedTuple):
85
+ """An action bound to its command; calling it is ``command(*args, **kwargs)``."""
86
+
87
+ name: Any
88
+ command: Callable[..., Any]
89
+ args: Iterable[Any] = ()
90
+ kwargs: Mapping[str, Any] = MappingProxyType({})
91
+
92
+
93
+ class ActionParser(Protocol):
94
+ """Binds one action to its command."""
95
+
96
+ def bind(self, action: Any, resolve: Resolver) -> BoundAction:
97
+ """Return ``action`` bound to the command ``resolve`` finds for its name; raise when it cannot."""
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class LegacyActionParser:
102
+ """
103
+ The rules APITestka, LoadDensity and MailThunder have always used.
104
+
105
+ The name is looked up first. A dict payload is passed as keyword arguments and any other payload is
106
+ unpacked as positional arguments. An unknown name or more than two elements raises ``error`` with
107
+ ``f"{message} {action}"``. An empty action raises ``IndexError``.
108
+ """
109
+
110
+ error: Type[Exception] = ActionExecuteException
111
+ message: str = "invalid action"
112
+
113
+ def bind(self, action: Any, resolve: Resolver) -> BoundAction:
114
+ """See the class docstring."""
115
+ command = resolve(action[0])
116
+ if command is None:
117
+ raise self.error(f"{self.message} {action}")
118
+ if len(action) == _NAME_AND_PAYLOAD:
119
+ payload = action[1]
120
+ if isinstance(payload, dict):
121
+ return BoundAction(action[0], command, (), payload)
122
+ return BoundAction(action[0], command, payload, {})
123
+ if len(action) == _NAME_ONLY:
124
+ return BoundAction(action[0], command)
125
+ raise self.error(f"{self.message} {action}")
126
+
127
+
128
+ class ParsedAction(NamedTuple):
129
+ """A checked action: ``kind`` is ``"none"``, ``"kwargs"`` or ``"args"``."""
130
+
131
+ name: str
132
+ kind: str
133
+ payload: Any
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class StrictActionParser:
138
+ """
139
+ FileAutomation's rules: the action must be a non-empty list, the name a string and the payload a dict or a
140
+ list. The shape is checked before the name is looked up.
141
+ """
142
+
143
+ error: Type[Exception] = ActionExecuteException
144
+
145
+ def parse(self, action: Any) -> ParsedAction:
146
+ """Check the shape of ``action`` without looking its name up."""
147
+ if not isinstance(action, list) or not action:
148
+ raise self.error(f"malformed action: {action!r}")
149
+ name = action[0]
150
+ if not isinstance(name, str):
151
+ raise self.error(f"action name must be str: {action!r}")
152
+ if len(action) == _NAME_ONLY:
153
+ return ParsedAction(name, "none", None)
154
+ if len(action) == _NAME_AND_PAYLOAD:
155
+ payload = action[1]
156
+ if isinstance(payload, dict):
157
+ return ParsedAction(name, "kwargs", payload)
158
+ if isinstance(payload, list):
159
+ return ParsedAction(name, "args", payload)
160
+ raise self.error(f"action {name!r} payload must be dict or list, got {type(payload).__name__}")
161
+ raise self.error(f"action has too many elements: {action!r}")
162
+
163
+ def bind(self, action: Any, resolve: Resolver) -> BoundAction:
164
+ """:meth:`parse`, then look the name up; an unknown name raises ``error``."""
165
+ parsed = self.parse(action)
166
+ command = resolve(parsed.name)
167
+ if command is None:
168
+ raise self.error(f"unknown action: {parsed.name!r}")
169
+ if parsed.kind == "kwargs":
170
+ return BoundAction(parsed.name, command, (), parsed.payload)
171
+ if parsed.kind == "args":
172
+ return BoundAction(parsed.name, command, parsed.payload, {})
173
+ return BoundAction(parsed.name, command)
@@ -0,0 +1,18 @@
1
+ """The Python builtins an action list may call: one allowlist for every executor that registers builtins."""
2
+ from __future__ import annotations
3
+
4
+ import builtins
5
+ from typing import Callable, Dict, FrozenSet
6
+
7
+ # Anything that can run code, reach attributes or namespaces, or touch files and stdin (eval, exec, compile,
8
+ # __import__, open, input, getattr, globals, ...) is left out, because action lists also arrive over sockets.
9
+ SAFE_BUILTINS: FrozenSet[str] = frozenset({
10
+ "abs", "all", "any", "ascii", "bin", "callable", "chr", "divmod",
11
+ "format", "hash", "hex", "len", "max", "min", "oct", "ord", "pow",
12
+ "print", "repr", "round", "sorted", "sum",
13
+ })
14
+
15
+
16
+ def safe_builtin_commands() -> Dict[str, Callable[..., object]]:
17
+ """Return ``{name: builtin}`` for every name in :data:`SAFE_BUILTINS`, in name order."""
18
+ return {name: getattr(builtins, name) for name in sorted(SAFE_BUILTINS)}