|
9 | 9 |
|
10 | 10 | from pydantic import BaseModel, ConfigDict, Field |
11 | 11 |
|
| 12 | +import apify_client._models as _client_models |
| 13 | +from apify_client._models import ( |
| 14 | + ActorChargeEvent as _ClientActorChargeEvent, |
| 15 | +) |
12 | 16 | from apify_client._models import ( |
13 | 17 | FlatPricePerMonthActorPricingInfo as _ClientFlatPricePerMonth, |
14 | 18 | ) |
|
21 | 25 | from apify_client._models import ( |
22 | 26 | PricePerDatasetItemActorPricingInfo as _ClientPricePerDatasetItem, |
23 | 27 | ) |
| 28 | +from apify_client._models import ( |
| 29 | + PricingPerEvent as _ClientPricingPerEvent, |
| 30 | +) |
24 | 31 |
|
25 | 32 | from apify._utils import ReentrantLock, docs_group, ensure_context |
26 | 33 | from apify.log import logger |
|
47 | 54 |
|
48 | 55 | # --- SDK-side Actor pricing-info models --------------------------------------------------------------- |
49 | 56 | # |
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. |
54 | 64 |
|
55 | 65 |
|
56 | | -class CommonActorPricingInfo(BaseModel): |
| 66 | +class _RelaxedPricingMetadata(BaseModel): |
| 67 | + """Mixin relaxing the `CommonActorPricingInfo` metadata fields the platform env var omits.""" |
| 68 | + |
57 | 69 | model_config = ConfigDict(populate_by_name=True, extra='allow') |
58 | 70 |
|
59 | 71 | apify_margin_percentage: Annotated[float | None, Field(alias='apifyMarginPercentage')] = None |
60 | 72 | created_at: Annotated[datetime | None, Field(alias='createdAt')] = None |
61 | 73 | 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 |
65 | 74 |
|
66 | 75 |
|
67 | 76 | @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 |
70 | 80 |
|
71 | 81 |
|
72 | 82 | @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 |
77 | 85 |
|
78 | 86 |
|
79 | 87 | @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 |
84 | 90 |
|
85 | 91 |
|
86 | 92 | @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 |
93 | 96 |
|
94 | 97 |
|
95 | 98 | @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. |
100 | 102 |
|
101 | 103 |
|
102 | 104 | @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. |
105 | 107 | pricing_per_event: Annotated[PricingPerEvent, Field(alias='pricingPerEvent')] |
106 | | - minimal_max_total_charge_usd: Annotated[float | None, Field(alias='minimalMaxTotalChargeUsd')] = None |
107 | 108 |
|
108 | 109 |
|
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) |
116 | 129 |
|
117 | 130 |
|
118 | 131 | @docs_group('Charging') |
@@ -278,10 +291,13 @@ async def __aenter__(self) -> None: |
278 | 291 | else: |
279 | 292 | self._pricing_model = pricing_info.pricing_model if pricing_info else None |
280 | 293 |
|
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): |
283 | 297 | actor_charge_events = pricing_info.pricing_per_event.actor_charge_events or {} |
284 | 298 | 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 |
285 | 301 | self._pricing_info[event_name] = PricingInfoItem( |
286 | 302 | price=Decimal(str(event_pricing.event_price_usd)), |
287 | 303 | title=event_pricing.event_title, |
@@ -510,7 +526,7 @@ async def _fetch_pricing_info(self) -> _FetchedPricingInfoDict: |
510 | 526 |
|
511 | 527 | max_charge = run.options.max_total_charge_usd |
512 | 528 | return _FetchedPricingInfoDict( |
513 | | - pricing_info=_from_client_pricing_info(run.pricing_info), |
| 529 | + pricing_info=run.pricing_info, |
514 | 530 | charged_event_counts=run.charged_event_counts or {}, |
515 | 531 | max_total_charge_usd=Decimal(str(max_charge)) if max_charge is not None else Decimal('inf'), |
516 | 532 | ) |
@@ -549,23 +565,3 @@ class _FetchedPricingInfoDict(TypedDict): |
549 | 565 | pricing_info: ActorPricingInfoModel | None |
550 | 566 | charged_event_counts: dict[str, int] |
551 | 567 | 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)) |
0 commit comments