Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/04_upgrading/upgrading_to_v4.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
158 changes: 17 additions & 141 deletions src/apify/_charging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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')
Expand Down Expand Up @@ -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:
Expand Down
36 changes: 27 additions & 9 deletions src/apify/_configuration.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,29 @@
from __future__ import annotations

import copy
import json
from datetime import datetime, timedelta
from decimal import Decimal
from logging import getLogger
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:
Expand Down Expand Up @@ -95,12 +97,28 @@ 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 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')
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 isinstance(event, dict) and 'eventDescription' not in event and 'event_description' not in event:
event['eventDescription'] = ''
return pricing_info


Expand All @@ -115,7 +133,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,
Expand Down
9 changes: 3 additions & 6 deletions src/apify/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion tests/unit/actor/test_actor_charge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 34 additions & 6 deletions tests/unit/actor/test_charging_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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': {},
},
}
},
}
Expand Down Expand Up @@ -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': '',
},
}
},
}
Expand Down
Loading
Loading