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
94 changes: 70 additions & 24 deletions src/apify/_charging.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from decimal import Decimal
from typing import TYPE_CHECKING, Annotated, Literal, Protocol, TypedDict

from pydantic import BaseModel, ConfigDict, Field
from pydantic import Field

import apify_client._models as _client_models
from apify_client._models import ActorChargeEvent as ClientActorChargeEvent
Expand All @@ -28,14 +28,17 @@

from apify._configuration import Configuration

PricingModel = Literal['PAY_PER_EVENT', 'PRICE_PER_DATASET_ITEM', 'FLAT_PRICE_PER_MONTH', 'FREE']
"""Pricing model for an Actor."""
charging_manager_ctx: ContextVar[ChargingManager | None] = ContextVar('charging_manager_ctx', default=None)
"""Holds the current `ChargingManager` instance, if any.

Allows PPE-aware dataset clients to access the charging manager without needing to pass it explicitly.
"""

DEFAULT_DATASET_ITEM_EVENT = 'apify-default-dataset-item'
"""Name of the synthetic event charged for each item pushed to the default dataset."""

# Context variable to hold the current `ChargingManager` instance, if any. This allows PPE-aware dataset clients to
# access the charging manager without needing to pass it explicitly.
charging_manager_ctx: ContextVar[ChargingManager | None] = ContextVar('charging_manager_ctx', default=None)
PricingModel = Literal['PAY_PER_EVENT', 'PRICE_PER_DATASET_ITEM', 'FLAT_PRICE_PER_MONTH', 'FREE']
"""Pricing model for an Actor."""

_ensure_context = ensure_context('active')

Expand All @@ -49,48 +52,91 @@
# `apify-client` instance) flows through the same code paths without conversion.


class _RelaxedPricingMetadata(BaseModel):
"""Mixin relaxing the `CommonActorPricingInfo` metadata fields the platform env var omits."""

model_config = ConfigDict(populate_by_name=True, extra='allow')

apify_margin_percentage: Annotated[float | None, Field(alias='apifyMarginPercentage')] = None
created_at: Annotated[datetime | None, Field(alias='createdAt')] = None
started_at: Annotated[datetime | None, Field(alias='startedAt')] = None


@docs_group('Charging')
class ActorChargeEvent(ClientActorChargeEvent):
# `event_description` is required in apify-client but omitted from the env var.
"""Definition of a single chargeable event in the pay-per-event pricing model."""

event_description: Annotated[str | None, Field(alias='eventDescription')] = 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."""

actor_charge_events: Annotated[dict[str, ActorChargeEvent] | None, Field(alias='actorChargeEvents')] = None
"""Mapping of event name to its charge definition."""


@docs_group('Charging')
class FreeActorPricingInfo(_RelaxedPricingMetadata, ClientFree):
pass
class FreeActorPricingInfo(ClientFree):
"""Pricing info for an Actor offered free of charge."""

apify_margin_percentage: Annotated[float | None, Field(alias='apifyMarginPercentage')] = None
"""Apify's margin on the price, as a percentage."""

created_at: Annotated[datetime | None, Field(alias='createdAt')] = None
"""Timestamp when this pricing info was created."""

started_at: Annotated[datetime | None, Field(alias='startedAt')] = None
"""Timestamp when this pricing became effective."""


@docs_group('Charging')
class FlatPricePerMonthActorPricingInfo(_RelaxedPricingMetadata, ClientFlatPricePerMonth):
class FlatPricePerMonthActorPricingInfo(ClientFlatPricePerMonth):
"""Pricing info for an Actor billed at a flat monthly price."""

apify_margin_percentage: Annotated[float | None, Field(alias='apifyMarginPercentage')] = None
"""Apify's margin on the price, as a percentage."""

created_at: Annotated[datetime | None, Field(alias='createdAt')] = None
"""Timestamp when this pricing info was created."""

started_at: Annotated[datetime | None, Field(alias='startedAt')] = None
"""Timestamp when this pricing became effective."""

trial_minutes: Annotated[int | None, Field(alias='trialMinutes')] = None
"""Length of the free trial period, in minutes."""

price_per_unit_usd: Annotated[float | None, Field(alias='pricePerUnitUsd')] = None
"""Price per unit, in USD."""


@docs_group('Charging')
class PricePerDatasetItemActorPricingInfo(_RelaxedPricingMetadata, ClientPricePerDatasetItem):
class PricePerDatasetItemActorPricingInfo(ClientPricePerDatasetItem):
"""Pricing info for an Actor billed per dataset item produced."""

apify_margin_percentage: Annotated[float | None, Field(alias='apifyMarginPercentage')] = None
"""Apify's margin on the price, as a percentage."""

created_at: Annotated[datetime | None, Field(alias='createdAt')] = None
"""Timestamp when this pricing info was created."""

started_at: Annotated[datetime | None, Field(alias='startedAt')] = None
"""Timestamp when this pricing became effective."""

unit_name: Annotated[str | None, Field(alias='unitName')] = None
# `price_per_unit_usd` is already optional in apify-client - inherited.
"""Name of the billed unit."""


@docs_group('Charging')
class PayPerEventActorPricingInfo(_RelaxedPricingMetadata, ClientPayPerEvent):
# Re-typed to the relaxed element so an omitted `eventDescription` validates; the field stays required.
class PayPerEventActorPricingInfo(ClientPayPerEvent):
"""Pricing info for an Actor billed per charged event."""

apify_margin_percentage: Annotated[float | None, Field(alias='apifyMarginPercentage')] = None
"""Apify's margin on the price, as a percentage."""

created_at: Annotated[datetime | None, Field(alias='createdAt')] = None
"""Timestamp when this pricing info was created."""

started_at: Annotated[datetime | None, Field(alias='startedAt')] = None
"""Timestamp when this pricing became effective."""

pricing_per_event: Annotated[PricingPerEvent, Field(alias='pricingPerEvent')]
"""The pay-per-event pricing details."""


ActorPricingInfoModel = ClientFree | ClientFlatPricePerMonth | ClientPricePerDatasetItem | ClientPayPerEvent
Expand Down
57 changes: 57 additions & 0 deletions src/apify/events/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,31 @@

@docs_group('Event data')
class SystemInfoEventData(BaseModel):
"""Resource usage metrics carried by a `systemInfo` event."""

mem_avg_bytes: Annotated[float, Field(alias='memAvgBytes')]
"""Average memory usage over the measured interval, in bytes."""

mem_current_bytes: Annotated[float, Field(alias='memCurrentBytes')]
"""Current memory usage, in bytes."""

mem_max_bytes: Annotated[float, Field(alias='memMaxBytes')]
"""Peak memory usage observed so far, in bytes."""

cpu_avg_usage: Annotated[float, Field(alias='cpuAvgUsage')]
"""Average CPU usage over the measured interval, in percent."""

cpu_max_usage: Annotated[float, Field(alias='cpuMaxUsage')]
"""Peak CPU usage observed so far, in percent."""

cpu_current_usage: Annotated[float, Field(alias='cpuCurrentUsage')]
"""Current CPU usage, in percent."""

is_cpu_overloaded: Annotated[bool, Field(alias='isCpuOverloaded')]
"""Whether the CPU is currently overloaded."""

created_at: Annotated[datetime, Field(alias='createdAt')]
"""Timestamp when the metrics were collected."""

def to_crawlee_format(self, dedicated_cpus: float) -> EventSystemInfoData:
return EventSystemInfoData.model_validate(
Expand All @@ -54,36 +71,63 @@ def to_crawlee_format(self, dedicated_cpus: float) -> EventSystemInfoData:

@docs_group('Events')
class PersistStateEvent(BaseModel):
"""A `persistState` event instructing the Actor to persist its state."""

name: Literal[Event.PERSIST_STATE]
"""The event name."""

data: Annotated[EventPersistStateData, Field(default_factory=lambda: EventPersistStateData(is_migrating=False))]
"""The event payload."""


@docs_group('Events')
class SystemInfoEvent(BaseModel):
"""A `systemInfo` event carrying the Actor's resource usage metrics."""

name: Literal[Event.SYSTEM_INFO]
"""The event name."""

data: SystemInfoEventData
"""The event payload."""


@docs_group('Events')
class MigratingEvent(BaseModel):
"""A `migrating` event signalling the Actor is about to be migrated to another host."""

name: Literal[Event.MIGRATING]
"""The event name."""

data: Annotated[EventMigratingData, Field(default_factory=EventMigratingData)]
"""The event payload."""


@docs_group('Events')
class AbortingEvent(BaseModel):
"""An `aborting` event signalling the Actor run is being aborted."""

name: Literal[Event.ABORTING]
"""The event name."""

data: Annotated[EventAbortingData, Field(default_factory=EventAbortingData)]
"""The event payload."""


@docs_group('Events')
class ExitEvent(BaseModel):
"""An `exit` event signalling the Actor process is about to exit."""

name: Literal[Event.EXIT]
"""The event name."""

data: Annotated[EventExitData, Field(default_factory=EventExitData)]
"""The event payload."""


@docs_group('Events')
class EventWithoutData(BaseModel):
"""A framework-level event that carries no payload (e.g. browser and page lifecycle events)."""

name: Literal[
Event.SESSION_RETIRED,
Event.BROWSER_LAUNCHED,
Expand All @@ -92,19 +136,32 @@ class EventWithoutData(BaseModel):
Event.PAGE_CREATED,
Event.PAGE_CLOSED,
]
"""The event name."""

data: Any = None
"""The event payload, always empty for this event."""


@docs_group('Events')
class DeprecatedEvent(BaseModel):
"""A deprecated event kept for backward compatibility (e.g. `cpuInfo`)."""

name: Literal['cpuInfo']
"""The event name."""

data: Annotated[dict[str, Any], Field(default_factory=dict)]
"""The event payload."""


@docs_group('Events')
class UnknownEvent(BaseModel):
"""A fallback for any event whose name is not recognized by the SDK."""

name: str
"""The event name."""

data: Annotated[dict[str, Any], Field(default_factory=dict)]
"""The event payload."""


EventMessage = PersistStateEvent | SystemInfoEvent | MigratingEvent | AbortingEvent | ExitEvent | EventWithoutData
Loading