diff --git a/.release-please-manifest.json b/.release-please-manifest.json index fed4b17f..c3e01e1e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.52.0" + ".": "0.53.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 33f284fe..2939ac96 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 112 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-4ce09d1a7546ab36f578cb27d819187eeb90c580b11834c7ff7a375aa22f9a20.yml -openapi_spec_hash: 1043ab2d699f6c828680c3352cd4cece +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel/kernel-64dac369ae935b0318cd611e1735d45359a663eb55cf43fbb8b09e9dd814d7a2.yml +openapi_spec_hash: cc0c6a4e716977df4b9eab5f4658d41b config_hash: 08d55086449943a8fec212b870061a3f diff --git a/CHANGELOG.md b/CHANGELOG.md index c0f9e33a..a15797c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 0.53.0 (2026-05-12) + +Full Changelog: [v0.52.0...v0.53.0](https://github.com/kernel/kernel-python-sdk/compare/v0.52.0...v0.53.0) + +### Features + +* Add 'switch' MFA option type for generic method-switcher links ([82ae224](https://github.com/kernel/kernel-python-sdk/commit/82ae224a85a22b16e48a26a8058cc992ef772cf2)) +* Add opt-in record_session flag to managed auth ([53fea1c](https://github.com/kernel/kernel-python-sdk/commit/53fea1c26cd8de2b93944bb7625c401d05362b1c)) +* **api:** server-side search on GET /projects ([cacb057](https://github.com/kernel/kernel-python-sdk/commit/cacb057a5d1d3bcb26b6df1f6fe83067f936d642)) +* browser_pools: add start_url config (KERNEL-1217 PR 2) ([e3f0b8d](https://github.com/kernel/kernel-python-sdk/commit/e3f0b8db4b2d05140d112317315ba1e393f385cf)) +* **internal/types:** support eagerly validating pydantic iterators ([b30bc1e](https://github.com/kernel/kernel-python-sdk/commit/b30bc1e0c3493f12a3499eb037f13561dfdcaedf)) +* managed-auth: surface awaiting_external_action even when fallback actions exist ([fd4ffe4](https://github.com/kernel/kernel-python-sdk/commit/fd4ffe40e037e95e93f46b7773d6a5e468c7b8fd)) +* Scope name uniqueness to project for profiles, session_pools, extensions, credentials ([c510a51](https://github.com/kernel/kernel-python-sdk/commit/c510a515b3c57846f0e681638167ba87b8ee15c3)) + + +### Bug Fixes + +* **client:** add missing f-string prefix in file type error message ([fb5340d](https://github.com/kernel/kernel-python-sdk/commit/fb5340dbf9ea49393d87b5760a2efaaf932c9442)) + + +### Chores + +* **internal:** reformat pyproject.toml ([27c799b](https://github.com/kernel/kernel-python-sdk/commit/27c799be452cb66af59b79d24b3d5147a85b70f7)) + ## 0.52.0 (2026-04-29) Full Changelog: [v0.51.0...v0.52.0](https://github.com/kernel/kernel-python-sdk/compare/v0.51.0...v0.52.0) diff --git a/pyproject.toml b/pyproject.toml index 13b5e4ca..10375b3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kernel" -version = "0.52.0" +version = "0.53.0" description = "The official Python library for the kernel API" dynamic = ["readme"] license = "Apache-2.0" @@ -168,7 +168,7 @@ show_error_codes = true # # We also exclude our `tests` as mypy doesn't always infer # types correctly and Pyright will still catch any type errors. -exclude = ['src/kernel/_files.py', '_dev/.*.py', 'tests/.*'] +exclude = ["src/kernel/_files.py", "_dev/.*.py", "tests/.*"] strict_equality = true implicit_reexport = true diff --git a/src/kernel/_files.py b/src/kernel/_files.py index f30ac58a..3fc9f62e 100644 --- a/src/kernel/_files.py +++ b/src/kernel/_files.py @@ -99,7 +99,7 @@ async def async_to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles elif is_sequence_t(files): files = [(key, await _async_transform_file(file)) for key, file in files] else: - raise TypeError("Unexpected file type input {type(files)}, expected mapping or sequence") + raise TypeError(f"Unexpected file type input {type(files)}, expected mapping or sequence") return files diff --git a/src/kernel/_models.py b/src/kernel/_models.py index 29070e05..8c5ab260 100644 --- a/src/kernel/_models.py +++ b/src/kernel/_models.py @@ -25,7 +25,9 @@ ClassVar, Protocol, Required, + Annotated, ParamSpec, + TypeAlias, TypedDict, TypeGuard, final, @@ -79,7 +81,15 @@ from ._constants import RAW_RESPONSE_HEADER if TYPE_CHECKING: + from pydantic import GetCoreSchemaHandler, ValidatorFunctionWrapHandler + from pydantic_core import CoreSchema, core_schema from pydantic_core.core_schema import ModelField, ModelSchema, LiteralSchema, ModelFieldsSchema +else: + try: + from pydantic_core import CoreSchema, core_schema + except ImportError: + CoreSchema = None + core_schema = None __all__ = ["BaseModel", "GenericModel"] @@ -396,6 +406,76 @@ def model_dump_json( ) +class _EagerIterable(list[_T], Generic[_T]): + """ + Accepts any Iterable[T] input (including generators), consumes it + eagerly, and validates all items upfront. + + Validation preserves the original container type where possible + (e.g. a set[T] stays a set[T]). Serialization (model_dump / JSON) + always emits a list — round-tripping through model_dump() will not + restore the original container type. + """ + + @classmethod + def __get_pydantic_core_schema__( + cls, + source_type: Any, + handler: GetCoreSchemaHandler, + ) -> CoreSchema: + (item_type,) = get_args(source_type) or (Any,) + item_schema: CoreSchema = handler.generate_schema(item_type) + list_of_items_schema: CoreSchema = core_schema.list_schema(item_schema) + + return core_schema.no_info_wrap_validator_function( + cls._validate, + list_of_items_schema, + serialization=core_schema.plain_serializer_function_ser_schema( + cls._serialize, + info_arg=False, + ), + ) + + @staticmethod + def _validate(v: Iterable[_T], handler: "ValidatorFunctionWrapHandler") -> Any: + original_type: type[Any] = type(v) + + # Normalize to list so list_schema can validate each item + if isinstance(v, list): + items: list[_T] = v + else: + try: + items = list(v) + except TypeError as e: + raise TypeError("Value is not iterable") from e + + # Validate items against the inner schema + validated: list[_T] = handler(items) + + # Reconstruct original container type + if original_type is list: + return validated + # str(list) produces the list's repr, not a string built from items, + # so skip reconstruction for str and its subclasses. + if issubclass(original_type, str): + return validated + try: + return original_type(validated) + except (TypeError, ValueError): + # If the type cannot be reconstructed, just return the validated list + return validated + + @staticmethod + def _serialize(v: Iterable[_T]) -> list[_T]: + """Always serialize as a list so Pydantic's JSON encoder is happy.""" + if isinstance(v, list): + return v + return list(v) + + +EagerIterable: TypeAlias = Annotated[Iterable[_T], _EagerIterable] + + def _construct_field(value: object, field: FieldInfo, key: str) -> object: if value is None: return field_get_default(field) diff --git a/src/kernel/_version.py b/src/kernel/_version.py index 1ce5f4bd..173fc2c2 100644 --- a/src/kernel/_version.py +++ b/src/kernel/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "kernel" -__version__ = "0.52.0" # x-release-please-version +__version__ = "0.53.0" # x-release-please-version diff --git a/src/kernel/resources/auth/connections.py b/src/kernel/resources/auth/connections.py index fd28bd13..4befc6e9 100644 --- a/src/kernel/resources/auth/connections.py +++ b/src/kernel/resources/auth/connections.py @@ -66,6 +66,7 @@ def create( health_check_interval: int | Omit = omit, login_url: str | Omit = omit, proxy: connection_create_params.Proxy | Omit = omit, + record_session: bool | Omit = omit, save_credentials: bool | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -122,6 +123,9 @@ def create( proxy: Proxy selection. Provide either id or name. The proxy must belong to the caller's org. + record_session: Whether to record browser sessions for this connection by default. Useful for + debugging. Can be overridden per-login. Defaults to false. + save_credentials: Whether to save credentials after every successful login. Defaults to true. One-time codes (TOTP, SMS, etc.) are not saved. @@ -144,6 +148,7 @@ def create( "health_check_interval": health_check_interval, "login_url": login_url, "proxy": proxy, + "record_session": record_session, "save_credentials": save_credentials, }, connection_create_params.ConnectionCreateParams, @@ -198,6 +203,7 @@ def update( health_check_interval: int | Omit = omit, login_url: str | Omit = omit, proxy: connection_update_params.Proxy | Omit = omit, + record_session: bool | Omit = omit, save_credentials: bool | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -228,6 +234,8 @@ def update( proxy: Proxy selection. Provide either id or name. The proxy must belong to the caller's org. + record_session: Whether to record browser sessions for this connection by default + save_credentials: Whether to save credentials after every successful login extra_headers: Send extra headers @@ -249,6 +257,7 @@ def update( "health_check_interval": health_check_interval, "login_url": login_url, "proxy": proxy, + "record_session": record_session, "save_credentials": save_credentials, }, connection_update_params.ConnectionUpdateParams, @@ -398,6 +407,7 @@ def login( id: str, *, proxy: connection_login_params.Proxy | Omit = omit, + record_session: bool | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -415,6 +425,9 @@ def login( proxy: Proxy selection. Provide either id or name. The proxy must belong to the caller's org. + record_session: Override the connection's default for recording this login's browser session. + When omitted, the connection's record_session default is used. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -427,7 +440,13 @@ def login( raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._post( path_template("/auth/connections/{id}/login", id=id), - body=maybe_transform({"proxy": proxy}, connection_login_params.ConnectionLoginParams), + body=maybe_transform( + { + "proxy": proxy, + "record_session": record_session, + }, + connection_login_params.ConnectionLoginParams, + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -529,6 +548,7 @@ async def create( health_check_interval: int | Omit = omit, login_url: str | Omit = omit, proxy: connection_create_params.Proxy | Omit = omit, + record_session: bool | Omit = omit, save_credentials: bool | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -585,6 +605,9 @@ async def create( proxy: Proxy selection. Provide either id or name. The proxy must belong to the caller's org. + record_session: Whether to record browser sessions for this connection by default. Useful for + debugging. Can be overridden per-login. Defaults to false. + save_credentials: Whether to save credentials after every successful login. Defaults to true. One-time codes (TOTP, SMS, etc.) are not saved. @@ -607,6 +630,7 @@ async def create( "health_check_interval": health_check_interval, "login_url": login_url, "proxy": proxy, + "record_session": record_session, "save_credentials": save_credentials, }, connection_create_params.ConnectionCreateParams, @@ -661,6 +685,7 @@ async def update( health_check_interval: int | Omit = omit, login_url: str | Omit = omit, proxy: connection_update_params.Proxy | Omit = omit, + record_session: bool | Omit = omit, save_credentials: bool | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -691,6 +716,8 @@ async def update( proxy: Proxy selection. Provide either id or name. The proxy must belong to the caller's org. + record_session: Whether to record browser sessions for this connection by default + save_credentials: Whether to save credentials after every successful login extra_headers: Send extra headers @@ -712,6 +739,7 @@ async def update( "health_check_interval": health_check_interval, "login_url": login_url, "proxy": proxy, + "record_session": record_session, "save_credentials": save_credentials, }, connection_update_params.ConnectionUpdateParams, @@ -861,6 +889,7 @@ async def login( id: str, *, proxy: connection_login_params.Proxy | Omit = omit, + record_session: bool | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -878,6 +907,9 @@ async def login( proxy: Proxy selection. Provide either id or name. The proxy must belong to the caller's org. + record_session: Override the connection's default for recording this login's browser session. + When omitted, the connection's record_session default is used. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -890,7 +922,13 @@ async def login( raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._post( path_template("/auth/connections/{id}/login", id=id), - body=await async_maybe_transform({"proxy": proxy}, connection_login_params.ConnectionLoginParams), + body=await async_maybe_transform( + { + "proxy": proxy, + "record_session": record_session, + }, + connection_login_params.ConnectionLoginParams, + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/kernel/resources/browser_pools.py b/src/kernel/resources/browser_pools.py index 045737af..10b23923 100644 --- a/src/kernel/resources/browser_pools.py +++ b/src/kernel/resources/browser_pools.py @@ -68,6 +68,7 @@ def create( name: str | Omit = omit, profile: BrowserProfile | Omit = omit, proxy_id: str | Omit = omit, + start_url: str | Omit = omit, stealth: bool | Omit = omit, timeout_seconds: int | Omit = omit, viewport: BrowserViewport | Omit = omit, @@ -100,7 +101,7 @@ def create( kiosk_mode: If true, launches the browser in kiosk mode to hide address bar and tabs in live view. - name: Optional name for the browser pool. Must be unique within the organization. + name: Optional name for the browser pool. Must be unique within the project. profile: Profile selection for the browser session. Provide either id or name. If specified, the matching profile will be loaded into the browser session. @@ -109,6 +110,12 @@ def create( proxy_id: Optional proxy to associate to the browser session. Must reference a proxy belonging to the caller's org. + start_url: Optional URL to navigate to when a new browser is warmed into the pool. + Best-effort: failures to navigate do not fail pool fill. Only applied to + newly-warmed browsers — browsers reused via release/acquire keep whatever URL + the previous lease left them on. Accepts any URL Chromium can resolve, including + chrome:// pages. + stealth: If true, launches the browser in stealth mode to reduce detection by anti-bot mechanisms. @@ -149,6 +156,7 @@ def create( "name": name, "profile": profile, "proxy_id": proxy_id, + "start_url": start_url, "stealth": stealth, "timeout_seconds": timeout_seconds, "viewport": viewport, @@ -208,6 +216,7 @@ def update( name: str | Omit = omit, profile: BrowserProfile | Omit = omit, proxy_id: str | Omit = omit, + start_url: str | Omit = omit, stealth: bool | Omit = omit, timeout_seconds: int | Omit = omit, viewport: BrowserViewport | Omit = omit, @@ -243,7 +252,7 @@ def update( kiosk_mode: If true, launches the browser in kiosk mode to hide address bar and tabs in live view. - name: Optional name for the browser pool. Must be unique within the organization. + name: Optional name for the browser pool. Must be unique within the project. profile: Profile selection for the browser session. Provide either id or name. If specified, the matching profile will be loaded into the browser session. @@ -252,6 +261,12 @@ def update( proxy_id: Optional proxy to associate to the browser session. Must reference a proxy belonging to the caller's org. + start_url: Optional URL to navigate to when a new browser is warmed into the pool. + Best-effort: failures to navigate do not fail pool fill. Only applied to + newly-warmed browsers — browsers reused via release/acquire keep whatever URL + the previous lease left them on. Accepts any URL Chromium can resolve, including + chrome:// pages. + stealth: If true, launches the browser in stealth mode to reduce detection by anti-bot mechanisms. @@ -295,6 +310,7 @@ def update( "name": name, "profile": profile, "proxy_id": proxy_id, + "start_url": start_url, "stealth": stealth, "timeout_seconds": timeout_seconds, "viewport": viewport, @@ -530,6 +546,7 @@ async def create( name: str | Omit = omit, profile: BrowserProfile | Omit = omit, proxy_id: str | Omit = omit, + start_url: str | Omit = omit, stealth: bool | Omit = omit, timeout_seconds: int | Omit = omit, viewport: BrowserViewport | Omit = omit, @@ -562,7 +579,7 @@ async def create( kiosk_mode: If true, launches the browser in kiosk mode to hide address bar and tabs in live view. - name: Optional name for the browser pool. Must be unique within the organization. + name: Optional name for the browser pool. Must be unique within the project. profile: Profile selection for the browser session. Provide either id or name. If specified, the matching profile will be loaded into the browser session. @@ -571,6 +588,12 @@ async def create( proxy_id: Optional proxy to associate to the browser session. Must reference a proxy belonging to the caller's org. + start_url: Optional URL to navigate to when a new browser is warmed into the pool. + Best-effort: failures to navigate do not fail pool fill. Only applied to + newly-warmed browsers — browsers reused via release/acquire keep whatever URL + the previous lease left them on. Accepts any URL Chromium can resolve, including + chrome:// pages. + stealth: If true, launches the browser in stealth mode to reduce detection by anti-bot mechanisms. @@ -611,6 +634,7 @@ async def create( "name": name, "profile": profile, "proxy_id": proxy_id, + "start_url": start_url, "stealth": stealth, "timeout_seconds": timeout_seconds, "viewport": viewport, @@ -670,6 +694,7 @@ async def update( name: str | Omit = omit, profile: BrowserProfile | Omit = omit, proxy_id: str | Omit = omit, + start_url: str | Omit = omit, stealth: bool | Omit = omit, timeout_seconds: int | Omit = omit, viewport: BrowserViewport | Omit = omit, @@ -705,7 +730,7 @@ async def update( kiosk_mode: If true, launches the browser in kiosk mode to hide address bar and tabs in live view. - name: Optional name for the browser pool. Must be unique within the organization. + name: Optional name for the browser pool. Must be unique within the project. profile: Profile selection for the browser session. Provide either id or name. If specified, the matching profile will be loaded into the browser session. @@ -714,6 +739,12 @@ async def update( proxy_id: Optional proxy to associate to the browser session. Must reference a proxy belonging to the caller's org. + start_url: Optional URL to navigate to when a new browser is warmed into the pool. + Best-effort: failures to navigate do not fail pool fill. Only applied to + newly-warmed browsers — browsers reused via release/acquire keep whatever URL + the previous lease left them on. Accepts any URL Chromium can resolve, including + chrome:// pages. + stealth: If true, launches the browser in stealth mode to reduce detection by anti-bot mechanisms. @@ -757,6 +788,7 @@ async def update( "name": name, "profile": profile, "proxy_id": proxy_id, + "start_url": start_url, "stealth": stealth, "timeout_seconds": timeout_seconds, "viewport": viewport, diff --git a/src/kernel/resources/browsers/browsers.py b/src/kernel/resources/browsers/browsers.py index f6a23729..d1dba086 100644 --- a/src/kernel/resources/browsers/browsers.py +++ b/src/kernel/resources/browsers/browsers.py @@ -160,6 +160,7 @@ def create( persistence: BrowserPersistenceParam | Omit = omit, profile: BrowserProfile | Omit = omit, proxy_id: str | Omit = omit, + start_url: str | Omit = omit, stealth: bool | Omit = omit, timeout_seconds: int | Omit = omit, viewport: BrowserViewport | Omit = omit, @@ -196,6 +197,12 @@ def create( proxy_id: Optional proxy to associate to the browser session. Must reference a proxy belonging to the caller's org. + start_url: Optional URL to navigate to immediately after the browser is created. + Best-effort: failures to navigate do not fail browser creation. Any pre-existing + tabs are reduced to a single tab which is then navigated. Accepts any URL + Chromium can resolve, including chrome:// pages. Ignored when reusing an + existing persistent session. + stealth: If true, launches the browser in stealth mode to reduce detection by anti-bot mechanisms. @@ -238,6 +245,7 @@ def create( "persistence": persistence, "profile": profile, "proxy_id": proxy_id, + "start_url": start_url, "stealth": stealth, "timeout_seconds": timeout_seconds, "viewport": viewport, @@ -718,6 +726,7 @@ async def create( persistence: BrowserPersistenceParam | Omit = omit, profile: BrowserProfile | Omit = omit, proxy_id: str | Omit = omit, + start_url: str | Omit = omit, stealth: bool | Omit = omit, timeout_seconds: int | Omit = omit, viewport: BrowserViewport | Omit = omit, @@ -754,6 +763,12 @@ async def create( proxy_id: Optional proxy to associate to the browser session. Must reference a proxy belonging to the caller's org. + start_url: Optional URL to navigate to immediately after the browser is created. + Best-effort: failures to navigate do not fail browser creation. Any pre-existing + tabs are reduced to a single tab which is then navigated. Accepts any URL + Chromium can resolve, including chrome:// pages. Ignored when reusing an + existing persistent session. + stealth: If true, launches the browser in stealth mode to reduce detection by anti-bot mechanisms. @@ -796,6 +811,7 @@ async def create( "persistence": persistence, "profile": profile, "proxy_id": proxy_id, + "start_url": start_url, "stealth": stealth, "timeout_seconds": timeout_seconds, "viewport": viewport, diff --git a/src/kernel/resources/credentials.py b/src/kernel/resources/credentials.py index 093fcc5d..7d00faab 100644 --- a/src/kernel/resources/credentials.py +++ b/src/kernel/resources/credentials.py @@ -68,7 +68,7 @@ def create( Args: domain: Target domain this credential is for - name: Unique name for the credential within the organization + name: Unique name for the credential within the project values: Field name to value mapping (e.g., username, password) @@ -365,7 +365,7 @@ async def create( Args: domain: Target domain this credential is for - name: Unique name for the credential within the organization + name: Unique name for the credential within the project values: Field name to value mapping (e.g., username, password) diff --git a/src/kernel/resources/extensions.py b/src/kernel/resources/extensions.py index 9ea6e0be..d5cf0eb7 100644 --- a/src/kernel/resources/extensions.py +++ b/src/kernel/resources/extensions.py @@ -211,7 +211,7 @@ def upload( Args: file: ZIP file containing the browser extension. - name: Optional unique name within the organization to reference this extension. + name: Optional unique name within the project to reference this extension. extra_headers: Send extra headers @@ -421,7 +421,7 @@ async def upload( Args: file: ZIP file containing the browser extension. - name: Optional unique name within the organization to reference this extension. + name: Optional unique name within the project to reference this extension. extra_headers: Send extra headers diff --git a/src/kernel/resources/profiles.py b/src/kernel/resources/profiles.py index c20ffb7d..4083f12d 100644 --- a/src/kernel/resources/profiles.py +++ b/src/kernel/resources/profiles.py @@ -68,7 +68,7 @@ def create( sessions. Args: - name: Optional name of the profile. Must be unique within the organization. + name: Optional name of the profile. Must be unique within the project. extra_headers: Send extra headers @@ -278,7 +278,7 @@ async def create( sessions. Args: - name: Optional name of the profile. Must be unique within the organization. + name: Optional name of the profile. Must be unique within the project. extra_headers: Send extra headers diff --git a/src/kernel/resources/projects/projects.py b/src/kernel/resources/projects/projects.py index 9a7f13b2..f73e70d7 100644 --- a/src/kernel/resources/projects/projects.py +++ b/src/kernel/resources/projects/projects.py @@ -179,6 +179,7 @@ def list( *, limit: int | Omit = omit, offset: int | Omit = omit, + query: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -194,6 +195,8 @@ def list( offset: Number of results to skip + query: Case-insensitive substring match against project name + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -214,6 +217,7 @@ def list( { "limit": limit, "offset": offset, + "query": query, }, project_list_params.ProjectListParams, ), @@ -404,6 +408,7 @@ def list( *, limit: int | Omit = omit, offset: int | Omit = omit, + query: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -419,6 +424,8 @@ def list( offset: Number of results to skip + query: Case-insensitive substring match against project name + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -439,6 +446,7 @@ def list( { "limit": limit, "offset": offset, + "query": query, }, project_list_params.ProjectListParams, ), diff --git a/src/kernel/types/auth/connection_create_params.py b/src/kernel/types/auth/connection_create_params.py index b021994c..bdd22681 100644 --- a/src/kernel/types/auth/connection_create_params.py +++ b/src/kernel/types/auth/connection_create_params.py @@ -66,6 +66,12 @@ class ConnectionCreateParams(TypedDict, total=False): Provide either id or name. The proxy must belong to the caller's org. """ + record_session: bool + """Whether to record browser sessions for this connection by default. + + Useful for debugging. Can be overridden per-login. Defaults to false. + """ + save_credentials: bool """Whether to save credentials after every successful login. diff --git a/src/kernel/types/auth/connection_follow_response.py b/src/kernel/types/auth/connection_follow_response.py index a9bda35d..1cd1a141 100644 --- a/src/kernel/types/auth/connection_follow_response.py +++ b/src/kernel/types/auth/connection_follow_response.py @@ -40,7 +40,7 @@ class ManagedAuthStateEventDiscoveredField(BaseModel): "Enter the phone ending in (**_) _**-\\**\\**92") """ - linked_mfa_type: Optional[Literal["sms", "call", "email", "totp", "push", "password"]] = None + linked_mfa_type: Optional[Literal["sms", "call", "email", "totp", "push", "password", "switch"]] = None """ If this field is associated with an MFA option, the type of that option (e.g., password field linked to "Enter password" option) @@ -59,9 +59,12 @@ class ManagedAuthStateEventMfaOption(BaseModel): label: str """The visible option text""" - type: Literal["sms", "call", "email", "totp", "push", "password"] - """ - The MFA delivery method type (includes password for auth method selection pages) + type: Literal["sms", "call", "email", "totp", "push", "password", "switch"] + """The MFA delivery method type. + + Includes 'password' for auth method selection pages and 'switch' for generic + method-switcher links like "Use another method" that do not name a specific + method. """ description: Optional[str] = None @@ -116,7 +119,10 @@ class ManagedAuthStateEvent(BaseModel): """Time the state was reported.""" discovered_fields: Optional[List[ManagedAuthStateEventDiscoveredField]] = None - """Fields awaiting input (present when flow_step=AWAITING_INPUT).""" + """ + Fields awaiting input (present when flow_step=AWAITING_INPUT; may also be + present with AWAITING_EXTERNAL_ACTION as fallback actions). + """ error_code: Optional[str] = None """Machine-readable error code (present when flow_status=FAILED).""" @@ -141,12 +147,15 @@ class ManagedAuthStateEvent(BaseModel): mfa_options: Optional[List[ManagedAuthStateEventMfaOption]] = None """ - MFA method options (present when flow_step=AWAITING_INPUT and MFA selection - required). + MFA method options (present when flow_step=AWAITING_INPUT; may also be present + with AWAITING_EXTERNAL_ACTION as fallback actions). """ pending_sso_buttons: Optional[List[ManagedAuthStateEventPendingSSOButton]] = None - """SSO buttons available (present when flow_step=AWAITING_INPUT).""" + """ + SSO buttons available (present when flow_step=AWAITING_INPUT; may also be + present with AWAITING_EXTERNAL_ACTION as fallback actions). + """ post_login_url: Optional[str] = None """URL where the browser landed after successful login.""" @@ -154,7 +163,8 @@ class ManagedAuthStateEvent(BaseModel): sign_in_options: Optional[List[ManagedAuthStateEventSignInOption]] = None """ Non-MFA choices presented during the auth flow, such as account selection or org - pickers (present when flow_step=AWAITING_INPUT). + pickers (present when flow_step=AWAITING_INPUT; may also be present with + AWAITING_EXTERNAL_ACTION as fallback actions). """ website_error: Optional[str] = None diff --git a/src/kernel/types/auth/connection_login_params.py b/src/kernel/types/auth/connection_login_params.py index 8ea6474c..39756cb1 100644 --- a/src/kernel/types/auth/connection_login_params.py +++ b/src/kernel/types/auth/connection_login_params.py @@ -14,6 +14,12 @@ class ConnectionLoginParams(TypedDict, total=False): Provide either id or name. The proxy must belong to the caller's org. """ + record_session: bool + """Override the connection's default for recording this login's browser session. + + When omitted, the connection's record_session default is used. + """ + class Proxy(TypedDict, total=False): """Proxy selection. diff --git a/src/kernel/types/auth/connection_update_params.py b/src/kernel/types/auth/connection_update_params.py index 77e738b7..23832778 100644 --- a/src/kernel/types/auth/connection_update_params.py +++ b/src/kernel/types/auth/connection_update_params.py @@ -33,6 +33,9 @@ class ConnectionUpdateParams(TypedDict, total=False): Provide either id or name. The proxy must belong to the caller's org. """ + record_session: bool + """Whether to record browser sessions for this connection by default""" + save_credentials: bool """Whether to save credentials after every successful login""" diff --git a/src/kernel/types/auth/managed_auth.py b/src/kernel/types/auth/managed_auth.py index 59b2d576..09f89deb 100644 --- a/src/kernel/types/auth/managed_auth.py +++ b/src/kernel/types/auth/managed_auth.py @@ -52,7 +52,7 @@ class DiscoveredField(BaseModel): "Enter the phone ending in (**_) _**-\\**\\**92") """ - linked_mfa_type: Optional[Literal["sms", "call", "email", "totp", "push", "password"]] = None + linked_mfa_type: Optional[Literal["sms", "call", "email", "totp", "push", "password", "switch"]] = None """ If this field is associated with an MFA option, the type of that option (e.g., password field linked to "Enter password" option) @@ -71,9 +71,12 @@ class MfaOption(BaseModel): label: str """The visible option text""" - type: Literal["sms", "call", "email", "totp", "push", "password"] - """ - The MFA delivery method type (includes password for auth method selection pages) + type: Literal["sms", "call", "email", "totp", "push", "password", "switch"] + """The MFA delivery method type. + + Includes 'password' for auth method selection pages and 'switch' for generic + method-switcher links like "Use another method" that do not name a specific + method. """ description: Optional[str] = None @@ -127,6 +130,12 @@ class ManagedAuth(BaseModel): profile_name: str """Name of the profile associated with this auth connection""" + record_session: bool + """ + Whether browser sessions for this connection are recorded by default for + debugging. Can be overridden per-login. + """ + save_credentials: bool """Whether credentials are saved after every successful login. @@ -182,7 +191,10 @@ class ManagedAuth(BaseModel): """ discovered_fields: Optional[List[DiscoveredField]] = None - """Fields awaiting input (present when flow_step=awaiting_input)""" + """ + Fields awaiting input (present when flow_step=awaiting_input; may also be + present with awaiting_external_action as fallback actions) + """ error_code: Optional[str] = None """Machine-readable error code (present when flow_status=failed)""" @@ -251,12 +263,15 @@ class ManagedAuth(BaseModel): mfa_options: Optional[List[MfaOption]] = None """ - MFA method options (present when flow_step=awaiting_input and MFA selection - required) + MFA method options (present when flow_step=awaiting_input; may also be present + with awaiting_external_action as fallback actions) """ pending_sso_buttons: Optional[List[PendingSSOButton]] = None - """SSO buttons available (present when flow_step=awaiting_input)""" + """ + SSO buttons available (present when flow_step=awaiting_input; may also be + present with awaiting_external_action as fallback actions) + """ post_login_url: Optional[str] = None """URL where the browser landed after successful login""" @@ -267,7 +282,8 @@ class ManagedAuth(BaseModel): sign_in_options: Optional[List[SignInOption]] = None """ Non-MFA choices presented during the auth flow, such as account selection or org - pickers (present when flow_step=awaiting_input). + pickers (present when flow_step=awaiting_input; may also be present with + awaiting_external_action as fallback actions). """ sso_provider: Optional[str] = None diff --git a/src/kernel/types/browser_create_params.py b/src/kernel/types/browser_create_params.py index 2827b1dc..1a4493f0 100644 --- a/src/kernel/types/browser_create_params.py +++ b/src/kernel/types/browser_create_params.py @@ -57,6 +57,15 @@ class BrowserCreateParams(TypedDict, total=False): Must reference a proxy belonging to the caller's org. """ + start_url: str + """Optional URL to navigate to immediately after the browser is created. + + Best-effort: failures to navigate do not fail browser creation. Any pre-existing + tabs are reduced to a single tab which is then navigated. Accepts any URL + Chromium can resolve, including chrome:// pages. Ignored when reusing an + existing persistent session. + """ + stealth: bool """ If true, launches the browser in stealth mode to reduce detection by anti-bot diff --git a/src/kernel/types/browser_create_response.py b/src/kernel/types/browser_create_response.py index 9356bb05..e63a6896 100644 --- a/src/kernel/types/browser_create_response.py +++ b/src/kernel/types/browser_create_response.py @@ -68,6 +68,12 @@ class BrowserCreateResponse(BaseModel): proxy_id: Optional[str] = None """ID of the proxy associated with this browser session, if any.""" + start_url: Optional[str] = None + """URL the session was asked to navigate to on creation, if any. + + Recorded for debugging — navigation is best-effort and may have failed. + """ + usage: Optional[BrowserUsage] = None """Session usage metrics.""" diff --git a/src/kernel/types/browser_list_response.py b/src/kernel/types/browser_list_response.py index f3a88f29..de37e26f 100644 --- a/src/kernel/types/browser_list_response.py +++ b/src/kernel/types/browser_list_response.py @@ -68,6 +68,12 @@ class BrowserListResponse(BaseModel): proxy_id: Optional[str] = None """ID of the proxy associated with this browser session, if any.""" + start_url: Optional[str] = None + """URL the session was asked to navigate to on creation, if any. + + Recorded for debugging — navigation is best-effort and may have failed. + """ + usage: Optional[BrowserUsage] = None """Session usage metrics.""" diff --git a/src/kernel/types/browser_pool.py b/src/kernel/types/browser_pool.py index 8ca0dc43..a691d0eb 100644 --- a/src/kernel/types/browser_pool.py +++ b/src/kernel/types/browser_pool.py @@ -48,7 +48,7 @@ class BrowserPoolConfig(BaseModel): """ name: Optional[str] = None - """Optional name for the browser pool. Must be unique within the organization.""" + """Optional name for the browser pool. Must be unique within the project.""" profile: Optional[BrowserProfile] = None """Profile selection for the browser session. @@ -63,6 +63,15 @@ class BrowserPoolConfig(BaseModel): Must reference a proxy belonging to the caller's org. """ + start_url: Optional[str] = None + """Optional URL to navigate to when a new browser is warmed into the pool. + + Best-effort: failures to navigate do not fail pool fill. Only applied to + newly-warmed browsers — browsers reused via release/acquire keep whatever URL + the previous lease left them on. Accepts any URL Chromium can resolve, including + chrome:// pages. + """ + stealth: Optional[bool] = None """ If true, launches the browser in stealth mode to reduce detection by anti-bot diff --git a/src/kernel/types/browser_pool_acquire_response.py b/src/kernel/types/browser_pool_acquire_response.py index 064c405d..cff8286d 100644 --- a/src/kernel/types/browser_pool_acquire_response.py +++ b/src/kernel/types/browser_pool_acquire_response.py @@ -68,6 +68,12 @@ class BrowserPoolAcquireResponse(BaseModel): proxy_id: Optional[str] = None """ID of the proxy associated with this browser session, if any.""" + start_url: Optional[str] = None + """URL the session was asked to navigate to on creation, if any. + + Recorded for debugging — navigation is best-effort and may have failed. + """ + usage: Optional[BrowserUsage] = None """Session usage metrics.""" diff --git a/src/kernel/types/browser_pool_create_params.py b/src/kernel/types/browser_pool_create_params.py index ecfb8881..99d6c9cd 100644 --- a/src/kernel/types/browser_pool_create_params.py +++ b/src/kernel/types/browser_pool_create_params.py @@ -47,7 +47,7 @@ class BrowserPoolCreateParams(TypedDict, total=False): """ name: str - """Optional name for the browser pool. Must be unique within the organization.""" + """Optional name for the browser pool. Must be unique within the project.""" profile: BrowserProfile """Profile selection for the browser session. @@ -62,6 +62,15 @@ class BrowserPoolCreateParams(TypedDict, total=False): Must reference a proxy belonging to the caller's org. """ + start_url: str + """Optional URL to navigate to when a new browser is warmed into the pool. + + Best-effort: failures to navigate do not fail pool fill. Only applied to + newly-warmed browsers — browsers reused via release/acquire keep whatever URL + the previous lease left them on. Accepts any URL Chromium can resolve, including + chrome:// pages. + """ + stealth: bool """ If true, launches the browser in stealth mode to reduce detection by anti-bot diff --git a/src/kernel/types/browser_pool_update_params.py b/src/kernel/types/browser_pool_update_params.py index e34664a4..5b069c4c 100644 --- a/src/kernel/types/browser_pool_update_params.py +++ b/src/kernel/types/browser_pool_update_params.py @@ -53,7 +53,7 @@ class BrowserPoolUpdateParams(TypedDict, total=False): """ name: str - """Optional name for the browser pool. Must be unique within the organization.""" + """Optional name for the browser pool. Must be unique within the project.""" profile: BrowserProfile """Profile selection for the browser session. @@ -68,6 +68,15 @@ class BrowserPoolUpdateParams(TypedDict, total=False): Must reference a proxy belonging to the caller's org. """ + start_url: str + """Optional URL to navigate to when a new browser is warmed into the pool. + + Best-effort: failures to navigate do not fail pool fill. Only applied to + newly-warmed browsers — browsers reused via release/acquire keep whatever URL + the previous lease left them on. Accepts any URL Chromium can resolve, including + chrome:// pages. + """ + stealth: bool """ If true, launches the browser in stealth mode to reduce detection by anti-bot diff --git a/src/kernel/types/browser_retrieve_response.py b/src/kernel/types/browser_retrieve_response.py index 5b5a8913..80a96d36 100644 --- a/src/kernel/types/browser_retrieve_response.py +++ b/src/kernel/types/browser_retrieve_response.py @@ -68,6 +68,12 @@ class BrowserRetrieveResponse(BaseModel): proxy_id: Optional[str] = None """ID of the proxy associated with this browser session, if any.""" + start_url: Optional[str] = None + """URL the session was asked to navigate to on creation, if any. + + Recorded for debugging — navigation is best-effort and may have failed. + """ + usage: Optional[BrowserUsage] = None """Session usage metrics.""" diff --git a/src/kernel/types/browser_update_response.py b/src/kernel/types/browser_update_response.py index 188895ad..4d1f61bc 100644 --- a/src/kernel/types/browser_update_response.py +++ b/src/kernel/types/browser_update_response.py @@ -68,6 +68,12 @@ class BrowserUpdateResponse(BaseModel): proxy_id: Optional[str] = None """ID of the proxy associated with this browser session, if any.""" + start_url: Optional[str] = None + """URL the session was asked to navigate to on creation, if any. + + Recorded for debugging — navigation is best-effort and may have failed. + """ + usage: Optional[BrowserUsage] = None """Session usage metrics.""" diff --git a/src/kernel/types/credential.py b/src/kernel/types/credential.py index bbf2af5e..323617c5 100644 --- a/src/kernel/types/credential.py +++ b/src/kernel/types/credential.py @@ -21,7 +21,7 @@ class Credential(BaseModel): """Target domain this credential is for""" name: str - """Unique name for the credential within the organization""" + """Unique name for the credential within the project""" updated_at: datetime """When the credential was last updated""" diff --git a/src/kernel/types/credential_create_params.py b/src/kernel/types/credential_create_params.py index 94964b9d..9d306844 100644 --- a/src/kernel/types/credential_create_params.py +++ b/src/kernel/types/credential_create_params.py @@ -13,7 +13,7 @@ class CredentialCreateParams(TypedDict, total=False): """Target domain this credential is for""" name: Required[str] - """Unique name for the credential within the organization""" + """Unique name for the credential within the project""" values: Required[Dict[str, str]] """Field name to value mapping (e.g., username, password)""" diff --git a/src/kernel/types/extension_list_response.py b/src/kernel/types/extension_list_response.py index 79a5c991..bf9e544d 100644 --- a/src/kernel/types/extension_list_response.py +++ b/src/kernel/types/extension_list_response.py @@ -27,7 +27,7 @@ class ExtensionListResponseItem(BaseModel): name: Optional[str] = None """Optional, easier-to-reference name for the extension. - Must be unique within the organization. + Must be unique within the project. """ diff --git a/src/kernel/types/extension_upload_params.py b/src/kernel/types/extension_upload_params.py index d36dde31..ab44d3eb 100644 --- a/src/kernel/types/extension_upload_params.py +++ b/src/kernel/types/extension_upload_params.py @@ -14,4 +14,4 @@ class ExtensionUploadParams(TypedDict, total=False): """ZIP file containing the browser extension.""" name: str - """Optional unique name within the organization to reference this extension.""" + """Optional unique name within the project to reference this extension.""" diff --git a/src/kernel/types/extension_upload_response.py b/src/kernel/types/extension_upload_response.py index 1b3be221..068b25dd 100644 --- a/src/kernel/types/extension_upload_response.py +++ b/src/kernel/types/extension_upload_response.py @@ -26,5 +26,5 @@ class ExtensionUploadResponse(BaseModel): name: Optional[str] = None """Optional, easier-to-reference name for the extension. - Must be unique within the organization. + Must be unique within the project. """ diff --git a/src/kernel/types/invocation_list_browsers_response.py b/src/kernel/types/invocation_list_browsers_response.py index 23eda779..71c22a7a 100644 --- a/src/kernel/types/invocation_list_browsers_response.py +++ b/src/kernel/types/invocation_list_browsers_response.py @@ -68,6 +68,12 @@ class Browser(BaseModel): proxy_id: Optional[str] = None """ID of the proxy associated with this browser session, if any.""" + start_url: Optional[str] = None + """URL the session was asked to navigate to on creation, if any. + + Recorded for debugging — navigation is best-effort and may have failed. + """ + usage: Optional[BrowserUsage] = None """Session usage metrics.""" diff --git a/src/kernel/types/profile_create_params.py b/src/kernel/types/profile_create_params.py index 0b2b12ae..bca1e17d 100644 --- a/src/kernel/types/profile_create_params.py +++ b/src/kernel/types/profile_create_params.py @@ -9,4 +9,4 @@ class ProfileCreateParams(TypedDict, total=False): name: str - """Optional name of the profile. Must be unique within the organization.""" + """Optional name of the profile. Must be unique within the project.""" diff --git a/src/kernel/types/project_list_params.py b/src/kernel/types/project_list_params.py index ea10f073..9e740e60 100644 --- a/src/kernel/types/project_list_params.py +++ b/src/kernel/types/project_list_params.py @@ -13,3 +13,6 @@ class ProjectListParams(TypedDict, total=False): offset: int """Number of results to skip""" + + query: str + """Case-insensitive substring match against project name""" diff --git a/tests/api_resources/auth/test_connections.py b/tests/api_resources/auth/test_connections.py index f5edd89d..e3da167f 100644 --- a/tests/api_resources/auth/test_connections.py +++ b/tests/api_resources/auth/test_connections.py @@ -50,6 +50,7 @@ def test_method_create_with_all_params(self, client: Kernel) -> None: "id": "id", "name": "name", }, + record_session=False, save_credentials=True, ) assert_matches_type(ManagedAuth, connection, path=["response"]) @@ -150,6 +151,7 @@ def test_method_update_with_all_params(self, client: Kernel) -> None: "id": "id", "name": "name", }, + record_session=False, save_credentials=True, ) assert_matches_type(ManagedAuth, connection, path=["response"]) @@ -327,6 +329,7 @@ def test_method_login_with_all_params(self, client: Kernel) -> None: "id": "id", "name": "name", }, + record_session=True, ) assert_matches_type(LoginResponse, connection, path=["response"]) @@ -456,6 +459,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncKernel) -> "id": "id", "name": "name", }, + record_session=False, save_credentials=True, ) assert_matches_type(ManagedAuth, connection, path=["response"]) @@ -556,6 +560,7 @@ async def test_method_update_with_all_params(self, async_client: AsyncKernel) -> "id": "id", "name": "name", }, + record_session=False, save_credentials=True, ) assert_matches_type(ManagedAuth, connection, path=["response"]) @@ -733,6 +738,7 @@ async def test_method_login_with_all_params(self, async_client: AsyncKernel) -> "id": "id", "name": "name", }, + record_session=True, ) assert_matches_type(LoginResponse, connection, path=["response"]) diff --git a/tests/api_resources/test_browser_pools.py b/tests/api_resources/test_browser_pools.py index 70c7ad85..959eeef2 100644 --- a/tests/api_resources/test_browser_pools.py +++ b/tests/api_resources/test_browser_pools.py @@ -51,6 +51,7 @@ def test_method_create_with_all_params(self, client: Kernel) -> None: "save_changes": True, }, proxy_id="proxy_id", + start_url="https://example.com", stealth=True, timeout_seconds=60, viewport={ @@ -162,6 +163,7 @@ def test_method_update_with_all_params(self, client: Kernel) -> None: "save_changes": True, }, proxy_id="proxy_id", + start_url="https://example.com", stealth=True, timeout_seconds=60, viewport={ @@ -473,6 +475,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncKernel) -> "save_changes": True, }, proxy_id="proxy_id", + start_url="https://example.com", stealth=True, timeout_seconds=60, viewport={ @@ -584,6 +587,7 @@ async def test_method_update_with_all_params(self, async_client: AsyncKernel) -> "save_changes": True, }, proxy_id="proxy_id", + start_url="https://example.com", stealth=True, timeout_seconds=60, viewport={ diff --git a/tests/api_resources/test_browsers.py b/tests/api_resources/test_browsers.py index 96742c72..94c52654 100644 --- a/tests/api_resources/test_browsers.py +++ b/tests/api_resources/test_browsers.py @@ -53,6 +53,7 @@ def test_method_create_with_all_params(self, client: Kernel) -> None: "save_changes": True, }, proxy_id="proxy_id", + start_url="https://example.com", stealth=True, timeout_seconds=10, viewport={ @@ -478,6 +479,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncKernel) -> "save_changes": True, }, proxy_id="proxy_id", + start_url="https://example.com", stealth=True, timeout_seconds=10, viewport={ diff --git a/tests/api_resources/test_projects.py b/tests/api_resources/test_projects.py index 488c191d..bb301454 100644 --- a/tests/api_resources/test_projects.py +++ b/tests/api_resources/test_projects.py @@ -158,6 +158,7 @@ def test_method_list_with_all_params(self, client: Kernel) -> None: project = client.projects.list( limit=100, offset=0, + query="query", ) assert_matches_type(SyncOffsetPagination[Project], project, path=["response"]) @@ -371,6 +372,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> N project = await async_client.projects.list( limit=100, offset=0, + query="query", ) assert_matches_type(AsyncOffsetPagination[Project], project, path=["response"]) diff --git a/tests/test_models.py b/tests/test_models.py index 78f0fd32..c3922db2 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,7 +1,8 @@ import json -from typing import TYPE_CHECKING, Any, Dict, List, Union, Optional, cast +from typing import TYPE_CHECKING, Any, Dict, List, Union, Iterable, Optional, cast from datetime import datetime, timezone -from typing_extensions import Literal, Annotated, TypeAliasType +from collections import deque +from typing_extensions import Literal, Annotated, TypedDict, TypeAliasType import pytest import pydantic @@ -9,7 +10,7 @@ from kernel._utils import PropertyInfo from kernel._compat import PYDANTIC_V1, parse_obj, model_dump, model_json -from kernel._models import DISCRIMINATOR_CACHE, BaseModel, construct_type +from kernel._models import DISCRIMINATOR_CACHE, BaseModel, EagerIterable, construct_type class BasicModel(BaseModel): @@ -961,3 +962,56 @@ def __getattr__(self, attr: str) -> Item: ... assert model.a.prop == 1 assert isinstance(model.a, Item) assert model.other == "foo" + + +# NOTE: Workaround for Pydantic Iterable behavior. +# Iterable fields are replaced with a ValidatorIterator and may be consumed +# during serialization, which can cause subsequent dumps to return empty data. +# See: https://github.com/pydantic/pydantic/issues/9541 +@pytest.mark.parametrize( + "data, expected_validated", + [ + ([1, 2, 3], [1, 2, 3]), + ((1, 2, 3), (1, 2, 3)), + (set([1, 2, 3]), set([1, 2, 3])), + (iter([1, 2, 3]), [1, 2, 3]), + ([], []), + ((x for x in [1, 2, 3]), [1, 2, 3]), + (map(lambda x: x, [1, 2, 3]), [1, 2, 3]), + (frozenset([1, 2, 3]), frozenset([1, 2, 3])), + (deque([1, 2, 3]), deque([1, 2, 3])), + ], + ids=["list", "tuple", "set", "iterator", "empty", "generator", "map", "frozenset", "deque"], +) +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2") +def test_iterable_construction(data: Iterable[int], expected_validated: Iterable[int]) -> None: + class TypeWithIterable(TypedDict): + items: EagerIterable[int] + + class Model(BaseModel): + data: TypeWithIterable + + m = Model.model_validate({"data": {"items": data}}) + assert m.data["items"] == expected_validated + + # Verify repeated dumps don't lose data (the original bug) + assert m.model_dump()["data"]["items"] == list(expected_validated) + assert m.model_dump()["data"]["items"] == list(expected_validated) + + +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2") +def test_iterable_construction_str_falls_back_to_list() -> None: + # str is iterable (over chars), but str(list_of_chars) produces the list's repr + # rather than reconstructing a string from items. We special-case str to fall + # back to list instead of attempting reconstruction. + class TypeWithIterable(TypedDict): + items: EagerIterable[str] + + class Model(BaseModel): + data: TypeWithIterable + + m = Model.model_validate({"data": {"items": "hello"}}) + + # falls back to list of chars rather than calling str(["h", "e", "l", "l", "o"]) + assert m.data["items"] == ["h", "e", "l", "l", "o"] + assert m.model_dump()["data"]["items"] == ["h", "e", "l", "l", "o"]