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
4 changes: 2 additions & 2 deletions .github/workflows/_check_code.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ jobs:
name: Lint check
uses: apify/workflows/.github/workflows/python_lint_check.yaml@main
with:
python_versions: '["3.10", "3.11", "3.12", "3.13", "3.14"]'
python_versions: '["3.11", "3.12", "3.13", "3.14"]'

type_check:
name: Type check
uses: apify/workflows/.github/workflows/python_type_check.yaml@main
with:
python_versions: '["3.10", "3.11", "3.12", "3.13", "3.14"]'
python_versions: '["3.11", "3.12", "3.13", "3.14"]'
4 changes: 2 additions & 2 deletions .github/workflows/_tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
uses: apify/workflows/.github/workflows/python_unit_tests.yaml@main
secrets: inherit
with:
python_versions: '["3.10", "3.11", "3.12", "3.13", "3.14"]'
python_versions: '["3.11", "3.12", "3.13", "3.14"]'
operating_systems: '["ubuntu-latest", "windows-latest"]'
python_version_for_codecov: "3.14"
operating_system_for_codecov: ubuntu-latest
Expand All @@ -32,7 +32,7 @@ jobs:
uses: apify/workflows/.github/workflows/python_integration_tests.yaml@main
secrets: inherit
with:
python_versions: '["3.10", "3.14"]'
python_versions: '["3.11", "3.14"]'
operating_systems: '["ubuntu-latest"]'
python_version_for_codecov: "3.14"
operating_system_for_codecov: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Here you'll find a contributing guide to get started with development.

## Environment

For local development, it is required to have Python 3.10 (or a later version) installed.
For local development, it is required to have Python 3.11 (or a later version) installed.

We use [uv](https://docs.astral.sh/uv/) for project management. Install it and set up your IDE accordingly.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ If you want to develop Apify Actors in Python, check out the [Apify SDK for Pyth

## Installation

Requires Python 3.10+
Requires Python 3.11+

You can install the package from its [PyPI listing](https://pypi.org/project/apify-client). To do that, simply run `pip install apify-client` in your terminal.

Expand Down
2 changes: 1 addition & 1 deletion docs/01_overview/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Key features:
Before installing the Apify client, ensure your system meets the following requirements:

- _An Apify account_
- _Python 3.10 or higher_: You can download Python from the [official website](https://www.python.org/downloads/).
- _Python 3.11 or higher_: You can download Python from the [official website](https://www.python.org/downloads/).
- _Python package manager_: While this guide uses [pip](https://pip.pypa.io/en/stable/), you can also use any package manager you want.

To verify that Python and pip are installed, run the following commands:
Expand Down
10 changes: 10 additions & 0 deletions docs/04_upgrading/upgrading_to_v3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
id: upgrading-to-v3
title: Upgrading to v3
---

This page summarizes the breaking changes between Apify Python API Client v2.x and v3.0.

## Python version support

Support for Python 3.10 has been dropped. The Apify Python API Client v3.x now requires Python 3.11 or later. Make sure your environment is running a compatible version before upgrading.
7 changes: 3 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,13 @@ description = "Apify API client for Python"
authors = [{ name = "Apify Technologies s.r.o.", email = "support@apify.com" }]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.11"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
Expand Down Expand Up @@ -172,7 +171,7 @@ asyncio_mode = "auto"
timeout = 1800

[tool.ty.environment]
python-version = "3.10"
python-version = "3.11"

[tool.ty.src]
include = ["src", "tests", "scripts", "docs", "website"]
Expand All @@ -194,7 +193,7 @@ context = 7
url = "https://docs.apify.com/api/openapi.json"
input_file_type = "openapi"
output = "src/apify_client/_models.py"
target_python_version = "3.10"
target_python_version = "3.11"
output_model_type = "pydantic_v2.BaseModel"
use_schema_description = true
use_field_description = true
Expand Down
4 changes: 2 additions & 2 deletions src/apify_client/_http_clients/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import json as jsonlib
import os
import sys
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from importlib import metadata
from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode
Expand Down Expand Up @@ -86,7 +86,7 @@ def _parse_params(params: dict[str, Any] | None) -> dict[str, Any] | None:
elif isinstance(value, list):
parsed_params[key] = ','.join(value)
elif isinstance(value, datetime):
utc_aware_dt = value.astimezone(timezone.utc)
utc_aware_dt = value.astimezone(UTC)
iso_str = utc_aware_dt.isoformat(timespec='milliseconds')
parsed_params[key] = iso_str.replace('+00:00', 'Z')
elif value is not None:
Expand Down
28 changes: 14 additions & 14 deletions src/apify_client/_models.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# generated by datamodel-codegen:
# filename: openapi.json
# timestamp: 2026-02-23T14:42:02+00:00
# filename: https://docs.apify.com/api/openapi.json
# timestamp: 2026-02-24T08:34:43+00:00

from __future__ import annotations

from enum import Enum
from enum import StrEnum
from typing import Annotated, Any, Literal

from pydantic import AnyUrl, AwareDatetime, BaseModel, ConfigDict, EmailStr, Field
Expand Down Expand Up @@ -96,7 +96,7 @@ class ErrorResponse(BaseModel):
error: Error


class VersionSourceType(str, Enum):
class VersionSourceType(StrEnum):
SOURCE_FILES = 'SOURCE_FILES'
GIT_REPO = 'GIT_REPO'
TARBALL = 'TARBALL'
Expand All @@ -112,7 +112,7 @@ class EnvVar(BaseModel):
is_secret: Annotated[bool | None, Field(alias='isSecret', examples=[False])] = None


class SourceCodeFileFormat(str, Enum):
class SourceCodeFileFormat(StrEnum):
BASE64 = 'BASE64'
TEXT = 'TEXT'

Expand Down Expand Up @@ -192,7 +192,7 @@ class CommonActorPricingInfo(BaseModel):
reason_for_change: Annotated[str | None, Field(alias='reasonForChange')] = None


class PricingModel(str, Enum):
class PricingModel(StrEnum):
PAY_PER_EVENT = 'PAY_PER_EVENT'
PRICE_PER_DATASET_ITEM = 'PRICE_PER_DATASET_ITEM'
FLAT_PRICE_PER_MONTH = 'FLAT_PRICE_PER_MONTH'
Expand Down Expand Up @@ -294,7 +294,7 @@ class CreateActorRequest(BaseModel):
default_run_options: Annotated[DefaultRunOptions | None, Field(alias='defaultRunOptions')] = None


class ActorPermissionLevel(str, Enum):
class ActorPermissionLevel(StrEnum):
"""Determines permissions that the Actor requires to run. For more information, see the [Actor permissions documentation](https://docs.apify.com/platform/actors/development/permissions)."""

LIMITED_PERMISSIONS = 'LIMITED_PERMISSIONS'
Expand Down Expand Up @@ -527,7 +527,7 @@ class EnvVarResponse(BaseModel):
data: EnvVar


class WebhookEventType(str, Enum):
class WebhookEventType(StrEnum):
"""Type of event that triggers the webhook."""

ACTOR_BUILD_ABORTED = 'ACTOR.BUILD.ABORTED'
Expand All @@ -553,7 +553,7 @@ class WebhookCondition(BaseModel):
actor_run_id: Annotated[str | None, Field(alias='actorRunId', examples=['hgdKZtadYvn4mBpoi'])] = None


class WebhookDispatchStatus(str, Enum):
class WebhookDispatchStatus(StrEnum):
"""Status of the webhook dispatch indicating whether the HTTP request was successful."""

ACTIVE = 'ACTIVE'
Expand Down Expand Up @@ -611,7 +611,7 @@ class ListOfWebhooksResponse(BaseModel):
data: ListOfWebhooks


class ActorJobStatus(str, Enum):
class ActorJobStatus(StrEnum):
"""Status of an Actor job (run or build)."""

READY = 'READY'
Expand All @@ -624,7 +624,7 @@ class ActorJobStatus(str, Enum):
ABORTED = 'ABORTED'


class RunOrigin(str, Enum):
class RunOrigin(StrEnum):
DEVELOPMENT = 'DEVELOPMENT'
WEB = 'WEB'
API = 'API'
Expand Down Expand Up @@ -909,7 +909,7 @@ class RunOptions(BaseModel):
max_total_charge_usd: Annotated[float | None, Field(alias='maxTotalChargeUsd', examples=[5], ge=0.0)] = None


class GeneralAccess(str, Enum):
class GeneralAccess(StrEnum):
"""Defines the general access level for the resource."""

ANYONE_WITH_ID_CAN_READ = 'ANYONE_WITH_ID_CAN_READ'
Expand Down Expand Up @@ -1289,7 +1289,7 @@ class ChargeRunRequest(BaseModel):
count: Annotated[int, Field(examples=[1])]


class StorageOwnership(str, Enum):
class StorageOwnership(StrEnum):
OWNED_BY_ME = 'ownedByMe'
SHARED_WITH_ME = 'sharedWithMe'

Expand Down Expand Up @@ -2418,7 +2418,7 @@ class WebhookDispatchResponse(BaseModel):
data: WebhookDispatch


class ScheduleActionType(str, Enum):
class ScheduleActionType(StrEnum):
"""Type of action to perform when the schedule triggers."""

RUN_ACTOR = 'RUN_ACTOR'
Expand Down
18 changes: 9 additions & 9 deletions src/apify_client/_resource_clients/_resource_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import asyncio
import time
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from functools import cached_property
from typing import TYPE_CHECKING, Any

Expand Down Expand Up @@ -244,14 +244,14 @@ def _wait_for_finish(
Raises:
ApifyApiError: If API returns errors other than 404.
"""
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
deadline = (now + wait_duration) if wait_duration is not None else None
not_found_deadline = now + DEFAULT_WAIT_WHEN_JOB_NOT_EXIST
actor_job: dict = {}

while True:
if deadline is not None:
remaining_secs = max(0, int(to_seconds(deadline - datetime.now(timezone.utc))))
remaining_secs = max(0, int(to_seconds(deadline - datetime.now(UTC))))
wait_for_finish = remaining_secs
else:
wait_for_finish = to_seconds(DEFAULT_WAIT_FOR_FINISH, as_int=True)
Expand All @@ -267,7 +267,7 @@ def _wait_for_finish(
actor_job = actor_job_response.data.model_dump()

is_terminal = actor_job_response.data.status in TERMINAL_STATUSES
is_timed_out = deadline is not None and datetime.now(timezone.utc) >= deadline
is_timed_out = deadline is not None and datetime.now(UTC) >= deadline

if is_terminal or is_timed_out:
break
Expand All @@ -277,7 +277,7 @@ def _wait_for_finish(

# If there are still not found errors after DEFAULT_WAIT_WHEN_JOB_NOT_EXIST, we give up
# and return None. In such case, the requested record probably really doesn't exist.
if datetime.now(timezone.utc) > not_found_deadline:
if datetime.now(UTC) > not_found_deadline:
return None

# It might take some time for database replicas to get up-to-date so sleep a bit before retrying
Expand Down Expand Up @@ -410,14 +410,14 @@ async def _wait_for_finish(
Raises:
ApifyApiError: If API returns errors other than 404.
"""
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
deadline = (now + wait_duration) if wait_duration is not None else None
not_found_deadline = now + DEFAULT_WAIT_WHEN_JOB_NOT_EXIST
actor_job: dict = {}

while True:
if deadline is not None:
remaining_secs = max(0, int(to_seconds(deadline - datetime.now(timezone.utc))))
remaining_secs = max(0, int(to_seconds(deadline - datetime.now(UTC))))
wait_for_finish = remaining_secs
else:
wait_for_finish = to_seconds(DEFAULT_WAIT_FOR_FINISH, as_int=True)
Expand All @@ -433,7 +433,7 @@ async def _wait_for_finish(
actor_job = actor_job_response.data.model_dump()

is_terminal = actor_job_response.data.status in TERMINAL_STATUSES
is_timed_out = deadline is not None and datetime.now(timezone.utc) >= deadline
is_timed_out = deadline is not None and datetime.now(UTC) >= deadline

if is_terminal or is_timed_out:
break
Expand All @@ -443,7 +443,7 @@ async def _wait_for_finish(

# If there are still not found errors after DEFAULT_WAIT_WHEN_JOB_NOT_EXIST, we give up
# and return None. In such case, the requested record probably really doesn't exist.
if datetime.now(timezone.utc) > not_found_deadline:
if datetime.now(UTC) > not_found_deadline:
return None

# It might take some time for database replicas to get up-to-date so sleep a bit before retrying
Expand Down
4 changes: 1 addition & 3 deletions src/apify_client/_resource_clients/status_message_watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@
from asyncio import Task
from datetime import timedelta
from threading import Thread
from typing import TYPE_CHECKING

from typing_extensions import Self
from typing import TYPE_CHECKING, Self

from apify_client._utils import to_seconds

Expand Down
10 changes: 4 additions & 6 deletions src/apify_client/_resource_clients/streamed_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@
import re
import threading
from asyncio import Task
from datetime import datetime, timezone
from datetime import UTC, datetime
from threading import Thread
from typing import TYPE_CHECKING, cast

from typing_extensions import Self
from typing import TYPE_CHECKING, Self, cast

if TYPE_CHECKING:
from types import TracebackType
Expand Down Expand Up @@ -46,7 +44,7 @@ def __init__(self, to_logger: logging.Logger, *, from_start: bool = True) -> Non
self._to_logger = to_logger
self._stream_buffer = list[bytes]()
self._split_marker = re.compile(rb'(?:\n|^)(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)')
self._relevancy_time_limit: datetime | None = None if from_start else datetime.now(tz=timezone.utc)
self._relevancy_time_limit: datetime | None = None if from_start else datetime.now(tz=UTC)

def _process_new_data(self, data: bytes) -> None:
new_chunk = data
Expand Down Expand Up @@ -76,7 +74,7 @@ def _log_buffer_content(self, *, include_last_part: bool = False) -> None:
decoded_marker = marker.decode('utf-8')
decoded_content = content.decode('utf-8')
if self._relevancy_time_limit:
log_time = datetime.fromisoformat(decoded_marker.replace('Z', '+00:00'))
log_time = datetime.fromisoformat(decoded_marker)
if log_time < self._relevancy_time_limit:
# Skip irrelevant logs
continue
Expand Down
3 changes: 1 addition & 2 deletions src/apify_client/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@
from base64 import b64encode, urlsafe_b64encode
from enum import Enum
from http import HTTPStatus
from typing import TYPE_CHECKING, Any, Literal, TypeVar
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload

import impit
from typing_extensions import overload

from apify_client.errors import InvalidResponseBodyError

Expand Down
4 changes: 2 additions & 2 deletions tests/integration/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from apify_client._models import Dataset, KeyValueStore, ListOfRuns, RequestQueue, Run


from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta

from ._utils import maybe_await, maybe_sleep
from apify_client._models import ActorJobStatus, Run
Expand Down Expand Up @@ -72,7 +72,7 @@ async def test_run_collection_list_accept_date_range(client: ApifyClient | Apify
try:
run_collection = client.runs()

date_obj = datetime(2100, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
date_obj = datetime(2100, 1, 1, 0, 0, 0, tzinfo=UTC)
iso_date_str = date_obj.strftime('%Y-%m-%dT%H:%M:%SZ')

# Here we test that date fields can be passed both as datetime objects and as ISO 8601 strings
Expand Down
Loading