Enhance Windows Compatibility with Event Loop Policy and Path Resolution (#3986)

Co-authored-by: Suchintan <suchintan@users.noreply.github.com>
Co-authored-by: Shuchang Zheng <wintonzheng0325@gmail.com>
Co-authored-by: Stanislav Novosad <stas@skyvern.com>
This commit is contained in:
Mohamed Khalil
2025-11-20 05:16:16 +02:00
committed by GitHub
parent db68d8a60c
commit d975ca0913
6 changed files with 71 additions and 11 deletions

View File

@@ -7,6 +7,7 @@ from ddtrace.ext import http
from ddtrace.trace import TraceFilter, Span from ddtrace.trace import TraceFilter, Span
from skyvern.forge.sdk.forge_log import setup_logger from skyvern.forge.sdk.forge_log import setup_logger
from skyvern.utils import setup_windows_event_loop_policy
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from skyvern.library import Skyvern # noqa: E402 from skyvern.library import Skyvern # noqa: E402
@@ -24,6 +25,7 @@ class FilterHeartbeat(TraceFilter):
return trace return trace
setup_windows_event_loop_policy()
tracer.configure(trace_processors=[FilterHeartbeat()]) tracer.configure(trace_processors=[FilterHeartbeat()])
setup_logger() setup_logger()

View File

@@ -54,7 +54,7 @@ def init(
backend_env_path = resolve_backend_env_path() backend_env_path = resolve_backend_env_path()
if backend_env_path.exists(): 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( redo_llm_setup = Confirm.ask(
"Do you want to go through [bold yellow]LLM provider setup again[/bold yellow]?", "Do you want to go through [bold yellow]LLM provider setup again[/bold yellow]?",
default=False, default=False,

View File

@@ -7,6 +7,10 @@ from typing import Any, Callable, ClassVar, Dict, List, Mapping, Optional, Set,
import pydantic 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.") IS_PYDANTIC_V2 = pydantic.VERSION.startswith("2.")
if IS_PYDANTIC_V2: 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_literal_type as is_literal_type # type: ignore[no-redef]
from pydantic.typing import is_union as is_union # 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") T = TypeVar("T")
Model = TypeVar("Model", bound=pydantic.BaseModel) 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: def _get_field_default(field: PydanticField) -> Any:
try: try:
value = field.get_default() # type: ignore[union-attr] value = field.get_default() # type: ignore[union-attr]
except: except Exception:
value = field.default value = field.default
if IS_PYDANTIC_V2: if IS_PYDANTIC_V2:
from pydantic_core import PydanticUndefined from pydantic_core import PydanticUndefined

View File

@@ -72,7 +72,7 @@ def convert_and_respect_annotation_metadata(
if ( if (
typing_extensions.get_origin(clean_type) == typing.Dict 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 or clean_type == typing.Dict
) and isinstance(object_, typing.Dict): ) and isinstance(object_, typing.Dict):
key_type = typing_extensions.get_args(clean_type)[0] 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 not isinstance(object_, str):
if ( if (
typing_extensions.get_origin(clean_type) == typing.Set 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 or clean_type == typing.Set
) and isinstance(object_, typing.Set): ) and isinstance(object_, typing.Set):
inner_type = typing_extensions.get_args(clean_type)[0] inner_type = typing_extensions.get_args(clean_type)[0]
@@ -108,14 +108,14 @@ def convert_and_respect_annotation_metadata(
elif ( elif (
( (
typing_extensions.get_origin(clean_type) == typing.List 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 or clean_type == typing.List
) )
and isinstance(object_, typing.List) and isinstance(object_, typing.List)
) or ( ) or (
( (
typing_extensions.get_origin(clean_type) == typing.Sequence 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 or clean_type == typing.Sequence
) )
and isinstance(object_, typing.Sequence) and isinstance(object_, typing.Sequence)
@@ -131,7 +131,7 @@ def convert_and_respect_annotation_metadata(
for item in object_ 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 # 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 # 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 # of the same name to a different name from another member

View File

@@ -1,3 +1,7 @@
import logging
import platform
from typing import Any
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from skyvern import constants from skyvern import constants
@@ -16,6 +20,9 @@ _DEFAULT_ENV_FILES = (
) )
LOG = logging.getLogger(__name__)
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=_DEFAULT_ENV_FILES, extra="ignore") 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 LONG_RUNNING_TASK_WARNING_RATIO: float = 0.95
MAX_RETRIES_PER_STEP: int = 5 MAX_RETRIES_PER_STEP: int = 5
DEBUG_MODE: bool = False 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 DATABASE_STATEMENT_TIMEOUT_MS: int = 60000
DISABLE_CONNECTION_POOL: bool = False DISABLE_CONNECTION_POOL: bool = False
PROMPT_ACTION_HISTORY_WINDOW: int = 1 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: def is_cloud_environment(self) -> bool:
""" """
:return: True if env is not local, else False :return: True if env is not local, else False

View File

@@ -1,3 +1,4 @@
import asyncio
import platform import platform
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -8,6 +9,28 @@ from alembic.config import Config
from skyvern.constants import REPO_ROOT_DIR 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: def migrate_db() -> None:
alembic_cfg = Config() alembic_cfg = Config()
path = f"{REPO_ROOT_DIR}/alembic" path = f"{REPO_ROOT_DIR}/alembic"