From d40af90a5e024d86344ad855629d39041582281a Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 12 Oct 2026 09:04:39 +0200 Subject: [PATCH 1/2] fix: resolve type errors reported by ty 0.0.86 --- docs/04_upgrading/upgrading_to_v4.md | 2 +- pyproject.toml | 1 + src/apify/_charging.py | 158 ++---------------- src/apify/_configuration.py | 29 +++- src/apify/_utils.py | 9 +- tests/unit/actor/test_charging_manager.py | 40 ++++- tests/unit/actor/test_configuration.py | 18 +- tests/unit/events/test_apify_event_manager.py | 4 +- uv.lock | 44 ++--- 9 files changed, 113 insertions(+), 192 deletions(-) diff --git a/docs/04_upgrading/upgrading_to_v4.md b/docs/04_upgrading/upgrading_to_v4.md index 8251283ae..f7c774885 100644 --- a/docs/04_upgrading/upgrading_to_v4.md +++ b/docs/04_upgrading/upgrading_to_v4.md @@ -151,7 +151,7 @@ Actor.on(Event.SYSTEM_INFO, callback) ### Actor pricing info models -The Actor pricing-info models exposed through `Actor.configuration.actor_pricing_info` — `FreeActorPricingInfo`, `FlatPricePerMonthActorPricingInfo`, `PricePerDatasetItemActorPricingInfo`, `PayPerEventActorPricingInfo`, and the nested `ActorChargeEvent` / `PricingPerEvent` — are now thin subclasses of the corresponding `apify-client` models instead of standalone SDK copies. The discriminated-union shape is unchanged, so existing access (`pricing_model`, per-event titles and prices) keeps working; the models now expose the full `apify-client` field set, and a charge event's `event_price_usd` is optional (it is unset for tier-priced events). `ChargingManager.get_pricing_info()` is unchanged. +The Actor pricing-info models exposed through `Actor.configuration.actor_pricing_info` — `FreeActorPricingInfo`, `FlatPricePerMonthActorPricingInfo`, `PricePerDatasetItemActorPricingInfo`, `PayPerEventActorPricingInfo`, and the nested `ActorChargeEvent` / `PricingPerEvent` — are now the corresponding `apify-client` models instead of standalone SDK copies. The platform leaves `eventDescription` out of the env var, so `event_description` is an empty string for events parsed from it. The discriminated-union shape is unchanged, so existing access (`pricing_model`, per-event titles and prices) keeps working; the models now expose the full `apify-client` field set, and a charge event's `event_price_usd` is optional (it is unset for tier-priced events). `ChargingManager.get_pricing_info()` is unchanged. ### Webhook API simplified diff --git a/pyproject.toml b/pyproject.toml index 62a79273e..bfb018bbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "lazy-object-proxy>=1.11.0", "more_itertools>=10.2.0", "pydantic>=2.11.0", + "pydantic-settings>=2.12.0", "typing-extensions>=4.4.0", "websockets>=14.0", "yarl>=1.18.0", diff --git a/src/apify/_charging.py b/src/apify/_charging.py index aebc1c1f5..7300ec216 100644 --- a/src/apify/_charging.py +++ b/src/apify/_charging.py @@ -10,22 +10,19 @@ from typing import TYPE_CHECKING, Literal, Protocol, TypedDict from cachetools import TTLCache -from pydantic import ConfigDict -from pydantic.alias_generators import to_camel -import apify_client._models as _client_models -from apify_client._models import ActorChargeEvent as ClientActorChargeEvent -from apify_client._models import FlatPricePerMonthActorPricingInfo as ClientFlatPricePerMonth -from apify_client._models import FreeActorPricingInfo as ClientFree -from apify_client._models import PayPerEventActorPricingInfo as ClientPayPerEvent -from apify_client._models import PricePerDatasetItemActorPricingInfo as ClientPricePerDatasetItem -from apify_client._models import PricingPerEvent as ClientPricingPerEvent +from apify_client._models import ( + FlatPricePerMonthActorPricingInfo, + FreeActorPricingInfo, + PayPerEventActorPricingInfo, + PricePerDatasetItemActorPricingInfo, +) from apify._utils import ReentrantLock, docs_group, ensure_context from apify.storages import Dataset if TYPE_CHECKING: - from collections.abc import AsyncIterator + from collections.abc import AsyncGenerator from types import TracebackType from apify_client import ApifyClientAsync @@ -50,7 +47,7 @@ @asynccontextmanager -async def charge_lock_if_charging() -> AsyncIterator[None]: +async def charge_lock_if_charging() -> AsyncGenerator[None]: """Acquire the charge lock if a charging manager is active, otherwise proceed without locking. The lock keeps a limit reservation and the charge that follows it atomic. Only pay-per-event runs charge @@ -66,133 +63,13 @@ async def charge_lock_if_charging() -> AsyncIterator[None]: yield -# These are thin subclasses of the `apify-client` pricing models. The Apify platform serializes Actor -# pricing info into the `APIFY_ACTOR_PRICING_INFO` env var (parsed by `Configuration.actor_pricing_info`), -# but omits several fields that `apify-client` v3 marks as required (`apifyMarginPercentage`, `createdAt`, -# `startedAt`, per-event `eventDescription`, and per-variant `trialMinutes` / `pricePerUnitUsd` / `unitName`). -# Each subclass relaxes only those omitted fields to optional, so the env var deserializes without faking -# values. Because every subclass is-a `apify-client` model, the API-returned `Run.pricing_info` (already an -# `apify-client` instance) flows through the same code paths without conversion. - - -@docs_group('Charging') -class ActorChargeEvent(ClientActorChargeEvent): - """Definition of a single chargeable event in the pay-per-event pricing model.""" - - model_config = ConfigDict(alias_generator=to_camel) - - event_description: str | None = None - """Human-readable description of the event. - - Required in apify-client but omitted from the env var, so it is relaxed to optional. - """ - - -@docs_group('Charging') -class PricingPerEvent(ClientPricingPerEvent): - """Pay-per-event pricing details - the chargeable events and their prices.""" - - model_config = ConfigDict(alias_generator=to_camel) - - actor_charge_events: dict[str, ActorChargeEvent] | None = None - """Mapping of event name to its charge definition.""" - - -@docs_group('Charging') -class FreeActorPricingInfo(ClientFree): - """Pricing info for an Actor offered free of charge.""" - - model_config = ConfigDict(alias_generator=to_camel) - - apify_margin_percentage: float | None = None - """Apify's margin on the price, as a percentage.""" - - created_at: datetime | None = None - """Timestamp when this pricing info was created.""" - - started_at: datetime | None = None - """Timestamp when this pricing became effective.""" - - -@docs_group('Charging') -class FlatPricePerMonthActorPricingInfo(ClientFlatPricePerMonth): - """Pricing info for an Actor billed at a flat monthly price.""" - - model_config = ConfigDict(alias_generator=to_camel) - - apify_margin_percentage: float | None = None - """Apify's margin on the price, as a percentage.""" - - created_at: datetime | None = None - """Timestamp when this pricing info was created.""" - - started_at: datetime | None = None - """Timestamp when this pricing became effective.""" - - trial_minutes: int | None = None - """Length of the free trial period, in minutes.""" - - price_per_unit_usd: float | None = None - """Price per unit, in USD.""" - - -@docs_group('Charging') -class PricePerDatasetItemActorPricingInfo(ClientPricePerDatasetItem): - """Pricing info for an Actor billed per dataset item produced.""" - - model_config = ConfigDict(alias_generator=to_camel) - - apify_margin_percentage: float | None = None - """Apify's margin on the price, as a percentage.""" - - created_at: datetime | None = None - """Timestamp when this pricing info was created.""" - - started_at: datetime | None = None - """Timestamp when this pricing became effective.""" - - unit_name: str | None = None - """Name of the billed unit.""" - - -@docs_group('Charging') -class PayPerEventActorPricingInfo(ClientPayPerEvent): - """Pricing info for an Actor billed per charged event.""" - - model_config = ConfigDict(alias_generator=to_camel) - - apify_margin_percentage: float | None = None - """Apify's margin on the price, as a percentage.""" - - created_at: datetime | None = None - """Timestamp when this pricing info was created.""" - - started_at: datetime | None = None - """Timestamp when this pricing became effective.""" - - pricing_per_event: PricingPerEvent - """The pay-per-event pricing details.""" - - -ActorPricingInfoModel = ClientFree | ClientFlatPricePerMonth | ClientPricePerDatasetItem | ClientPayPerEvent -"""Common supertype of both env-var-parsed SDK subclasses and the API-returned `Run.pricing_info`.""" - -# apify-client ships these models with deferred forward refs (`__pydantic_complete__` is False), so the -# subclasses must be rebuilt - with the `TieredPricing*` names in scope - before the env-var discriminated -# union can validate standalone. -_pricing_rebuild_namespace = vars(_client_models) | { - 'ActorChargeEvent': ActorChargeEvent, - 'PricingPerEvent': PricingPerEvent, -} -for _pricing_model in ( - ActorChargeEvent, - PricingPerEvent, - FreeActorPricingInfo, - FlatPricePerMonthActorPricingInfo, - PricePerDatasetItemActorPricingInfo, - PayPerEventActorPricingInfo, -): - _pricing_model.model_rebuild(_types_namespace=_pricing_rebuild_namespace) +ActorPricingInfoModel = ( + FreeActorPricingInfo + | FlatPricePerMonthActorPricingInfo + | PricePerDatasetItemActorPricingInfo + | PayPerEventActorPricingInfo +) +"""Pricing info of an Actor run, parsed from the env var or returned by the API.""" @docs_group('Charging') @@ -366,9 +243,8 @@ async def __aenter__(self) -> None: else: self._pricing_model = pricing_info.pricing_model if pricing_info else None - # Load per-event pricing information. Check against the apify-client base so both env-var-parsed - # SDK subclasses and the API-returned model match. - if isinstance(pricing_info, ClientPayPerEvent): + # Load per-event pricing information + if isinstance(pricing_info, PayPerEventActorPricingInfo): actor_charge_events = pricing_info.pricing_per_event.actor_charge_events or {} for event_name, event_pricing in actor_charge_events.items(): if event_pricing.event_price_usd is None: diff --git a/src/apify/_configuration.py b/src/apify/_configuration.py index b54ad08a8..7b491168c 100644 --- a/src/apify/_configuration.py +++ b/src/apify/_configuration.py @@ -7,21 +7,22 @@ from pathlib import Path from typing import TYPE_CHECKING, Annotated, Any, Self -from pydantic import AliasChoices, AliasGenerator, BeforeValidator, ConfigDict, Field, model_validator +from pydantic import AliasChoices, AliasGenerator, BeforeValidator, Field, model_validator from pydantic.alias_generators import to_camel +from pydantic_settings import SettingsConfigDict from typing_extensions import TypedDict -from crawlee import service_locator -from crawlee._utils.models import timedelta_ms -from crawlee._utils.urls import validate_http_url -from crawlee.configuration import Configuration as CrawleeConfiguration - -from apify._charging import ( +from apify_client._models import ( FlatPricePerMonthActorPricingInfo, FreeActorPricingInfo, PayPerEventActorPricingInfo, PricePerDatasetItemActorPricingInfo, ) +from crawlee import service_locator +from crawlee._utils.models import timedelta_ms +from crawlee._utils.urls import validate_http_url +from crawlee.configuration import Configuration as CrawleeConfiguration + from apify._utils import docs_group if TYPE_CHECKING: @@ -95,12 +96,22 @@ def _parse_actor_pricing_info(data: Any) -> Any: Deserializes a JSON string when needed. Treats `None`, an empty string, and an empty/ discriminator-less JSON object (`{}` - the value the platform sets for Actors without a configured pricing model) as "no pricing info" so the union validator doesn't fail on a missing discriminator. + + The platform strips `eventDescription` from each charge event in the env var, while `apify-client` requires + it, so a missing description is filled in as an empty string. """ if data is None or data == '': return None pricing_info = json.loads(data) if isinstance(data, str) else data - if isinstance(pricing_info, dict) and not (pricing_info.get('pricingModel') or pricing_info.get('pricing_model')): + if not isinstance(pricing_info, dict): + return pricing_info + if not (pricing_info.get('pricingModel') or pricing_info.get('pricing_model')): return None + pricing_per_event = pricing_info.get('pricingPerEvent') or pricing_info.get('pricing_per_event') or {} + charge_events = pricing_per_event.get('actorChargeEvents') or pricing_per_event.get('actor_charge_events') or {} + for event in charge_events.values(): + if 'eventDescription' not in event and 'event_description' not in event: + event['eventDescription'] = '' return pricing_info @@ -115,7 +126,7 @@ class Configuration(CrawleeConfiguration): # Fields are validated from environment variables via their `validation_alias`, but serialized under a # camelCase name derived from the Python field name. This keeps `model_dump(by_alias=True)` consistent # (e.g. `is_at_home` -> `isAtHome`) instead of leaking the raw env-var names. - model_config = ConfigDict(alias_generator=AliasGenerator(serialization_alias=to_camel)) + model_config = SettingsConfigDict(alias_generator=AliasGenerator(serialization_alias=to_camel)) actor_id: Annotated[ str | None, diff --git a/src/apify/_utils.py b/src/apify/_utils.py index 097795e83..323bd915d 100644 --- a/src/apify/_utils.py +++ b/src/apify/_utils.py @@ -4,14 +4,11 @@ import builtins import inspect import sys -from collections.abc import Callable +from collections.abc import AsyncGenerator, Callable from contextlib import asynccontextmanager from functools import wraps from importlib import metadata -from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast - -if TYPE_CHECKING: - from collections.abc import AsyncIterator +from typing import Any, Literal, TypeVar, cast T = TypeVar('T', bound=Callable[..., Any]) @@ -131,7 +128,7 @@ def __init__(self) -> None: self._owner: asyncio.Task | None = None @asynccontextmanager - async def __call__(self) -> AsyncIterator[None]: + async def __call__(self) -> AsyncGenerator[None]: """Acquire the lock if it's not already owned by the current task, otherwise proceed without acquiring.""" me = asyncio.current_task() if me is None: diff --git a/tests/unit/actor/test_charging_manager.py b/tests/unit/actor/test_charging_manager.py index f0ace2e94..86fa4d789 100644 --- a/tests/unit/actor/test_charging_manager.py +++ b/tests/unit/actor/test_charging_manager.py @@ -7,7 +7,9 @@ import pytest -from apify._charging import ActorChargeEvent, ChargingManagerImplementation, PayPerEventActorPricingInfo +from apify_client._models import ActorChargeEvent, PayPerEventActorPricingInfo + +from apify._charging import ChargingManagerImplementation from apify._configuration import Configuration @@ -190,10 +192,21 @@ async def test_tier_priced_event_is_skipped(mock_client: MagicMock) -> None: pricing_info = PayPerEventActorPricingInfo.model_validate( { 'pricingModel': 'PAY_PER_EVENT', + 'apifyMarginPercentage': 0.0, + 'createdAt': '2024-01-01T00:00:00.000Z', + 'startedAt': '2024-01-01T00:00:00.000Z', 'pricingPerEvent': { 'actorChargeEvents': { - 'flat': {'eventPriceUsd': 0.02, 'eventTitle': 'Flat event'}, - 'tiered': {'eventTitle': 'Tiered event', 'eventTieredPricingUsd': {}}, + 'flat': { + 'eventPriceUsd': 0.02, + 'eventTitle': 'Flat event', + 'eventDescription': '', + }, + 'tiered': { + 'eventTitle': 'Tiered event', + 'eventDescription': '', + 'eventTieredPricingUsd': {}, + }, } }, } @@ -531,11 +544,26 @@ async def test_charge_deduplicates_events_the_api_never_receives(mock_client: Ma pricing_info = PayPerEventActorPricingInfo.model_validate( { 'pricingModel': 'PAY_PER_EVENT', + 'apifyMarginPercentage': 0.0, + 'createdAt': '2024-01-01T00:00:00.000Z', + 'startedAt': '2024-01-01T00:00:00.000Z', 'pricingPerEvent': { 'actorChargeEvents': { - 'apify-default-dataset-item': {'eventPriceUsd': 0.50, 'eventTitle': 'Dataset item'}, - 'search': {'eventPriceUsd': 1.00, 'eventTitle': 'Search event'}, - 'tiered': {'eventTieredPricingUsd': {}, 'eventTitle': 'Tiered event'}, + 'apify-default-dataset-item': { + 'eventPriceUsd': 0.50, + 'eventTitle': 'Dataset item', + 'eventDescription': '', + }, + 'search': { + 'eventPriceUsd': 1.00, + 'eventTitle': 'Search event', + 'eventDescription': '', + }, + 'tiered': { + 'eventTieredPricingUsd': {}, + 'eventTitle': 'Tiered event', + 'eventDescription': '', + }, } }, } diff --git a/tests/unit/actor/test_configuration.py b/tests/unit/actor/test_configuration.py index 486cbe07c..0fc94da5a 100644 --- a/tests/unit/actor/test_configuration.py +++ b/tests/unit/actor/test_configuration.py @@ -4,6 +4,7 @@ import pytest +from apify_client._models import PayPerEventActorPricingInfo from crawlee import Request, service_locator from crawlee._types import BasicCrawlingContext from crawlee.configuration import Configuration as CrawleeConfiguration @@ -340,16 +341,18 @@ def test_actor_pricing_info_from_json_env_var(monkeypatch: pytest.MonkeyPatch) - assert config.actor_pricing_info.pricing_model == 'PAY_PER_EVENT' -def test_actor_pricing_info_env_var_tolerates_platform_omissions(monkeypatch: pytest.MonkeyPatch) -> None: - """The platform env var may omit fields that apify-client models require; they should be injected with defaults.""" - +def test_actor_pricing_info_env_var_without_event_description(monkeypatch: pytest.MonkeyPatch) -> None: + """A charge event without `eventDescription`, as the platform sends it, parses with an empty description.""" pricing_json = json.dumps( { 'pricingModel': 'PAY_PER_EVENT', + 'apifyMarginPercentage': 0.0, + 'createdAt': '2024-01-01T00:00:00.000Z', + 'startedAt': '2024-01-01T00:00:00.000Z', 'pricingPerEvent': { 'actorChargeEvents': { 'search': { - 'eventPriceUsd': '0.01', + 'eventPriceUsd': 0.01, 'eventTitle': 'Search event', } } @@ -358,8 +361,11 @@ def test_actor_pricing_info_env_var_tolerates_platform_omissions(monkeypatch: py ) monkeypatch.setenv('APIFY_ACTOR_PRICING_INFO', pricing_json) config = ApifyConfiguration() - assert config.actor_pricing_info is not None - assert config.actor_pricing_info.pricing_model == 'PAY_PER_EVENT' + assert isinstance(config.actor_pricing_info, PayPerEventActorPricingInfo) + actor_charge_events = config.actor_pricing_info.pricing_per_event.actor_charge_events + assert actor_charge_events is not None + assert actor_charge_events['search'].event_title == 'Search event' + assert actor_charge_events['search'].event_description == '' @pytest.mark.parametrize('env_value', ['', '{}']) diff --git a/tests/unit/events/test_apify_event_manager.py b/tests/unit/events/test_apify_event_manager.py index 6d6ec65c0..181fbf9f6 100644 --- a/tests/unit/events/test_apify_event_manager.py +++ b/tests/unit/events/test_apify_event_manager.py @@ -25,7 +25,7 @@ from apify.events._types import SystemInfoEventData if TYPE_CHECKING: - from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Iterator + from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Generator DUMMY_SYSTEM_INFO = { @@ -179,7 +179,7 @@ async def handler(_reader: asyncio.StreamReader, writer: asyncio.StreamWriter) - @contextlib.contextmanager -def _unreachable_ws_url(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: +def _unreachable_ws_url(monkeypatch: pytest.MonkeyPatch) -> Generator[None]: """Point the events WebSocket URL at a `127.0.0.1` port that is guaranteed to refuse connections. A hard-coded port number cannot be assumed dead: every port in the OS ephemeral range is fair game for any diff --git a/uv.lock b/uv.lock index ead98435e..7bc8fc893 100644 --- a/uv.lock +++ b/uv.lock @@ -232,6 +232,7 @@ dependencies = [ { name = "lazy-object-proxy" }, { name = "more-itertools" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "typing-extensions" }, { name = "websockets" }, { name = "yarl" }, @@ -275,6 +276,7 @@ requires-dist = [ { name = "lazy-object-proxy", specifier = ">=1.11.0" }, { name = "more-itertools", specifier = ">=10.2.0" }, { name = "pydantic", specifier = ">=2.11.0" }, + { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "scrapy", marker = "extra == 'scrapy'", specifier = ">=2.14.0" }, { name = "typing-extensions", specifier = ">=4.4.0" }, { name = "websockets", specifier = ">=14.0" }, @@ -3056,27 +3058,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.84" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/f6/34f8869c45ea87fe6a931ffa824b1fe4428167173543d92988d631add355/ty-0.0.84.tar.gz", hash = "sha256:0cefdb0cd5d399418dbe83c349ba605047b7dff815ae6f460c80e8522b40be67", size = 7409537, upload-time = "2026-09-24T13:16:24.367Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/0c/600648778e1c79133ce6b6a5c4c2531ae16b90c9b30f5041b38b574f61b8/ty-0.0.84-py3-none-linux_armv6l.whl", hash = "sha256:868438e2b9abfc835dd5b5b07ed693db95d8348ac40e3228252511d63ae87695", size = 13935333, upload-time = "2026-09-24T13:15:31.167Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3b/27f06f49945c36170765fad82076898039ae3873338e9865f876dd62f12c/ty-0.0.84-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b00f39b521f0b559cde0f6abf8dd47213b513b04f33d58efeb9b98486a16bb17", size = 13511328, upload-time = "2026-09-24T13:15:34.446Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b6/b12dd130e7f30141f0645614f454c925991cd389b1cc6c8284fd6bf32326/ty-0.0.84-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f78c2915567f2ee62ce0c24d86091bc76f81577677755e242d09951db588b9a", size = 13445445, upload-time = "2026-09-24T13:15:37.476Z" }, - { url = "https://files.pythonhosted.org/packages/44/96/f012de4b3c1d9a4773326621380d768b4d48c47e620dc012775c5e01c7d7/ty-0.0.84-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61155cad0921e890d86d1d911a40b3a0e924ef4bdc3b4f3865a6c89c7e3012dc", size = 13471235, upload-time = "2026-09-24T13:15:40.64Z" }, - { url = "https://files.pythonhosted.org/packages/05/32/dca630f5f5353fa7bf67eebccf1549df99b16656d21071d0df6677c826a5/ty-0.0.84-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:417c3bf14947ed16b0b92976ab333c27915590354e96d4f581709068d3811355", size = 13680704, upload-time = "2026-09-24T13:15:43.817Z" }, - { url = "https://files.pythonhosted.org/packages/44/8d/33702c8f62f05ad45b33644fb914d483de6a0a7bc8e51884e9b8773e6d69/ty-0.0.84-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e06ef117759f654f5379efe9ae79bda8a186016c016f1023d05c34f8e15c1546", size = 14518923, upload-time = "2026-09-24T13:15:46.802Z" }, - { url = "https://files.pythonhosted.org/packages/c8/a1/da3246e6ef2ae0e842bca4058b25c54bee55ab058ffff4ee54e72517ec01/ty-0.0.84-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a05d1d53db8b64410ab38c6e32c5898510584869d1d5dd9c0605db81c9aef7df", size = 15027731, upload-time = "2026-09-24T13:15:49.755Z" }, - { url = "https://files.pythonhosted.org/packages/20/17/b0099098a560aeb4f9fde6c912f9a81622c3e44e6555e1fbe322b368ec31/ty-0.0.84-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b92400494ab855cfb1204ac1101e3f53b2d09a8e21d171758b2cf9acdab1809e", size = 14748260, upload-time = "2026-09-24T13:15:52.713Z" }, - { url = "https://files.pythonhosted.org/packages/60/d5/32cd67aedb271f006e716ad093806c96c5d7e2af86be8255469dbd3d76c8/ty-0.0.84-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af17eb391ae3027fed9be1780bea555c2b8a25ba68e184c627b4981b2c9eaab7", size = 14205858, upload-time = "2026-09-24T13:15:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/43/c4/638ac62ca2e8eca1b92e3e16273085744fada728b993500819b5c258ca15/ty-0.0.84-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:44cf06af0e7ec78ea6d8ff33a3450fc19627756a4fba681f7410d4e3ef63047a", size = 14573196, upload-time = "2026-09-24T13:15:59.165Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f6/9ee81c5d8921e5fd5f10889ae563c95cd3272b31df2ada528919ca1cef6b/ty-0.0.84-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c6e56b443ff9ba462e34bfbc1e582004c2719ed35f8f93a433744b0ba0afb2d9", size = 13480760, upload-time = "2026-09-24T13:16:02.313Z" }, - { url = "https://files.pythonhosted.org/packages/f6/83/fbe5d1177667ae16d84c3242846b143809c9cc09568b333e5a9b4d5908f6/ty-0.0.84-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b6562fcc48820c1030e2a4509ef2c14ef93043ca4860280074314ac3a825b95", size = 13696346, upload-time = "2026-09-24T13:16:05.391Z" }, - { url = "https://files.pythonhosted.org/packages/86/9a/1b4ac9ddc34cc0ab4624bc219825d66af52f1ad87f4778ad08ad03fa06d5/ty-0.0.84-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dee7421451efcbd70f03e95a8f2c8d9e5ec3d35edcd27b4095ee00ee062abfc4", size = 13959410, upload-time = "2026-09-24T13:16:08.661Z" }, - { url = "https://files.pythonhosted.org/packages/c9/04/70e289437a67dfd686056ab4fd91880261a8233033a6a95d29393741fb97/ty-0.0.84-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:31e96307cf621babe969307dd657783d672e2d8bdbd06493710da635bf63fa07", size = 14350294, upload-time = "2026-09-24T13:16:11.865Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f4/ae7dbd118425c850981d23efb850f0d9d7f6b8d2b852bde2767434f668b4/ty-0.0.84-py3-none-win32.whl", hash = "sha256:4bdc85f91cbaae35825f2b17bb4ac5de0563367875cb0f42b2bf07a1bf7ff2ba", size = 13176439, upload-time = "2026-09-24T13:16:15.049Z" }, - { url = "https://files.pythonhosted.org/packages/29/ec/994d87252f065dda5a500bc8afc726d18f6582d81d6f4ff1feb8a267c800/ty-0.0.84-py3-none-win_amd64.whl", hash = "sha256:71cc9aa7a7c89d4e12c6a814e9c0c3310ad5bee44efedbd6f5a42d91e9ac0f0d", size = 13962879, upload-time = "2026-09-24T13:16:18.191Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1c/e6e91852b72566b1ab9aca504712857dfb89324c12092a5bd61d42d34427/ty-0.0.84-py3-none-win_arm64.whl", hash = "sha256:6c9d3718c4a0d318bc9c92af9b2aa85c3294e7dff54cf1d69ad79568f56516da", size = 13656453, upload-time = "2026-09-24T13:16:21.51Z" }, +version = "0.0.86" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/fa/9b35728cc3c04d64f434c656836fa3c5f6e384a7813e48e63245510d5c69/ty-0.0.86.tar.gz", hash = "sha256:6edcaf52e207d653873d5f495c1a8470075b232030714c981f07f0a1075bc343", size = 7911723, upload-time = "2026-10-09T22:20:57.83Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/27/dd422283667ce10d60af57edc3ccab078084282ee304a5c22b5ed79390c6/ty-0.0.86-py3-none-linux_armv6l.whl", hash = "sha256:305c238df5dedf97f1b65e52d2c446a41d11551291e37054858c00c5627bf15b", size = 14159390, upload-time = "2026-10-09T22:20:17.583Z" }, + { url = "https://files.pythonhosted.org/packages/57/e3/838ea547c177983976103d3a54b346afc22d0ff97943ab58c7b7aff1f753/ty-0.0.86-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27a8a7cf26a1cd17d93586eb20aadbcc535fa6ad4b535813ee8fc52560c31efd", size = 13684426, upload-time = "2026-10-09T22:20:19.939Z" }, + { url = "https://files.pythonhosted.org/packages/bc/62/dfde84ffab702545f866350675b16f8df6555d3c41123c25e7a509fe857e/ty-0.0.86-py3-none-macosx_11_0_arm64.whl", hash = "sha256:40e5fc602af41b0ea89dd1d19fe8da7e75322e6e0db95ea23340a858b409ac2b", size = 12845457, upload-time = "2026-10-09T22:20:21.979Z" }, + { url = "https://files.pythonhosted.org/packages/47/b2/7e0e7ffc8b3961f5f74893ba9b98433b922d3f1ae6621d1e3e3e20663e1d/ty-0.0.86-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4b525ce4feb60aa2dd6becae4c470daea889149ba9dc7699ffbcf41448f69a9", size = 13656310, upload-time = "2026-10-09T22:20:24.544Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/977594e024d154f0e064b48a6f7e39a0e1e55a524349dba22146f01e02db/ty-0.0.86-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a000ddcc0b99bd1ab8c8277630711061a36ae83395780fdb935bad540ebe7103", size = 13887036, upload-time = "2026-10-09T22:20:26.844Z" }, + { url = "https://files.pythonhosted.org/packages/3a/75/67e8e7bf89a6be7535768380cb20569b460b89cb09d5c82ce27411782786/ty-0.0.86-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3848f3cab206e9798e5a9f7c4ce8a80297b0a88ba9aa8a091360a298e97424", size = 14805103, upload-time = "2026-10-09T22:20:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a1/14/a7f3b88cc76156feaf5d48601bd5da96a116f846ad0abc526df03a735bcc/ty-0.0.86-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:087bad6ae8fa7248a16c01f5646c121dccaacbaf320ff544eed4e8c5c1fd7555", size = 15203931, upload-time = "2026-10-09T22:20:31.736Z" }, + { url = "https://files.pythonhosted.org/packages/a7/23/42f6ad5d4bb7091d70a0e96d7d436d6bc9a490210a928727dde5d883ed26/ty-0.0.86-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe7f2721746c9ee64626c69da9245faedc0f375024bf41458e45eb7b28fe5f63", size = 15094434, upload-time = "2026-10-09T22:20:34.269Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b8/211781d3c8de5fa88437412b7a8c0d7a632596033cff51d5004b08e94e1c/ty-0.0.86-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5c18501c54874149d05e5199211146876afeaeee2af234042e56dccc9d5812a", size = 14436812, upload-time = "2026-10-09T22:20:36.795Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2a/518cc7ef2586384345be675d470e0087591810304b5d994d0a2194c7d68e/ty-0.0.86-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0a48967c8adba6b12035243f225a0585b0bc5f0bf940a4829876bc455ebf3d2f", size = 14671116, upload-time = "2026-10-09T22:20:39.277Z" }, + { url = "https://files.pythonhosted.org/packages/7a/18/a820553ad3206d0cdfad5866dd64939c7e3be73e891d88c73f7fd3acb5d6/ty-0.0.86-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:561a4eae98f6ff05395e8d7be8151fdb328eeaead1ac63dc721ca891623f7924", size = 13586361, upload-time = "2026-10-09T22:20:41.444Z" }, + { url = "https://files.pythonhosted.org/packages/22/58/f1700176e6ae4d0a3d25e447734c60ca464d6e76ed2e8a32920ebd41e523/ty-0.0.86-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ccad053ce880a1f8c0850d9dd1f277f3ee559a98bded3f93472268376ceccc2d", size = 13900289, upload-time = "2026-10-09T22:20:43.955Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/2f0e3c9a055f8606911a80311763a089784b66af22dd80fd3290e3b167c6/ty-0.0.86-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7c16d22857bad1da56b147bf53ef0ed2252d7677dc5e7ee51c757b2e52ab6351", size = 14235785, upload-time = "2026-10-09T22:20:46.401Z" }, + { url = "https://files.pythonhosted.org/packages/b4/1d/72fa2347b641143b0440296144a65f424fe047302cb13af059988f1de7a4/ty-0.0.86-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d863f8f190e96878ed1900a8a6d5093f37761c0c47409e2bdb3d9370e37471e1", size = 14559840, upload-time = "2026-10-09T22:20:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/47/4a/a798be4aee4ea0bb427c22f157945d0ac41310f4d6e058b3758dfb859451/ty-0.0.86-py3-none-win32.whl", hash = "sha256:1ac3d8fe9efae04fbf1407583ed4118e0364a45b49a020d62b202846695a2ba1", size = 13133227, upload-time = "2026-10-09T22:20:51.034Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a0/c9d585fa1c5bf4ed2727f576f1542bc13e268f036451a79d057c249fc26f/ty-0.0.86-py3-none-win_amd64.whl", hash = "sha256:6fe4116fb1a7ad3c4f7804d9241dc81187717fbd9f5fed5b50e2752a20e17a67", size = 13665391, upload-time = "2026-10-09T22:20:53.237Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7e/d9811358bce1e54ac1b93bd44f4c45eba5e2e92672c5410248776c54319e/ty-0.0.86-py3-none-win_arm64.whl", hash = "sha256:0df36f494c5d07f68ebec3edb74fc719c4c959db7061c2018f1dff2dfe62150b", size = 13237981, upload-time = "2026-10-09T22:20:55.586Z" }, ] [[package]] From 5da59b3e16ee8b23f0d298b87c482c33da32d266 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 12 Oct 2026 09:14:20 +0200 Subject: [PATCH 2/2] fix: copy pricing info input before filling in event descriptions --- src/apify/_configuration.py | 13 ++++++++++--- tests/unit/actor/test_actor_charge.py | 4 +++- tests/unit/actor/test_configuration.py | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/apify/_configuration.py b/src/apify/_configuration.py index 7b491168c..c6c158410 100644 --- a/src/apify/_configuration.py +++ b/src/apify/_configuration.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import json from datetime import datetime, timedelta from decimal import Decimal @@ -105,12 +106,18 @@ def _parse_actor_pricing_info(data: Any) -> Any: pricing_info = json.loads(data) if isinstance(data, str) else data if not isinstance(pricing_info, dict): return pricing_info + if pricing_info is data: + pricing_info = copy.deepcopy(data) if not (pricing_info.get('pricingModel') or pricing_info.get('pricing_model')): return None - pricing_per_event = pricing_info.get('pricingPerEvent') or pricing_info.get('pricing_per_event') or {} - charge_events = pricing_per_event.get('actorChargeEvents') or pricing_per_event.get('actor_charge_events') or {} + pricing_per_event = pricing_info.get('pricingPerEvent') or pricing_info.get('pricing_per_event') + if not isinstance(pricing_per_event, dict): + return pricing_info + charge_events = pricing_per_event.get('actorChargeEvents') or pricing_per_event.get('actor_charge_events') + if not isinstance(charge_events, dict): + return pricing_info for event in charge_events.values(): - if 'eventDescription' not in event and 'event_description' not in event: + if isinstance(event, dict) and 'eventDescription' not in event and 'event_description' not in event: event['eventDescription'] = '' return pricing_info diff --git a/tests/unit/actor/test_actor_charge.py b/tests/unit/actor/test_actor_charge.py index 848e03b9d..87083db25 100644 --- a/tests/unit/actor/test_actor_charge.py +++ b/tests/unit/actor/test_actor_charge.py @@ -6,8 +6,10 @@ from typing import TYPE_CHECKING, NamedTuple from unittest.mock import AsyncMock, Mock, patch +from apify_client._models import PayPerEventActorPricingInfo + from apify import Actor, Configuration -from apify._charging import ChargingManagerImplementation, PayPerEventActorPricingInfo, PricingInfoItem +from apify._charging import ChargingManagerImplementation, PricingInfoItem if TYPE_CHECKING: from collections.abc import AsyncGenerator diff --git a/tests/unit/actor/test_configuration.py b/tests/unit/actor/test_configuration.py index 0fc94da5a..1e0bb9349 100644 --- a/tests/unit/actor/test_configuration.py +++ b/tests/unit/actor/test_configuration.py @@ -368,6 +368,21 @@ def test_actor_pricing_info_env_var_without_event_description(monkeypatch: pytes assert actor_charge_events['search'].event_description == '' +def test_actor_pricing_info_dict_without_event_description_is_not_mutated() -> None: + """Filling in a missing `eventDescription` leaves the caller's pricing-info dict untouched.""" + search_event = {'eventPriceUsd': 0.01, 'eventTitle': 'Search event'} + pricing_info = { + 'pricingModel': 'PAY_PER_EVENT', + 'apifyMarginPercentage': 0.0, + 'createdAt': '2024-01-01T00:00:00.000Z', + 'startedAt': '2024-01-01T00:00:00.000Z', + 'pricingPerEvent': {'actorChargeEvents': {'search': search_event}}, + } + config = ApifyConfiguration(actor_pricing_info=pricing_info) + assert isinstance(config.actor_pricing_info, PayPerEventActorPricingInfo) + assert 'eventDescription' not in search_event + + @pytest.mark.parametrize('env_value', ['', '{}']) def test_actor_pricing_info_env_var_empty_becomes_none(monkeypatch: pytest.MonkeyPatch, env_value: str) -> None: """Platform sends `APIFY_ACTOR_PRICING_INFO={}` for Actors without a pricing model.