From fe5800629913911541c7c8ee5444e8540d52fffd Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Mon, 15 Jun 2026 14:08:27 -0700 Subject: [PATCH] Do not sort OrderedDict keyword arguments OrderedDict(b=2, a=1) iterates in argument order and is a distinct, unequal value from OrderedDict(a=1, b=2), so alphabetizing its keyword arguments changed the resulting object. Exempt calls to OrderedDict (bare or dotted, such as collections.OrderedDict) from keyword-argument sorting via a new ORDER_SENSITIVE_CALLS set. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGES.rst | 5 ++++ README.rst | 3 ++- codesorter/const.py | 4 ++++ codesorter/sort_code.py | 23 ++++++++++++++++++- .../test_files/order_sensitive_calls_input.py | 13 +++++++++++ .../order_sensitive_calls_output.py | 13 +++++++++++ tests/test_sort_code.py | 9 ++++++++ 7 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 tests/test_files/order_sensitive_calls_input.py create mode 100644 tests/test_files/order_sensitive_calls_output.py diff --git a/CHANGES.rst b/CHANGES.rst index db280d5..99d0180 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -17,6 +17,11 @@ codesorter follows `semantic versioning `_. 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) diff --git a/README.rst b/README.rst index 40541f6..68fd365 100644 --- a/README.rst +++ b/README.rst @@ -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 diff --git a/codesorter/const.py b/codesorter/const.py index 692ba27..cd0e461 100644 --- a/codesorter/const.py +++ b/codesorter/const.py @@ -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 diff --git a/codesorter/sort_code.py b/codesorter/sort_code.py index e3e2b54..e20d3bc 100644 --- a/codesorter/sort_code.py +++ b/codesorter/sort_code.py @@ -16,6 +16,7 @@ from codesorter.const import ( ORDER_SENSITIVE_BASES, + ORDER_SENSITIVE_CALLS, ORDER_SENSITIVE_DECORATORS, PLAIN_DECORATOR_PARTS, PROPERTY_DECORATOR_PARTS, @@ -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], @@ -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, diff --git a/tests/test_files/order_sensitive_calls_input.py b/tests/test_files/order_sensitive_calls_input.py new file mode 100644 index 0000000..67c2e14 --- /dev/null +++ b/tests/test_files/order_sensitive_calls_input.py @@ -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 diff --git a/tests/test_files/order_sensitive_calls_output.py b/tests/test_files/order_sensitive_calls_output.py new file mode 100644 index 0000000..3fb4cc6 --- /dev/null +++ b/tests/test_files/order_sensitive_calls_output.py @@ -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 diff --git a/tests/test_sort_code.py b/tests/test_sort_code.py index adbc8b2..5c28257 100644 --- a/tests/test_sort_code.py +++ b/tests/test_sort_code.py @@ -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