Skip to content

Commit 7895a4e

Browse files
vdusekclaude
andauthored
refactor!: Drop support for Python 3.10 (#636)
## Summary - Bump minimum Python version from 3.10 to 3.11 - Update `pyproject.toml` (`requires-python`, classifiers, `ty` and `datamodel-codegen` target versions) - Remove Python 3.10 from CI matrices (unit tests, integration tests, lint, type check) - Update documentation (`README.md`, `CONTRIBUTING.md`, overview docs, upgrade guide) - Apply Python 3.11+ lint fixes: `datetime.UTC` alias, `StrEnum`, import `Self`/`overload` from `typing`, `fromisoformat` Z-suffix support - Regenerate `uv.lock` (removes `backports-asyncio-runner` and `exceptiongroup` backports) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d3c88a9 commit 7895a4e

File tree

17 files changed

+58
-182
lines changed

17 files changed

+58
-182
lines changed

.github/workflows/_check_code.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ jobs:
3333
name: Lint check
3434
uses: apify/workflows/.github/workflows/python_lint_check.yaml@main
3535
with:
36-
python_versions: '["3.10", "3.11", "3.12", "3.13", "3.14"]'
36+
python_versions: '["3.11", "3.12", "3.13", "3.14"]'
3737

3838
type_check:
3939
name: Type check
4040
uses: apify/workflows/.github/workflows/python_type_check.yaml@main
4141
with:
42-
python_versions: '["3.10", "3.11", "3.12", "3.13", "3.14"]'
42+
python_versions: '["3.11", "3.12", "3.13", "3.14"]'

.github/workflows/_tests.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
uses: apify/workflows/.github/workflows/python_unit_tests.yaml@main
1717
secrets: inherit
1818
with:
19-
python_versions: '["3.10", "3.11", "3.12", "3.13", "3.14"]'
19+
python_versions: '["3.11", "3.12", "3.13", "3.14"]'
2020
operating_systems: '["ubuntu-latest", "windows-latest"]'
2121
python_version_for_codecov: "3.14"
2222
operating_system_for_codecov: ubuntu-latest
@@ -32,7 +32,7 @@ jobs:
3232
uses: apify/workflows/.github/workflows/python_integration_tests.yaml@main
3333
secrets: inherit
3434
with:
35-
python_versions: '["3.10", "3.14"]'
35+
python_versions: '["3.11", "3.14"]'
3636
operating_systems: '["ubuntu-latest"]'
3737
python_version_for_codecov: "3.14"
3838
operating_system_for_codecov: ubuntu-latest

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Here you'll find a contributing guide to get started with development.
44

55
## Environment
66

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

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

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ If you want to develop Apify Actors in Python, check out the [Apify SDK for Pyth
1414

1515
## Installation
1616

17-
Requires Python 3.10+
17+
Requires Python 3.11+
1818

1919
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.
2020

docs/01_overview/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Key features:
3232
Before installing the Apify client, ensure your system meets the following requirements:
3333

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

3838
To verify that Python and pip are installed, run the following commands:
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
id: upgrading-to-v3
3+
title: Upgrading to v3
4+
---
5+
6+
This page summarizes the breaking changes between Apify Python API Client v2.x and v3.0.
7+
8+
## Python version support
9+
10+
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.

pyproject.toml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,13 @@ description = "Apify API client for Python"
99
authors = [{ name = "Apify Technologies s.r.o.", email = "support@apify.com" }]
1010
license = { file = "LICENSE" }
1111
readme = "README.md"
12-
requires-python = ">=3.10"
12+
requires-python = ">=3.11"
1313
classifiers = [
1414
"Development Status :: 5 - Production/Stable",
1515
"Environment :: Console",
1616
"Intended Audience :: Developers",
1717
"License :: OSI Approved :: Apache Software License",
1818
"Operating System :: OS Independent",
19-
"Programming Language :: Python :: 3.10",
2019
"Programming Language :: Python :: 3.11",
2120
"Programming Language :: Python :: 3.12",
2221
"Programming Language :: Python :: 3.13",
@@ -172,7 +171,7 @@ asyncio_mode = "auto"
172171
timeout = 1800
173172

174173
[tool.ty.environment]
175-
python-version = "3.10"
174+
python-version = "3.11"
176175

177176
[tool.ty.src]
178177
include = ["src", "tests", "scripts", "docs", "website"]
@@ -194,7 +193,7 @@ context = 7
194193
url = "https://docs.apify.com/api/openapi.json"
195194
input_file_type = "openapi"
196195
output = "src/apify_client/_models.py"
197-
target_python_version = "3.10"
196+
target_python_version = "3.11"
198197
output_model_type = "pydantic_v2.BaseModel"
199198
use_schema_description = true
200199
use_field_description = true

src/apify_client/_http_clients/_base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import json as jsonlib
55
import os
66
import sys
7-
from datetime import datetime, timedelta, timezone
7+
from datetime import UTC, datetime, timedelta
88
from importlib import metadata
99
from typing import TYPE_CHECKING, Any
1010
from urllib.parse import urlencode
@@ -86,7 +86,7 @@ def _parse_params(params: dict[str, Any] | None) -> dict[str, Any] | None:
8686
elif isinstance(value, list):
8787
parsed_params[key] = ','.join(value)
8888
elif isinstance(value, datetime):
89-
utc_aware_dt = value.astimezone(timezone.utc)
89+
utc_aware_dt = value.astimezone(UTC)
9090
iso_str = utc_aware_dt.isoformat(timespec='milliseconds')
9191
parsed_params[key] = iso_str.replace('+00:00', 'Z')
9292
elif value is not None:

src/apify_client/_models.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# generated by datamodel-codegen:
2-
# filename: openapi.json
3-
# timestamp: 2026-02-23T14:42:02+00:00
2+
# filename: https://docs.apify.com/api/openapi.json
3+
# timestamp: 2026-02-24T08:34:43+00:00
44

55
from __future__ import annotations
66

7-
from enum import Enum
7+
from enum import StrEnum
88
from typing import Annotated, Any, Literal
99

1010
from pydantic import AnyUrl, AwareDatetime, BaseModel, ConfigDict, EmailStr, Field
@@ -96,7 +96,7 @@ class ErrorResponse(BaseModel):
9696
error: Error
9797

9898

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

114114

115-
class SourceCodeFileFormat(str, Enum):
115+
class SourceCodeFileFormat(StrEnum):
116116
BASE64 = 'BASE64'
117117
TEXT = 'TEXT'
118118

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

194194

195-
class PricingModel(str, Enum):
195+
class PricingModel(StrEnum):
196196
PAY_PER_EVENT = 'PAY_PER_EVENT'
197197
PRICE_PER_DATASET_ITEM = 'PRICE_PER_DATASET_ITEM'
198198
FLAT_PRICE_PER_MONTH = 'FLAT_PRICE_PER_MONTH'
@@ -294,7 +294,7 @@ class CreateActorRequest(BaseModel):
294294
default_run_options: Annotated[DefaultRunOptions | None, Field(alias='defaultRunOptions')] = None
295295

296296

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

300300
LIMITED_PERMISSIONS = 'LIMITED_PERMISSIONS'
@@ -527,7 +527,7 @@ class EnvVarResponse(BaseModel):
527527
data: EnvVar
528528

529529

530-
class WebhookEventType(str, Enum):
530+
class WebhookEventType(StrEnum):
531531
"""Type of event that triggers the webhook."""
532532

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

555555

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

559559
ACTIVE = 'ACTIVE'
@@ -611,7 +611,7 @@ class ListOfWebhooksResponse(BaseModel):
611611
data: ListOfWebhooks
612612

613613

614-
class ActorJobStatus(str, Enum):
614+
class ActorJobStatus(StrEnum):
615615
"""Status of an Actor job (run or build)."""
616616

617617
READY = 'READY'
@@ -624,7 +624,7 @@ class ActorJobStatus(str, Enum):
624624
ABORTED = 'ABORTED'
625625

626626

627-
class RunOrigin(str, Enum):
627+
class RunOrigin(StrEnum):
628628
DEVELOPMENT = 'DEVELOPMENT'
629629
WEB = 'WEB'
630630
API = 'API'
@@ -909,7 +909,7 @@ class RunOptions(BaseModel):
909909
max_total_charge_usd: Annotated[float | None, Field(alias='maxTotalChargeUsd', examples=[5], ge=0.0)] = None
910910

911911

912-
class GeneralAccess(str, Enum):
912+
class GeneralAccess(StrEnum):
913913
"""Defines the general access level for the resource."""
914914

915915
ANYONE_WITH_ID_CAN_READ = 'ANYONE_WITH_ID_CAN_READ'
@@ -1289,7 +1289,7 @@ class ChargeRunRequest(BaseModel):
12891289
count: Annotated[int, Field(examples=[1])]
12901290

12911291

1292-
class StorageOwnership(str, Enum):
1292+
class StorageOwnership(StrEnum):
12931293
OWNED_BY_ME = 'ownedByMe'
12941294
SHARED_WITH_ME = 'sharedWithMe'
12951295

@@ -2418,7 +2418,7 @@ class WebhookDispatchResponse(BaseModel):
24182418
data: WebhookDispatch
24192419

24202420

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

24242424
RUN_ACTOR = 'RUN_ACTOR'

src/apify_client/_resource_clients/_resource_client.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import asyncio
44
import time
5-
from datetime import datetime, timedelta, timezone
5+
from datetime import UTC, datetime, timedelta
66
from functools import cached_property
77
from typing import TYPE_CHECKING, Any
88

@@ -244,14 +244,14 @@ def _wait_for_finish(
244244
Raises:
245245
ApifyApiError: If API returns errors other than 404.
246246
"""
247-
now = datetime.now(timezone.utc)
247+
now = datetime.now(UTC)
248248
deadline = (now + wait_duration) if wait_duration is not None else None
249249
not_found_deadline = now + DEFAULT_WAIT_WHEN_JOB_NOT_EXIST
250250
actor_job: dict = {}
251251

252252
while True:
253253
if deadline is not None:
254-
remaining_secs = max(0, int(to_seconds(deadline - datetime.now(timezone.utc))))
254+
remaining_secs = max(0, int(to_seconds(deadline - datetime.now(UTC))))
255255
wait_for_finish = remaining_secs
256256
else:
257257
wait_for_finish = to_seconds(DEFAULT_WAIT_FOR_FINISH, as_int=True)
@@ -267,7 +267,7 @@ def _wait_for_finish(
267267
actor_job = actor_job_response.data.model_dump()
268268

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

272272
if is_terminal or is_timed_out:
273273
break
@@ -277,7 +277,7 @@ def _wait_for_finish(
277277

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

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

418418
while True:
419419
if deadline is not None:
420-
remaining_secs = max(0, int(to_seconds(deadline - datetime.now(timezone.utc))))
420+
remaining_secs = max(0, int(to_seconds(deadline - datetime.now(UTC))))
421421
wait_for_finish = remaining_secs
422422
else:
423423
wait_for_finish = to_seconds(DEFAULT_WAIT_FOR_FINISH, as_int=True)
@@ -433,7 +433,7 @@ async def _wait_for_finish(
433433
actor_job = actor_job_response.data.model_dump()
434434

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

438438
if is_terminal or is_timed_out:
439439
break
@@ -443,7 +443,7 @@ async def _wait_for_finish(
443443

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

449449
# It might take some time for database replicas to get up-to-date so sleep a bit before retrying

0 commit comments

Comments
 (0)