Skip to content
Open
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
27 changes: 22 additions & 5 deletions src/apify/_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from decimal import Decimal
from logging import getLogger
from pathlib import Path
from typing import Annotated, Any, Self
from typing import TYPE_CHECKING, Annotated, Any, Self

from pydantic import AliasChoices, BeforeValidator, Field, model_validator
from typing_extensions import TypedDict
Expand All @@ -23,6 +23,9 @@
)
from apify._utils import docs_group

if TYPE_CHECKING:
from collections.abc import Callable

logger = getLogger(__name__)


Expand All @@ -34,6 +37,20 @@ def _transform_to_list(value: Any) -> list[str] | None:
return value if isinstance(value, list) else str(value).split(',')


def _default_if_empty(*, default: Any) -> Callable[[Any], Any]:
"""Build a validator that substitutes `default` for an empty-string env var.

The Apify platform sometimes sets an env var to an empty string instead of leaving it unset. For fields whose
target type cannot parse `''` (datetimes, numbers, booleans, ...), passing the value straight through would crash
validation and, in turn, `Actor.init()`. Treat `''` as "not provided" and fall back to the field default instead.
"""

def transform(value: Any) -> Any:
return default if value == '' else value

return transform


class ActorStorages(TypedDict):
"""Mapping of storage aliases to their IDs, grouped by storage type.

Expand Down Expand Up @@ -294,7 +311,7 @@ class Configuration(CrawleeConfiguration):
alias='actor_max_paid_dataset_items',
description='For paid-per-result Actors, the user-set limit on returned results. Do not exceed this limit',
),
BeforeValidator(lambda val: val if val != '' else None),
BeforeValidator(_default_if_empty(default=None)),
] = None

max_total_charge_usd: Annotated[
Expand All @@ -303,7 +320,7 @@ class Configuration(CrawleeConfiguration):
alias='actor_max_total_charge_usd',
description='For pay-per-event Actors, the user-set limit on total charges. Do not exceed this limit',
),
BeforeValidator(lambda val: val if val != '' else None),
BeforeValidator(_default_if_empty(default=None)),
] = None

test_pay_per_event: Annotated[
Expand Down Expand Up @@ -382,7 +399,7 @@ class Configuration(CrawleeConfiguration):
),
description='Date when the Actor will time out',
),
BeforeValidator(lambda val: val if val != '' else None), # We should accept empty environment variables as well
BeforeValidator(_default_if_empty(default=None)),
] = None

standby_url: Annotated[
Expand Down Expand Up @@ -416,7 +433,7 @@ class Configuration(CrawleeConfiguration):
alias='apify_user_is_paying',
description='True if the user calling the Actor is paying user',
),
BeforeValidator(lambda val: False if val == '' else val),
BeforeValidator(_default_if_empty(default=False)),
] = False

web_server_port: Annotated[
Expand Down
18 changes: 18 additions & 0 deletions tests/unit/actor/test_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,3 +392,21 @@ def test_actor_storage_json_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
assert config.actor_storages['datasets'] == datasets
assert config.actor_storages['request_queues'] == request_queues
assert config.actor_storages['key_value_stores'] == key_value_stores


@pytest.mark.parametrize(
('env_var', 'attr', 'expected'),
[
('APIFY_TIMEOUT_AT', 'timeout_at', None),
('ACTOR_MAX_PAID_DATASET_ITEMS', 'max_paid_dataset_items', None),
('ACTOR_MAX_TOTAL_CHARGE_USD', 'max_total_charge_usd', None),
('APIFY_USER_IS_PAYING', 'user_is_paying', False),
],
)
def test_typed_env_var_empty_string_falls_back_to_default(
monkeypatch: pytest.MonkeyPatch, env_var: str, attr: str, expected: object
) -> None:
"""Platform may set a typed env var to '' instead of leaving it unset; that must not crash `Actor.init()`."""
monkeypatch.setenv(env_var, '')
config = ApifyConfiguration()
assert getattr(config, attr) == expected