diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cb65dac..58ae281 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,13 +13,13 @@ updates: # .pre-commit-config.yaml, and the two move together by hand. A # Dependabot bump of the cap alone splits CI from the local hook. # Name-only on purpose: an update-types ignore is computed from the first - # X.Y.Z version in the requirement, and `>=24.0,<25.10` has none, so it + # X.Y.Z version in the requirement, and `>=26.5,<26.6` has none, so it # would match nothing. Security updates are not blocked by it: in # dependabot-core, IgnoreCondition#ignored_versions returns early for # security_updates_only with just the explicit `versions`, and a - # name-only ignore has none. Black's security fixes since 25.9.0 need - # Python 3.10+, so a security PR for it is a decision about the 3.9 - # floor and the hook rev, not a plain merge. + # name-only ignore has none. Security updates are off for this repo, + # though, so a black advisory has to be caught by hand, and the fix + # moves the hook rev in .pre-commit-config.yaml alongside the cap. - dependency-name: "black" # GitHub Actions used by the CI and publish workflows. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90986db..71f4064 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3a57c34..d7188bf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,7 +36,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d9b751c..eca73c2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,10 +10,9 @@ repos: - id: ruff-check - repo: https://github.com/psf/black - # Kept in sync with the [dev] cap in pyproject.toml (<25.10); move both - # together by hand. Stay on a black that runs on Python 3.9 (the project's - # runtime floor): 25.11.0 is the last one, 25.12.0+ require 3.10. - rev: 25.9.0 + # Kept in sync with the [dev] cap in pyproject.toml (<26.6); move both + # together by hand. + rev: 26.5.1 hooks: - id: black diff --git a/AGENTS.md b/AGENTS.md index 09ea728..b208c14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,14 +116,6 @@ the linked regression test first. (`query_stream`, Phase 8). The hook fires for `query`/`execute`/`submit` today; there is no streaming path yet, so no regression test until the feature lands (this is spike bug #2, intentionally not yet covered). -- **No PEP 604 unions at runtime** (py3.9 floor). ruff's `UP` (pyupgrade) rule is - omitted on purpose: it would rewrite `Optional[...]` / `Union[...]` to - `X | None`, which raises at import time on 3.9 for typing generics (PEP 604 on - generics is 3.10+). Keep `from __future__ import annotations` plus - `Optional`/`Union`. -- **mypy `python_version = "3.10"` vs the 3.9-3.13 matrix.** 3.10 is the lowest - this mypy accepts; true 3.9 runtime compatibility is enforced by the pytest - matrix, which imports every module under 3.9. - **Coverage uses `coverage run`, not `pytest --cov`.** The package ships a pytest plugin via the `pytest11` entry point, so `snowflake_sql_api.testing` (and the whole package) is imported at plugin-load time, before pytest-cov starts @@ -140,7 +132,7 @@ the linked regression test first. fixtures). No respx. See `docs/testing.md`. - Each fixed bug gets a named regression test (`test_regression_*`) so it cannot silently return. -- Target coverage: >= 89%, enforced across Python 3.9-3.13. Run with +- Target coverage: >= 89%, enforced across Python 3.11-3.15. Run with `coverage run -m pytest && coverage report` (see Known Quirks). ## Common Mistakes @@ -153,7 +145,6 @@ the linked regression test first. - Adding a runtime dependency without strong justification. The small install / fast cold start is the whole point; new optional features go behind an extra. -- Rewriting `Optional[...]` to `X | None` (breaks the 3.9 runtime). - Conflating the JWT claim account with the API host (see Known Quirks). - Forgetting the async counterpart of a sync change (sync/async parity). - Forgetting a `test_regression_*` for a fixed bug. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13c494b..c6667b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,7 @@ All four must pass (and `pre-commit run --all-files` is clean). Use `coverage ru (`snowflake_sql_api.testing`), and `coverage run` starts tracing before that plugin is imported so import-time lines are measured correctly. The coverage gate is enforced (`fail_under = 89` in `pyproject.toml`); `coverage report` exits -non-zero below it. CI runs the same checks across Python 3.9-3.13. +non-zero below it. CI runs the same checks across Python 3.11-3.15. Checklist: diff --git a/docs/getting-started.md b/docs/getting-started.md index 2000980..3ac09f6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -17,7 +17,7 @@ pip install "snowflake-sql-api[pandas]" # DataFrame output helpers pip install "snowflake-sql-api[pydantic]" # typed-row mapping ``` -Requires Python 3.9 or newer. Core dependencies are `httpx`, `PyJWT`, and +Requires Python 3.11 or newer. Core dependencies are `httpx`, `PyJWT`, and `cryptography`. ## Prerequisites diff --git a/pyproject.toml b/pyproject.toml index ab699d1..9883a55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "snowflake-sql-api" dynamic = ["version"] description = "Lightweight, pure-Python client for Snowflake's SQL API v2" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.11" license = { file = "LICENSE" } authors = [{ name = "hampsterx" }] keywords = ["snowflake", "sql", "rest", "api", "httpx", "serverless", "lambda"] @@ -17,11 +17,11 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "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 :: 3.15", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Database", "Typing :: Typed", @@ -41,12 +41,10 @@ dev = [ "pytest-asyncio>=0.21", "respx>=0.20", "ruff>=0.4", - # Capped below 25.10 so CI formats with the same black the pre-commit hook - # pins (rev 25.9.0 in .pre-commit-config.yaml). Move the two together by - # hand; Dependabot ignores black (.github/dependabot.yml). Keep the cap at - # or below 25.12 while Python 3.9 is supported: 25.12.0+ require 3.10, so - # a higher cap resolves a different black per interpreter. - "black>=24.0,<25.10", + # Held to 26.5.x so CI formats with the same black the pre-commit hook + # pins (rev 26.5.1 in .pre-commit-config.yaml). Move the two together by + # hand; Dependabot ignores black (.github/dependabot.yml). + "black>=26.5,<26.6", "mypy>=1.8", "build>=1.0", "pre-commit>=3.5", @@ -89,13 +87,13 @@ include = [ [tool.ruff] line-length = 88 -target-version = "py39" +target-version = "py311" [tool.ruff.lint] select = ["E", "F", "W", "I", "B", "C4", "SIM"] -# E501 (line length) is owned by black. `UP` (pyupgrade) is omitted: it would -# rewrite runtime type aliases like `Optional[Sequence[Any]]` to `X | None`, -# which raises at import time on Python 3.9 (PEP 604 on typing generics is 3.10+). +# E501 (line length) is owned by black. `UP` (pyupgrade) is not enabled yet: the +# code predates the 3.11 floor and still spells `Optional`/`List`, so turning it +# on is a ~250-site annotation rewrite of its own. ignore = ["E501"] [tool.ruff.lint.per-file-ignores] @@ -103,14 +101,14 @@ ignore = ["E501"] [tool.black] line-length = 88 -# Pin to py39 grammar: our code uses no newer syntax, and a single floor target -# keeps black's AST safety check parseable by the interpreter running CI. -target-version = ["py39"] +# Pin to the floor's grammar: a single floor target keeps black's AST safety +# check parseable by the interpreter running CI. +target-version = ["py311"] [tool.mypy] -# Lowest target this mypy accepts; true 3.9 runtime compat is gated by the -# pytest matrix (3.9-3.13), which imports every module under each interpreter. -python_version = "3.10" +# The runtime floor; the pytest matrix (3.11-3.15) imports every module under +# each supported interpreter. +python_version = "3.11" files = ["snowflake_sql_api"] disallow_untyped_defs = true disallow_incomplete_defs = true diff --git a/snowflake_sql_api/types.py b/snowflake_sql_api/types.py index 13f9534..937e11b 100644 --- a/snowflake_sql_api/types.py +++ b/snowflake_sql_api/types.py @@ -178,6 +178,9 @@ def coerce_rows(raw_rows: List[List[Any]], columns: List[ColumnMeta]) -> List[Ro """Coerce a partition of raw rows into a list of name-keyed :data:`Row`.""" names = [c.name for c in columns] return [ - {name: coerce_value(cell, col) for name, col, cell in zip(names, columns, row)} + { + name: coerce_value(cell, col) + for name, col, cell in zip(names, columns, row, strict=False) + } for row in raw_rows ] diff --git a/tests/test_import_footprint.py b/tests/test_import_footprint.py index e915985..147fb3a 100644 --- a/tests/test_import_footprint.py +++ b/tests/test_import_footprint.py @@ -64,9 +64,7 @@ def exec_module(self, module): sys.meta_path.insert(0, _Recorder()) import snowflake_sql_api # noqa: F401 print(",".join(sorted(set(attempted)))) -""".format( - forbidden=FORBIDDEN_ON_BARE_IMPORT -) +""".format(forbidden=FORBIDDEN_ON_BARE_IMPORT) _TIMING_SCRIPT = """ import time diff --git a/tests/test_testing.py b/tests/test_testing.py index c15aed0..fa47155 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -137,8 +137,6 @@ def test_register_match_predicate() -> None: def test_unregistered_statement_raises() -> None: - # Single `with` per statement here: a parenthesized multi-context `with` - # is 3.10+ syntax and breaks the 3.9 leg of the test matrix. fake = FakeSnowflake() client = make_client(fake) with pytest.raises(FakeSnowflakeError, match="no result registered"):