Skip to content

Commit b86be73

Browse files
committed
refactor: collapse SDK pricing models into relaxing apify-client subclasses
The SDK kept full standalone pydantic copies of apify-client's four Actor pricing-info models (plus ActorChargeEvent / PricingPerEvent) and a _from_client_pricing_info converter. The copies existed only because the platform's APIFY_ACTOR_PRICING_INFO env var omits fields apify-client v3 marks required (apifyMarginPercentage, createdAt, startedAt, per-event eventDescription, per-variant trialMinutes / pricePerUnitUsd / unitName). Replace the copies with thin subclasses of the apify-client models that relax only those omitted fields to optional. Since each subclass is-a apify-client model, the charging manager can isinstance-check against the client base, so Run.pricing_info from the API flows through unchanged and the converter is removed. Configuration.actor_pricing_info keeps its exact discriminated-union shape, so there is no public API change. Also fixes a latent bug: event_price_usd is now correctly optional (inherited), so tier-priced pay-per-event Actors no longer fail env-var validation; such events are skipped in the flat-price charging path.
1 parent dd58d25 commit b86be73

2 files changed

Lines changed: 83 additions & 61 deletions

File tree

src/apify/_charging.py

Lines changed: 57 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99

1010
from pydantic import BaseModel, ConfigDict, Field
1111

12+
import apify_client._models as _client_models
13+
from apify_client._models import (
14+
ActorChargeEvent as _ClientActorChargeEvent,
15+
)
1216
from apify_client._models import (
1317
FlatPricePerMonthActorPricingInfo as _ClientFlatPricePerMonth,
1418
)
@@ -21,6 +25,9 @@
2125
from apify_client._models import (
2226
PricePerDatasetItemActorPricingInfo as _ClientPricePerDatasetItem,
2327
)
28+
from apify_client._models import (
29+
PricingPerEvent as _ClientPricingPerEvent,
30+
)
2431

2532
from apify._utils import ReentrantLock, docs_group, ensure_context
2633
from apify.log import logger
@@ -47,72 +54,78 @@
4754

4855
# --- SDK-side Actor pricing-info models ---------------------------------------------------------------
4956
#
50-
# The Apify platform serializes Actor pricing info into the `APIFY_ACTOR_PRICING_INFO` env var, but it
51-
# omits several metadata fields that `apify-client` v3 treats as required (`apifyMarginPercentage`,
52-
# `createdAt`, `startedAt`, per-event `eventDescription`). The SDK keeps its own minimal copy with
53-
# those fields optional so `Configuration` validates the env var without injecting fake defaults.
57+
# These are thin subclasses of the `apify-client` pricing models. The Apify platform serializes Actor
58+
# pricing info into the `APIFY_ACTOR_PRICING_INFO` env var (parsed by `Configuration.actor_pricing_info`),
59+
# but omits several fields that `apify-client` v3 marks as required (`apifyMarginPercentage`, `createdAt`,
60+
# `startedAt`, per-event `eventDescription`, and per-variant `trialMinutes` / `pricePerUnitUsd` / `unitName`).
61+
# Each subclass relaxes only those omitted fields to optional, so the env var deserializes without faking
62+
# values. Because every subclass is-a `apify-client` model, the API-returned `Run.pricing_info` (already an
63+
# `apify-client` instance) flows through the same code paths without conversion.
5464

5565

56-
class CommonActorPricingInfo(BaseModel):
66+
class _RelaxedPricingMetadata(BaseModel):
67+
"""Mixin relaxing the `CommonActorPricingInfo` metadata fields the platform env var omits."""
68+
5769
model_config = ConfigDict(populate_by_name=True, extra='allow')
5870

5971
apify_margin_percentage: Annotated[float | None, Field(alias='apifyMarginPercentage')] = None
6072
created_at: Annotated[datetime | None, Field(alias='createdAt')] = None
6173
started_at: Annotated[datetime | None, Field(alias='startedAt')] = None
62-
notified_about_future_change_at: Annotated[datetime | None, Field(alias='notifiedAboutFutureChangeAt')] = None
63-
notified_about_change_at: Annotated[datetime | None, Field(alias='notifiedAboutChangeAt')] = None
64-
reason_for_change: Annotated[str | None, Field(alias='reasonForChange')] = None
6574

6675

6776
@docs_group('Charging')
68-
class FreeActorPricingInfo(CommonActorPricingInfo):
69-
pricing_model: Annotated[Literal['FREE'], Field(alias='pricingModel')]
77+
class ActorChargeEvent(_ClientActorChargeEvent):
78+
# `event_description` is required in apify-client but omitted from the env var.
79+
event_description: Annotated[str | None, Field(alias='eventDescription')] = None
7080

7181

7282
@docs_group('Charging')
73-
class FlatPricePerMonthActorPricingInfo(CommonActorPricingInfo):
74-
pricing_model: Annotated[Literal['FLAT_PRICE_PER_MONTH'], Field(alias='pricingModel')]
75-
trial_minutes: Annotated[int | None, Field(alias='trialMinutes')] = None
76-
price_per_unit_usd: Annotated[float | None, Field(alias='pricePerUnitUsd')] = None
83+
class PricingPerEvent(_ClientPricingPerEvent):
84+
actor_charge_events: Annotated[dict[str, ActorChargeEvent] | None, Field(alias='actorChargeEvents')] = None
7785

7886

7987
@docs_group('Charging')
80-
class PricePerDatasetItemActorPricingInfo(CommonActorPricingInfo):
81-
pricing_model: Annotated[Literal['PRICE_PER_DATASET_ITEM'], Field(alias='pricingModel')]
82-
unit_name: Annotated[str | None, Field(alias='unitName')] = None
83-
price_per_unit_usd: Annotated[float | None, Field(alias='pricePerUnitUsd')] = None
88+
class FreeActorPricingInfo(_RelaxedPricingMetadata, _ClientFree):
89+
pass
8490

8591

8692
@docs_group('Charging')
87-
class ActorChargeEvent(BaseModel):
88-
model_config = ConfigDict(populate_by_name=True, extra='allow')
89-
90-
event_price_usd: Annotated[float, Field(alias='eventPriceUsd')]
91-
event_title: Annotated[str, Field(alias='eventTitle')]
92-
event_description: Annotated[str | None, Field(alias='eventDescription')] = None
93+
class FlatPricePerMonthActorPricingInfo(_RelaxedPricingMetadata, _ClientFlatPricePerMonth):
94+
trial_minutes: Annotated[int | None, Field(alias='trialMinutes')] = None
95+
price_per_unit_usd: Annotated[float | None, Field(alias='pricePerUnitUsd')] = None
9396

9497

9598
@docs_group('Charging')
96-
class PricingPerEvent(BaseModel):
97-
model_config = ConfigDict(populate_by_name=True, extra='allow')
98-
99-
actor_charge_events: Annotated[dict[str, ActorChargeEvent] | None, Field(alias='actorChargeEvents')] = None
99+
class PricePerDatasetItemActorPricingInfo(_RelaxedPricingMetadata, _ClientPricePerDatasetItem):
100+
unit_name: Annotated[str | None, Field(alias='unitName')] = None
101+
# `price_per_unit_usd` is already optional in apify-client - inherited.
100102

101103

102104
@docs_group('Charging')
103-
class PayPerEventActorPricingInfo(CommonActorPricingInfo):
104-
pricing_model: Annotated[Literal['PAY_PER_EVENT'], Field(alias='pricingModel')]
105+
class PayPerEventActorPricingInfo(_RelaxedPricingMetadata, _ClientPayPerEvent):
106+
# Re-typed to the relaxed element so an omitted `eventDescription` validates; the field stays required.
105107
pricing_per_event: Annotated[PricingPerEvent, Field(alias='pricingPerEvent')]
106-
minimal_max_total_charge_usd: Annotated[float | None, Field(alias='minimalMaxTotalChargeUsd')] = None
107108

108109

109-
ActorPricingInfoModel = (
110-
FreeActorPricingInfo
111-
| FlatPricePerMonthActorPricingInfo
112-
| PricePerDatasetItemActorPricingInfo
113-
| PayPerEventActorPricingInfo
114-
)
115-
"""Discriminated union of Actor pricing-info models, keyed by `pricing_model`."""
110+
ActorPricingInfoModel = _ClientFree | _ClientFlatPricePerMonth | _ClientPricePerDatasetItem | _ClientPayPerEvent
111+
"""Common supertype of both env-var-parsed SDK subclasses and the API-returned `Run.pricing_info`."""
112+
113+
# apify-client ships these models with deferred forward refs (`__pydantic_complete__` is False), so the
114+
# subclasses must be rebuilt - with the `TieredPricing*` names in scope - before the env-var discriminated
115+
# union can validate standalone.
116+
_pricing_rebuild_namespace = vars(_client_models) | {
117+
'ActorChargeEvent': ActorChargeEvent,
118+
'PricingPerEvent': PricingPerEvent,
119+
}
120+
for _pricing_model in (
121+
ActorChargeEvent,
122+
PricingPerEvent,
123+
FreeActorPricingInfo,
124+
FlatPricePerMonthActorPricingInfo,
125+
PricePerDatasetItemActorPricingInfo,
126+
PayPerEventActorPricingInfo,
127+
):
128+
_pricing_model.model_rebuild(_types_namespace=_pricing_rebuild_namespace)
116129

117130

118131
@docs_group('Charging')
@@ -278,10 +291,13 @@ async def __aenter__(self) -> None:
278291
else:
279292
self._pricing_model = pricing_info.pricing_model if pricing_info else None
280293

281-
# Load per-event pricing information
282-
if pricing_info is not None and isinstance(pricing_info, PayPerEventActorPricingInfo):
294+
# Load per-event pricing information. Check against the apify-client base so both env-var-parsed
295+
# SDK subclasses and the API-returned model match.
296+
if isinstance(pricing_info, _ClientPayPerEvent):
283297
actor_charge_events = pricing_info.pricing_per_event.actor_charge_events or {}
284298
for event_name, event_pricing in actor_charge_events.items():
299+
if event_pricing.event_price_usd is None:
300+
continue # tier-priced event - not chargeable via the SDK's flat-price path
285301
self._pricing_info[event_name] = PricingInfoItem(
286302
price=Decimal(str(event_pricing.event_price_usd)),
287303
title=event_pricing.event_title,
@@ -510,7 +526,7 @@ async def _fetch_pricing_info(self) -> _FetchedPricingInfoDict:
510526

511527
max_charge = run.options.max_total_charge_usd
512528
return _FetchedPricingInfoDict(
513-
pricing_info=_from_client_pricing_info(run.pricing_info),
529+
pricing_info=run.pricing_info,
514530
charged_event_counts=run.charged_event_counts or {},
515531
max_total_charge_usd=Decimal(str(max_charge)) if max_charge is not None else Decimal('inf'),
516532
)
@@ -549,23 +565,3 @@ class _FetchedPricingInfoDict(TypedDict):
549565
pricing_info: ActorPricingInfoModel | None
550566
charged_event_counts: dict[str, int]
551567
max_total_charge_usd: Decimal
552-
553-
554-
def _from_client_pricing_info(
555-
pricing_info: _ClientFree | _ClientFlatPricePerMonth | _ClientPricePerDatasetItem | _ClientPayPerEvent | None,
556-
) -> ActorPricingInfoModel | None:
557-
"""Project an `apify-client` pricing-info model (from `Run.pricingInfo`) into the SDK's pricing model.
558-
559-
The SDK keeps its own minimal pydantic copies so the platform's `APIFY_ACTOR_PRICING_INFO` env var
560-
(which omits some metadata fields apify-client treats as required) deserializes cleanly. The API
561-
response uses apify-client's models, so we convert here on the boundary.
562-
"""
563-
if pricing_info is None:
564-
return None
565-
if isinstance(pricing_info, _ClientPayPerEvent):
566-
return PayPerEventActorPricingInfo.model_validate(pricing_info.model_dump(by_alias=True))
567-
if isinstance(pricing_info, _ClientFlatPricePerMonth):
568-
return FlatPricePerMonthActorPricingInfo.model_validate(pricing_info.model_dump(by_alias=True))
569-
if isinstance(pricing_info, _ClientPricePerDatasetItem):
570-
return PricePerDatasetItemActorPricingInfo.model_validate(pricing_info.model_dump(by_alias=True))
571-
return FreeActorPricingInfo.model_validate(pricing_info.model_dump(by_alias=True))

tests/unit/actor/test_charging_manager.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,32 @@ async def test_get_pricing_info_structure(mock_client: MagicMock) -> None:
184184
assert info.per_event_prices['search'] == Decimal('0.01')
185185

186186

187+
async def test_tier_priced_event_is_skipped(mock_client: MagicMock) -> None:
188+
"""A tier-priced PPE event (no eventPriceUsd) must parse and be skipped, not crash."""
189+
pricing_info = PayPerEventActorPricingInfo.model_validate(
190+
{
191+
'pricingModel': 'PAY_PER_EVENT',
192+
'pricingPerEvent': {
193+
'actorChargeEvents': {
194+
'flat': {'eventPriceUsd': 0.02, 'eventTitle': 'Flat event'},
195+
'tiered': {'eventTitle': 'Tiered event', 'eventTieredPricingUsd': {}},
196+
}
197+
},
198+
}
199+
)
200+
config = _make_config(
201+
test_pay_per_event=True,
202+
actor_pricing_info=pricing_info,
203+
charged_event_counts={},
204+
max_total_charge_usd=Decimal('10.00'),
205+
)
206+
cm = ChargingManagerImplementation(config, mock_client)
207+
async with cm:
208+
info = cm.get_pricing_info()
209+
# The flat event is registered with its price; the tier-priced event is skipped.
210+
assert info.per_event_prices == {'flat': Decimal('0.02')}
211+
212+
187213
async def test_get_charged_event_count_unknown_event(mock_client: MagicMock) -> None:
188214
"""Test get_charged_event_count returns 0 for unknown events."""
189215
config = _make_config(actor_pricing_info=None, charged_event_counts={})

0 commit comments

Comments
 (0)