Skip to content

Commit 5f0e226

Browse files
authored
Merge pull request #51 from praw-dev/fix-ordereddict-kwargs
Do not sort OrderedDict keyword arguments
2 parents e192be1 + fe58006 commit 5f0e226

7 files changed

Lines changed: 68 additions & 2 deletions

File tree

CHANGES.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ codesorter follows `semantic versioning <https://semver.org/>`_.
1717
raised ``NameError`` at import. The runtime references reachable through a called
1818
class or function (transitively) are now treated as dependencies of the calling
1919
assignment.
20+
- Do not sort the keyword arguments of an ``OrderedDict`` call. ``OrderedDict(b=2,
21+
a=1)`` iterates in argument order and is a distinct value from ``OrderedDict(a=1,
22+
b=2)``, so reordering its keyword arguments changed the resulting object. Calls to
23+
``OrderedDict`` (bare or dotted, such as ``collections.OrderedDict``) are now left
24+
untouched.
2025

2126
********************
2227
0.2.7 (2026/06/15)

README.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ CodeSorter is a LibCST codemod that automatically sorts and organizes Python cod
2424
- **Constant Grouping**: Orders each scope as constants, then classes, then functions,
2525
sorting constants by dependency while preserving enum and dataclass field order
2626
- **Keyword Sorting**: Alphabetizes keyword arguments in calls, keyword-only parameters,
27-
and dict string keys, while preserving ``*``/``**`` unpacking semantics
27+
and dict string keys, while preserving ``*``/``**`` unpacking semantics and the
28+
keyword-argument order of order-sensitive callables such as ``OrderedDict``
2829
- **Pytest Integration**: Special handling for pytest fixtures with proper scope
2930
ordering
3031
- **CLI Interface**: Simple command-line interface for easy integration

codesorter/const.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@
3434
"NamedTuple",
3535
"TypedDict",
3636
})
37+
# Callables whose keyword-argument order is semantically significant, so their calls must
38+
# not have keyword arguments reordered. ``OrderedDict(a=1, b=2)`` iterates in argument order,
39+
# and ``OrderedDict(b=2, a=1)`` is a distinct (unequal) value, so the order must be preserved.
40+
ORDER_SENSITIVE_CALLS: frozenset[str] = frozenset({"OrderedDict"})
3741
ORDER_SENSITIVE_DECORATORS: frozenset[str] = frozenset({"dataclass", "define", "frozen", "mutable", "attrs"})
3842
PLAIN_DECORATOR_PARTS = 1
3943

codesorter/sort_code.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from codesorter.const import (
1818
ORDER_SENSITIVE_BASES,
19+
ORDER_SENSITIVE_CALLS,
1920
ORDER_SENSITIVE_DECORATORS,
2021
PLAIN_DECORATOR_PARTS,
2122
PROPERTY_DECORATOR_PARTS,
@@ -69,6 +70,22 @@ class KeywordArgumentSorter(cst.CSTTransformer):
6970
7071
"""
7172

73+
@staticmethod
74+
def _called_name(func: cst.BaseExpression) -> str | None:
75+
"""Return the trailing name of a call target, or ``None`` if it is not a name.
76+
77+
Resolves both a bare ``OrderedDict`` (``Name``) and a dotted
78+
``collections.OrderedDict`` (``Attribute``) to ``"OrderedDict"``. An import
79+
alias is not followed, matching the suffix-based name matching used elsewhere
80+
for order-sensitive classes.
81+
82+
"""
83+
if isinstance(func, cst.Attribute):
84+
return func.attr.value
85+
if isinstance(func, cst.Name):
86+
return func.value
87+
return None
88+
7289
@staticmethod
7390
def _sort_comma_runs(
7491
elements: Sequence[_CommaT],
@@ -107,9 +124,13 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Cal
107124
barriers: a call raises ``TypeError`` on any duplicate keyword regardless of
108125
order, so moving keyword arguments across a ``**`` cannot change the result.
109126
Positional arguments and ``*`` unpackings stay put because their order
110-
determines positional binding.
127+
determines positional binding. Calls to an order-sensitive callable such as
128+
``OrderedDict`` are left untouched, since their keyword-argument order is the
129+
iteration order and reordering it would change the resulting value.
111130
112131
"""
132+
if self._called_name(updated_node.func) in ORDER_SENSITIVE_CALLS:
133+
return updated_node
113134
return updated_node.with_changes(
114135
args=self._sort_comma_runs(
115136
updated_node.args,
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""OrderedDict keeps its keyword-argument order; other calls and dict literals are sorted."""
2+
3+
import collections
4+
from collections import OrderedDict
5+
6+
7+
def build():
8+
"""OrderedDict argument order is preserved; the regular call and dict literal are sorted."""
9+
ordered = OrderedDict(zeta=1, alpha=2, mu=3)
10+
qualified = collections.OrderedDict(zeta=1, alpha=2)
11+
regular = submit(zeta=1, alpha=2)
12+
literal = {"zeta": 1, "alpha": 2}
13+
return literal, ordered, qualified, regular
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""OrderedDict keeps its keyword-argument order; other calls and dict literals are sorted."""
2+
3+
import collections
4+
from collections import OrderedDict
5+
6+
7+
def build():
8+
"""OrderedDict argument order is preserved; the regular call and dict literal are sorted."""
9+
ordered = OrderedDict(zeta=1, alpha=2, mu=3)
10+
qualified = collections.OrderedDict(zeta=1, alpha=2)
11+
regular = submit(alpha=2, zeta=1)
12+
literal = {"alpha": 2, "zeta": 1}
13+
return literal, ordered, qualified, regular

tests/test_sort_code.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,15 @@ def test_order_sensitive(self, test_files):
309309

310310
assert expected_code == result.code
311311

312+
def test_order_sensitive_calls(self, test_files):
313+
"""Test that OrderedDict keeps its keyword-argument order while other calls are sorted."""
314+
input_code, expected_code = test_files
315+
context = CodemodContext()
316+
command = SortCodeCommand(context)
317+
result = command.transform_module(cst.parse_module(input_code))
318+
319+
assert expected_code == result.code
320+
312321
def test_property(self, test_files):
313322
"""Test that properties are sorted correctly."""
314323
input_code, expected_code = test_files

0 commit comments

Comments
 (0)