diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index e09b9c1..2518020 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -9,16 +9,16 @@ jobs: fail-fast: false matrix: include: - - python-version: 3.13 + - python-version: 3.14 env: TOXENV: typing - - python-version: 3.13 + - python-version: 3.14 env: TOXENV: docs - - python-version: 3.13 + - python-version: 3.14 env: TOXENV: twinecheck - - python-version: 3.13 + - python-version: 3.14 env: TOXENV: pylint diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aa82c0d..9e9e504 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v5 - uses: actions/setup-python@v6 with: - python-version: 3.13 + python-version: 3.14 - run: | python -m pip install --upgrade build python -m build diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4f54ed4..c6aa077 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,21 +10,18 @@ jobs: fail-fast: false matrix: include: - - python-version: "3.9" + - python-version: "3.10" env: TOXENV: min-attrs - - python-version: "3.9" + - python-version: "3.10" env: TOXENV: min-pydantic - - python-version: "3.9" + - python-version: "3.10" env: TOXENV: min-scrapy - - python-version: "3.9" + - python-version: "3.10" env: TOXENV: min-extra - - python-version: "3.9" - env: - TOXENV: py - python-version: "3.10" env: TOXENV: py @@ -37,21 +34,19 @@ jobs: - python-version: "3.13" env: TOXENV: py - - python-version: "3.14.0-rc.2" + - python-version: "3.14" env: TOXENV: py - - python-version: "3.14.0-rc.2" + - python-version: "3.14" env: TOXENV: attrs - # pydantic doesn't yet support 3.14 - - python-version: "3.13" + - python-version: "3.14" env: TOXENV: pydantic - - python-version: "3.14.0-rc.2" + - python-version: "3.14" env: TOXENV: scrapy - # pydantic doesn't yet support 3.14 - - python-version: "3.13" + - python-version: "3.14" env: TOXENV: extra - python-version: "pypy3.11" @@ -86,7 +81,7 @@ jobs: uses: codecov/codecov-action@v5 tests-other-os: - name: "Test: py39, ${{ matrix.os }}" + name: "Test: py310, ${{ matrix.os }}" runs-on: "${{ matrix.os }}" strategy: matrix: @@ -98,7 +93,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: 3.9 + python-version: 3.10 - name: Install tox run: pip install tox diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9ef4361..a4aae43 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.13.0 + rev: v0.14.4 hooks: - id: ruff-check args: [ --fix ] diff --git a/README.md b/README.md index 3988ecc..bf18170 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ a pre-defined interface (see [extending `itemadapter`](#extending-itemadapter)). ## Requirements -* Python 3.9+, either the CPython implementation (default) or the PyPy +* Python 3.10+, either the CPython implementation (default) or the PyPy implementation * [`scrapy`](https://scrapy.org/) 2.2+: optional, needed to interact with `scrapy` items diff --git a/itemadapter/_json_schema.py b/itemadapter/_json_schema.py index 4fbdc3a..75f8ea1 100644 --- a/itemadapter/_json_schema.py +++ b/itemadapter/_json_schema.py @@ -9,6 +9,7 @@ from copy import copy from enum import Enum from textwrap import dedent +from types import MappingProxyType, UnionType from typing import ( TYPE_CHECKING, Any, @@ -24,8 +25,6 @@ from .utils import _is_pydantic_model if TYPE_CHECKING: - from types import MappingProxyType - from .adapter import AdapterInterface, ItemAdapter @@ -139,7 +138,7 @@ def array_type(type_hint): unique_args = set(args) if len(unique_args) == 1: return next(iter(unique_args)) - return Union[tuple(unique_args)] + return Union[tuple(unique_args)] # noqa: UP007 def update_prop_from_pattern(prop: dict[str, Any], pattern: str) -> None: @@ -147,12 +146,7 @@ def update_prop_from_pattern(prop: dict[str, Any], pattern: str) -> None: prop.setdefault("pattern", pattern) -try: - from types import UnionType -except ImportError: # Python < 3.10 - UNION_TYPES: set[Any] = {Union} -else: - UNION_TYPES = {Union, UnionType} +UNION_TYPES = {Union, UnionType} def update_prop_from_origin( @@ -204,7 +198,7 @@ def update_prop_from_type(prop: dict[str, Any], prop_type: Any, state: _JsonSche if issubclass(prop_type, Enum): values = [item.value for item in prop_type] value_types = tuple({type(v) for v in values}) - prop_type = value_types[0] if len(value_types) == 1 else Union[value_types] + prop_type = value_types[0] if len(value_types) == 1 else Union[value_types] # noqa: UP007 update_prop_from_type(prop, prop_type, state) prop.setdefault("enum", values) return diff --git a/pyproject.toml b/pyproject.toml index 44f6434..6fee66f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,17 +11,17 @@ authors = [ readme = "README.md" license = "BSD-3-Clause" license-files = ["LICENSE"] -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Operating System :: OS Independent", @@ -74,6 +74,9 @@ regex = true [[tool.bumpversion.files]] filename = "itemadapter/__init__.py" +[tool.coverage.run] +branch = true + [tool.pylint.MASTER] persistent = "no" load-plugins=[ @@ -257,7 +260,3 @@ split-on-trailing-comma = false [tool.ruff.lint.pydocstyle] convention = "pep257" - -[tool.ruff.lint.pyupgrade] -# for Pydantic annotations while we support Python 3.9 -keep-runtime-typing = true diff --git a/tests/__init__.py b/tests/__init__.py index 5735527..7464c26 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -5,13 +5,13 @@ from contextlib import contextmanager from dataclasses import dataclass, field from enum import Enum -from typing import TYPE_CHECKING, Any, Callable, Optional, Union +from typing import TYPE_CHECKING, Any from itemadapter import ItemAdapter from itemadapter._imports import pydantic, pydantic_v1 if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Callable, Generator def make_mock_import(block_name: str) -> Callable: @@ -88,7 +88,7 @@ class DataClassItemJsonSchema: name: str = field(metadata={"json_schema_extra": {"title": "Name"}}) """Display name""" color: Color - answer: Union[str, float, int, None] + answer: str | float | int | None numbers: list[float] aliases: dict[str, str] nested: DataClassItemJsonSchemaNested @@ -151,7 +151,7 @@ class AttrsItemJsonSchema: name: str = attr.ib(metadata={"json_schema_extra": {"title": "Name"}}) """Display name""" color: Color = attr.ib() - answer: Union[str, float, int, None] = attr.ib() + answer: str | float | int | None = attr.ib() numbers: list[float] = attr.ib() aliases: dict[str, str] = attr.ib() nested: AttrsItemJsonSchemaNested = attr.ib() @@ -173,17 +173,17 @@ class AttrsItemJsonSchema: else: class PydanticV1Model(pydantic_v1.BaseModel): - name: Optional[str] = pydantic_v1.Field( + name: str | None = pydantic_v1.Field( default_factory=lambda: None, serializer=str, ) - value: Optional[int] = pydantic_v1.Field( + value: int | None = pydantic_v1.Field( default_factory=lambda: None, serializer=int, ) class PydanticV1SpecialCasesModel(pydantic_v1.BaseModel): - special_cases: Optional[int] = pydantic_v1.Field( + special_cases: int | None = pydantic_v1.Field( default_factory=lambda: None, alias="special_cases", allow_mutation=False, @@ -220,7 +220,7 @@ class PydanticV1ModelJsonSchema(pydantic_v1.BaseModel): value: Any = None color: Color produced: bool - answer: Union[str, float, int, None] + answer: str | float | int | None numbers: list[float] aliases: dict[str, str] nested: PydanticV1ModelJsonSchemaNested @@ -245,17 +245,17 @@ class Config: else: class PydanticModel(pydantic.BaseModel): - name: Optional[str] = pydantic.Field( + name: str | None = pydantic.Field( default_factory=lambda: None, json_schema_extra={"serializer": str}, ) - value: Optional[int] = pydantic.Field( + value: int | None = pydantic.Field( default_factory=lambda: None, json_schema_extra={"serializer": int}, ) class PydanticSpecialCasesModel(pydantic.BaseModel): - special_cases: Optional[int] = pydantic.Field( + special_cases: int | None = pydantic.Field( default_factory=lambda: None, alias="special_cases", frozen=True, @@ -294,7 +294,7 @@ class PydanticModelJsonSchema(pydantic.BaseModel): value: Any = None color: Color produced: bool = pydantic.Field(default_factory=lambda: True) - answer: Union[str, float, int, None] + answer: str | float | int | None numbers: list[float] aliases: dict[str, str] nested: PydanticModelJsonSchemaNested @@ -367,7 +367,7 @@ class ScrapySubclassedItemJsonSchema(ScrapyItem): ) color: Color = Field() produced = Field() - answer: Union[str, float, int, None] = Field() + answer: str | float | int | None = Field() numbers: list[float] = Field() aliases: dict[str, str] = Field() nested: ScrapySubclassedItemJsonSchemaNested = Field() diff --git a/tests/test_adapter_pydantic.py b/tests/test_adapter_pydantic.py index a444fc2..7d2057b 100644 --- a/tests/test_adapter_pydantic.py +++ b/tests/test_adapter_pydantic.py @@ -1,6 +1,7 @@ +from __future__ import annotations + import unittest from types import MappingProxyType -from typing import Optional from unittest import mock import pytest @@ -86,7 +87,7 @@ def test_true(self): mapping_proxy_type = get_field_meta_from_class(PydanticModel, "name") assert mapping_proxy_type == MappingProxyType( { - "annotation": Optional[str], + "annotation": str | None, "default_factory": mapping_proxy_type["default_factory"], "json_schema_extra": {"serializer": str}, "repr": True, @@ -95,7 +96,7 @@ def test_true(self): mapping_proxy_type = get_field_meta_from_class(PydanticModel, "value") assert get_field_meta_from_class(PydanticModel, "value") == MappingProxyType( { - "annotation": Optional[int], + "annotation": int | None, "default_factory": mapping_proxy_type["default_factory"], "json_schema_extra": {"serializer": int}, "repr": True, @@ -104,7 +105,7 @@ def test_true(self): mapping_proxy_type = get_field_meta_from_class(PydanticSpecialCasesModel, "special_cases") assert mapping_proxy_type == MappingProxyType( { - "annotation": Optional[int], + "annotation": int | None, "alias": "special_cases", "alias_priority": 2, "default_factory": mapping_proxy_type["default_factory"], diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py index d201482..cef3ec7 100644 --- a/tests/test_json_schema.py +++ b/tests/test_json_schema.py @@ -7,7 +7,7 @@ from collections.abc import Mapping, Sequence # noqa: TC003 from dataclasses import dataclass, field from enum import Enum -from typing import Any, Optional, Union +from typing import Any import pytest @@ -48,7 +48,7 @@ class OptionalItemListNestedItem: @dataclass class OptionalItemListItem: - foo: Optional[list[OptionalItemListNestedItem]] = None + foo: list[OptionalItemListNestedItem] | None = None @dataclass @@ -330,7 +330,7 @@ class TestItem: def test_union_single(self): @dataclass class TestItem: - foo: Union[str] + foo: str actual = ItemAdapter.get_json_schema(TestItem) expected = { @@ -346,7 +346,7 @@ class TestItem: def test_custom_any_of(self): @dataclass class TestItem: - foo: Union[str, SimpleItem] = field( + foo: str | SimpleItem = field( metadata={"json_schema_extra": {"anyOf": []}}, ) @@ -459,7 +459,6 @@ class TestItem: check_schemas(actual, expected) @unittest.skipIf(not AttrsItem, "attrs module is not available") - @unittest.skipIf(PYTHON_VERSION < (3, 10), "Modern optional annotations require Python 3.10+") def test_modern_optional_annotations(self): import attr @@ -583,7 +582,7 @@ class TestItem: @unittest.skipIf(not ScrapySubclassedItem, "scrapy module is not available") @unittest.skipIf(not AttrsItem, "attrs module is not available") def test_scrapy_attrs(self): - actual = ItemAdapter.get_json_schema(ScrapySubclassedItemCrossNested) + actual = ItemAdapter.get_json_schema(ScrapySubclassedItemCrossNested) # pylint: disable=possibly-used-before-assignment expected = { "type": "object", "additionalProperties": False, diff --git a/tox.ini b/tox.ini index 0af2c34..ca29344 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,12 @@ [tox] -envlist = min-attrs,min-pydantic,min-scrapy,min-extra,py39,py310,py311,py312,py313,py314,attrs,pydantic1,pydantic,scrapy,extra,extra-pydantic1,pre-commit,typing,docs,twinecheck,pylint +envlist = min-attrs,min-pydantic,min-scrapy,min-extra,py310,py311,py312,py313,py314,attrs,pydantic1,pydantic,scrapy,extra,extra-pydantic1,pre-commit,typing,docs,twinecheck,pylint [testenv] basepython = - min-attrs,min-pydantic,min-scrapy,min-extra: python3.9 + min-attrs,min-pydantic,min-scrapy,min-extra: python3.10 deps = pytest>=5.4 - pytest-cov>=2.8 + pytest-cov>=7.0.0 packaging min-attrs,min-extra: attrs==20.1.0 min-pydantic,min-extra: pydantic==1.8 @@ -33,16 +33,16 @@ commands = [testenv:typing] basepython = python3 deps = - mypy==1.18.1 - attrs==25.3.0 - pydantic==2.11.9 + mypy==1.18.2 + attrs==25.4.0 + pydantic==2.12.4 scrapy==2.13.3 commands = mypy {posargs:itemadapter} [testenv:pylint] deps = - pylint==3.3.6 + pylint==4.0.2 pylint-per-file-ignores==1.4.0 commands = pylint {posargs:itemadapter tests}