From 0e202ed4baec25b6aaa5fac922627b35cffe52f9 Mon Sep 17 00:00:00 2001 From: northpowered Date: Sat, 1 Nov 2025 19:22:35 +0300 Subject: [PATCH 1/3] chore: streamline CI workflow for Poetry installation and path management - Removed unnecessary checks for Poetry installation and simplified the process of adding Poetry to the PATH. - Enhanced the installation step for dependencies by ensuring a cleaner setup without redundant commands. These changes aim to improve the efficiency and reliability of the CI workflow. --- .github/workflows/ci_prepare.yml | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci_prepare.yml b/.github/workflows/ci_prepare.yml index f3cd088..41dcb5a 100644 --- a/.github/workflows/ci_prepare.yml +++ b/.github/workflows/ci_prepare.yml @@ -118,45 +118,29 @@ jobs: uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: ${{ matrix.python-version }} - cache: "poetry" - name: Install Poetry uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1 - id: poetry with: version: latest virtualenvs-create: true installer-parallel: true - - - name: Ensure Poetry is available + + - name: Add Poetry to PATH run: | - # Add Poetry to PATH echo "$HOME/.local/bin" >> $GITHUB_PATH echo "$HOME/.poetry/bin" >> $GITHUB_PATH - echo "${{ github.workspace }}" >> $GITHUB_PATH - - # Check if Poetry is available - if ! command -v poetry >/dev/null 2>&1 && [ ! -f "$HOME/.local/bin/poetry" ] && [ ! -f "$HOME/.poetry/bin/poetry" ]; then - echo "Poetry not found, installing via official installer..." - curl -sSL https://install.python-poetry.org | python3 - - export PATH="$HOME/.local/bin:$PATH" - echo "$HOME/.local/bin" >> $GITHUB_PATH - fi - - # Verify Poetry is available - poetry --version || $HOME/.local/bin/poetry --version || $HOME/.poetry/bin/poetry --version || exit 1 - echo "Poetry installation verified" - shell: bash - name: Load cached Poetry dependencies uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - id: cache with: path: ~/.cache/pypoetry key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }} restore-keys: | ${{ runner.os }}-poetry- + + - name: Install dependencies run: | poetry install --with test --no-root --no-interaction From d1cf47787db4686572f1e54f7c8118916dd27f13 Mon Sep 17 00:00:00 2001 From: northpowered Date: Sat, 1 Nov 2025 20:46:57 +0300 Subject: [PATCH 2/3] chore: enhance CI workflow for Poetry installation and dependency management - Updated the CI workflow to streamline the installation of Poetry and its dependencies, ensuring a more efficient setup process. - Improved the handling of the PATH variable to guarantee consistent access to the Poetry binary across different steps. - Added verification checks for Poetry installation to enhance reliability during the CI process. These changes aim to improve the robustness and efficiency of the CI workflow. --- tests/test_asgi_registry.py | 139 +++++++++++ tests/test_boost_app.py | 410 +++++++++++++++++++++++++++++++++ tests/test_common.py | 55 +++++ tests/test_config.py | 85 +++++++ tests/test_temporal_client.py | 169 ++++++++++++++ tests/test_temporal_runtime.py | 117 ++++++++++ tests/test_temporal_worker.py | 405 ++++++++++++++++++++++++++++++++ 7 files changed, 1380 insertions(+) create mode 100644 tests/test_asgi_registry.py create mode 100644 tests/test_boost_app.py create mode 100644 tests/test_common.py create mode 100644 tests/test_config.py create mode 100644 tests/test_temporal_client.py create mode 100644 tests/test_temporal_runtime.py create mode 100644 tests/test_temporal_worker.py diff --git a/tests/test_asgi_registry.py b/tests/test_asgi_registry.py new file mode 100644 index 0000000..bb39f16 --- /dev/null +++ b/tests/test_asgi_registry.py @@ -0,0 +1,139 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from temporal_boost.workers.asgi_registry import ( + ASGIWorkerRegistry, + ASGIWorkerType, + asgi_worker_registry, + get_asgi_worker_class, +) + + +class MockASGIWorker: + pass + + +class TestASGIWorkerRegistry: + def test_init(self): + registry = ASGIWorkerRegistry() + assert registry._data == {} + + def test_register(self): + registry = ASGIWorkerRegistry() + + @registry.register("test_worker") + class TestWorker(MockASGIWorker): + pass + + assert "test_worker" in registry._data + assert registry._data["test_worker"] == TestWorker + + def test_register_with_packages(self): + registry = ASGIWorkerRegistry() + + @registry.register("test_worker", packages=["os"]) + class TestWorker(MockASGIWorker): + pass + + assert "test_worker" in registry._data + + def test_register_with_missing_package(self): + registry = ASGIWorkerRegistry() + + @registry.register("test_worker", packages=["nonexistent_package_12345"]) + class TestWorker(MockASGIWorker): + pass + + assert "test_worker" not in registry._data + + def test_get_existing(self): + registry = ASGIWorkerRegistry() + + @registry.register("test_worker") + class TestWorker(MockASGIWorker): + pass + + worker_class = registry.get("test_worker") + assert worker_class == TestWorker + + def test_get_nonexistent(self): + registry = ASGIWorkerRegistry() + + with pytest.raises(RuntimeError, match="is not available"): + registry.get("nonexistent") + + def test_available_keys(self): + registry = ASGIWorkerRegistry() + + @registry.register("worker1") + class Worker1(MockASGIWorker): + pass + + @registry.register("worker2") + class Worker2(MockASGIWorker): + pass + + keys = registry.available_keys() + assert "worker1" in keys + assert "worker2" in keys + assert len(keys) == 2 + + +class TestASGIWorkerType: + def test_enum_values(self): + assert ASGIWorkerType.uvicorn.value == "uvicorn" + assert ASGIWorkerType.hypercorn.value == "hypercorn" + assert ASGIWorkerType.granian.value == "granian" + assert ASGIWorkerType.auto.value == "auto" + + +class TestGetASGIWorkerClass: + def test_get_uvicorn(self): + with patch.object(asgi_worker_registry, "get") as mock_get: + mock_get.return_value = MockASGIWorker + worker_class = get_asgi_worker_class(ASGIWorkerType.uvicorn) + mock_get.assert_called_once_with("uvicorn") + assert worker_class == MockASGIWorker + + def test_get_hypercorn(self): + with patch.object(asgi_worker_registry, "get") as mock_get: + mock_get.return_value = MockASGIWorker + worker_class = get_asgi_worker_class(ASGIWorkerType.hypercorn) + mock_get.assert_called_once_with("hypercorn") + assert worker_class == MockASGIWorker + + def test_get_granian(self): + with patch.object(asgi_worker_registry, "get") as mock_get: + mock_get.return_value = MockASGIWorker + worker_class = get_asgi_worker_class(ASGIWorkerType.granian) + mock_get.assert_called_once_with("granian") + assert worker_class == MockASGIWorker + + def test_get_auto_with_uvicorn_available(self): + with patch.object(asgi_worker_registry, "available_keys") as mock_keys: + mock_keys.return_value = ["uvicorn", "hypercorn"] + + with patch.object(asgi_worker_registry, "get") as mock_get: + mock_get.return_value = MockASGIWorker + worker_class = get_asgi_worker_class(ASGIWorkerType.auto) + mock_get.assert_called_once_with("uvicorn") + assert worker_class == MockASGIWorker + + def test_get_auto_without_uvicorn(self): + with patch.object(asgi_worker_registry, "available_keys") as mock_keys: + mock_keys.return_value = ["hypercorn", "granian"] + + with patch.object(asgi_worker_registry, "get") as mock_get: + mock_get.return_value = MockASGIWorker + worker_class = get_asgi_worker_class(ASGIWorkerType.auto) + mock_get.assert_called_once_with("hypercorn") + assert worker_class == MockASGIWorker + + def test_get_auto_with_no_workers(self): + with patch.object(asgi_worker_registry, "available_keys") as mock_keys: + mock_keys.return_value = [] + + with pytest.raises(RuntimeError, match="No ASGI worker implementation is available"): + get_asgi_worker_class(ASGIWorkerType.auto) + diff --git a/tests/test_boost_app.py b/tests/test_boost_app.py new file mode 100644 index 0000000..dd90586 --- /dev/null +++ b/tests/test_boost_app.py @@ -0,0 +1,410 @@ +import json +import logging +import os +import sys +import tempfile +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +from temporal_boost.boost_app import BoostApp +from temporal_boost.workers.temporal import TemporalBoostWorker + + +class TestBoostApp: + def test_init_with_defaults(self): + app = BoostApp() + assert app._name == "temporal_generic_service" + assert app._global_temporal_endpoint is None + assert app._global_temporal_namespace is None + assert app._global_use_pydantic is None + assert app._debug_mode is False + assert app._registered_workers == [] + assert app._registered_cron_workers == [] + assert app._registered_asgi_workers == [] + + def test_init_with_custom_name(self): + app = BoostApp(name="test_app") + assert app._name == "test_app" + + def test_init_with_temporal_endpoint(self): + app = BoostApp(temporal_endpoint="localhost:7233") + assert app._global_temporal_endpoint == "localhost:7233" + + def test_init_with_temporal_namespace(self): + app = BoostApp(temporal_namespace="test_namespace") + assert app._global_temporal_namespace == "test_namespace" + + def test_init_with_debug_mode(self): + app = BoostApp(debug_mode=True) + assert app._debug_mode is True + + def test_init_with_use_pydantic(self): + app = BoostApp(use_pydantic=True) + assert app._global_use_pydantic is True + + def test_init_with_logger_config_dict(self): + log_config = {"version": 1, "disable_existing_loggers": False} + app = BoostApp(logger_config=log_config) + assert app._logger_config == log_config + + def test_init_with_logger_config_path_json(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + log_config = {"version": 1, "disable_existing_loggers": False} + json.dump(log_config, f) + temp_path = f.name + + try: + app = BoostApp(logger_config=temp_path) + assert app._logger_config == log_config + finally: + os.unlink(temp_path) + + def test_init_with_logger_config_path_yaml(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + log_config = {"version": 1, "disable_existing_loggers": False} + yaml.dump(log_config, f) + temp_path = f.name + + try: + app = BoostApp(logger_config=temp_path) + assert app._logger_config == log_config + finally: + os.unlink(temp_path) + + def test_init_with_logger_config_path_ini(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: + f.write( + "[loggers]\nkeys=root\n\n[handlers]\nkeys=console\n\n[formatters]\nkeys=default\n\n" + "[logger_root]\nlevel=DEBUG\nhandlers=console\n\n[handler_console]\n" + "class=StreamHandler\nlevel=DEBUG\nformatter=default\n\n[formatter_default]\n" + "format=%(asctime)s [%(levelname)s] %(name)s: %(message)s\n" + ) + temp_path = f.name + + try: + app = BoostApp(logger_config=temp_path) + assert app._logger_config is None + finally: + os.unlink(temp_path) + + def test_init_with_invalid_logger_config(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("invalid json") + temp_path = f.name + + try: + with pytest.raises(RuntimeError, match="Logging configuration failed"): + BoostApp(logger_config=temp_path) + finally: + os.unlink(temp_path) + + def test_get_registered_workers_empty(self): + app = BoostApp() + assert app.get_registered_workers() == [] + + def test_add_worker_success(self): + app = BoostApp() + + def dummy_activity(): + pass + + class DummyWorkflow: + pass + + worker = app.add_worker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + workflows=[DummyWorkflow], + ) + + assert isinstance(worker, TemporalBoostWorker) + assert worker.name == "test_worker" + assert len(app.get_registered_workers()) == 1 + + def test_add_worker_with_reserved_name(self): + app = BoostApp() + + def dummy_activity(): + pass + + with pytest.raises(RuntimeError, match="is reserved and cannot be used"): + app.add_worker( + worker_name="run", + task_queue="test_queue", + activities=[dummy_activity], + ) + + def test_add_worker_duplicate_name(self): + app = BoostApp() + + def dummy_activity(): + pass + + app.add_worker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + with pytest.raises(RuntimeError, match="is already registered"): + app.add_worker( + worker_name="test_worker", + task_queue="test_queue2", + activities=[dummy_activity], + ) + + def test_add_worker_with_global_temporal_endpoint(self): + app = BoostApp(temporal_endpoint="localhost:7233") + + def dummy_activity(): + pass + + worker = app.add_worker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + assert worker._client_builder is not None + assert worker._client_builder._target_host == "localhost:7233" + + def test_add_worker_with_global_temporal_namespace(self): + app = BoostApp(temporal_namespace="test_namespace") + + def dummy_activity(): + pass + + worker = app.add_worker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + assert worker._client_builder is not None + assert worker._client_builder._namespace == "test_namespace" + + def test_add_worker_with_global_use_pydantic(self): + app = BoostApp(use_pydantic=True) + + def dummy_activity(): + pass + + worker = app.add_worker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + assert worker._client_builder is not None + assert worker._client_builder._data_converter is not None + + def test_add_worker_with_cron_schedule(self): + app = BoostApp() + + def dummy_activity(): + pass + + async def cron_runner(): + pass + + worker = app.add_worker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + cron_schedule="0 0 * * *", + cron_runner=cron_runner, + ) + + assert worker in app._registered_cron_workers + assert len(app._registered_cron_workers) == 1 + + def test_add_asgi_worker_success(self): + app = BoostApp() + + class MockASGIApp: + pass + + with patch("temporal_boost.boost_app.get_asgi_worker_class") as mock_get_worker: + mock_worker_class = MagicMock() + mock_worker_instance = MagicMock() + mock_worker_instance.name = "" + mock_worker_class.return_value = mock_worker_instance + mock_get_worker.return_value = mock_worker_class + + app.add_asgi_worker( + worker_name="asgi_worker", + asgi_app=MockASGIApp(), + host="0.0.0.0", + port=8000, + ) + + assert len(app.get_registered_workers()) == 1 + mock_worker_class.assert_called_once() + + def test_add_asgi_worker_with_reserved_name(self): + app = BoostApp() + + class MockASGIApp: + pass + + with pytest.raises(RuntimeError, match="is reserved and cannot be used"): + app.add_asgi_worker( + worker_name="run", + asgi_app=MockASGIApp(), + host="0.0.0.0", + port=8000, + ) + + def test_add_asgi_worker_duplicate_name(self): + app = BoostApp() + + class MockASGIApp: + pass + + with patch("temporal_boost.boost_app.get_asgi_worker_class") as mock_get_worker: + mock_worker_class = MagicMock() + mock_worker_instance = MagicMock() + mock_worker_instance.name = "" + mock_worker_class.return_value = mock_worker_instance + mock_get_worker.return_value = mock_worker_class + + app.add_asgi_worker( + worker_name="asgi_worker", + asgi_app=MockASGIApp(), + host="0.0.0.0", + port=8000, + ) + + with pytest.raises(RuntimeError, match="is already registered"): + app.add_asgi_worker( + worker_name="asgi_worker", + asgi_app=MockASGIApp(), + host="0.0.0.0", + port=8001, + ) + + def test_add_faststream_worker_success(self): + app = BoostApp() + + class MockFastStreamApp: + pass + + with patch("temporal_boost.boost_app.FastStreamBoostWorker") as mock_worker_class: + mock_worker_instance = MagicMock() + mock_worker_instance.name = "" + mock_worker_class.return_value = mock_worker_instance + + worker = app.add_faststream_worker( + worker_name="faststream_worker", + faststream_app=MockFastStreamApp(), + ) + + assert len(app.get_registered_workers()) == 1 + mock_worker_class.assert_called_once() + + def test_add_faststream_worker_with_reserved_name(self): + app = BoostApp() + + class MockFastStreamApp: + pass + + with pytest.raises(RuntimeError, match="is reserved and cannot be used"): + app.add_faststream_worker( + worker_name="exec", + faststream_app=MockFastStreamApp(), + ) + + def test_add_exec_method_sync(self): + app = BoostApp() + + def exec_callback(): + return "executed" + + app.add_exec_method_sync("test_exec", exec_callback) + assert app._exec_typer is not None + + def test_add_async_runtime(self): + app = BoostApp() + + def dummy_activity(): + pass + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + app.add_async_runtime("test_worker", worker) + assert len(app.get_registered_workers()) == 1 + + def test_add_async_runtime_with_reserved_name(self): + app = BoostApp() + + def dummy_activity(): + pass + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + with pytest.raises(RuntimeError, match="is reserved and cannot be used"): + app.add_async_runtime("all", worker) + + def test_run_all_workers_no_workers(self): + app = BoostApp() + app.run_all_workers() + assert len(app.get_registered_workers()) == 0 + + def test_run_all_workers_with_workers(self): + app = BoostApp() + + def dummy_activity(): + pass + + worker = app.add_worker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + with patch.object(worker, "run") as mock_run: + mock_run.side_effect = KeyboardInterrupt() + + app.run_all_workers() + + mock_run.assert_called_once() + + def test_run_with_args(self): + app = BoostApp() + with patch.object(app._root_typer, "__call__", return_value=None) as mock_run: + try: + app.run("test", "args") + except SystemExit: + pass + mock_run.assert_called_once() + + def test_run_with_args(self): + app = BoostApp() + + def dummy_activity(): + pass + + app.add_worker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + with patch.object(app._root_typer, "__call__", return_value=None) as mock_run: + try: + app.run("run", "test_worker") + except (SystemExit, Exception): + pass + assert mock_run.called or len(app.get_registered_workers()) == 1 + diff --git a/tests/test_common.py b/tests/test_common.py new file mode 100644 index 0000000..7a502d4 --- /dev/null +++ b/tests/test_common.py @@ -0,0 +1,55 @@ +from temporal_boost.common import DEFAULT_LOGGING_CONFIG + + +class TestCommon: + def test_default_logging_config_structure(self): + assert isinstance(DEFAULT_LOGGING_CONFIG, dict) + assert "version" in DEFAULT_LOGGING_CONFIG + assert "disable_existing_loggers" in DEFAULT_LOGGING_CONFIG + assert "formatters" in DEFAULT_LOGGING_CONFIG + assert "handlers" in DEFAULT_LOGGING_CONFIG + assert "root" in DEFAULT_LOGGING_CONFIG + assert "loggers" in DEFAULT_LOGGING_CONFIG + + def test_default_logging_config_version(self): + assert DEFAULT_LOGGING_CONFIG["version"] == 1 + + def test_default_logging_config_disable_existing_loggers(self): + assert DEFAULT_LOGGING_CONFIG["disable_existing_loggers"] is False + + def test_default_logging_config_formatters(self): + assert "default" in DEFAULT_LOGGING_CONFIG["formatters"] + formatter = DEFAULT_LOGGING_CONFIG["formatters"]["default"] + assert "format" in formatter + assert "datefmt" in formatter + + def test_default_logging_config_handlers(self): + assert "default" in DEFAULT_LOGGING_CONFIG["handlers"] + handler = DEFAULT_LOGGING_CONFIG["handlers"]["default"] + assert "class" in handler + assert handler["class"] == "logging.StreamHandler" + assert "formatter" in handler + assert handler["formatter"] == "default" + + def test_default_logging_config_root(self): + root = DEFAULT_LOGGING_CONFIG["root"] + assert "level" in root + assert root["level"] == "DEBUG" + assert "handlers" in root + assert "default" in root["handlers"] + + def test_default_logging_config_loggers(self): + loggers = DEFAULT_LOGGING_CONFIG["loggers"] + assert "" in loggers + assert "uvicorn" in loggers + assert "hypercorn" in loggers + assert "_granian" in loggers + assert "faststream" in loggers + assert "temporalio" in loggers + + def test_default_logging_config_logger_levels(self): + loggers = DEFAULT_LOGGING_CONFIG["loggers"] + for logger_name, logger_config in loggers.items(): + assert "level" in logger_config + assert logger_config["level"] == "DEBUG" + diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..0ece49d --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,85 @@ +import os +from datetime import timedelta +from unittest.mock import patch + +import pytest + +from temporal_boost.temporal import config + + +class TestConfigUtilityFunctions: + def test_get_env_bool_default(self): + with patch.dict(os.environ, {}, clear=True): + assert config.get_env_bool("NONEXISTENT", default=False) is False + assert config.get_env_bool("NONEXISTENT", default=True) is True + + def test_get_env_bool_true_values(self): + true_values = ["true", "True", "TRUE", "1", "yes", "Yes", "YES"] + for value in true_values: + with patch.dict(os.environ, {"TEST_VAR": value}, clear=False): + assert config.get_env_bool("TEST_VAR", default=False) is True + + def test_get_env_bool_false_values(self): + false_values = ["false", "False", "FALSE", "0", "no", "No", "NO", "anything_else"] + for value in false_values: + with patch.dict(os.environ, {"TEST_VAR": value}, clear=False): + assert config.get_env_bool("TEST_VAR", default=True) is False + + def test_get_env_int_valid(self): + with patch.dict(os.environ, {"TEST_VAR": "123"}, clear=False): + assert config.get_env_int("TEST_VAR", default=0) == 123 + + def test_get_env_int_invalid(self): + with patch.dict(os.environ, {"TEST_VAR": "not_a_number"}, clear=False): + assert config.get_env_int("TEST_VAR", default=42) == 42 + + def test_get_env_int_missing(self): + with patch.dict(os.environ, {}, clear=True): + assert config.get_env_int("NONEXISTENT", default=99) == 99 + + def test_get_env_float_valid(self): + with patch.dict(os.environ, {"TEST_VAR": "123.45"}, clear=False): + assert config.get_env_float("TEST_VAR", default=0.0) == 123.45 + + def test_get_env_float_invalid(self): + with patch.dict(os.environ, {"TEST_VAR": "not_a_number"}, clear=False): + assert config.get_env_float("TEST_VAR", default=3.14) == 3.14 + + def test_get_env_float_missing(self): + with patch.dict(os.environ, {}, clear=True): + assert config.get_env_float("NONEXISTENT", default=2.71) == 2.71 + + def test_config_constants_exist(self): + assert hasattr(config, "TARGET_HOST") + assert hasattr(config, "CLIENT_NAMESPACE") + assert hasattr(config, "CLIENT_TLS") + assert hasattr(config, "CLIENT_API_KEY") + assert hasattr(config, "CLIENT_IDENTITY") + assert hasattr(config, "USE_PYDANTIC_DATA_CONVERTER") + assert hasattr(config, "MAX_CONCURRENT_WORKFLOW_TASKS") + assert hasattr(config, "MAX_CONCURRENT_ACTIVITIES") + assert hasattr(config, "MAX_CONCURRENT_LOCAL_ACTIVITIES") + assert hasattr(config, "MAX_WORKFLOW_TASK_POLLS") + assert hasattr(config, "MAX_ACTIVITY_TASK_POLLS") + assert hasattr(config, "NONSTICKY_STICKY_RATIO") + assert hasattr(config, "GRACEFUL_SHUTDOWN_TIMEOUT") + assert hasattr(config, "PROMETHEUS_BIND_ADDRESS") + assert hasattr(config, "PROMETHEUS_COUNTERS_TOTAL_SUFFIX") + assert hasattr(config, "PROMETHEUS_UNIT_SUFFIX") + assert hasattr(config, "PROMETHEUS_DURATIONS_AS_SECONDS") + + def test_config_default_values(self): + assert config.TARGET_HOST == "localhost:7233" + assert config.CLIENT_NAMESPACE == "default" + assert config.CLIENT_TLS is False + assert config.MAX_CONCURRENT_WORKFLOW_TASKS == 300 + assert config.MAX_CONCURRENT_ACTIVITIES == 300 + assert config.MAX_CONCURRENT_LOCAL_ACTIVITIES == 100 + assert config.MAX_WORKFLOW_TASK_POLLS == 10 + assert config.MAX_ACTIVITY_TASK_POLLS == 10 + assert config.NONSTICKY_STICKY_RATIO == 0.2 + assert config.GRACEFUL_SHUTDOWN_TIMEOUT == timedelta(seconds=30) + assert config.PROMETHEUS_COUNTERS_TOTAL_SUFFIX is False + assert config.PROMETHEUS_UNIT_SUFFIX is False + assert config.PROMETHEUS_DURATIONS_AS_SECONDS is False + diff --git a/tests/test_temporal_client.py b/tests/test_temporal_client.py new file mode 100644 index 0000000..3fffb74 --- /dev/null +++ b/tests/test_temporal_client.py @@ -0,0 +1,169 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from temporal_boost.temporal.client import TemporalClientBuilder + + +class TestTemporalClientBuilder: + def test_init_with_defaults(self): + builder = TemporalClientBuilder() + assert builder._target_host is not None + assert builder._namespace is not None + assert builder._runtime is None + assert builder._data_converter is None + + def test_init_with_target_host(self): + builder = TemporalClientBuilder(target_host="localhost:7233") + assert builder._target_host == "localhost:7233" + + def test_init_with_namespace(self): + builder = TemporalClientBuilder(namespace="test_namespace") + assert builder._namespace == "test_namespace" + + def test_init_with_api_key(self): + builder = TemporalClientBuilder(api_key="test_key") + assert builder._api_key == "test_key" + + def test_init_with_tls(self): + builder = TemporalClientBuilder(tls=True) + assert builder._tls is True + + def test_init_with_identity(self): + builder = TemporalClientBuilder(identity="test_identity") + assert builder._identity == "test_identity" + + def test_init_with_pydantic_data_converter(self): + builder = TemporalClientBuilder(use_pydantic_data_converter=True) + assert builder._data_converter is not None + + def test_init_with_kwargs(self): + builder = TemporalClientBuilder(custom_param="value") + assert builder._client_kwargs["custom_param"] == "value" + + def test_set_runtime(self): + builder = TemporalClientBuilder() + runtime = MagicMock() + builder.set_runtime(runtime) + assert builder._runtime == runtime + + def test_set_target_host(self): + builder = TemporalClientBuilder() + builder.set_target_host("new_host:7233") + assert builder._target_host == "new_host:7233" + + def test_set_namespace(self): + builder = TemporalClientBuilder() + builder.set_namespace("new_namespace") + assert builder._namespace == "new_namespace" + + def test_set_api_key(self): + builder = TemporalClientBuilder() + builder.set_api_key("new_key") + assert builder._api_key == "new_key" + + def test_set_tls(self): + builder = TemporalClientBuilder() + builder.set_tls(True) + assert builder._tls is True + + def test_set_identity(self): + builder = TemporalClientBuilder() + builder.set_identity("new_identity") + assert builder._identity == "new_identity" + + def test_set_kwargs(self): + builder = TemporalClientBuilder() + builder.set_kwargs(param1="value1", param2="value2") + assert builder._client_kwargs["param1"] == "value1" + assert builder._client_kwargs["param2"] == "value2" + + def test_set_kwargs_updates_existing(self): + builder = TemporalClientBuilder(existing="old") + builder.set_kwargs(existing="new", new_param="value") + assert builder._client_kwargs["existing"] == "new" + assert builder._client_kwargs["new_param"] == "value" + + def test_set_pydantic_data_converter(self): + builder = TemporalClientBuilder() + builder.set_pydantic_data_converter() + assert builder._data_converter is not None + + @pytest.mark.asyncio + async def test_build_with_default_runtime(self): + builder = TemporalClientBuilder(target_host="localhost:7233") + + mock_client = AsyncMock() + with patch("temporalio.client.Client.connect", new_callable=AsyncMock) as mock_connect: + mock_connect.return_value = mock_client + + client = await builder.build() + + assert client == mock_client + mock_connect.assert_called_once() + call_kwargs = mock_connect.call_args[1] + assert call_kwargs["target_host"] == "localhost:7233" + assert call_kwargs["runtime"] is not None + + @pytest.mark.asyncio + async def test_build_with_custom_runtime(self): + builder = TemporalClientBuilder(target_host="localhost:7233") + mock_runtime = MagicMock() + builder.set_runtime(mock_runtime) + + mock_client = AsyncMock() + with patch("temporalio.client.Client.connect", new_callable=AsyncMock) as mock_connect: + mock_connect.return_value = mock_client + + client = await builder.build() + + assert client == mock_client + call_kwargs = mock_connect.call_args[1] + assert call_kwargs["runtime"] == mock_runtime + + @pytest.mark.asyncio + async def test_build_with_pydantic_data_converter(self): + builder = TemporalClientBuilder(target_host="localhost:7233") + builder.set_pydantic_data_converter() + + mock_client = AsyncMock() + with patch("temporalio.client.Client.connect", new_callable=AsyncMock) as mock_connect: + mock_connect.return_value = mock_client + + client = await builder.build() + + assert client == mock_client + call_kwargs = mock_connect.call_args[1] + assert "data_converter" in call_kwargs + assert call_kwargs["data_converter"] is not None + + @pytest.mark.asyncio + async def test_build_with_all_parameters(self): + builder = TemporalClientBuilder( + target_host="localhost:7233", + namespace="test_namespace", + api_key="test_key", + identity="test_identity", + tls=True, + custom_param="value", + ) + + mock_runtime = MagicMock() + builder.set_runtime(mock_runtime) + + mock_client = AsyncMock() + with patch("temporalio.client.Client.connect", new_callable=AsyncMock) as mock_connect: + mock_connect.return_value = mock_client + + client = await builder.build() + + assert client == mock_client + call_kwargs = mock_connect.call_args[1] + assert call_kwargs["target_host"] == "localhost:7233" + assert call_kwargs["namespace"] == "test_namespace" + assert call_kwargs["api_key"] == "test_key" + assert call_kwargs["identity"] == "test_identity" + assert call_kwargs["tls"] is True + assert call_kwargs["custom_param"] == "value" + assert call_kwargs["runtime"] == mock_runtime + diff --git a/tests/test_temporal_runtime.py b/tests/test_temporal_runtime.py new file mode 100644 index 0000000..81a66e6 --- /dev/null +++ b/tests/test_temporal_runtime.py @@ -0,0 +1,117 @@ +import logging +from unittest.mock import MagicMock, patch + +import pytest + +from temporal_boost.temporal.runtime import TemporalRuntimeBuilder + + +class TestTemporalRuntimeBuilder: + def test_init_with_defaults(self): + builder = TemporalRuntimeBuilder() + assert builder._logging is not None + assert builder._metrics is None + assert builder._global_tags == {} + assert builder._attach_service_name is True + + def test_init_with_logging(self): + logging_config = MagicMock() + builder = TemporalRuntimeBuilder(logging=logging_config) + assert builder._logging == logging_config + + def test_init_with_metrics(self): + metrics_config = MagicMock() + builder = TemporalRuntimeBuilder(metrics=metrics_config) + assert builder._metrics == metrics_config + + def test_init_with_global_tags(self): + builder = TemporalRuntimeBuilder(global_tags={"tag1": "value1", "tag2": "value2"}) + assert builder._global_tags == {"tag1": "value1", "tag2": "value2"} + + def test_init_with_attach_service_name(self): + builder = TemporalRuntimeBuilder(attach_service_name=False) + assert builder._attach_service_name is False + + def test_init_with_metric_prefix(self): + builder = TemporalRuntimeBuilder(metric_prefix="test_prefix") + assert builder._metric_prefix == "test_prefix" + + def test_init_with_prometheus_bind_address(self): + builder = TemporalRuntimeBuilder(prometheus_bind_address="0.0.0.0:9090") + assert builder._prometheus_bind_address == "0.0.0.0:9090" + + def test_init_with_prometheus_counters_total_suffix(self): + builder = TemporalRuntimeBuilder(prometheus_counters_total_suffix=True) + assert builder._prometheus_counters_total_suffix is True + + def test_init_with_prometheus_unit_suffix(self): + builder = TemporalRuntimeBuilder(prometheus_unit_suffix=True) + assert builder._prometheus_unit_suffix is True + + def test_init_with_prometheus_durations_as_seconds(self): + builder = TemporalRuntimeBuilder(prometheus_durations_as_seconds=True) + assert builder._prometheus_durations_as_seconds is True + + def test_build_without_metrics(self): + builder = TemporalRuntimeBuilder() + runtime = builder.build() + assert runtime is not None + + def test_build_with_prometheus_config(self): + builder = TemporalRuntimeBuilder(prometheus_bind_address="0.0.0.0:9090") + runtime = builder.build() + assert runtime is not None + + def test_build_with_custom_metrics(self): + from temporalio.runtime import PrometheusConfig + + metrics_config = PrometheusConfig(bind_address="0.0.0.0:9091") + builder = TemporalRuntimeBuilder(metrics=metrics_config) + runtime = builder.build() + assert runtime is not None + + def test_build_with_all_prometheus_options(self): + builder = TemporalRuntimeBuilder( + prometheus_bind_address="0.0.0.0:9090", + prometheus_counters_total_suffix=True, + prometheus_unit_suffix=True, + prometheus_durations_as_seconds=True, + ) + runtime = builder.build() + assert runtime is not None + + def test_build_with_global_tags(self): + builder = TemporalRuntimeBuilder(global_tags={"service": "test"}) + runtime = builder.build() + assert runtime is not None + + def test_build_with_metric_prefix(self): + builder = TemporalRuntimeBuilder(metric_prefix="test_prefix") + runtime = builder.build() + assert runtime is not None + + def test_build_with_address_in_use_error(self, caplog): + builder = TemporalRuntimeBuilder(prometheus_bind_address="0.0.0.0:9093") + + with patch("temporal_boost.temporal.runtime.Runtime") as mock_runtime_class: + mock_runtime_instance = MagicMock() + mock_runtime_class.side_effect = [ + ValueError("Address already in use"), + mock_runtime_instance, + ] + + with caplog.at_level(logging.WARNING, logger="temporal_boost.temporal.runtime"): + runtime = builder.build() + + assert runtime is not None + assert mock_runtime_class.call_count == 2 + + def test_build_with_other_value_error(self): + builder = TemporalRuntimeBuilder(prometheus_bind_address="0.0.0.0:9094") + + with patch("temporal_boost.temporal.runtime.Runtime") as mock_runtime_class: + mock_runtime_class.side_effect = ValueError("Other error message") + + with pytest.raises(ValueError, match="Other error message"): + builder.build() + diff --git a/tests/test_temporal_worker.py b/tests/test_temporal_worker.py new file mode 100644 index 0000000..e7fc117 --- /dev/null +++ b/tests/test_temporal_worker.py @@ -0,0 +1,405 @@ +import logging +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from temporal_boost.temporal.worker import TemporalWorkerBuilder +from temporal_boost.workers.temporal import TemporalBoostWorker + + +class TestTemporalWorkerBuilder: + def test_init_with_defaults(self): + builder = TemporalWorkerBuilder(task_queue="test_queue") + assert builder.task_queue == "test_queue" + assert builder._client is None + assert builder._activities == [] + assert builder._workflows == [] + assert builder._interceptors == [] + assert builder._debug_mode is False + + def test_init_with_debug_mode(self): + builder = TemporalWorkerBuilder(task_queue="test_queue", debug_mode=True) + assert builder._debug_mode is True + + def test_init_with_max_concurrent_workflow_tasks(self): + builder = TemporalWorkerBuilder(task_queue="test_queue", max_concurrent_workflow_tasks=100) + assert builder._max_concurrent_workflow_tasks == 100 + + def test_init_with_max_concurrent_activities(self): + builder = TemporalWorkerBuilder(task_queue="test_queue", max_concurrent_activities=50) + assert builder._max_concurrent_activities == 50 + + def test_init_with_kwargs(self): + builder = TemporalWorkerBuilder(task_queue="test_queue", custom_param="value") + assert builder._worker_kwargs["custom_param"] == "value" + + def test_client_property_not_set(self): + builder = TemporalWorkerBuilder(task_queue="test_queue") + with pytest.raises(RuntimeError): + _ = builder.client + + def test_client_property_set(self): + builder = TemporalWorkerBuilder(task_queue="test_queue") + mock_client = MagicMock() + builder.set_client(mock_client) + assert builder.client == mock_client + + def test_set_client(self): + builder = TemporalWorkerBuilder(task_queue="test_queue") + mock_client = MagicMock() + builder.set_client(mock_client) + assert builder._client == mock_client + + def test_set_activities(self): + builder = TemporalWorkerBuilder(task_queue="test_queue") + + def activity1(): + pass + + def activity2(): + pass + + builder.set_activities([activity1, activity2]) + assert builder._activities == [activity1, activity2] + + def test_set_workflows(self): + builder = TemporalWorkerBuilder(task_queue="test_queue") + + class Workflow1: + pass + + class Workflow2: + pass + + builder.set_workflows([Workflow1, Workflow2]) + assert builder._workflows == [Workflow1, Workflow2] + + def test_set_interceptors(self): + builder = TemporalWorkerBuilder(task_queue="test_queue") + interceptor1 = MagicMock() + interceptor2 = MagicMock() + + builder.set_interceptors([interceptor1, interceptor2]) + assert builder._interceptors == [interceptor1, interceptor2] + + def test_build(self): + builder = TemporalWorkerBuilder(task_queue="test_queue") + mock_client = MagicMock() + builder.set_client(mock_client) + + def activity(): + pass + + class Workflow: + pass + + builder.set_activities([activity]) + builder.set_workflows([Workflow]) + + with patch("temporal_boost.temporal.worker.Worker") as mock_worker_class: + mock_worker_instance = MagicMock() + mock_worker_class.return_value = mock_worker_instance + + worker = builder.build() + + assert worker == mock_worker_instance + mock_worker_class.assert_called_once() + call_kwargs = mock_worker_class.call_args[1] + assert call_kwargs["client"] == mock_client + assert call_kwargs["task_queue"] == "test_queue" + assert call_kwargs["activities"] == [activity] + assert call_kwargs["workflows"] == [Workflow] + + +class TestTemporalBoostWorker: + def test_init_with_minimal_params(self): + def dummy_activity(): + pass + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + assert worker.name == "test_worker" + assert worker._worker_builder.task_queue == "test_queue" + assert worker._worker is None + assert worker._client is None + assert worker._client_builder is None + + def test_init_with_no_workflows_or_activities(self): + with pytest.raises(RuntimeError, match="must have at least one workflow or activity"): + TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[], + workflows=[], + ) + + def test_init_with_workflows_only(self): + class DummyWorkflow: + pass + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + workflows=[DummyWorkflow], + ) + + assert worker.name == "test_worker" + + def test_init_with_cron_schedule(self): + def dummy_activity(): + pass + + async def cron_runner(): + pass + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + cron_schedule="0 0 * * *", + cron_runner=cron_runner, + ) + + assert worker._cron_schedule == "0 0 * * *" + assert worker._cron_runner == cron_runner + + def test_init_with_debug_mode(self): + def dummy_activity(): + pass + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + debug_mode=True, + ) + + assert worker._worker_builder._debug_mode is True + + def test_init_with_interceptors(self): + def dummy_activity(): + pass + + interceptor = MagicMock() + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + interceptors=[interceptor], + ) + + assert interceptor in worker._worker_builder._interceptors + + def test_temporal_client_property_not_initialized(self): + def dummy_activity(): + pass + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + with pytest.raises(RuntimeError, match="Temporal client has not been initialized"): + _ = worker.temporal_client + + def test_temporal_worker_property_not_initialized(self): + def dummy_activity(): + pass + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + with pytest.raises(RuntimeError, match="Temporal worker has not been initialized"): + _ = worker.temporal_worker + + def test_temporal_cron_runner_property_not_set(self): + def dummy_activity(): + pass + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + with pytest.raises(RuntimeError, match="Cron runner is not configured"): + _ = worker.temporal_cron_runner + + def test_temporal_cron_runner_property_set(self): + def dummy_activity(): + pass + + async def cron_runner(): + pass + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + cron_runner=cron_runner, + ) + + assert worker.temporal_cron_runner == cron_runner + + def test_configure_temporal_client(self): + def dummy_activity(): + pass + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + worker.configure_temporal_client( + target_host="localhost:7233", + namespace="test_namespace", + api_key="test_key", + identity="test_identity", + tls=True, + ) + + assert worker._client_builder is not None + assert worker._client_builder._target_host == "localhost:7233" + assert worker._client_builder._namespace == "test_namespace" + assert worker._client_builder._api_key == "test_key" + assert worker._client_builder._identity == "test_identity" + assert worker._client_builder._tls is True + + def test_configure_temporal_client_pydantic(self): + def dummy_activity(): + pass + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + worker.configure_temporal_client(use_pydantic_data_converter=True) + + assert worker._client_builder is not None + assert worker._client_builder._data_converter is not None + + def test_configure_temporal_client_updates_existing(self): + def dummy_activity(): + pass + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + worker.configure_temporal_client(target_host="localhost:7233") + worker.configure_temporal_client(target_host="new_host:7233") + + assert worker._client_builder._target_host == "new_host:7233" + + def test_configure_temporal_runtime(self): + def dummy_activity(): + pass + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + worker.configure_temporal_runtime( + prometheus_bind_address="0.0.0.0:9090", + prometheus_counters_total_suffix=True, + ) + + assert worker._runtime_builder is not None + assert worker._runtime_builder._prometheus_bind_address == "0.0.0.0:9090" + assert worker._runtime_builder._prometheus_counters_total_suffix is True + + def test_temporal_client_runtime_property(self): + def dummy_activity(): + pass + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + runtime = worker.temporal_client_runtime + assert runtime is not None + + @pytest.mark.asyncio + async def test_build_worker(self): + def dummy_activity(): + pass + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + worker.configure_temporal_client(target_host="localhost:7233") + + mock_client = AsyncMock() + mock_worker = MagicMock() + + with patch.object(worker._client_builder, "build", new_callable=AsyncMock) as mock_client_build: + mock_client_build.return_value = mock_client + + with patch.object(worker._worker_builder, "build") as mock_worker_build: + mock_worker_build.return_value = mock_worker + + await worker._build_worker() + + assert worker._client == mock_client + assert worker._worker == mock_worker + + @pytest.mark.asyncio + async def test_shutdown(self): + def dummy_activity(): + pass + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + ) + + mock_worker = MagicMock() + mock_worker.shutdown = AsyncMock() + worker._worker = mock_worker + + await worker.shutdown() + + mock_worker.shutdown.assert_called_once() + + def test_log_worker_start(self, caplog): + def dummy_activity(): + pass + + class DummyWorkflow: + pass + + worker = TemporalBoostWorker( + worker_name="test_worker", + task_queue="test_queue", + activities=[dummy_activity], + workflows=[DummyWorkflow], + ) + + with caplog.at_level(logging.INFO): + worker._log_worker_start() + + assert "Worker 'test_worker' started" in caplog.text + assert "test_queue" in caplog.text + From 43dd2847d8fa377a3e85161836beb1cd22247057 Mon Sep 17 00:00:00 2001 From: northpowered Date: Sat, 1 Nov 2025 20:54:48 +0300 Subject: [PATCH 3/3] chore: update CI workflow for Python version and caching - Set Python version to 3.11 in the CI workflow to ensure compatibility with dependencies. - Enabled caching for Poetry to improve installation speed and efficiency during the CI process. - Simplified the addition of the Poetry binary to the PATH variable for better accessibility. These changes aim to enhance the reliability and performance of the CI workflow. --- .github/workflows/documentation.yml | 7 ++++--- tests/test_config.py | 9 +++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 47f948c..701871a 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -21,6 +21,9 @@ jobs: - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + cache: "poetry" - name: Install Poetry uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1 @@ -30,9 +33,7 @@ jobs: installer-parallel: true - name: Add Poetry to PATH - run: | - echo "$HOME/.local/bin" >> $GITHUB_PATH - echo "$HOME/.poetry/bin" >> $GITHUB_PATH + run: echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Load cached Poetry dependencies uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 diff --git a/tests/test_config.py b/tests/test_config.py index 0ece49d..19a267c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,3 +1,4 @@ +import math import os from datetime import timedelta from unittest.mock import patch @@ -39,15 +40,15 @@ def test_get_env_int_missing(self): def test_get_env_float_valid(self): with patch.dict(os.environ, {"TEST_VAR": "123.45"}, clear=False): - assert config.get_env_float("TEST_VAR", default=0.0) == 123.45 + assert math.isclose(config.get_env_float("TEST_VAR", default=0.0), 123.45) def test_get_env_float_invalid(self): with patch.dict(os.environ, {"TEST_VAR": "not_a_number"}, clear=False): - assert config.get_env_float("TEST_VAR", default=3.14) == 3.14 + assert math.isclose(config.get_env_float("TEST_VAR", default=3.14), 3.14) def test_get_env_float_missing(self): with patch.dict(os.environ, {}, clear=True): - assert config.get_env_float("NONEXISTENT", default=2.71) == 2.71 + assert math.isclose(config.get_env_float("NONEXISTENT", default=2.71), 2.71) def test_config_constants_exist(self): assert hasattr(config, "TARGET_HOST") @@ -77,7 +78,7 @@ def test_config_default_values(self): assert config.MAX_CONCURRENT_LOCAL_ACTIVITIES == 100 assert config.MAX_WORKFLOW_TASK_POLLS == 10 assert config.MAX_ACTIVITY_TASK_POLLS == 10 - assert config.NONSTICKY_STICKY_RATIO == 0.2 + assert math.isclose(config.NONSTICKY_STICKY_RATIO, 0.2) assert config.GRACEFUL_SHUTDOWN_TIMEOUT == timedelta(seconds=30) assert config.PROMETHEUS_COUNTERS_TOTAL_SUFFIX is False assert config.PROMETHEUS_UNIT_SUFFIX is False