Apply dict_type only to the dict cursor it was given to - #930
Conversation
DictCursor, AsyncDictCursor and AioDictCursor assigned dict_type to the class attribute of the shared dict result set class, so one cursor's dict_type changed the row type of every dict cursor in the process. When dict_type is given, the cursor now uses its own subclass of the result set class that carries it, and the shared class is left unchanged. Closes #923 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Classes created with type() under ABCMeta took their __module__ from the abc module, so they were shown as abc.AthenaDictResultSet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| AthenaDictResultSet.__name__, | ||
| (AthenaDictResultSet,), | ||
| { | ||
| "__module__": AthenaDictResultSet.__module__, |
There was a problem hiding this comment.
Self-review round one (implementation behavior) — FINDINGS, repaired
Scope: base 6be315ec4c87758f993778b0b2167696e848390a (merge-base with master) .. head 0b236dd9ace2834ab680e3726b4cfd2d4b8a34b1, full diff (3 cursor modules, 3 test modules).
Covered:
- Behavior: the only constructions of
_result_set_classareCursor.execute(pyathena/cursor.py:188),AsyncCursor._collect_result_set(pyathena/async_cursor.py:194) andAioCursor.executeviacreate()(pyathena/aio/cursor.py:170); all instantiate through the class, so the subclass inherits construction, pre-fetch andcreate()unchanged. No code compares result set types by identity;isinstancechecks still hold. - Shared state: cursors without
dict_typekeep the base class, which is no longer modified (checked with the issue's no-AWS reproduction for all three cursor classes;AthenaAioDictResultSetno longer inherits a value written toAthenaDictResultSetby a sync cursor). - Resources: the generated classes are collected once their cursor is gone (checked with
weakref+gc.collect());ABCMetais applied via the base's metaclass,__abstractmethods__is empty. - Tests: each new
test_dict_typefailed on the unfixed code (assert <class 'collections.OrderedDict'> is dict) and asserts the observable row type of a cursor created before thedict_typecursor.
Finding (introduced, repaired in 27626643): classes created with type() under ABCMeta took __module__ from the abc module, so repr() showed <class 'abc.AthenaDictResultSet'>. The namespace now passes the base class's __module__; repr is pyathena.result_set.AthenaDictResultSet / pyathena.aio.result_set.AthenaAioDictResultSet. Lint and the three dict cursor test classes (13 passed) re-run on the repair.
Not changed: the 10-line block is repeated in the three cursors; a shared helper on the result set class would be a new method, outside the approved shape of this fix.
| ``dict_type``, it is also assigned to the class attribute | ||
| ``AthenaDictResultSet.dict_type``, the type used to build each row. | ||
| ``dict_type``, it is the type used to build each row of this | ||
| cursor's result sets; other cursors are not affected. |
There was a problem hiding this comment.
Self-review round two (claims, callers, operations) — FINDINGS, PR description corrected
Scope: base 6be315ec4c87758f993778b0b2167696e848390a .. head 27626643b1770bd9cc143c3a5f1486c018475f3e, full pass over the PR body, the three changed docstrings, both commit messages, docs and issue premises.
Claims checked:
- "rows are built from that attribute on each fetch" / "including cursors created before it":
AthenaDictResultSet._get_rowsreadsself.dict_typeper page (pyathena/result_set.py:815); each new test's fixture cursor is created before thedict_typecursor and failed on the unfixed code. cursor_kwargs={"dict_type": ...}path:Connection.cursormerges{**self.cursor_kwargs, **kwargs}and forwards the remainder as**kwargs, so the dict cursors seedict_typeexactly as for a direct argument; unrecognized keys still end inBaseCursor.__init__(**kwargs)as before.- Docstrings ("other cursors are not affected") and docs:
docs/cursor.md:76,85,284,293anddocs/aio.md:192passdict_typeper cursor and stay correct; no prose describes the old global effect. - Existing callers: same trigger (
"dict_type" in kwargs, including an explicitNone, which now breaks only that cursor instead of all of them); a globalAthenaDictResultSet.dict_type = ...still applies to cursors withoutdict_type, now also toAthenaAioDictResultSet, which is no longer shadowed by an aio cursor's assignment. - Concurrency: no shared mutable state remains, so threads creating dict cursors with different
dict_typeno longer race. - AWS: no runtime API change; the tests add 2
one_rowqueries per dict cursor type.
Findings (PR description, corrected):
- "subclassing the result set class still works as before" was too broad. A
DictCursorsubclass that replaces_result_set_classafterDictCursor.__init__previously receiveddict_typethrough the shared class attribute and no longer does. The body now states this in the behavior-change note. - "The result set reads the first page in its constructor" did not hold for aio (
AthenaAioResultSet.create()pre-fetches); reworded.
Evidence limits: local runs cover the three dict cursor test classes (13 passed on 27626643) and lint; the full just test pyathena matrix runs in CI after Ready.
| self._result_set_class = AthenaDictResultSet | ||
| if "dict_type" in kwargs: | ||
| AthenaDictResultSet.dict_type = kwargs["dict_type"] | ||
| self._result_set_class = cast( |
There was a problem hiding this comment.
Independent review (relayed) — FINDINGS
Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, sandbox read-only, --ephemeral, session 01a0ffc3-91e5-7322-8076-eb77dba8a8a7. Static review only (no builds, tests or network). Range: merge-base 6be315ec4c87758f993778b0b2167696e848390a .. head 27626643b1770bd9cc143c3a5f1486c018475f3e, detached snapshot without .env; the prompt carried the diff and intended behavior, not the PR number, description, commits or earlier findings. Snapshot and PR worktree verified unchanged after the run.
Reviewer output (verbatim)
Surfaces covered:
- Direct keywords and connection
cursor_kwargs, including override precedence. - Sync pre-fetch, thread-pool execution/description, asyncio
create(cls), MRO, and pagination. - Cursor/result-set subclasses, global defaults, and explicit
dict_type=None. - Dynamic-class ABC/metaclass behavior, repr, garbage collection, and typing.
- Changed docstrings and tests. All three tests assert observable row types and would fail on the old code’s final
is dictassertion.
FINDINGS
-
P2 — Regression: custom result-set selection loses
dict_type.
Locations: pyathena/cursor.py:230, pyathena/async_cursor.py:349, pyathena/aio/cursor.py:260.An existing subclass can select its result-set implementation after initialization:
class CustomResultSet(AthenaDictResultSet): pass class CustomCursor(DictCursor): def __init__(self, **kwargs): super().__init__(**kwargs) self._result_set_class = CustomResultSet
With the global default still
dict, creating this cursor withdict_type=OrderedDictand executingSELECT 1 AS anow returns a plaindict. Previously,CustomResultSetinherited the configuredOrderedDictfrom the base class.The option now exists only on the generated class, which the subclass replaces. Equivalent thread-pool and asyncio subclasses have the same regression. Preserve the cursor’s option and apply it to the selected result-set implementation before pre-fetch. The added tests do not cover this case.
Pre-existing behavior: explicit dict_type=None still raises TypeError when constructing a row; it did not previously mean “use the default.”
Source inspection only; no builds, tests, mypy, modifications, or network access.
Author verification: Finding 1 is reproducible by reading the code: a DictCursor subclass that assigns _result_set_class after super().__init__() replaces the generated class, so dict_type is lost, in all three cursors. It is the same case recorded in round two and in the PR body's behavior-change note. Status: awaiting the maintainer's decision between accepting it as a documented behavior change (it depends on replacing the private _result_set_class) and extending the fix to apply the cursor's dict_type to whichever class is selected, which would touch the result set construction paths. The dict_type=None note is pre-existing behavior, not a regression.
There was a problem hiding this comment.
Repair (maintainer chose to apply dict_type to a replaced class) — b2ece224, 2ee5fbc0 on top of 27626643; range-diff over merge-base 6be315ec4c87758f993778b0b2167696e848390a shows the first two commits unchanged.
pyathena/cursor.py,pyathena/async_cursor.py,pyathena/aio/cursor.py: each dict cursor storesself._dict_typebeforesuper().__init__()and overrides_result_set_classwith a property (@override,explicit-overridefalse positive when overriding property with setter python/mypy#15900 ignore as forarraysize). The setter wraps any assignedAthenaDictResultSetsubclass in a subclass carryingdict_type(and__module__); other classes are stored as is. The base cursors' own assignments (AthenaResultSet/AthenaAioResultSet) pass through unwrapped. No change to result sets,execute()or base cursors.- Explicit
dict_type=Nonenow means the default type (previouslyNonewas assigned to the shared class attribute). Docstrings updated in2ee5fbc0; PR body lists both behavior changes. - Tests:
test_dict_type_custom_result_setper cursor type (subclass assigns its own result set subclass after__init__, assertsisinstanceandOrderedDictrows); all three failed on27626643withassert <class 'dict'> is OrderedDict. Onb2ece224:just lintpassed, the three dict cursor test classes 16 passed, no-AWS reproduction keeps the shared classes atdict.
Self-review of the repair, round one (behavior): callers of _result_set_class are unchanged (pyathena/cursor.py:188, pyathena/async_cursor.py:194 from the executor thread, pyathena/aio/cursor.py:170 via create()), and all now read the property. Re-assignment wraps again (a subclass of a subclass), which is harmless. Noted, not changed: a subclass that assigns _result_set_class before super().__init__() now gets AttributeError because _dict_type is not set yet; that assignment was always overwritten by DictCursor.__init__, so it had no effect before either.
Round two (claims): the __init__ docstrings said "If they include dict_type", which no longer held for an explicit None → corrected in 2ee5fbc0 (lint only). PR body claims ("without changing the result set, execute() or base cursor code", the None behavior change, the tested commit) re-checked against the diff.
An independent follow-up review of the repair is next.
There was a problem hiding this comment.
Independent follow-up review (relayed) — CLEAN
Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, sandbox read-only, --ephemeral, session 01a0ffd2-b6a5-7a61-97d4-058d374fbcc2. Static review only. Scope: follow-up diff 27626643b1770bd9cc143c3a5f1486c018475f3e..2ee5fbc006b51c41a29d4ef9635228bc72bb9c85 (range-diff over merge-base 6be315ec4c87758f993778b0b2167696e848390a shows the earlier commits unchanged) plus the affected contracts, in a detached snapshot of 2ee5fbc0 without .env. Snapshot and PR worktree verified unchanged after the run.
Reviewer output (verbatim)
Surfaces covered:
- Every
_result_set_classreader and writer, including base initialization order, synchronous execution, thread-pool collection, and asyncio creation. - Property/MRO behavior on all three dict cursors, subclass reassignment, shared-class isolation,
cursor_kwargsprecedence, and explicitdict_type=None. - Mypy annotations, override markers, changed docstrings, and existing callers.
- All three new tests: each asserts the custom result-set subclass and observable fetched-row type. Without the follow-up, its
OrderedDictassertion would fail.
CLEAN
The earlier subclass-reassignment finding is resolved. No actionable regressions or additional in-scope pre-existing findings identified.
Static review only; builds, tests, and mypy were not run.
A DictCursor subclass that assigned its own AthenaDictResultSet subclass to _result_set_class after DictCursor.__init__ replaced the class that carried dict_type, so its rows were built with dict. The dict cursors now make _result_set_class a property whose setter wraps any assigned AthenaDictResultSet subclass with the cursor's dict_type. An explicit dict_type=None uses the default dict type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the per-cursor result set subclasses and the _result_set_class property with plain argument passing. AthenaDictResultSet takes an optional dict_type that overrides the class attribute for that instance, AthenaAioResultSet.create() forwards extra arguments to the constructor, and the base cursors pass _result_set_kwargs, which the dict cursors set to their dict_type, to each result set they create. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A result set subclass whose __init__ does not accept dict_type keeps working with dict cursors created without dict_type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| self._result_set_class = AthenaDictResultSet | ||
| if "dict_type" in kwargs: | ||
| AthenaDictResultSet.dict_type = kwargs["dict_type"] | ||
| if dict_type is not None: |
There was a problem hiding this comment.
Self-review round one (implementation behavior), rewrite — FINDINGS, repaired
The maintainer replaced the per-cursor subclass / _result_set_class property design with plain argument passing (e27e6e20), so this round is a full pass, not a narrow follow-up.
Scope: base 6be315ec4c87758f993778b0b2167696e848390a .. head e27e6e206520264a6e5ada11cfed553c50e37134, full diff (5 source, 3 test modules).
Covered:
- Construction paths:
Cursor.executepasses 5 positional arguments plus keywords,AsyncCursor._collect_result_setkeywords only,AioCursor.executeviacreate(), which now forwards**kwargs.AthenaDictResultSet.__init__sets the instancedict_typebeforesuper().__init__(), so the sync pre-fetch inAthenaResultSet.__init__already uses it; aio pre-fetches increate()after construction. - MRO: for
AthenaAioDictResultSetthe next__init__isAthenaAioResultSet.__init__(5 positional +result_set_type_hints), which receives nodict_typebecause it is consumed first. - Base cursors:
_result_set_kwargsdefaults to{}; non-dict cursors construct result sets exactly as before. - Shared state: no class attribute is written; the no-AWS reproduction keeps
AthenaDictResultSet.dict_type/AthenaAioDictResultSet.dict_typeatdict. - Tests:
test_dict_type(failed on the original code) andtest_dict_type_custom_result_set; 16 passed in the three dict cursor classes, 4 intests/pyathena/aio/test_result_set.py.
Finding (introduced, repaired in ee872f7a): the dict cursors always passed dict_type (also None), so a user AthenaDictResultSet subclass with a fixed __init__ signature would raise TypeError even for cursors created without dict_type. They now add it to _result_set_kwargs only when it is not None; lint and the 16 tests re-run on ee872f7a.
| # You can override this to use OrderedDict or other dict-like types. | ||
| dict_type: type[Any] = dict | ||
|
|
||
| def __init__(self, *args: Any, dict_type: type[Any] | None = None, **kwargs: Any) -> None: |
There was a problem hiding this comment.
Self-review round two (claims, callers, operations), rewrite — FINDINGS, PR description corrected
Scope: base 6be315ec4c87758f993778b0b2167696e848390a .. head ee872f7a91da39ff2e60969718c57366a723ee6e, full pass over the rewritten PR body, the changed docstrings (AthenaDictResultSet.__init__, AthenaAioResultSet.create, three dict cursor __init__s), commit messages and docs.
Claims checked:
- "sets
dict_typeon that instance before the first page is read": true for sync (set beforesuper().__init__(), which pre-fetches) and aio (create()pre-fetches after the constructor). TheAthenaDictResultSet.__init__docstring originally said it fetches the first page, which is false for aio; reworded ine27e6e20before commit. cursor_kwargsand direct arguments:Connection.cursorpasses everything as keywords, so the new explicitdict_typeparameter receives both; nothing passes cursor arguments positionally.- Global default: a value assigned to
AthenaDictResultSet.dict_typestill applies to cursors withoutdict_type, including aio through inheritance. - Docs:
docs/cursor.mdanddocs/aio.mdexamples passdict_typeper cursor and stay correct. - AWS: no API change; the tests add one or two
one_rowqueries per test.
Findings (PR description, corrected):
- "A cursor subclass that assigns its own
AthenaDictResultSetsubclass ... getsdict_typeapplied" holds only if that class's__init__acceptsdict_type; now qualified. - Missing compatibility note: a result set subclass whose
__init__does not acceptdict_typenow raisesTypeErrorwhen its cursor is givendict_type(before, the class attribute reached it). Added to the release-note list; cursors withoutdict_typeare unaffected afteree872f7a.
Independent review of this revision is next.
| arraysize=self._arraysize, | ||
| retry_config=self._retry_config, | ||
| result_set_type_hints=result_set_type_hints, | ||
| **self._result_set_kwargs, |
There was a problem hiding this comment.
Independent review (relayed), rewrite — CLEAN
Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, sandbox read-only, --ephemeral, session 01a0fff0-6833-72c2-a3c5-6e9e897e0f6e. Static review only (no builds, tests, mypy or network). Scope: full range merge-base 6be315ec4c87758f993778b0b2167696e848390a .. head ee872f7a91da39ff2e60969718c57366a723ee6e in a detached snapshot without .env; the prompt carried the diff and intended behavior, not the PR number, description, commits or earlier findings. Snapshot and PR worktree verified unchanged after the run.
Reviewer output (verbatim)
Surfaces covered:
- Sync
execute, thread-pool_collect_result_set(includingdescription), asynciocreate, repeated execution, and pagination. _result_set_classand_result_set_kwargs, direct constructor arguments, connectioncursor_kwargsprecedence, anddict_type=None.- Cooperative initialization through
AthenaAioDictResultSet’s MRO; row type assignment occurs before synchronous or asynchronous pre-fetch. - Cursor isolation, preservation of shared classes, and custom result-set subclasses.
- Arrow, Pandas, Polars, S3FS, and Spark inheritance/construction paths; existing result-set callers.
- Constructor compatibility, mypy annotations/configuration, and changed docstrings, inspected statically.
- All six new tests and their fixtures. The three isolation tests would fail on the old code. The three custom-result-set tests would also pass on the old code, but assert observable subclass selection and fetched row types.
CLEAN
No actionable regression identified in the supplied range. Builds, tests, and mypy were not run; no network access was used. The checkout remains clean at the requested head.
Author note: the reviewer's observation that test_dict_type_custom_result_set would also pass on the original code is correct (the shared class attribute reached the subclass there); it guards the subclass path of the new argument passing, while test_dict_type is the regression test for #923.
WHAT
DictCursor,AsyncDictCursorandAioDictCursornow applydict_typeonly to the result sets of the cursor it was given to, by passing it to each result set as a constructor argument.AthenaDictResultSet.__init__takes an optional keyword-onlydict_type; when it is not None, it setsdict_typeon that instance before the first page is read. The class attribute stays the default.AthenaAioResultSet.create()forwards additional keyword arguments to the constructor.Cursor,AsyncCursorandAioCursorkeep_result_set_kwargs(empty by default) and pass it to each result set they create.dict_typeas an explicit keyword argument and, when it is not None, put it in_result_set_kwargs. A cursor subclass that assigns its ownAthenaDictResultSetsubclass to_result_set_classgetsdict_typeapplied as well, as long as that class's__init__acceptsdict_type(it does unless the subclass overrides__init__without forwarding keyword arguments).__init__docstrings added in Document every public API in pyathena/ and check docstrings with ruff #919 are updated.Behavior changes for the 4.0.0 release notes:
dict_typepassed to one dict cursor no longer changes the row type of other dict cursors. Code that relied on this must passdict_type(orcursor_kwargs={"dict_type": ...}) to each cursor.dict_type=Nonenow uses the default (dict, or a value assigned toAthenaDictResultSet.dict_type) instead of assigningNoneto the class attribute, which made rows fail to build in every dict cursor.__init__does not acceptdict_typeraisesTypeErrorwhen its cursor is givendict_type. Before,dict_typereached such a class through the shared class attribute. Cursors withoutdict_typepass no extra argument.Setting
AthenaDictResultSet.dict_typedirectly still applies to dict cursors created withoutdict_type.WHY
Closes #923.
The constructors assigned
dict_typeto the class attribute of the shared result set class, and rows are built from that attribute on each fetch.One cursor's
dict_typetherefore changed the row type of every dict cursor in the process, including cursors created before it.The result set reads the first page while it is created (in its constructor, or in
create()for the aio result set), so the cursor passes the type when it creates the result set.TEST
Tested commit: ee872f7.
just lint: passed.test_dict_typeinTestDictCursor,TestAsyncDictCursorandTestAioDictCursor: opens a second cursor withdict_type=OrderedDicton the fixture's connection and checks that it returnsOrderedDictwhile the fixture cursor still returnsdict. All three failed on the original code (assert <class 'collections.OrderedDict'> is dict).test_dict_type_custom_result_setin the same classes: a cursor subclass that assigns its own result set subclass after__init__getsOrderedDictrows from that class.uv run --env-file .env pytest -n 1 tests/pyathena/test_cursor.py::TestDictCursor tests/pyathena/test_async_cursor.py::TestAsyncDictCursor tests/pyathena/aio/test_cursor.py::TestAioDictCursor: 16 passed (also on e27e6e2, withtests/pyathena/aio/test_result_set.py: 4 passed).AthenaDictResultSet.dict_typeandAthenaAioDictResultSet.dict_typestaydict, and a dict cursor withoutdict_typepasses no extra argument to its result set.just test pyathenasuite; it runs in CI once the PR is Ready.🤖 Generated with Claude Code