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
5 changes: 5 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ codesorter follows `semantic versioning <https://semver.org/>`_.
raised ``NameError`` at import. The runtime references reachable through a called
class or function (transitively) are now treated as dependencies of the calling
assignment.
- Do not sort the keyword arguments of an ``OrderedDict`` call. ``OrderedDict(b=2,
a=1)`` iterates in argument order and is a distinct value from ``OrderedDict(a=1,
b=2)``, so reordering its keyword arguments changed the resulting object. Calls to
``OrderedDict`` (bare or dotted, such as ``collections.OrderedDict``) are now left
untouched.

********************
0.2.7 (2026/06/15)
Expand Down
3 changes: 2 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ CodeSorter is a LibCST codemod that automatically sorts and organizes Python cod
- **Constant Grouping**: Orders each scope as constants, then classes, then functions,
sorting constants by dependency while preserving enum and dataclass field order
- **Keyword Sorting**: Alphabetizes keyword arguments in calls, keyword-only parameters,
and dict string keys, while preserving ``*``/``**`` unpacking semantics
and dict string keys, while preserving ``*``/``**`` unpacking semantics and the
keyword-argument order of order-sensitive callables such as ``OrderedDict``
- **Pytest Integration**: Special handling for pytest fixtures with proper scope
ordering
- **CLI Interface**: Simple command-line interface for easy integration
Expand Down
4 changes: 4 additions & 0 deletions codesorter/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
"NamedTuple",
"TypedDict",
})
# Callables whose keyword-argument order is semantically significant, so their calls must
# not have keyword arguments reordered. ``OrderedDict(a=1, b=2)`` iterates in argument order,
# and ``OrderedDict(b=2, a=1)`` is a distinct (unequal) value, so the order must be preserved.
ORDER_SENSITIVE_CALLS: frozenset[str] = frozenset({"OrderedDict"})
ORDER_SENSITIVE_DECORATORS: frozenset[str] = frozenset({"dataclass", "define", "frozen", "mutable", "attrs"})
PLAIN_DECORATOR_PARTS = 1

Expand Down
23 changes: 22 additions & 1 deletion codesorter/sort_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from codesorter.const import (
ORDER_SENSITIVE_BASES,
ORDER_SENSITIVE_CALLS,
ORDER_SENSITIVE_DECORATORS,
PLAIN_DECORATOR_PARTS,
PROPERTY_DECORATOR_PARTS,
Expand Down Expand Up @@ -69,6 +70,22 @@ class KeywordArgumentSorter(cst.CSTTransformer):

"""

@staticmethod
def _called_name(func: cst.BaseExpression) -> str | None:
"""Return the trailing name of a call target, or ``None`` if it is not a name.

Resolves both a bare ``OrderedDict`` (``Name``) and a dotted
``collections.OrderedDict`` (``Attribute``) to ``"OrderedDict"``. An import
alias is not followed, matching the suffix-based name matching used elsewhere
for order-sensitive classes.

"""
if isinstance(func, cst.Attribute):
return func.attr.value
if isinstance(func, cst.Name):
return func.value
return None

@staticmethod
def _sort_comma_runs(
elements: Sequence[_CommaT],
Expand Down Expand Up @@ -107,9 +124,13 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Cal
barriers: a call raises ``TypeError`` on any duplicate keyword regardless of
order, so moving keyword arguments across a ``**`` cannot change the result.
Positional arguments and ``*`` unpackings stay put because their order
determines positional binding.
determines positional binding. Calls to an order-sensitive callable such as
``OrderedDict`` are left untouched, since their keyword-argument order is the
iteration order and reordering it would change the resulting value.

"""
if self._called_name(updated_node.func) in ORDER_SENSITIVE_CALLS:
return updated_node
return updated_node.with_changes(
args=self._sort_comma_runs(
updated_node.args,
Expand Down
13 changes: 13 additions & 0 deletions tests/test_files/order_sensitive_calls_input.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""OrderedDict keeps its keyword-argument order; other calls and dict literals are sorted."""

import collections
from collections import OrderedDict


def build():
"""OrderedDict argument order is preserved; the regular call and dict literal are sorted."""
ordered = OrderedDict(zeta=1, alpha=2, mu=3)
qualified = collections.OrderedDict(zeta=1, alpha=2)
regular = submit(zeta=1, alpha=2)
literal = {"zeta": 1, "alpha": 2}
return literal, ordered, qualified, regular
13 changes: 13 additions & 0 deletions tests/test_files/order_sensitive_calls_output.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""OrderedDict keeps its keyword-argument order; other calls and dict literals are sorted."""

import collections
from collections import OrderedDict


def build():
"""OrderedDict argument order is preserved; the regular call and dict literal are sorted."""
ordered = OrderedDict(zeta=1, alpha=2, mu=3)
qualified = collections.OrderedDict(zeta=1, alpha=2)
regular = submit(alpha=2, zeta=1)
literal = {"alpha": 2, "zeta": 1}
return literal, ordered, qualified, regular
9 changes: 9 additions & 0 deletions tests/test_sort_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,15 @@ def test_order_sensitive(self, test_files):

assert expected_code == result.code

def test_order_sensitive_calls(self, test_files):
"""Test that OrderedDict keeps its keyword-argument order while other calls are sorted."""
input_code, expected_code = test_files
context = CodemodContext()
command = SortCodeCommand(context)
result = command.transform_module(cst.parse_module(input_code))

assert expected_code == result.code

def test_property(self, test_files):
"""Test that properties are sorted correctly."""
input_code, expected_code = test_files
Expand Down