From d975ca091307db907cf06846bb0d6cb1ab653cb7 Mon Sep 17 00:00:00 2001 From: Mohamed Khalil <69445107+mohamedmamdouh22@users.noreply.github.com> Date: Thu, 20 Nov 2025 05:16:16 +0200 Subject: [PATCH] Enhance Windows Compatibility with Event Loop Policy and Path Resolution (#3986) Co-authored-by: Suchintan Co-authored-by: Shuchang Zheng Co-authored-by: Stanislav Novosad --- skyvern/__init__.py | 2 ++ skyvern/cli/init_command.py | 2 +- skyvern/client/core/pydantic_utilities.py | 9 +++--- skyvern/client/core/serialization.py | 10 +++---- skyvern/config.py | 36 ++++++++++++++++++++++- skyvern/utils/__init__.py | 23 +++++++++++++++ 6 files changed, 71 insertions(+), 11 deletions(-) diff --git a/skyvern/__init__.py b/skyvern/__init__.py index 46e51aa0..1dcde7d3 100644 --- a/skyvern/__init__.py +++ b/skyvern/__init__.py @@ -7,6 +7,7 @@ from ddtrace.ext import http from ddtrace.trace import TraceFilter, Span from skyvern.forge.sdk.forge_log import setup_logger +from skyvern.utils import setup_windows_event_loop_policy if typing.TYPE_CHECKING: from skyvern.library import Skyvern # noqa: E402 @@ -24,6 +25,7 @@ class FilterHeartbeat(TraceFilter): return trace +setup_windows_event_loop_policy() tracer.configure(trace_processors=[FilterHeartbeat()]) setup_logger() diff --git a/skyvern/cli/init_command.py b/skyvern/cli/init_command.py index 3fde1c62..888a312e 100644 --- a/skyvern/cli/init_command.py +++ b/skyvern/cli/init_command.py @@ -54,7 +54,7 @@ def init( backend_env_path = resolve_backend_env_path() if backend_env_path.exists(): - console.print(f"💡 [{backend_env_path}] file already exists.", style="yellow") + console.print(f"💡 [{backend_env_path}] file already exists.", style="yellow", markup=False) redo_llm_setup = Confirm.ask( "Do you want to go through [bold yellow]LLM provider setup again[/bold yellow]?", default=False, diff --git a/skyvern/client/core/pydantic_utilities.py b/skyvern/client/core/pydantic_utilities.py index 8906cdfa..3c9f525e 100644 --- a/skyvern/client/core/pydantic_utilities.py +++ b/skyvern/client/core/pydantic_utilities.py @@ -7,6 +7,10 @@ from typing import Any, Callable, ClassVar, Dict, List, Mapping, Optional, Set, import pydantic +from .datetime_utils import serialize_datetime +from .serialization import convert_and_respect_annotation_metadata +from typing_extensions import TypeAlias + IS_PYDANTIC_V2 = pydantic.VERSION.startswith("2.") if IS_PYDANTIC_V2: @@ -28,9 +32,6 @@ else: from pydantic.typing import is_literal_type as is_literal_type # type: ignore[no-redef] from pydantic.typing import is_union as is_union # type: ignore[no-redef] -from .datetime_utils import serialize_datetime -from .serialization import convert_and_respect_annotation_metadata -from typing_extensions import TypeAlias T = TypeVar("T") Model = TypeVar("Model", bound=pydantic.BaseModel) @@ -247,7 +248,7 @@ def _get_model_fields(model: Type["Model"]) -> Mapping[str, PydanticField]: def _get_field_default(field: PydanticField) -> Any: try: value = field.get_default() # type: ignore[union-attr] - except: + except Exception: value = field.default if IS_PYDANTIC_V2: from pydantic_core import PydanticUndefined diff --git a/skyvern/client/core/serialization.py b/skyvern/client/core/serialization.py index c36e865c..c898c34f 100644 --- a/skyvern/client/core/serialization.py +++ b/skyvern/client/core/serialization.py @@ -72,7 +72,7 @@ def convert_and_respect_annotation_metadata( if ( typing_extensions.get_origin(clean_type) == typing.Dict - or typing_extensions.get_origin(clean_type) == dict + or typing_extensions.get_origin(clean_type) is dict or clean_type == typing.Dict ) and isinstance(object_, typing.Dict): key_type = typing_extensions.get_args(clean_type)[0] @@ -92,7 +92,7 @@ def convert_and_respect_annotation_metadata( if not isinstance(object_, str): if ( typing_extensions.get_origin(clean_type) == typing.Set - or typing_extensions.get_origin(clean_type) == set + or typing_extensions.get_origin(clean_type) is set or clean_type == typing.Set ) and isinstance(object_, typing.Set): inner_type = typing_extensions.get_args(clean_type)[0] @@ -108,14 +108,14 @@ def convert_and_respect_annotation_metadata( elif ( ( typing_extensions.get_origin(clean_type) == typing.List - or typing_extensions.get_origin(clean_type) == list + or typing_extensions.get_origin(clean_type) is list or clean_type == typing.List ) and isinstance(object_, typing.List) ) or ( ( typing_extensions.get_origin(clean_type) == typing.Sequence - or typing_extensions.get_origin(clean_type) == collections.abc.Sequence + or typing_extensions.get_origin(clean_type) is collections.abc.Sequence or clean_type == typing.Sequence ) and isinstance(object_, typing.Sequence) @@ -131,7 +131,7 @@ def convert_and_respect_annotation_metadata( for item in object_ ] - if typing_extensions.get_origin(clean_type) == typing.Union: + if typing_extensions.get_origin(clean_type) is typing.Union: # We should be able to ~relatively~ safely try to convert keys against all # member types in the union, the edge case here is if one member aliases a field # of the same name to a different name from another member diff --git a/skyvern/config.py b/skyvern/config.py index 76c59545..d5a38049 100644 --- a/skyvern/config.py +++ b/skyvern/config.py @@ -1,3 +1,7 @@ +import logging +import platform +from typing import Any + from pydantic_settings import BaseSettings, SettingsConfigDict from skyvern import constants @@ -16,6 +20,9 @@ _DEFAULT_ENV_FILES = ( ) +LOG = logging.getLogger(__name__) + + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=_DEFAULT_ENV_FILES, extra="ignore") @@ -46,7 +53,11 @@ class Settings(BaseSettings): LONG_RUNNING_TASK_WARNING_RATIO: float = 0.95 MAX_RETRIES_PER_STEP: int = 5 DEBUG_MODE: bool = False - DATABASE_STRING: str = "postgresql+psycopg://skyvern@localhost/skyvern" + DATABASE_STRING: str = ( + "postgresql+asyncpg://skyvern@localhost/skyvern" + if platform.system() == "Windows" + else "postgresql+psycopg://skyvern@localhost/skyvern" + ) DATABASE_STATEMENT_TIMEOUT_MS: int = 60000 DISABLE_CONNECTION_POOL: bool = False PROMPT_ACTION_HISTORY_WINDOW: int = 1 @@ -460,6 +471,29 @@ class Settings(BaseSettings): }, } + def model_post_init(self, __context: Any) -> None: # type: ignore[override] + super().model_post_init(__context) + if platform.system() != "Windows": + return + + scheme, sep, remainder = self.DATABASE_STRING.partition("://") + if not sep: + return + + dialect, driver_sep, driver = scheme.partition("+") + if not driver_sep or driver not in {"psycopg", "psycopg2"}: + return + + updated_string = f"{dialect}+asyncpg://{remainder}" + if updated_string == self.DATABASE_STRING: + return + + LOG.warning( + "Detected Windows environment: switching DATABASE_STRING driver from psycopg to asyncpg " + "for compatibility with the Proactor event loop policy." + ) + object.__setattr__(self, "DATABASE_STRING", updated_string) + def is_cloud_environment(self) -> bool: """ :return: True if env is not local, else False diff --git a/skyvern/utils/__init__.py b/skyvern/utils/__init__.py index c615c57f..dfc00800 100644 --- a/skyvern/utils/__init__.py +++ b/skyvern/utils/__init__.py @@ -1,3 +1,4 @@ +import asyncio import platform import subprocess from pathlib import Path @@ -8,6 +9,28 @@ from alembic.config import Config from skyvern.constants import REPO_ROOT_DIR +def setup_windows_event_loop_policy() -> None: + """ + Ensure the Windows event loop policy supports subprocesses (required by Playwright). + + Explicitly setting the Proactor event loop policy prevents third-party packages or older + Python defaults from forcing the Selector policy, which does not implement subprocess + transports and causes runtime failures when Playwright tries to launch a browser. + """ + if platform.system() != "Windows": + return + + windows_policy_cls = getattr(asyncio, "WindowsProactorEventLoopPolicy", None) + if windows_policy_cls is None: + return + + current_policy = asyncio.get_event_loop_policy() + if isinstance(current_policy, windows_policy_cls): + return + + asyncio.set_event_loop_policy(windows_policy_cls()) + + def migrate_db() -> None: alembic_cfg = Config() path = f"{REPO_ROOT_DIR}/alembic"