Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,16 @@ jobs:
pytest tests -n auto --dist loadfile
pytest --ignore=tentacles/Trading/Exchange tentacles -n auto --dist loadfile
else
cd ${{ matrix.package }}
if [ "${{ matrix.package }}" = "packages/tentacles_manager" ] || [ "${{ matrix.package }}" = "packages/node" ]; then
pytest tests
if [ "${{ matrix.package }}" = "packages/node" ]; then
echo "Running node tests from root dir to allow tentacles import"
pytest packages/node/tests
else
pytest tests -n auto --dist loadfile
cd ${{ matrix.package }}
if [ "${{ matrix.package }}" = "packages/tentacles_manager" ]; then
pytest tests
else
pytest tests -n auto --dist loadfile
fi
fi
fi
env:
Expand Down
21 changes: 21 additions & 0 deletions packages/commons/octobot_commons/asyncio_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@
# You should have received a copy of the GNU Lesser General Public
# License along with this library.
import asyncio
import contextlib
import time
import traceback
import concurrent.futures
import typing

import octobot_commons.constants as constants
import octobot_commons.logging as logging_util
Expand Down Expand Up @@ -117,6 +120,24 @@ async def gather_waiting_for_all_before_raising(*coros):
return maybe_exceptions


@contextlib.contextmanager
def logged_waiter(self, name: str, sleep_time: float = 30) -> typing.Generator[None, None, None]:
async def _waiter() -> None:
t0 = time.time()
try:
await asyncio.sleep(sleep_time)
self.logger.info(f"{name} is still processing [{time.time() - t0:.2f} seconds] ...")
except asyncio.CancelledError:
pass
task = None
try:
task = asyncio.create_task(_waiter())
yield
finally:
if task is not None and not task.done():
task.cancel()


class RLock(asyncio.Lock):
"""
Async Lock implementing reentrancy
Expand Down
1 change: 1 addition & 0 deletions packages/commons/octobot_commons/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ def parse_boolean_environment_var(env_key: str, default_value: str) -> bool:
# DSL interpreter
BASE_OPERATORS_LIBRARY = "base"
CONTEXTUAL_OPERATORS_LIBRARY = "contextual"
UNRESOLVED_PARAMETER_PLACEHOLDER = "UNRESOLVED_PARAMETER"

# Logging
EXCEPTION_DESC = "exception_desc"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

@dataclasses.dataclass
class FlexibleDataclass:
_class_field_cache: typing.ClassVar[dict] = {}
_class_field_cache: typing.ClassVar[dict] = dataclasses.field(default={}, repr=False)
"""
Implements from_dict which can be called to instantiate a new instance of this class from a dict. Using from_dict
ignores any additional key from the given dict that is not defined as a dataclass field.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
from octobot_commons.dsl_interpreter.parameters_util import (
format_parameter_value,
resove_operator_params,
apply_resolved_parameter_value,
has_unresolved_parameters,
)
from octobot_commons.dsl_interpreter.dsl_call_result import DSLCallResult

Expand All @@ -67,5 +69,7 @@
"InterpreterDependency",
"format_parameter_value",
"resove_operator_params",
"apply_resolved_parameter_value",
"DSLCallResult",
"has_unresolved_parameters",
]
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@

import dataclasses
import typing

import octobot_commons.dataclasses
import octobot_commons.errors


@dataclasses.dataclass
class DSLCallResult(octobot_commons.dataclasses.FlexibleDataclass):
statement: str
result: typing.Optional[typing.Any] = None
error: typing.Optional[str] = None

def succeeded(self) -> bool:
return self.error is None
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import octobot_commons.dsl_interpreter.operator as dsl_interpreter_operator
import octobot_commons.errors
import octobot_commons.constants


def format_parameter_value(value: typing.Any) -> str: # pylint: disable=too-many-return-statements
Expand Down Expand Up @@ -111,3 +112,21 @@ def resolve_operator_args_and_kwargs(
)

return merged_args, remaining_kwargs


def apply_resolved_parameter_value(script: str, parameter: str, value: typing.Any):
"""
Apply a resolved parameter value to a DSL script.
"""
to_replace = f"{parameter}={octobot_commons.constants.UNRESOLVED_PARAMETER_PLACEHOLDER}"
if to_replace not in script:
raise octobot_commons.errors.ResolvedParameterNotFoundError(f"Parameter {parameter} not found in script: {script}")
new_value = f"{parameter}={format_parameter_value(value)}"
return script.replace(to_replace, new_value)


def has_unresolved_parameters(script: str) -> bool:
"""
Check if a DSL script has unresolved parameters.
"""
return octobot_commons.constants.UNRESOLVED_PARAMETER_PLACEHOLDER in script
6 changes: 6 additions & 0 deletions packages/commons/octobot_commons/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@ class InvalidParameterFormatError(InvalidParametersError):
"""


class ResolvedParameterNotFoundError(DSLInterpreterError):
"""
Raised when a resolved parameter is not found in the script
"""


class ErrorStatementEncountered(DSLInterpreterError):
"""
Raised when a error statement is encountered when executing a script
Expand Down
7 changes: 6 additions & 1 deletion packages/commons/octobot_commons/time_frame_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library.
import typing

import octobot_commons.constants as constants
import octobot_commons.logging as logging_util
import octobot_commons.enums as enums
Expand Down Expand Up @@ -99,7 +101,10 @@ def get_previous_time_frame(config_time_frames, time_frame, origin_time_frame):
return origin_time_frame


def find_min_time_frame(time_frames, min_time_frame=None):
def find_min_time_frame(
time_frames: list[typing.Union[str, enums.TimeFrames]],
min_time_frame: typing.Optional[str] = None
) -> enums.TimeFrames:
"""
Find the minimum time frame
:param time_frames: the time frame list
Expand Down
78 changes: 78 additions & 0 deletions packages/commons/tests/dsl_interpreter/test_parameters_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import mock
import pytest

import octobot_commons.constants as constants
import octobot_commons.dsl_interpreter.parameters_util as parameters_util
import octobot_commons.dsl_interpreter.operator_parameter as operator_parameter
import octobot_commons.errors as commons_errors
Expand Down Expand Up @@ -282,3 +283,80 @@ def test_partial_params_allowed(self):
)
assert args == [1]
assert kwargs == {}


class TestApplyResolvedParameterValue:
def test_replaces_single_parameter_with_int(self):
script = f"op(x=1, y={constants.UNRESOLVED_PARAMETER_PLACEHOLDER})"
result = parameters_util.apply_resolved_parameter_value(script, "y", 42)
assert result == "op(x=1, y=42)"

def test_replaces_single_parameter_with_string(self):
script = f"op(name={constants.UNRESOLVED_PARAMETER_PLACEHOLDER})"
result = parameters_util.apply_resolved_parameter_value(script, "name", "hello")
assert result == "op(name='hello')"

def test_replaces_single_parameter_with_bool(self):
script = f"op(flag={constants.UNRESOLVED_PARAMETER_PLACEHOLDER})"
result = parameters_util.apply_resolved_parameter_value(script, "flag", True)
assert result == "op(flag=True)"

def test_replaces_single_parameter_with_list(self):
script = f"op(items={constants.UNRESOLVED_PARAMETER_PLACEHOLDER})"
result = parameters_util.apply_resolved_parameter_value(script, "items", [1, 2])
assert result == "op(items=[1, 2])"

def test_replaces_single_parameter_with_dict(self):
script = f"op(config={constants.UNRESOLVED_PARAMETER_PLACEHOLDER})"
result = parameters_util.apply_resolved_parameter_value(
script, "config", {"a": 1}
)
assert result == "op(config={'a': 1})"

def test_replaces_single_parameter_with_none(self):
script = f"op(val={constants.UNRESOLVED_PARAMETER_PLACEHOLDER})"
result = parameters_util.apply_resolved_parameter_value(script, "val", None)
assert result == "op(val=None)"

def test_raises_when_parameter_not_found(self):
script = "op(x=1, y=2)"
with pytest.raises(commons_errors.ResolvedParameterNotFoundError, match="Parameter z not found in script"):
parameters_util.apply_resolved_parameter_value(script, "z", 42)

def test_raises_when_placeholder_not_in_script_for_parameter(self):
script = f"op(x={constants.UNRESOLVED_PARAMETER_PLACEHOLDER}, y=2)"
with pytest.raises(commons_errors.ResolvedParameterNotFoundError, match="Parameter z not found in script"):
parameters_util.apply_resolved_parameter_value(script, "z", 42)

def test_replaces_only_exact_parameter_pattern(self):
script = f"op(a=1, b={constants.UNRESOLVED_PARAMETER_PLACEHOLDER})"
result = parameters_util.apply_resolved_parameter_value(script, "b", 100)
assert result == "op(a=1, b=100)"
# Ensure 'a' was not touched
assert "a=1" in result


class TestHasUnresolvedParameters:
def test_returns_true_when_placeholder_present(self):
script = f"op(x={constants.UNRESOLVED_PARAMETER_PLACEHOLDER})"
assert parameters_util.has_unresolved_parameters(script) is True

def test_returns_true_when_multiple_placeholders(self):
placeholder = constants.UNRESOLVED_PARAMETER_PLACEHOLDER
script = f"op(a={placeholder}, b={placeholder})"
assert parameters_util.has_unresolved_parameters(script) is True

def test_returns_false_when_no_placeholder(self):
script = "op(x=1, y=2)"
assert parameters_util.has_unresolved_parameters(script) is False

def test_returns_false_for_empty_script(self):
assert parameters_util.has_unresolved_parameters("") is False

def test_returns_true_when_placeholder_part_of_larger_string(self):
script = f"op(x='prefix_{constants.UNRESOLVED_PARAMETER_PLACEHOLDER}_suffix')"
assert parameters_util.has_unresolved_parameters(script) is True

def test_returns_true_when_placeholder_alone(self):
script = constants.UNRESOLVED_PARAMETER_PLACEHOLDER
assert parameters_util.has_unresolved_parameters(script) is True
47 changes: 47 additions & 0 deletions packages/commons/tests/test_asyncio_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
# You should have received a copy of the GNU Lesser General Public
# License along with this library.
import asyncio
import mock
import pytest

import octobot_commons.asyncio_tools as asyncio_tools
Expand Down Expand Up @@ -255,5 +256,51 @@ async def test_RLock_error_setup_2():
pass


async def test_logged_waiter_cancels_task_on_quick_exit():
mock_self = mock.Mock()
mock_self.logger = mock.Mock()

with asyncio_tools.logged_waiter(mock_self, "quick op", sleep_time=30):
await asyncio.sleep(0.001)

mock_self.logger.info.assert_not_called()


async def test_logged_waiter_logs_when_body_runs_long():
mock_self = mock.Mock()
mock_self.logger = mock.Mock()

with asyncio_tools.logged_waiter(mock_self, "long op", sleep_time=0.05):
await asyncio.sleep(0.15)

assert mock_self.logger.info.call_count >= 1
call_args = mock_self.logger.info.call_args[0][0]
assert "long op" in call_args
assert "is still processing" in call_args


async def test_logged_waiter_cancels_on_exception():
mock_self = mock.Mock()
mock_self.logger = mock.Mock()

with pytest.raises(ValueError, match="body failed"):
with asyncio_tools.logged_waiter(mock_self, "failing op", sleep_time=30):
raise ValueError("body failed")

mock_self.logger.info.assert_not_called()


async def test_logged_waiter_uses_custom_sleep_time():
mock_self = mock.Mock()
mock_self.logger = mock.Mock()

with mock.patch.object(asyncio, "sleep", wraps=asyncio.sleep) as mock_sleep:
with asyncio_tools.logged_waiter(mock_self, "custom sleep", sleep_time=0.1):
await asyncio.sleep(0.2)

sleep_calls = [c[0][0] for c in mock_sleep.call_args_list]
assert 0.1 in sleep_calls


def _exception_raiser():
raise RuntimeError("error")
2 changes: 1 addition & 1 deletion packages/node/octobot_node/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ class TaskResultKeys(enum.Enum):


class SchedulerQueues(enum.Enum):
BOT_WORKFLOW_QUEUE = "bot_workflow_queue"
AUTOMATIONS_WORKFLOW_QUEUE = "automations_workflow_queue"
4 changes: 4 additions & 0 deletions packages/node/octobot_node/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ class WorkflowError(Exception):

class WorkflowInputError(WorkflowError):
"""Raised when a workflow input is invalid"""


class WorkflowActionExecutionError(WorkflowError):
"""Raised when a workflow action execution fails"""
4 changes: 1 addition & 3 deletions packages/node/octobot_node/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,10 @@ class TaskStatus(str, Enum):


class TaskType(str, Enum):
START_OCTOBOT = "start_octobot"
EXECUTE_ACTIONS = "execute_actions"
STOP_OCTOBOT = "stop_octobot"

class Task(BaseModel):
id: uuid.UUID = uuid.uuid4()
id: str = str(uuid.uuid4())
name: typing.Optional[str] = None
description: typing.Optional[str] = None
content: typing.Optional[str] = None
Expand Down
Loading
Loading