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
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ jobs:
REQUESTED_VERSIONS: ${{ inputs.python-versions }}
# Every supported version, oldest first; keep in sync with the
# pyproject.toml classifiers.
PYTHON_VERSIONS: '["3.10", "3.11", "3.12", "3.13", "3.14"]'
PYTHON_VERSIONS: '["3.11", "3.12", "3.13", "3.14"]'

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review (relayed): CLEAN (static)

Reviewer: OpenAI Codex CLI 0.157.1 (codex exec, model gpt-6-sol, sandbox read-only, ephemeral session 01a0e8db-e3d6-70d1-8e69-5b348cd195a7), which did not author the change.
Scope: base a86a180ebbe5e18920cd75802ea5350ce8d33f10 .. head 6aeb2ae9967f378478737cf6bcd7113c3f4342b6, in a clean detached snapshot without .env. The prompt omitted the PR number, description, commit message, and self-review findings. The reviewer was not allowed to edit, build, test, use the network, or write to GitHub.

Reviewer's result:

Surfaces covered: The specified diff; Python requirements, classifiers, Ruff and tox configuration; CI version selection for pull requests, schedules, dispatches, and releases; ARRAY timestamp decoding and its tests; UTC rewrites; repository-wide Python 3.10 references; docs, benchmarks, scripts, and uv.lock.

Verdict: CLEAN. I found no actionable regression or pre-existing issue in those surfaces. The remaining cp310-abi3 lock entries are wheels usable on supported Python versions, and tomli remains a transitive dependency on Python 3.11. This was a static review; no builds or tests were run.

After the review, the snapshot and PR worktree were unchanged (HEAD 6aeb2ae, clean status).

run: |
case "$EVENT_NAME" in
pull_request | schedule)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ PyAthena is a Python [DB API 2.0 (PEP 249)](https://www.python.org/dev/peps/pep-

- Python

- CPython 3.10, 3.11, 3.12, 3.13, 3.14
- CPython 3.11, 3.12, 3.13, 3.14

## Installation

Expand Down
2 changes: 1 addition & 1 deletion benchmarks/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ name = "pyathena-benchmarks"
version = "0.1.0"
description = "Reproducible Athena cursor measurements"
# The workspace lock covers the root's Python range; benchmark runs use Python 3.12.
requires-python = ">=3.10"
requires-python = ">=3.11"
classifiers = ["Private :: Do Not Upload"]
dependencies = [
"PyAthena[pandas,arrow,polars]",
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/tests/test_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def test_main_distributions_exclude_benchmark_code_and_dependencies():
archive.read(next(n for n in names if n.endswith("/METADATA"))).decode()
)
assert metadata["Name"].lower() == "pyathena"
assert metadata["Requires-Python"] == ">=3.10"
assert metadata["Requires-Python"] == ">=3.11"
requirements = "\n".join(metadata.get_all("Requires-Dist", []))
assert "awswrangler" not in requirements
assert "psutil" not in requirements
Expand Down
4 changes: 2 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# https://www.sphinx-doc.org/en/master/usage/configuration.html
import re
import subprocess
from datetime import datetime, timezone
from datetime import UTC, datetime


def get_version():
Expand Down Expand Up @@ -139,7 +139,7 @@ def setup(app):
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information

project = "PyAthena"
copyright = f"2017-{datetime.now(timezone.utc).year}, The PyAthena authors"
copyright = f"2017-{datetime.now(UTC).year}, The PyAthena authors"
author = "The PyAthena authors"
# Version will be set dynamically in setup() function
version = ""
Expand Down
2 changes: 1 addition & 1 deletion docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ SPDX-License-Identifier: MIT

- Python

- CPython 3.10, 3.11, 3.12, 3.13, 3.14
- CPython 3.11, 3.12, 3.13, 3.14

(installation)=

Expand Down
9 changes: 4 additions & 5 deletions pyathena/aio/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import logging
import sys
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from typing import Any, TypeVar, cast

from botocore.exceptions import BotoCoreError, ClientError
Expand Down Expand Up @@ -240,9 +240,9 @@ async def _find_previous_query_id( # type: ignore[override]
if cache_size == 0 and cache_expiration_time > 0:
cache_size = sys.maxsize
if cache_expiration_time > 0:
expiration_time = datetime.now(timezone.utc) - timedelta(seconds=cache_expiration_time)
expiration_time = datetime.now(UTC) - timedelta(seconds=cache_expiration_time)
else:
expiration_time = datetime.now(timezone.utc)
expiration_time = datetime.now(UTC)
try:
next_token = None
while cache_size > 0:
Expand All @@ -264,8 +264,7 @@ async def _find_previous_query_id( # type: ignore[override]
if (
cache_expiration_time > 0
and execution.completion_date_time
and execution.completion_date_time.astimezone(timezone.utc)
< expiration_time
and execution.completion_date_time.astimezone(UTC) < expiration_time
):
next_token = None
break
Expand Down
9 changes: 4 additions & 5 deletions pyathena/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import time
from abc import ABCMeta, abstractmethod
from collections.abc import Callable
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, TypeVar, cast

from botocore.exceptions import BotoCoreError, ClientError
Expand Down Expand Up @@ -925,9 +925,9 @@ def _find_previous_query_id(
if cache_size == 0 and cache_expiration_time > 0:
cache_size = sys.maxsize
if cache_expiration_time > 0:
expiration_time = datetime.now(timezone.utc) - timedelta(seconds=cache_expiration_time)
expiration_time = datetime.now(UTC) - timedelta(seconds=cache_expiration_time)
else:
expiration_time = datetime.now(timezone.utc)
expiration_time = datetime.now(UTC)
try:
next_token = None
while cache_size > 0:
Expand All @@ -950,8 +950,7 @@ def _find_previous_query_id(
if (
cache_expiration_time > 0
and execution.completion_date_time
and execution.completion_date_time.astimezone(timezone.utc)
< expiration_time
and execution.completion_date_time.astimezone(UTC) < expiration_time
):
next_token = None
break
Expand Down
4 changes: 2 additions & 2 deletions pyathena/formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from collections.abc import Callable
from copy import deepcopy
from dataclasses import dataclass
from datetime import date, datetime, timezone
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import Any, Literal

Expand Down Expand Up @@ -140,7 +140,7 @@ def wrap_unload(

operation_upper = operation.strip().upper()
if operation_upper.startswith(("SELECT", "WITH")):
now = datetime.now(timezone.utc).strftime("%Y%m%d")
now = datetime.now(UTC).strftime("%Y%m%d")
location = f"{s3_staging_dir}unload/{now}/{uuid.uuid4()!s}/"
operation = textwrap.dedent(
f"""
Expand Down
2 changes: 1 addition & 1 deletion pyathena/pandas/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ def _get_available_engine(self, engine_candidates: list[str]) -> str:
try:
module = importlib.import_module(engine)
return module.__name__
except ImportError as e: # noqa: PERF203
except ImportError as e:
error_msgs += f"\n - {e!s}"

available_engines = ", ".join(f"'{e}'" for e in engine_candidates)
Expand Down
20 changes: 1 addition & 19 deletions pyathena/sqlalchemy/array.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
from sqlalchemy.sql.type_api import TypeEngine
from sqlalchemy.sql.visitors import InternalTraversal

from pyathena.converter import _parse_datetime
from pyathena.formatter import _ComplexParameter
from pyathena.sqlalchemy.map import AthenaMap
from pyathena.sqlalchemy.struct import AthenaStruct
Expand Down Expand Up @@ -167,23 +166,6 @@ def __init__(self, element, type_):
self.array_type = type_


def _decode_datetime(value: str) -> datetime:
"""Decode an ARRAY element as a datetime.

Args:
value: The element as ISO 8601 text, or as Athena TIMESTAMP text of any
precision.

Returns:
The datetime. Fractional digits beyond microseconds are truncated.
"""
try:
return datetime.fromisoformat(value)
except ValueError:
# Python 3.10 accepts only 3 or 6 fractional digits.
return _parse_datetime(value)


class _ArrayTypeInspector:
"""Interpret nested ARRAY element types for SQL compilation and value conversion.

Expand Down Expand Up @@ -414,7 +396,7 @@ def _decode(self, value: Any, type_: TypeEngine[Any], as_tuple: bool = False) ->
if isinstance(type_, types.Numeric):
return Decimal(value) if type_.asdecimal else float(value)
if isinstance(type_, (types.DateTime, AthenaTimestamp)):
return value if isinstance(value, datetime) else _decode_datetime(value)
return value if isinstance(value, datetime) else datetime.fromisoformat(value)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round one (implementation behavior): CLEAN

Scope: base a86a180ebbe5e18920cd75802ea5350ce8d33f10 .. head 6aeb2ae9967f378478737cf6bcd7113c3f4342b6, all 21 changed files.

Covered:

  • ARRAY timestamp decoding: on Python 3.11+ the old helper already returned datetime.fromisoformat(value) whenever it succeeded, so inlining only removes the branch that ran when fromisoformat raised. Checked on 3.11.11 and 3.14.5: text with 0–7, 9 and 12 fractional digits and the ISO T form parses and truncates to microseconds, like _parse_datetime. Text that neither accepts (e.g. a trailing UTC) still raises ValueError. _parse_datetime keeps its converter callers (pyathena/converter.py).
  • timezone.utc → UTC (UP017, 31 sites in library, scripts, tests, docs/conf.py): datetime.UTC is datetime.timezone.utc is True, so the cache-expiration comparison in pyathena/common.py/pyathena/aio/common.py and the UNLOAD location date in pyathena/formatter.py are unchanged.
  • RUF100 in pyathena/pandas/result_set.py:395: only a stale noqa: PERF203 (the rule targets Python < 3.11) is removed.
  • scripts/check_license_headers.py: tomllib is stdlib from 3.11; no other tomli users remain.
  • Test workflow: PYTHON_VERSIONS still matches the classifiers; the PR/schedule path (last) still selects 3.14, and the dispatch path now rejects 3.10 with the existing error.
  • No other sys.version_info gates or 3.10 comments remain (git grep). uv.lock has requires-python = ">=3.11", and only 3.10-only resolutions were removed.

Tests: the existing test_array_result_conversion cases (1- and 9-digit fractions, T form) pass offline on 3.11 and 3.14. The 12-digit case was only checked by hand.

Limitation: the AWS suites have not been run for this head.

if isinstance(type_, (types.Date, AthenaDate)):
return value if isinstance(value, date) else date.fromisoformat(value)
if isinstance(type_, (types.LargeBinary, types.BINARY, types.VARBINARY)):
Expand Down
9 changes: 3 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ dependencies = [
"fsspec",
"python-dateutil",
]
requires-python = ">=3.10"
requires-python = ">=3.11"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round two (claims, callers, operations): FINDINGS (PR description only, corrected)

Scope: base a86a180ebbe5e18920cd75802ea5350ce8d33f10 .. head 6aeb2ae9967f378478737cf6bcd7113c3f4342b6, the full PR body, commit message, and changed docs.

Claims checked:

  • "Python 3.10 reaches end of life on 2026-10-31": PEP 619 only gives "approximately October 2026". The PR body now says "in October 2026 (PEP 619)"; the commit message keeps the issue's date.
  • The uv.lock removal list, checked by diffing the locked package/version pairs: backports-asyncio-runner, exceptiongroup, markdown-it-py 3.0.0, myst-parser 4.0.1, networkx 3.4.2, numpy 2.2.6, pandas 2.3.3, pytz, sphinx 7.4.7, and sphinx-design 0.6.1. sphinx-design was missing from the description; added. tomli stays in the lock as a transitive dependency for python_full_version <= '3.11', not as our dev dependency.
  • "pandas 3.0 requires Python 3.11": pandas 3.0.0 release notes and the locked 3.0.6 metadata (Requires-Python >=3.11).
  • "fromisoformat accepts any number of fractional digits" (commit): matches the Python 3.11 datetime.fromisoformat docs and the 3.11.11/3.14.5 run recorded in round one.
  • "UP017 at 31 sites": ruff check --statistics reported 31 UP017 and 1 RUF100 before --fix.

Existing callers: Python 3.10 installers resolve to an earlier release through Requires-Python. pyathena.sqlalchemy.array._decode_datetime was added after v3.36.0 (git tag --contains 9a5ca5c is empty), so no released API disappears.

Operations: docs/testing.md stays accurate ("every supported Python version", dispatch example 3.11,3.14). The Release and full-dispatch runs now start 4 matrix jobs instead of 5, while PR/schedule runs still use 3.14 only, so AWS usage drops and nothing is added.

Evidence limits: all validation so far is local and offline. AWS behavior on 3.11 will come from a Test workflow dispatch with python-versions=3.11 before Ready.

readme = "README.md"
license = "MIT"
license-files = ["LICENSE", "NOTICE"]
Expand All @@ -24,7 +24,6 @@ classifiers = [
"Operating System :: OS Independent",
"Topic :: Database :: Front-Ends",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
Expand Down Expand Up @@ -94,7 +93,6 @@ dev = [
"sphinx-design",
"types-python-dateutil",
"cfn-lint>=1",
"tomli>=2.0.0; python_version<'3.11'",
]

[build-system]
Expand Down Expand Up @@ -142,7 +140,7 @@ exclude = [
".tox",
"benchmarks",
]
target-version = "py310"
target-version = "py311"

[tool.ruff.lint]
# https://docs.astral.sh/ruff/rules/
Expand Down Expand Up @@ -208,11 +206,10 @@ exclude = [
legacy_tox_ini = """
[tox]
isolated_build = true
envlist = py{310,311,312,313,314}-{pyathena,sqla,sqla_async}
envlist = py{311,312,313,314}-{pyathena,sqla,sqla_async}

[gh-actions]
python =
3.10: py310
3.11: py311
3.12: py312
3.13: py313
Expand Down
6 changes: 1 addition & 5 deletions scripts/check_license_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,10 @@
import re
import subprocess
import sys
import tomllib
from dataclasses import dataclass
from pathlib import Path

if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib

HEADER_LINES = (
r"Copyright \d{4} The PyAthena authors",
"",
Expand Down
6 changes: 3 additions & 3 deletions scripts/sweep_databases.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
import os
import re
import time
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from typing import Any

import boto3
Expand Down Expand Up @@ -75,7 +75,7 @@ def sweep_databases(client: Any, catalog_id: str, *, dry_run: bool = True) -> di
Databases younger than seven days are retained, including concurrent CI runs.
Only Glue metadata is deleted; S3 objects and child catalogs are untouched.
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
cutoff = datetime.now(UTC) - timedelta(days=7)
# Finish pagination before deleting anything from the catalog.
candidates = [
database
Expand Down Expand Up @@ -141,7 +141,7 @@ def sweep_s3tables_namespaces(
Returns:
The numbers of eligible, deleted and skipped namespaces.
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
cutoff = datetime.now(UTC) - timedelta(days=7)
# Finish pagination before deleting anything from the table bucket.
candidates = [
namespace
Expand Down
8 changes: 4 additions & 4 deletions scripts/tests/test_sweep_databases.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
#
# SPDX-License-Identifier: MIT

from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from unittest.mock import Mock

import boto3
Expand All @@ -22,7 +22,7 @@
)

CATALOG = "123456789012"
OLD = datetime.now(timezone.utc) - timedelta(days=10)
OLD = datetime.now(UTC) - timedelta(days=10)
DATABASE = {"Name": "pyathena_test_abcdefghij", "CreateTime": OLD}
BUCKET_ARN = f"arn:aws:s3tables:us-west-2:{CATALOG}:bucket/table-bucket"
NAMESPACE = {
Expand Down Expand Up @@ -109,7 +109,7 @@ def test_preview_and_apply_finish_pagination_before_mutating(glue):
"current",
[
{**DATABASE, "CreateTime": OLD - timedelta(days=1)},
{**DATABASE, "CreateTime": datetime.now(timezone.utc)},
{**DATABASE, "CreateTime": datetime.now(UTC)},
{**DATABASE, "TargetDatabase": {"CatalogId": CATALOG, "DatabaseName": "shared"}},
],
)
Expand Down Expand Up @@ -283,7 +283,7 @@ def test_namespace_sweep_deletes_tables_then_namespace(s3tables):
@pytest.mark.parametrize(
"current",
[
{**NAMESPACE, "createdAt": datetime.now(timezone.utc)},
{**NAMESPACE, "createdAt": datetime.now(UTC)},
RECREATED,
],
)
Expand Down
6 changes: 3 additions & 3 deletions tests/pyathena/aio/test_cursor.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import asyncio
import re
import threading
from datetime import datetime, timezone
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
Expand Down Expand Up @@ -171,7 +171,7 @@ def execution(schema):
"QueryExecutionContext": {"Database": schema},
"Status": {
"State": AthenaQueryExecution.STATE_SUCCEEDED,
"CompletionDateTime": datetime.now(timezone.utc),
"CompletionDateTime": datetime.now(UTC),
},
}
}
Expand Down Expand Up @@ -208,7 +208,7 @@ def execution(catalog):
"QueryExecutionContext": {"Database": schema, "Catalog": catalog},
"Status": {
"State": AthenaQueryExecution.STATE_SUCCEEDED,
"CompletionDateTime": datetime.now(timezone.utc),
"CompletionDateTime": datetime.now(UTC),
},
}
}
Expand Down
4 changes: 2 additions & 2 deletions tests/pyathena/filesystem/test_s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import urllib.request
import uuid
from concurrent.futures import Future, ThreadPoolExecutor
from datetime import datetime, timezone
from datetime import UTC, datetime
from itertools import chain
from pathlib import Path
from types import SimpleNamespace
Expand Down Expand Up @@ -786,7 +786,7 @@ def test_info_file(self, fs):
with pytest.raises(FileNotFoundError):
fs.info(file)

now = datetime.now(timezone.utc)
now = datetime.now(UTC)
fs.pipe(file, b"a")
bucket, key, version_id = fs.parse_path(file)
fs.invalidate_cache()
Expand Down
4 changes: 2 additions & 2 deletions tests/pyathena/filesystem/test_s3_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import urllib.parse
import urllib.request
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from itertools import chain
from pathlib import Path

Expand Down Expand Up @@ -371,7 +371,7 @@ async def test_info_file(self, fs):
with pytest.raises(FileNotFoundError):
await fs._info(file)

now = datetime.now(timezone.utc)
now = datetime.now(UTC)
await fs._pipe_file(file, b"a")
bucket, key, version_id = fs.parse_path(file)
fs.invalidate_cache()
Expand Down
Loading
Loading