Skip to content

Commit cc89815

Browse files
committed
[Flow] add update_automation_configuration keywords
1 parent b773345 commit cc89815

27 files changed

Lines changed: 2694 additions & 857 deletions

packages/commons/octobot_commons/dsl_interpreter/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
ExpressionOperator,
4040
PreComputingCallOperator,
4141
ReCallableOperatorMixin,
42+
SignalableOperatorMixin,
43+
OperatorSignal,
44+
OperatorSignals,
4245
ReCallingOperatorResult,
4346
ReCallingOperatorResultKeys,
4447
ProcessBoundOperatorMixin,
@@ -75,6 +78,9 @@
7578
"ExpressionOperator",
7679
"PreComputingCallOperator",
7780
"ReCallableOperatorMixin",
81+
"SignalableOperatorMixin",
82+
"OperatorSignal",
83+
"OperatorSignals",
7884
"ProcessBoundOperatorMixin",
7985
"is_process_bound",
8086
"InterpreterDependency",

packages/commons/octobot_commons/dsl_interpreter/operators/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@
5050
ReCallingOperatorResult,
5151
ReCallingOperatorResultKeys,
5252
)
53+
from octobot_commons.dsl_interpreter.operators.signalable_operator_mixin import (
54+
OperatorSignal,
55+
OperatorSignals,
56+
SignalableOperatorMixin,
57+
)
5358
from octobot_commons.dsl_interpreter.operators.process_bound_operator_mixin import (
5459
ProcessBoundOperatorMixin,
5560
is_process_bound,
@@ -69,6 +74,9 @@
6974
"ReCallableOperatorMixin",
7075
"ReCallingOperatorResult",
7176
"ReCallingOperatorResultKeys",
77+
"SignalableOperatorMixin",
78+
"OperatorSignal",
79+
"OperatorSignals",
7280
"ProcessBoundOperatorMixin",
7381
"is_process_bound",
7482
]

packages/commons/octobot_commons/dsl_interpreter/operators/process_bound_operator_mixin.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@
1414
# You should have received a copy of the GNU Lesser General Public
1515
# License along with this library.
1616

17+
import asyncio
1718
import pathlib
1819
import socket
1920
import subprocess
21+
import time
2022
import typing
2123

2224
import octobot_commons.dsl_interpreter.operator as dsl_interpreter_operator
2325
import octobot_commons.errors as commons_errors
26+
import octobot_commons.logging as commons_logging
2427
import octobot_commons.process_util as process_util
2528

2629

@@ -52,6 +55,37 @@ def request_graceful_stop(
5255
)
5356
return process_util.request_graceful_stop_via_sigterm(self.pid, logger=logger)
5457

58+
async def wait_until_pid_stopped(
59+
self,
60+
pid: int,
61+
*,
62+
logger: typing.Optional[typing.Any] = None,
63+
timeout_seconds: float,
64+
poll_interval: float = 0.2,
65+
) -> None:
66+
"""Poll until ``pid`` is gone or ``timeout_seconds`` elapses (after e.g. SIGTERM)."""
67+
resolved_logger = logger or commons_logging.get_logger(self.__class__.__name__)
68+
if pid <= 0:
69+
resolved_logger.info(
70+
"wait_until_pid_stopped: pid=%s treated as already stopped (non-positive)",
71+
pid,
72+
)
73+
return
74+
resolved_logger.info(
75+
"wait_until_pid_stopped: waiting for pid=%s to exit (timeout=%ss)",
76+
pid,
77+
timeout_seconds,
78+
)
79+
deadline = time.monotonic() + timeout_seconds
80+
while time.monotonic() < deadline:
81+
if not process_util.pid_is_running(pid):
82+
resolved_logger.info("wait_until_pid_stopped: pid=%s exited", pid)
83+
return
84+
await asyncio.sleep(poll_interval)
85+
raise commons_errors.DSLInterpreterError(
86+
f"Timed out after {timeout_seconds}s waiting for pid={pid} to exit."
87+
)
88+
5589
def spawn_subprocess(
5690
self,
5791
argv: list[str],

packages/commons/octobot_commons/dsl_interpreter/operators/re_callable_operator_mixin.py

Lines changed: 1 addition & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@
1313
#
1414
# You should have received a copy of the GNU Lesser General Public
1515
# License along with this library.
16-
import contextlib
17-
import contextvars
1816
import dataclasses
1917
import enum
2018
import time
@@ -73,66 +71,19 @@ def get_script_override(result: typing.Any) -> typing.Optional[str]:
7371
)
7472

7573
@staticmethod
76-
def get_keyword(result: typing.Any) -> typing.Optional[str]:
74+
def get_keyword(result: dict[str, typing.Any]) -> typing.Optional[str]:
7775
"""
7876
Returns the keyword from the re-calling operator result.
7977
"""
8078
return result[ReCallingOperatorResult.__name__]["keyword"]
8179

8280

83-
# Per logical context (e.g. asyncio Task), not process-global: concurrent or nested `interprete`
84-
# calls do not share this value. Values are operator `get_name()` strings for which
85-
# `set_execution_stop()` is active; `get_execution_stop()` checks membership for one class only.
86-
_execution_stop_operator_names: contextvars.ContextVar[frozenset[str]] = contextvars.ContextVar(
87-
"re_callable_execution_stop_operator_names",
88-
default=frozenset(),
89-
)
90-
91-
9281
class ReCallableOperatorMixin:
9382
"""
9483
Mixin for re-callable operators.
9584
"""
9685
LAST_EXECUTION_RESULT_KEY = "last_execution_result"
9786

98-
@classmethod
99-
def get_execution_stop(cls) -> bool:
100-
"""
101-
True when this operator class is the current execution_stop target
102-
(see set_execution_stop). Scopes per operator get_name() so one class
103-
does not see another's stop mode.
104-
"""
105-
return cls.get_name() in _execution_stop_operator_names.get()
106-
107-
@classmethod
108-
@contextlib.contextmanager
109-
def set_execution_stop(cls) -> typing.Iterator[None]:
110-
"""
111-
Context manager: for the duration of the block, get_execution_stop() is
112-
True for this class only (other re-callable classes remain False unless
113-
also entered in an outer or parallel context).
114-
"""
115-
previous = _execution_stop_operator_names.get()
116-
token = _execution_stop_operator_names.set(
117-
frozenset(previous | {cls.get_name()})
118-
)
119-
try:
120-
yield
121-
finally:
122-
_execution_stop_operator_names.reset(token)
123-
124-
@classmethod
125-
def should_run_execution_stop_for_result( # pylint: disable=unused-argument
126-
cls, re_calling_result: typing.Optional[dict]
127-
) -> bool:
128-
"""
129-
When draining execution_stop for automation shutdown, return whether this
130-
operator should run a stop branch for the given previous re-calling
131-
result dict (the same shape as last_execution_result on the action).
132-
Default: do nothing (subclasses e.g. run_octobot may override).
133-
"""
134-
return False
135-
13687
@classmethod
13788
def get_re_callable_parameters(cls) -> list[operator_parameter.OperatorParameter]:
13889
"""
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Drakkar-Software OctoBot-Commons
2+
# Copyright (c) Drakkar-Software, All rights reserved.
3+
#
4+
# This library is free software; you can redistribute it and/or
5+
# modify it under the terms of the GNU Lesser General Public
6+
# License as published by the Free Software Foundation; either
7+
# version 3.0 of the License, or (at your option) any later version.
8+
#
9+
# This library is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12+
# Lesser General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU Lesser General Public
15+
# License along with this library.
16+
17+
import enum
18+
import typing
19+
20+
21+
class OperatorSignal(enum.StrEnum):
22+
"""
23+
Canonical operator signal strings. Call sites pass *.value where a plain str API is expected.
24+
"""
25+
STOP = "STOP"
26+
UPDATE_CONFIG = "UPDATE_CONFIG"
27+
28+
29+
class OperatorSignals:
30+
"""
31+
Mutable map of DSL operator name to execution signal string for one interpreter/run.
32+
33+
``sync`` clears and replaces ``signal_by_operator`` (same pattern as DSLExecutor before each
34+
action execution).
35+
"""
36+
37+
def __init__(self):
38+
self.signal_by_operator: typing.Dict[str, typing.Any] = {}
39+
40+
def sync(self, signals: typing.Dict[str, typing.Any]) -> None:
41+
"""
42+
Replaces signals mapping with the given signals.
43+
"""
44+
self.signal_by_operator.clear()
45+
self.signal_by_operator.update(signals)
46+
47+
48+
class SignalableOperatorMixin:
49+
"""
50+
Mixin for operators whose behavior depends on execution signals keyed by operator name.
51+
52+
Each instance holds an optional ``OperatorSignals`` shared for the DSL run (typically one per
53+
interpreter). Callers fill the map via ``OperatorSignals.sync`` (e.g. DSLExecutor before
54+
interpretation). ``get_name()`` identifies which map entry applies to ``matches_operator_signal``.
55+
"""
56+
57+
def __init__(self, signals: typing.Optional[OperatorSignals] = None):
58+
self.signals: typing.Optional[OperatorSignals] = signals
59+
60+
def matches_operator_signal(self, signal: str) -> bool:
61+
"""Return whether ``self.signals`` maps this operator's name to ``signal``."""
62+
if self.signals is None:
63+
return False
64+
return self.signals.signal_by_operator.get(self.get_name()) == signal # type: ignore
65+
66+
@classmethod
67+
def should_dispatch_operator_signal_for_result( # pylint: disable=unused-argument
68+
cls,
69+
signal: str,
70+
re_calling_result: typing.Optional[dict],
71+
) -> bool:
72+
"""
73+
When draining dispatcher-driven operator signals for automation shutdown, whether this
74+
operator should run its branch for the given previous re-calling payload.
75+
Default: do nothing; subclasses override.
76+
"""
77+
return False

packages/commons/octobot_commons/process_util.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,32 +35,65 @@ def spawn_managed_subprocess(
3535
working_directory: str,
3636
environment: typing.Optional[typing.Mapping[str, str]] = None,
3737
hide_console_window: bool = False,
38+
forward_terminal_output: bool = False,
3839
) -> subprocess.Popen:
3940
"""
4041
Launch a child process without a shell (``creationflags``: hide console on Windows when asked).
42+
43+
When ``forward_terminal_output`` is True, the child inherits the parent stdout/stderr (live terminal
44+
output). On Windows, ``hide_console_window`` is ignored in that case: ``CREATE_NO_WINDOW`` would
45+
detach console output and hide logs even when streams are inherited.
46+
47+
When ``forward_terminal_output`` is False, stdout and stderr are discarded (``subprocess.DEVNULL``).
4148
"""
4249
resolved_env = dict(environment) if environment is not None else os.environ.copy()
43-
creationflags = subprocess.CREATE_NO_WINDOW if (hide_console_window and sys.platform == "win32") else 0
50+
use_hidden_console = (
51+
hide_console_window and sys.platform == "win32" and not forward_terminal_output
52+
)
53+
# subprocess.CREATE_NO_WINDOW exists only on Windows; tests may patch platform on Linux CI.
54+
creationflags = (
55+
getattr(subprocess, "CREATE_NO_WINDOW", 0) if use_hidden_console else 0
56+
)
57+
if forward_terminal_output:
58+
child_stdout: typing.Optional[int] = None
59+
child_stderr: typing.Optional[int] = None
60+
else:
61+
child_stdout = subprocess.DEVNULL
62+
child_stderr = subprocess.DEVNULL
4463
return subprocess.Popen(
4564
argv,
4665
cwd=working_directory,
4766
env=resolved_env,
4867
creationflags=creationflags,
68+
stdout=child_stdout,
69+
stderr=child_stderr,
4970
)
5071

5172

52-
def pid_is_running(pid: int) -> bool:
53-
"""Best-effort: whether ``pid`` denotes a running OS process."""
73+
def pid_is_running(pid: int) -> bool: # pylint: disable=too-many-return-statements
74+
"""Best-effort: whether ``pid`` denotes a running OS process (zombies are treated as not running)."""
5475
if pid <= 0:
5576
return False
56-
if psutil is not None:
57-
try:
58-
return psutil.Process(pid).is_running()
59-
except psutil.NoSuchProcess:
77+
try:
78+
proc = psutil.Process(pid)
79+
except psutil.NoSuchProcess:
80+
return False
81+
except psutil.AccessDenied:
82+
return True
83+
try:
84+
if proc.status() == psutil.STATUS_ZOMBIE:
6085
return False
61-
except psutil.AccessDenied:
62-
return True
63-
return pid > 0
86+
except psutil.ZombieProcess:
87+
return False
88+
except psutil.NoSuchProcess:
89+
# PID can disappear between Process() creation and status() (e.g. SIGTERM on Windows).
90+
return False
91+
try:
92+
return proc.is_running()
93+
except psutil.ZombieProcess:
94+
return False
95+
except psutil.NoSuchProcess:
96+
return False
6497

6598

6699
def request_graceful_stop_via_sigterm(

packages/commons/tests/dsl_interpreter/operators/test_process_bound_operator_mixin.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,30 @@ def test_delegates_to_process_util(self):
7777
stop_mock.assert_called_once_with(99, logger=mock.sentinel.log)
7878

7979

80+
@pytest.mark.asyncio
81+
class TestWaitUntilPidStopped:
82+
async def test_non_positive_pid_returns_without_poll(self):
83+
bound = process_bound_operator_mixin.ProcessBoundOperatorMixin()
84+
with mock.patch.object(process_util, "pid_is_running") as running_mock:
85+
await bound.wait_until_pid_stopped(0, timeout_seconds=5.0, logger=mock.Mock())
86+
running_mock.assert_not_called()
87+
88+
async def test_returns_when_pid_not_running(self):
89+
bound = process_bound_operator_mixin.ProcessBoundOperatorMixin()
90+
with mock.patch.object(process_util, "pid_is_running", return_value=False):
91+
await bound.wait_until_pid_stopped(7, timeout_seconds=5.0)
92+
93+
async def test_timeout_raises_dsl_error(self):
94+
bound = process_bound_operator_mixin.ProcessBoundOperatorMixin()
95+
with mock.patch.object(process_util, "pid_is_running", return_value=True):
96+
with pytest.raises(commons_errors.DSLInterpreterError, match="Timed out"):
97+
await bound.wait_until_pid_stopped(
98+
99,
99+
timeout_seconds=0.05,
100+
poll_interval=0.01,
101+
)
102+
103+
80104
class TestSpawnSubprocess:
81105
def test_sets_self_pid_from_child_and_returns_popen(self):
82106
bound = process_bound_operator_mixin.ProcessBoundOperatorMixin()

0 commit comments

Comments
 (0)