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
6 changes: 3 additions & 3 deletions .github/workflows/_checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,13 @@ 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"]'

markdown_lint_check:
name: Markdown lint check
Expand Down Expand Up @@ -135,7 +135,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", "macos-latest"]'
python_version_for_codecov: "3.14"
operating_system_for_codecov: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion .rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Note: `uv run poe unit-tests` first runs tests marked `@pytest.mark.run_alone` i
- **Line length**: 120 characters
- **Quotes**: Single quotes (double for docstrings)
- **Docstrings**: Google format (enforced by Ruff)
- **Type checker**: ty (Astral's type checker), target Python 3.10
- **Type checker**: ty (Astral's type checker), target Python 3.11
- **Async mode**: pytest-asyncio in `auto` mode (no need for `@pytest.mark.asyncio`)
- **Commits**: [Conventional Commits](https://www.conventionalcommits.org/) format. Choose the type based on *what* changed, not just *why*:
- `feat:` / `fix:` / `perf:` / `refactor:` / `style:` — **source code only**; these trigger a release and appear in the changelog
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 @@ -16,7 +16,7 @@
<p align="center">
<a href="https://badge.fury.io/py/crawlee" rel="nofollow"><img src="https://badge.fury.io/py/crawlee.svg" alt="PyPI package version"></a>
<a href="https://pypi.org/project/crawlee/" rel="nofollow"><img src="https://img.shields.io/pypi/dm/crawlee" alt="PyPI package downloads"></a>
<a href="https://pypi.org/project/crawlee/" rel="nofollow"><img src="https://img.shields.io/badge/python-3.10%2B-blue" alt="PyPI Python version"></a>
<a href="https://pypi.org/project/crawlee/" rel="nofollow"><img src="https://img.shields.io/badge/python-3.11%2B-blue" alt="PyPI Python version"></a>
<a href="https://github.com/apify/crawlee-python/actions/workflows/on_master.yaml"><img src="https://github.com/apify/crawlee-python/actions/workflows/on_master.yaml/badge.svg?branch=master" alt="Build status"></a>
<a href="https://codecov.io/gh/apify/crawlee-python"><img src="https://codecov.io/gh/apify/crawlee-python/graph/badge.svg?token=cCju61iPQG" alt="Codecov report"></a>
<a href="https://github.com/apify/crawlee-python/blob/master/LICENSE"><img src="https://img.shields.io/pypi/l/crawlee" alt="License"></a>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from dataclasses import dataclass, fields
from typing import Self

from selectolax.lexbor import LexborHTMLParser
from typing_extensions import Self

from crawlee.crawlers._abstract_http import ParsedHttpCrawlingContext

Expand Down
2 changes: 1 addition & 1 deletion docs/introduction/01_setting_up.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ This guide will help you get started with Crawlee by setting it up on your compu

Before installing Crawlee itself, make sure that your system meets the following requirements:

- **Python 3.10 or higher**: Crawlee requires Python 3.10 or a newer version. You can download Python from the [official website](https://python.org/downloads/).
- **Python 3.11 or higher**: Crawlee requires Python 3.11 or a newer version. You can download Python from the [official website](https://python.org/downloads/).
- **Python package manager**: While this guide uses [pip](https://pip.pypa.io/) (the most common package manager), you can also use any package manager you want. You can download pip from the [official website](https://pip.pypa.io/en/stable/installation/).

### Verifying prerequisites
Expand Down
2 changes: 1 addition & 1 deletion docs/quick-start/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Crawlee offers the following main crawler classes: <ApiLink to="class/BeautifulS

:::caution Minimum Python version

Crawlee requires Python 3.10 or higher.
Crawlee requires Python 3.11 or higher.

:::

Expand Down
14 changes: 14 additions & 0 deletions docs/upgrading/upgrading_to_v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
id: upgrading-to-v2
title: Upgrading to v2
---

This page summarizes the breaking changes between Crawlee for Python v1 and v2.

## Python version support

We drop support for Python 3.10, which reached its end of life in October 2026. The minimum supported version is now Python 3.11.

## `Event` is a `StrEnum`

`Event` now subclasses `enum.StrEnum`. Calling `str()` on a member or formatting it in an f-string returns its value, for example `'systemInfo'`, where v1 returned `'Event.SYSTEM_INFO'`. Comparisons with plain strings work the same way as in v1.
5 changes: 2 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,12 @@ authors = [{ name = "Apify Technologies s.r.o.", email = "support@apify.com" }]
license = "Apache-2.0"
license-files = ["LICENSE"]
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.11"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"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 @@ -279,7 +278,7 @@ filterwarnings = [
]

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

[tool.ty.src]
include = ["src", "tests", "scripts", "docs", "website"]
Expand Down
12 changes: 6 additions & 6 deletions src/crawlee/_autoscaling/_types.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Annotated

from pydantic import Field
Expand Down Expand Up @@ -46,7 +46,7 @@ class SystemInfo:
client_info: LoadRatioInfo
"""The client load ratio."""

created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
"""The time at which the system load information was measured."""

@property
Expand Down Expand Up @@ -80,7 +80,7 @@ class CpuSnapshot:
max_used_ratio: float
"""The maximum ratio of CPU that is considered acceptable."""

created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
"""The time at which the system load information was measured."""

@property
Expand Down Expand Up @@ -108,7 +108,7 @@ class MemorySnapshot:
max_used_memory_ratio: float
"""The maximum acceptable ratio of `current_size` to `max_memory_size`."""

created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
"""The time at which the system load information was measured."""

@property
Expand All @@ -132,7 +132,7 @@ class EventLoopSnapshot:
max_delay: timedelta
"""The maximum delay that is considered acceptable."""

created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
"""The time at which the system load information was measured."""

@property
Expand All @@ -159,7 +159,7 @@ class ClientSnapshot:
max_error_count: int
"""The maximum number of errors that is considered acceptable."""

created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
"""The time at which the system load information was measured."""

@property
Expand Down
4 changes: 2 additions & 2 deletions src/crawlee/_autoscaling/autoscaled_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ async def _worker_task_orchestrator(self, run: _AutoscaledPoolRun) -> None:

continue

with suppress(asyncio.TimeoutError):
with suppress(TimeoutError):
await asyncio.wait_for(run.worker_tasks_updated.wait(), timeout=0.5)
except Exception as exc:
# Surface the error through `run.result` only once the cleanup below has awaited the worker tasks,
Expand Down Expand Up @@ -290,7 +290,7 @@ async def _worker_task(self) -> None:
self._run_task_function(),
timeout=self._TASK_TIMEOUT.total_seconds() if self._TASK_TIMEOUT is not None else None,
)
except asyncio.TimeoutError:
except TimeoutError:
if self._TASK_TIMEOUT is None:
# Without a timeout, `wait_for` cannot time out - the error comes from the task function itself.
raise
Expand Down
4 changes: 2 additions & 2 deletions src/crawlee/_autoscaling/snapshotter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

from bisect import insort
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from logging import WARNING, getLogger
from typing import TYPE_CHECKING, TypeVar, cast

Expand Down Expand Up @@ -107,7 +107,7 @@ def __init__(
self._snapshot_event_loop_task = RecurringTask(self._snapshot_event_loop, self._EVENT_LOOP_SNAPSHOT_INTERVAL)
self._snapshot_client_task = RecurringTask(self._snapshot_client, self._CLIENT_SNAPSHOT_INTERVAL)

self._timestamp_of_last_memory_warning: datetime = datetime.now(timezone.utc) - timedelta(hours=1)
self._timestamp_of_last_memory_warning: datetime = datetime.now(UTC) - timedelta(hours=1)

# Flag to indicate the context state.
self._active = False
Expand Down
3 changes: 1 addition & 2 deletions src/crawlee/_log_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@
import logging
import sys
import textwrap
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, assert_never

from colorama import Fore, Style, just_fix_windows_console
from typing_extensions import assert_never

from crawlee import service_locator

Expand Down
2 changes: 1 addition & 1 deletion src/crawlee/_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from crawlee._utils.urls import validate_http_url

if TYPE_CHECKING:
from typing_extensions import NotRequired, Required, Self
from typing import NotRequired, Required, Self


class RequestState(IntEnum):
Expand Down
3 changes: 2 additions & 1 deletion src/crawlee/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
import re
from collections.abc import Awaitable, Coroutine, MutableMapping, Sequence
from datetime import timedelta
from typing import NotRequired, Required, Self

from typing_extensions import NotRequired, Required, Self, Unpack
from typing_extensions import Unpack

from crawlee import Glob, Request
from crawlee._request import RequestOptions
Expand Down
3 changes: 2 additions & 1 deletion src/crawlee/_utils/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@

if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from typing import NotRequired

from lxml.html import HtmlElement
from typing_extensions import NotRequired, Unpack
from typing_extensions import Unpack

from crawlee._types import JsonSerializable

Expand Down
6 changes: 3 additions & 3 deletions src/crawlee/_utils/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

import re
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from email.utils import parsedate_to_datetime
from logging import getLogger

Expand Down Expand Up @@ -56,9 +56,9 @@ def parse_retry_after_header(value: str | None) -> timedelta | None:
# `parsedate_to_datetime` may return a naive datetime when the input has no timezone info.
# Treat such values as UTC — HTTP-dates are GMT per RFC 7231.
if retry_date.tzinfo is None:
retry_date = retry_date.replace(tzinfo=timezone.utc)
retry_date = retry_date.replace(tzinfo=UTC)

delay = retry_date - datetime.now(timezone.utc)
delay = retry_date - datetime.now(UTC)
if delay.total_seconds() > 0:
return delay
logger.debug(f'Retry-After HTTP-date {value!r} is in the past; ignoring.')
Expand Down
3 changes: 1 addition & 2 deletions src/crawlee/_utils/recurring_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
from collections.abc import Callable
from datetime import timedelta
from types import TracebackType

from typing_extensions import Self
from typing import Self

logger = getLogger(__name__)

Expand Down
2 changes: 1 addition & 1 deletion src/crawlee/_utils/retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
for attempt in range(max_attempts):
try:
return await func(*args, **kwargs)
except exception_types as exc: # noqa: PERF203
except exception_types as exc:
if attempt >= max_attempts - 1:
raise

Expand Down
2 changes: 1 addition & 1 deletion src/crawlee/_utils/robots.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from crawlee._utils.web import is_status_code_client_error, is_status_code_server_error

if TYPE_CHECKING:
from typing_extensions import Self
from typing import Self

from crawlee._types import EnqueueStrategy
from crawlee.http_clients import HttpClient
Expand Down
10 changes: 5 additions & 5 deletions src/crawlee/_utils/sitemap.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@
from hashlib import sha256
from http import HTTPStatus
from logging import getLogger
from typing import TYPE_CHECKING, Literal, TypedDict
from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict
from xml.sax import SAXParseException
from xml.sax.expatreader import ExpatParser
from xml.sax.handler import ContentHandler

from typing_extensions import NotRequired, override
from typing_extensions import override
from yarl import URL

from crawlee._utils.urls import filter_url
Expand Down Expand Up @@ -151,7 +151,7 @@ def endElement(self, name: str) -> None:

elif name == 'lastmod' and text:
with suppress(ValueError):
self._current_url['lastmod'] = datetime.fromisoformat(text.replace('Z', '+00:00'))
self._current_url['lastmod'] = datetime.fromisoformat(text)

elif name == 'priority' and text:
with suppress(ValueError):
Expand Down Expand Up @@ -228,7 +228,7 @@ async def flush(self) -> AsyncGenerator[_SitemapItem, None]:
"""Process any remaining data in the buffer, yielding items one by one."""
try:
# `ExpatParser.flush` isn't part of the `IncrementalParser` interface and is missing before CPython
# 3.10.14, 3.11.9 and 3.12.3, whose bundled expat predates reparse deferral, so nothing stays buffered.
# 3.11.9 and 3.12.3, whose bundled expat predates reparse deferral, so nothing stays buffered.
if (flush := getattr(self._parser, 'flush', None)) is not None:
flush()

Expand Down Expand Up @@ -744,7 +744,7 @@ def _check_and_add(url: str) -> bool:
yield candidate
except ProxyError:
logger.warning(f'Proxy error when checking {candidate} with sitemap discovery for {hostname}')
except asyncio.TimeoutError:
except TimeoutError:
logger.warning(f'Timeout when checking {candidate} with sitemap discovery for {hostname}')
except Exception:
logger.warning(f'Error when checking {candidate} with sitemap discovery for {hostname}', exc_info=True)
Expand Down
10 changes: 5 additions & 5 deletions src/crawlee/_utils/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import os
import sys
from datetime import datetime, timezone
from datetime import UTC, datetime
from logging import WARNING, getLogger
from typing import TYPE_CHECKING, Annotated

Expand Down Expand Up @@ -135,14 +135,14 @@ class CpuInfo(BaseModel):

# Workaround for Pydantic and type checkers when using Annotated with default_factory
if TYPE_CHECKING:
created_at: datetime = datetime.now(timezone.utc)
created_at: datetime = datetime.now(UTC)
"""The time at which the measurement was taken."""
else:
created_at: Annotated[
datetime,
Field(
alias='createdAt',
default_factory=lambda: datetime.now(timezone.utc),
default_factory=lambda: datetime.now(UTC),
),
]
"""The time at which the measurement was taken."""
Expand All @@ -168,14 +168,14 @@ class MemoryUsageInfo(BaseModel):

# Workaround for Pydantic and type checkers when using Annotated with default_factory
if TYPE_CHECKING:
created_at: datetime = datetime.now(timezone.utc)
created_at: datetime = datetime.now(UTC)
"""The time at which the measurement was taken."""
else:
created_at: Annotated[
datetime,
Field(
alias='createdAt',
default_factory=lambda: datetime.now(timezone.utc),
default_factory=lambda: datetime.now(UTC),
),
]
"""The time at which the measurement was taken."""
Expand Down
3 changes: 1 addition & 2 deletions src/crawlee/_utils/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@

import tempfile
from functools import lru_cache
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, assert_never

from pydantic import AnyHttpUrl, TypeAdapter
from tldextract import TLDExtract
from typing_extensions import assert_never
from yarl import URL

if TYPE_CHECKING:
Expand Down
Loading
Loading