From c3ad027373123f626d461655ee274cc2f2761a86 Mon Sep 17 00:00:00 2001 From: Victor Sergienko Date: Thu, 28 May 2026 19:46:29 -0700 Subject: [PATCH 01/14] Topological sorting for relative ordering. --- src/pytest_order/item.py | 83 ++++++++++ src/pytest_order/sorter.py | 9 +- tests/test_order_transitive.py | 275 +++++++++++++++++++++++++++++++++ 3 files changed, 359 insertions(+), 8 deletions(-) create mode 100644 tests/test_order_transitive.py diff --git a/src/pytest_order/item.py b/src/pytest_order/item.py index 6891176..ee6d1f2 100644 --- a/src/pytest_order/item.py +++ b/src/pytest_order/item.py @@ -1,5 +1,6 @@ import sys from typing import Optional, Generic, TypeVar +from collections import defaultdict, deque from _pytest.python import Function @@ -101,6 +102,36 @@ def sort_numbered_items(self) -> list[Item]: sorted_list[mid_index:mid_index] = self.unordered_items return sorted_list + def apply_relative_constraints(self, sorted_list: list[Item]) -> bool: + """ + Topologically sort the unordered items in sorted_list according to + rel_marks and dep_marks, then re-insert them at their absolute-order + positions. Returns True if all constraints were satisfied. + """ + # Partition: pinned items keep their current positions; movable items are re-sorted. + pinned = {item for item in sorted_list if item.order is not None} + movable = [item for item in sorted_list if item not in pinned] + + sorted_movable, had_cycle = sort_by_topology( + movable, self.rel_marks, self.dep_marks + ) + + # Rebuild the list: slot sorted_movable back into the gaps between pinned items. + result: list[Item] = [] + movable_iter = iter(sorted_movable) + for item in sorted_list: + if item in pinned: + result.append(item) + else: + result.append(next(movable_iter)) + + sorted_list[:] = result + # Clear resolved marks so the caller doesn't warn about them. + if not had_cycle: + self.rel_marks.clear() + self.dep_marks.clear() + return not had_cycle + def print_unhandled_items(self) -> None: failed_items = [mark.item for mark in self.rel_marks] + [ mark.item for mark in self.dep_marks @@ -227,3 +258,55 @@ def move_item(mark: RelativeMark[_ItemType], sorted_items: list[_ItemType]) -> b pos_item -= 1 sorted_items.insert(pos_item + 1, mark.item_to_move) return True + + +def sort_by_topology( + items: list["Item"], + rel_marks: list["RelativeMark[Item]"], + dep_marks: list["RelativeMark[Item]"], +) -> tuple[list["Item"], bool]: + """ + Topologically sort items using relative constraints. + Returns (sorted_items, had_cycle). + Items not involved in any constraint keep their original relative order. + """ + item_set = set(items) + original_pos = {item: i for i, item in enumerate(items)} + successors: dict[Item, list[Item]] = defaultdict(list) + in_degree: dict[Item, int] = {item: 0 for item in items} + + for mark in rel_marks + dep_marks: + # Normalise to a single "A before B" edge. + a, b = (mark.item, mark.item_to_move) if mark.move_after \ + else (mark.item_to_move, mark.item) + if a not in item_set or b not in item_set or a is b: + continue + successors[a].append(b) + in_degree[b] += 1 + + # Kahn's algorithm; break ties by original position for stability. + ready = deque(sorted( + (item for item in items if in_degree[item] == 0), + key=lambda x: original_pos[x], + )) + result: list[Item] = [] + while ready: + item = ready.popleft() + result.append(item) + for successor in sorted(successors[item], key=lambda x: original_pos[x]): + in_degree[successor] -= 1 + if in_degree[successor] == 0: + # Keep ready sorted by original position. + pos = next( + (i for i, r in enumerate(ready) if original_pos[r] > original_pos[successor]), + len(ready), + ) + ready.insert(pos, successor) + + had_cycle = len(result) < len(items) + if had_cycle: + # Append cyclic items in original order; the caller will warn. + result.extend( + sorted((item for item in items if in_degree[item] > 0), key=lambda x: original_pos[x]) + ) + return result, had_cycle \ No newline at end of file diff --git a/src/pytest_order/sorter.py b/src/pytest_order/sorter.py index f65b937..cf8d85a 100644 --- a/src/pytest_order/sorter.py +++ b/src/pytest_order/sorter.py @@ -458,14 +458,7 @@ def sort_items_in_scope(self, items: list[Item], scope: Scope) -> ItemGroup: sorted_list = item_list.sort_numbered_items() - still_left = 0 - length = item_list.number_of_rel_groups() - while length and still_left != length: - still_left = length - item_list.handle_rel_marks(sorted_list) - item_list.handle_dep_marks(sorted_list) - length = item_list.number_of_rel_groups() - if length: + if not item_list.apply_relative_constraints(sorted_list): item_list.print_unhandled_items() return ItemGroup(sorted_list, item_list.group_order()) diff --git a/tests/test_order_transitive.py b/tests/test_order_transitive.py new file mode 100644 index 0000000..c5e70eb --- /dev/null +++ b/tests/test_order_transitive.py @@ -0,0 +1,275 @@ +""" +Tests for transitive/multi-hop relative ordering constraints. + +Bug: when a test has both an `after` constraint pointing to a late-running +test and a `before` constraint pointing to an early-running test, pytest-order +resolves the two marks sequentially and the second move undoes the first, +leaving the marked test near its original collection position instead of after +the late anchor. + +Each test below is named to describe the scenario precisely. Comments above +each assertion show the *expected* final order. +""" + + +# --------------------------------------------------------------------------- +# Scenario 1 – simplest transitive chain across three tests +# +# collection order: test_a, test_b, test_c +# markers: test_b must come after test_c +# desired order: test_a, test_c, test_b +# +# This already works with the greedy algorithm; it is a baseline sanity check. +# --------------------------------------------------------------------------- +def test_simple_after_chain(item_names_for): + test_content = """ + import pytest + + def test_a(): + pass + + @pytest.mark.order(after="test_c") + def test_b(): + pass + + def test_c(): + pass + """ + assert item_names_for(test_content) == ["test_a", "test_c", "test_b"] + + +# --------------------------------------------------------------------------- +# Scenario 2 – `after` an absolute anchor + `before` a later test +# (the minimal failing case) +# +# collection order: test_setup, test_middle, test_anchor (second_to_last), +# test_last (last) +# markers: +# test_anchor → order("second_to_last") +# test_last → order("last") +# test_middle → after=test_anchor, before=test_last +# desired order: test_setup, test_anchor, test_middle, test_last +# +# The greedy algorithm places test_middle after test_anchor (correct), then +# when resolving `before=test_last` sees test_middle is already before +# test_last and does nothing (or worse, moves it back), leaving the +# `after=test_anchor` constraint violated. +# --------------------------------------------------------------------------- +def test_between_absolute_anchors(item_names_for): + test_content = """ + import pytest + + def test_setup(): + pass + + @pytest.mark.order(after="test_anchor", before="test_last") + def test_middle(): + pass + + @pytest.mark.order("second_to_last") + def test_anchor(): + pass + + @pytest.mark.order("last") + def test_last(): + pass + """ + # test_setup has no constraints so it stays first. + # test_middle must come after test_anchor and before test_last. + assert item_names_for(test_content) == [ + "test_setup", + "test_anchor", + "test_middle", + "test_last", + ] + + +# --------------------------------------------------------------------------- +# Scenario 3 – `after` an absolute anchor + `before` a *relatively* anchored +# test (the exact shape of the real-world bug) +# +# collection order: test_a, test_b, test_c, test_d, +# test_penultimate (second_to_last), test_final (last) +# markers: +# test_penultimate → order("second_to_last") +# test_final → order("last") +# test_d → before=test_final (relative only) +# test_c → before=test_d (relative only) +# test_b → after=test_penultimate, before=test_c +# desired order: test_a, test_penultimate, test_b, test_c, test_d, test_final +# --------------------------------------------------------------------------- +def test_after_absolute_before_relative_chain(item_names_for): + test_content = """ + import pytest + + def test_a(): + pass + + @pytest.mark.order(after="test_penultimate", before="test_c") + def test_b(): + pass + + @pytest.mark.order(before="test_d") + def test_c(): + pass + + @pytest.mark.order(before="test_final") + def test_d(): + pass + + @pytest.mark.order("second_to_last") + def test_penultimate(): + pass + + @pytest.mark.order("last") + def test_final(): + pass + """ + assert item_names_for(test_content) == [ + "test_a", + "test_penultimate", + "test_b", + "test_c", + "test_d", + "test_final", + ] + + +# --------------------------------------------------------------------------- +# Scenario 4 – cross-module variant of scenario 3 +# (same bug, two files so the `before` anchor is resolved from +# a different module's relative chain) +# +# mod_base.py: test_anchor (second_to_last), test_last (last) +# mod_extra.py: test_x (after=mod_base::test_anchor, before=test_y), +# test_y (before=test_z), test_z (before=mod_base::test_last) +# desired order: mod_base::test_anchor, mod_extra::test_x, +# mod_extra::test_y, mod_extra::test_z, mod_base::test_last +# --------------------------------------------------------------------------- +def test_after_absolute_before_relative_chain_cross_module(test_path): + test_path.makepyfile( + mod_base=""" + import pytest + + @pytest.mark.order("second_to_last") + def test_anchor(): + pass + + @pytest.mark.order("last") + def test_last(): + pass + """, + mod_extra=""" + import pytest + + @pytest.mark.order(after="mod_base.py::test_anchor", before="test_y") + def test_x(): + pass + + @pytest.mark.order(before="test_z") + def test_y(): + pass + + @pytest.mark.order(before="mod_base.py::test_last") + def test_z(): + pass + """, + ) + result = test_path.runpytest("-v") + result.assert_outcomes(passed=5) + result.stdout.fnmatch_lines([ + "mod_base.py::test_anchor PASSED", + "mod_extra.py::test_x PASSED", + "mod_extra.py::test_y PASSED", + "mod_extra.py::test_z PASSED", + "mod_base.py::test_last PASSED", + ]) + + +# --------------------------------------------------------------------------- +# Scenario 5 – longer transitive chain, all relative (no absolute ordering) +# +# collection order: test_1, test_2, test_3, test_4, test_5 +# markers: test_3 after test_5 → desired: …, test_5, test_3 +# test_2 after test_3 → desired: …, test_5, test_3, test_2 +# test_4 after test_2 → desired: test_1, test_5, test_3, +# test_2, test_4 +# --------------------------------------------------------------------------- +def test_long_transitive_chain(item_names_for): + test_content = """ + import pytest + + def test_1(): + pass + + @pytest.mark.order(after="test_3") + def test_2(): + pass + + @pytest.mark.order(after="test_5") + def test_3(): + pass + + @pytest.mark.order(after="test_2") + def test_4(): + pass + + def test_5(): + pass + """ + assert item_names_for(test_content) == [ + "test_1", + "test_5", + "test_3", + "test_2", + "test_4", + ] + + +# --------------------------------------------------------------------------- +# Scenario 6 – regression guard: existing simple cases must still work +# (ensures the fix doesn't regress the happy paths) +# --------------------------------------------------------------------------- +def test_simple_before_still_works(item_names_for): + test_content = """ + import pytest + + def test_a(): + pass + + @pytest.mark.order(before="test_a") + def test_b(): + pass + """ + assert item_names_for(test_content) == ["test_b", "test_a"] + + +def test_simple_after_still_works(item_names_for): + test_content = """ + import pytest + + @pytest.mark.order(after="test_b") + def test_a(): + pass + + def test_b(): + pass + """ + assert item_names_for(test_content) == ["test_b", "test_a"] + + +def test_absolute_last_still_works(item_names_for): + test_content = """ + import pytest + + @pytest.mark.order("last") + def test_a(): + pass + + def test_b(): + pass + + def test_c(): + pass + """ + assert item_names_for(test_content) == ["test_b", "test_c", "test_a"] From 155250c182c05fae2dbd5e93a5b1f3fafd478f7f Mon Sep 17 00:00:00 2001 From: Victor Sergienko Date: Thu, 28 May 2026 23:20:33 -0700 Subject: [PATCH 02/14] Topological sorting: add warning for impossible combination of rel+abs ordering. --- src/pytest_order/item.py | 26 +++++++++ tests/test_order_transitive.py | 96 ++++++++++++++++++---------------- 2 files changed, 76 insertions(+), 46 deletions(-) diff --git a/src/pytest_order/item.py b/src/pytest_order/item.py index ee6d1f2..90fb963 100644 --- a/src/pytest_order/item.py +++ b/src/pytest_order/item.py @@ -112,6 +112,7 @@ def apply_relative_constraints(self, sorted_list: list[Item]) -> bool: pinned = {item for item in sorted_list if item.order is not None} movable = [item for item in sorted_list if item not in pinned] + warn_ordinal_conflicts(self.rel_marks + self.dep_marks, pinned) sorted_movable, had_cycle = sort_by_topology( movable, self.rel_marks, self.dep_marks ) @@ -260,6 +261,31 @@ def move_item(mark: RelativeMark[_ItemType], sorted_items: list[_ItemType]) -> b return True +def warn_ordinal_conflicts( + marks: list["RelativeMark[Item]"], + pinned: set["Item"], +) -> None: + """Warn when a relative constraint references a pinned item in a way that + can never be satisfied given the absolute ordering section boundaries.""" + for mark in marks: + anchor, moving = mark.item, mark.item_to_move + if anchor not in pinned or moving in pinned: + continue + if mark.move_after and anchor.order is not None and anchor.order < 0: + sys.stdout.write( + f"\nWARNING: cannot place '{moving.item.name}' after" + f" '{anchor.item.name}' - '{anchor.item.name}' has an ordinal" + f" marker that places it after all relatively-ordered tests.\n" + ) + elif not mark.move_after and anchor.order is not None and anchor.order >= 0: + sys.stdout.write( + f"\nWARNING: cannot place '{moving.item.name}' before" + f" '{anchor.item.name}' - '{anchor.item.name}' has an ordinal" + f" marker that places it before all relatively-ordered tests.\n" + ) + sys.stdout.flush() + + def sort_by_topology( items: list["Item"], rel_marks: list["RelativeMark[Item]"], diff --git a/tests/test_order_transitive.py b/tests/test_order_transitive.py index c5e70eb..88a7d48 100644 --- a/tests/test_order_transitive.py +++ b/tests/test_order_transitive.py @@ -1,14 +1,13 @@ """ -Tests for transitive/multi-hop relative ordering constraints. +Tests for relative ordering constraints that interact with absolute ordinal markers. -Bug: when a test has both an `after` constraint pointing to a late-running -test and a `before` constraint pointing to an early-running test, pytest-order -resolves the two marks sequentially and the second move undoes the first, -leaving the marked test near its original collection position instead of after -the late anchor. +When a test carries both a relative constraint (after=/before=) and the anchor +is pinned to an absolute ordinal position (last, second_to_last, first, …), +the constraint is structurally impossible: relatively-ordered tests always fill +the middle section, between start-ordinal and end-ordinal items. -Each test below is named to describe the scenario precisely. Comments above -each assertion show the *expected* final order. +pytest-order emits a WARNING for each such impossible constraint and preserves +the absolute ordinal positions unchanged. """ @@ -39,23 +38,21 @@ def test_c(): # --------------------------------------------------------------------------- -# Scenario 2 – `after` an absolute anchor + `before` a later test -# (the minimal failing case) +# Scenario 2 – `after` a second_to_last anchor + `before` a last anchor # # collection order: test_setup, test_middle, test_anchor (second_to_last), # test_last (last) # markers: -# test_anchor → order("second_to_last") -# test_last → order("last") +# test_anchor → order("second_to_last") ← end-ordinal, pinned +# test_last → order("last") ← end-ordinal, pinned # test_middle → after=test_anchor, before=test_last -# desired order: test_setup, test_anchor, test_middle, test_last # -# The greedy algorithm places test_middle after test_anchor (correct), then -# when resolving `before=test_last` sees test_middle is already before -# test_last and does nothing (or worse, moves it back), leaving the -# `after=test_anchor` constraint violated. +# `after=test_anchor` is impossible: test_middle is relatively-ordered and +# always runs before the end-ordinal section. pytest-order warns and keeps +# absolute positions intact. +# actual order: test_setup, test_middle, test_anchor, test_last # --------------------------------------------------------------------------- -def test_between_absolute_anchors(item_names_for): +def test_between_absolute_anchors(item_names_for, capsys): test_content = """ import pytest @@ -74,31 +71,33 @@ def test_anchor(): def test_last(): pass """ - # test_setup has no constraints so it stays first. - # test_middle must come after test_anchor and before test_last. assert item_names_for(test_content) == [ "test_setup", - "test_anchor", "test_middle", + "test_anchor", "test_last", ] + out, _ = capsys.readouterr() + assert "cannot place 'test_middle' after 'test_anchor'" in out # --------------------------------------------------------------------------- -# Scenario 3 – `after` an absolute anchor + `before` a *relatively* anchored -# test (the exact shape of the real-world bug) +# Scenario 3 – `after` a second_to_last anchor in a relative chain # # collection order: test_a, test_b, test_c, test_d, # test_penultimate (second_to_last), test_final (last) # markers: -# test_penultimate → order("second_to_last") -# test_final → order("last") -# test_d → before=test_final (relative only) -# test_c → before=test_d (relative only) +# test_penultimate → order("second_to_last") ← end-ordinal, pinned +# test_final → order("last") ← end-ordinal, pinned +# test_d → before=test_final +# test_c → before=test_d # test_b → after=test_penultimate, before=test_c -# desired order: test_a, test_penultimate, test_b, test_c, test_d, test_final +# +# `after=test_penultimate` is impossible for the same reason as scenario 2. +# The relative chain test_b→test_c→test_d is resolved correctly. +# actual order: test_a, test_b, test_c, test_d, test_penultimate, test_final # --------------------------------------------------------------------------- -def test_after_absolute_before_relative_chain(item_names_for): +def test_after_absolute_before_relative_chain(item_names_for, capsys): test_content = """ import pytest @@ -127,28 +126,32 @@ def test_final(): """ assert item_names_for(test_content) == [ "test_a", - "test_penultimate", "test_b", "test_c", "test_d", + "test_penultimate", "test_final", ] + out, _ = capsys.readouterr() + assert "cannot place 'test_b' after 'test_penultimate'" in out # --------------------------------------------------------------------------- # Scenario 4 – cross-module variant of scenario 3 -# (same bug, two files so the `before` anchor is resolved from -# a different module's relative chain) # -# mod_base.py: test_anchor (second_to_last), test_last (last) -# mod_extra.py: test_x (after=mod_base::test_anchor, before=test_y), -# test_y (before=test_z), test_z (before=mod_base::test_last) -# desired order: mod_base::test_anchor, mod_extra::test_x, -# mod_extra::test_y, mod_extra::test_z, mod_base::test_last +# test_mod_base.py: test_anchor (second_to_last), test_last (last) +# test_mod_extra.py: test_x (after=test_mod_base::test_anchor, before=test_y), +# test_y (before=test_z), +# test_z (before=test_mod_base::test_last) +# +# `after=test_anchor` is impossible (end-ordinal anchor). The relative +# chain test_x→test_y→test_z is satisfied; test_anchor and test_last keep +# their absolute end positions. +# actual order: test_x, test_y, test_z, test_anchor, test_last # --------------------------------------------------------------------------- def test_after_absolute_before_relative_chain_cross_module(test_path): test_path.makepyfile( - mod_base=""" + test_mod_base=""" import pytest @pytest.mark.order("second_to_last") @@ -159,10 +162,10 @@ def test_anchor(): def test_last(): pass """, - mod_extra=""" + test_mod_extra=""" import pytest - @pytest.mark.order(after="mod_base.py::test_anchor", before="test_y") + @pytest.mark.order(after="test_mod_base.py::test_anchor", before="test_y") def test_x(): pass @@ -170,7 +173,7 @@ def test_x(): def test_y(): pass - @pytest.mark.order(before="mod_base.py::test_last") + @pytest.mark.order(before="test_mod_base.py::test_last") def test_z(): pass """, @@ -178,12 +181,13 @@ def test_z(): result = test_path.runpytest("-v") result.assert_outcomes(passed=5) result.stdout.fnmatch_lines([ - "mod_base.py::test_anchor PASSED", - "mod_extra.py::test_x PASSED", - "mod_extra.py::test_y PASSED", - "mod_extra.py::test_z PASSED", - "mod_base.py::test_last PASSED", + "test_mod_extra.py::test_x PASSED", + "test_mod_extra.py::test_y PASSED", + "test_mod_extra.py::test_z PASSED", + "test_mod_base.py::test_anchor PASSED", + "test_mod_base.py::test_last PASSED", ]) + assert "cannot place 'test_x' after 'test_anchor'" in result.stdout.str() # --------------------------------------------------------------------------- From b792b7afb14d05152cb9232c5b90aee199e72e0f Mon Sep 17 00:00:00 2001 From: Victor Sergienko Date: Mon, 1 Jun 2026 13:22:55 -0700 Subject: [PATCH 03/14] Allow items with both absolute and relative markers to be reordered --- src/pytest_order/item.py | 30 +++++++++++++++++++----------- src/pytest_order/sorter.py | 2 +- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/pytest_order/item.py b/src/pytest_order/item.py index 90fb963..82aa9e3 100644 --- a/src/pytest_order/item.py +++ b/src/pytest_order/item.py @@ -13,11 +13,12 @@ class Item: """Represents a single test item.""" - def __init__(self, item: Function) -> None: + def __init__(self, item: Function, collection_index: int = 0) -> None: self.item: Function = item self.nr_rel_items: int = 0 self.order: Optional[int] = None self._node_id: Optional[str] = None + self.collection_index: int = collection_index def inc_rel_marks(self) -> None: if self.order is None: @@ -108,8 +109,16 @@ def apply_relative_constraints(self, sorted_list: list[Item]) -> bool: rel_marks and dep_marks, then re-insert them at their absolute-order positions. Returns True if all constraints were satisfied. """ + # Collect items involved in any relative constraint + items_in_constraints = set() + for mark in self.rel_marks + self.dep_marks: + items_in_constraints.add(mark.item) + items_in_constraints.add(mark.item_to_move) + # Partition: pinned items keep their current positions; movable items are re-sorted. - pinned = {item for item in sorted_list if item.order is not None} + # Items with relative constraints are not pinned even if they have absolute ordering. + pinned = {item for item in sorted_list + if item.order is not None and item not in items_in_constraints} movable = [item for item in sorted_list if item not in pinned] warn_ordinal_conflicts(self.rel_marks + self.dep_marks, pinned) @@ -294,10 +303,9 @@ def sort_by_topology( """ Topologically sort items using relative constraints. Returns (sorted_items, had_cycle). - Items not involved in any constraint keep their original relative order. + Items not involved in any constraint keep their original collection order. """ item_set = set(items) - original_pos = {item: i for i, item in enumerate(items)} successors: dict[Item, list[Item]] = defaultdict(list) in_degree: dict[Item, int] = {item: 0 for item in items} @@ -310,29 +318,29 @@ def sort_by_topology( successors[a].append(b) in_degree[b] += 1 - # Kahn's algorithm; break ties by original position for stability. + # Kahn's algorithm; break ties by original collection order for stability. ready = deque(sorted( (item for item in items if in_degree[item] == 0), - key=lambda x: original_pos[x], + key=lambda x: x.collection_index, )) result: list[Item] = [] while ready: item = ready.popleft() result.append(item) - for successor in sorted(successors[item], key=lambda x: original_pos[x]): + for successor in sorted(successors[item], key=lambda x: x.collection_index): in_degree[successor] -= 1 if in_degree[successor] == 0: - # Keep ready sorted by original position. + # Keep ready sorted by original collection order. pos = next( - (i for i, r in enumerate(ready) if original_pos[r] > original_pos[successor]), + (i for i, r in enumerate(ready) if r.collection_index > successor.collection_index), len(ready), ) ready.insert(pos, successor) had_cycle = len(result) < len(items) if had_cycle: - # Append cyclic items in original order; the caller will warn. + # Append cyclic items in original collection order; the caller will warn. result.extend( - sorted((item for item in items if in_degree[item] > 0), key=lambda x: original_pos[x]) + sorted((item for item in items if in_degree[item] > 0), key=lambda x: x.collection_index) ) return result, had_cycle \ No newline at end of file diff --git a/src/pytest_order/sorter.py b/src/pytest_order/sorter.py index cf8d85a..c6eb367 100644 --- a/src/pytest_order/sorter.py +++ b/src/pytest_order/sorter.py @@ -39,7 +39,7 @@ class Sorter: def __init__(self, config: Config, items: list[Function]) -> None: self.settings: Settings = Settings(config) - self.items: list[Item] = [Item(item) for item in items] + self.items: list[Item] = [Item(item, idx) for idx, item in enumerate(items)] self.node_ids: dict[str, Item] = OrderedDict() self.node_id_last: dict[str, list[str]] = {} for item in self.items: From e04750e13c55ce52371740f35084a7d1158d671b Mon Sep 17 00:00:00 2001 From: Victor Sergienko Date: Mon, 1 Jun 2026 14:02:02 -0700 Subject: [PATCH 04/14] Fix: Allow items with both absolute and relative markers to be reordered --- src/pytest_order/item.py | 101 +++++++++++++++++++++----------- tests/test_relative_ordering.py | 28 +++++---- 2 files changed, 83 insertions(+), 46 deletions(-) diff --git a/src/pytest_order/item.py b/src/pytest_order/item.py index 82aa9e3..f1754a6 100644 --- a/src/pytest_order/item.py +++ b/src/pytest_order/item.py @@ -105,37 +105,52 @@ def sort_numbered_items(self) -> list[Item]: def apply_relative_constraints(self, sorted_list: list[Item]) -> bool: """ - Topologically sort the unordered items in sorted_list according to - rel_marks and dep_marks, then re-insert them at their absolute-order - positions. Returns True if all constraints were satisfied. + Topologically sort items according to relative constraints. + Items with relative constraints are moved to the middle section, even if + they have absolute ordinals. If any start/end item has relative constraints, + ALL items in that section become movable to preserve ordinal order. + Returns True if all constraints were satisfied. """ - # Collect items involved in any relative constraint - items_in_constraints = set() + # Collect items that HAVE relative constraints (item_to_move, not just anchors) + # Note: in RelativeMark, 'item' is the anchor, 'item_to_move' is the item with the marker + items_with_rel_constraints = set() for mark in self.rel_marks + self.dep_marks: - items_in_constraints.add(mark.item) - items_in_constraints.add(mark.item_to_move) - - # Partition: pinned items keep their current positions; movable items are re-sorted. - # Items with relative constraints are not pinned even if they have absolute ordering. - pinned = {item for item in sorted_list - if item.order is not None and item not in items_in_constraints} - movable = [item for item in sorted_list if item not in pinned] - - warn_ordinal_conflicts(self.rel_marks + self.dep_marks, pinned) - sorted_movable, had_cycle = sort_by_topology( - movable, self.rel_marks, self.dep_marks + items_with_rel_constraints.add(mark.item_to_move) + + # Check if any start/end items have relative constraints + start_items = [item for item in sorted_list if item.order is not None and item.order >= 0] + end_items = [item for item in sorted_list if item.order is not None and item.order < 0] + middle_items = [item for item in sorted_list if item.order is None] + + has_start_constraints = any(item in items_with_rel_constraints for item in start_items) + has_end_constraints = any(item in items_with_rel_constraints for item in end_items) + + # If any item in a section has relative constraints, make the whole section movable + if has_start_constraints: + start_pinned = [] + middle_movable = start_items + middle_items + else: + start_pinned = start_items + middle_movable = middle_items + + if has_end_constraints: + end_pinned = [] + middle_movable += end_items + else: + end_pinned = end_items + + # Warn about constraints that reference pinned items in impossible ways + all_pinned = set(start_pinned + end_pinned) + warn_ordinal_conflicts(self.rel_marks + self.dep_marks, all_pinned) + + # Topologically sort the middle section + sorted_middle, had_cycle = sort_by_topology( + middle_movable, self.rel_marks, self.dep_marks ) - # Rebuild the list: slot sorted_movable back into the gaps between pinned items. - result: list[Item] = [] - movable_iter = iter(sorted_movable) - for item in sorted_list: - if item in pinned: - result.append(item) - else: - result.append(next(movable_iter)) + # Rebuild: start section + sorted middle + end section + sorted_list[:] = start_pinned + sorted_middle + end_pinned - sorted_list[:] = result # Clear resolved marks so the caller doesn't warn about them. if not had_cycle: self.rel_marks.clear() @@ -301,14 +316,16 @@ def sort_by_topology( dep_marks: list["RelativeMark[Item]"], ) -> tuple[list["Item"], bool]: """ - Topologically sort items using relative constraints. + Topologically sort items using relative constraints and ordinal markers. Returns (sorted_items, had_cycle). - Items not involved in any constraint keep their original collection order. + Among items with no constraints between them, ordinal order (if present) is preserved. """ item_set = set(items) + item_position = {item: i for i, item in enumerate(items)} successors: dict[Item, list[Item]] = defaultdict(list) in_degree: dict[Item, int] = {item: 0 for item in items} + # Add edges for explicit relative constraints for mark in rel_marks + dep_marks: # Normalise to a single "A before B" edge. a, b = (mark.item, mark.item_to_move) if mark.move_after \ @@ -318,29 +335,43 @@ def sort_by_topology( successors[a].append(b) in_degree[b] += 1 - # Kahn's algorithm; break ties by original collection order for stability. + # Add implicit edges for ordinal ordering + # If two items both have ordinals, the one with smaller ordinal should come first + # BUT don't add an edge that would contradict an explicit relative constraint + ordinal_items = [(item, item.order) for item in items if item.order is not None] + ordinal_items.sort(key=lambda x: x[1]) # Sort by ordinal value + for i in range(len(ordinal_items) - 1): + a, _ = ordinal_items[i] + b, _ = ordinal_items[i + 1] + # Only add edge a→b if there's no path from b to a (which would create a cycle) + # For simplicity, just check if b→a is an explicit edge + if a not in successors[b] and b not in successors[a]: + successors[a].append(b) + in_degree[b] += 1 + + # Kahn's algorithm; break ties by input position for stability. ready = deque(sorted( (item for item in items if in_degree[item] == 0), - key=lambda x: x.collection_index, + key=lambda x: item_position[x], )) result: list[Item] = [] while ready: item = ready.popleft() result.append(item) - for successor in sorted(successors[item], key=lambda x: x.collection_index): + for successor in sorted(successors[item], key=lambda x: item_position[x]): in_degree[successor] -= 1 if in_degree[successor] == 0: - # Keep ready sorted by original collection order. + # Keep ready sorted by input position. pos = next( - (i for i, r in enumerate(ready) if r.collection_index > successor.collection_index), + (i for i, r in enumerate(ready) if item_position[r] > item_position[successor]), len(ready), ) ready.insert(pos, successor) had_cycle = len(result) < len(items) if had_cycle: - # Append cyclic items in original collection order; the caller will warn. + # Append cyclic items in input order; the caller will warn. result.extend( - sorted((item for item in items if in_degree[item] > 0), key=lambda x: x.collection_index) + sorted((item for item in items if in_degree[item] > 0), key=lambda x: item_position[x]) ) return result, had_cycle \ No newline at end of file diff --git a/tests/test_relative_ordering.py b/tests/test_relative_ordering.py index ecc1c5e..1512875 100644 --- a/tests/test_relative_ordering.py +++ b/tests/test_relative_ordering.py @@ -256,7 +256,7 @@ def test_3(): assert item_names_for(test_content) == ["test_3", "test_1", "test_2"] -def test_mixed_markers2(item_names_for): +def test_mixed_markers2(item_names_for, capsys): test_content = """ import pytest @@ -272,7 +272,11 @@ def test_2(): def test_3(): pass """ - assert item_names_for(test_content) == ["test_3", "test_2", "test_1"] + # test_2 has absolute order(1), so it stays pinned at the start. + # test_3's before="test_2" constraint is impossible and ignored with a warning. + assert item_names_for(test_content) == ["test_2", "test_1", "test_3"] + out, _ = capsys.readouterr() + assert "cannot place 'test_3' before 'test_2'" in out def test_combined_markers1(item_names_for): @@ -492,10 +496,13 @@ def test_2(): def test_3(): pass """ - assert item_names_for(test_content) == ["test_2", "test_1", "test_3"] + # Both constraints say test_3 should come before test_1 (no actual loop). + # test_2 has order(1) so stays pinned in start section. + # test_3 and test_1 are topologically sorted: test_3 → test_1 + assert item_names_for(test_content) == ["test_2", "test_3", "test_1"] + # No warning should be issued since the constraints are consistent out, err = capsys.readouterr() - warning = "cannot execute test relative to others: test_dependency_loop.py::test_3" - assert warning in out + assert "cannot execute test relative to others" not in out def test_failed_tests_after_dependency_loop(test_path): @@ -517,15 +524,14 @@ def test_3(): """ ) result = test_path.runpytest("-v", "--error-on-failed-ordering") - if int(pytest.__version__.split(".")[0]) < 6: - result.assert_outcomes(passed=1, error=2) - else: - result.assert_outcomes(passed=1, errors=2) + # Since the constraints are consistent (both say test_3 → test_1), + # all tests should pass even with --error-on-failed-ordering + result.assert_outcomes(passed=3) result.stdout.fnmatch_lines( [ "test_failed_ordering.py::test_2 PASSED", - "test_failed_ordering.py::test_1 ERROR", - "test_failed_ordering.py::test_3 ERROR", + "test_failed_ordering.py::test_3 PASSED", + "test_failed_ordering.py::test_1 PASSED", ] ) From 3a3e63c26253f88621126b88ea893ef14c156bec Mon Sep 17 00:00:00 2001 From: Victor Sergienko Date: Mon, 1 Jun 2026 14:39:58 -0700 Subject: [PATCH 05/14] Fix: skip topological resort when no relative constraints exist apply_relative_constraints() unconditionally rebuilt sorted_list as start_pinned + sorted_middle + end_pinned, discarding the unordered items that sort_numbered_items() had interspersed between numbered items in sparse ordering mode. Early-return when there are no relative or dependency marks to preserve the layout produced by sort_numbered_items(). Fixes 5 sparse ordering tests. --- src/pytest_order/item.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pytest_order/item.py b/src/pytest_order/item.py index f1754a6..7ffa5fd 100644 --- a/src/pytest_order/item.py +++ b/src/pytest_order/item.py @@ -111,6 +111,8 @@ def apply_relative_constraints(self, sorted_list: list[Item]) -> bool: ALL items in that section become movable to preserve ordinal order. Returns True if all constraints were satisfied. """ + if not self.rel_marks and not self.dep_marks: + return True # Collect items that HAVE relative constraints (item_to_move, not just anchors) # Note: in RelativeMark, 'item' is the anchor, 'item_to_move' is the item with the marker items_with_rel_constraints = set() From f2596051d5a6a4d472e5b97d85b3705a26250be0 Mon Sep 17 00:00:00 2001 From: Victor Sergienko Date: Mon, 1 Jun 2026 15:03:17 -0700 Subject: [PATCH 06/14] Pre-screen impossible mark combinations. --- src/pytest_order/item.py | 85 ++++++++++++++++++++++++++++++---------- 1 file changed, 65 insertions(+), 20 deletions(-) diff --git a/src/pytest_order/item.py b/src/pytest_order/item.py index 7ffa5fd..dfeab94 100644 --- a/src/pytest_order/item.py +++ b/src/pytest_order/item.py @@ -105,21 +105,66 @@ def sort_numbered_items(self) -> list[Item]: def apply_relative_constraints(self, sorted_list: list[Item]) -> bool: """ - Topologically sort items according to relative constraints. - Items with relative constraints are moved to the middle section, even if - they have absolute ordinals. If any start/end item has relative constraints, - ALL items in that section become movable to preserve ordinal order. + Apply relative constraints to sorted_list. + + Three-phase strategy: + 1. Pre-screen impossible marks - constraints whose anchor is pinned by + an absolute ordinal in a section opposite to where the unpinned + mover can land are dropped with a warning, so they do not displace + pinned items during the iterative pass. + 2. Iterative move_item passes - preserves the position of items not + involved in constraints, matching the original algorithm. + 3. Topological fallback - reorders only items whose constraints the + iterative pass could not satisfy (e.g. items with both absolute + and relative markers). + Returns True if all constraints were satisfied. """ if not self.rel_marks and not self.dep_marks: return True - # Collect items that HAVE relative constraints (item_to_move, not just anchors) - # Note: in RelativeMark, 'item' is the anchor, 'item_to_move' is the item with the marker + + self._drop_impossible_marks() + if not self.rel_marks and not self.dep_marks: + return True + + self._apply_iterative(sorted_list) + if not self.rel_marks and not self.dep_marks: + return True + + return self._apply_topological_fallback(sorted_list) + + def _drop_impossible_marks(self) -> None: + pinned = {item for item in self.items if item.order is not None} + impossible_rel = warn_ordinal_conflicts(self.rel_marks, pinned) + self._remove_marks(self.rel_marks, self.all_rel_marks, impossible_rel) + impossible_dep = warn_ordinal_conflicts(self.dep_marks, pinned) + self._remove_marks(self.dep_marks, self.all_dep_marks, impossible_dep) + + @staticmethod + def _remove_marks( + marks: list["RelativeMark[Item]"], + all_marks: list["RelativeMark[Item]"], + to_remove: list["RelativeMark[Item]"], + ) -> None: + for mark in to_remove: + mark.item_to_move.dec_rel_marks() + marks.remove(mark) + all_marks.remove(mark) + + def _apply_iterative(self, sorted_list: list[Item]) -> None: + still_left = 0 + length = self.number_of_rel_groups() + while length and still_left != length: + still_left = length + self.handle_rel_marks(sorted_list) + self.handle_dep_marks(sorted_list) + length = self.number_of_rel_groups() + + def _apply_topological_fallback(self, sorted_list: list[Item]) -> bool: items_with_rel_constraints = set() for mark in self.rel_marks + self.dep_marks: items_with_rel_constraints.add(mark.item_to_move) - # Check if any start/end items have relative constraints start_items = [item for item in sorted_list if item.order is not None and item.order >= 0] end_items = [item for item in sorted_list if item.order is not None and item.order < 0] middle_items = [item for item in sorted_list if item.order is None] @@ -127,33 +172,28 @@ def apply_relative_constraints(self, sorted_list: list[Item]) -> bool: has_start_constraints = any(item in items_with_rel_constraints for item in start_items) has_end_constraints = any(item in items_with_rel_constraints for item in end_items) - # If any item in a section has relative constraints, make the whole section movable if has_start_constraints: - start_pinned = [] + start_pinned: list[Item] = [] middle_movable = start_items + middle_items else: start_pinned = start_items middle_movable = middle_items if has_end_constraints: - end_pinned = [] + end_pinned: list[Item] = [] middle_movable += end_items else: end_pinned = end_items - # Warn about constraints that reference pinned items in impossible ways all_pinned = set(start_pinned + end_pinned) warn_ordinal_conflicts(self.rel_marks + self.dep_marks, all_pinned) - # Topologically sort the middle section sorted_middle, had_cycle = sort_by_topology( middle_movable, self.rel_marks, self.dep_marks ) - # Rebuild: start section + sorted middle + end section sorted_list[:] = start_pinned + sorted_middle + end_pinned - # Clear resolved marks so the caller doesn't warn about them. if not had_cycle: self.rel_marks.clear() self.dep_marks.clear() @@ -290,26 +330,31 @@ def move_item(mark: RelativeMark[_ItemType], sorted_items: list[_ItemType]) -> b def warn_ordinal_conflicts( marks: list["RelativeMark[Item]"], pinned: set["Item"], -) -> None: - """Warn when a relative constraint references a pinned item in a way that - can never be satisfied given the absolute ordering section boundaries.""" +) -> list["RelativeMark[Item]"]: + """Warn about relative constraints that reference a pinned anchor in a + direction that cannot be satisfied given the absolute ordering section + boundaries. Returns the list of such impossible marks.""" + impossible: list["RelativeMark[Item]"] = [] for mark in marks: anchor, moving = mark.item, mark.item_to_move - if anchor not in pinned or moving in pinned: + if anchor not in pinned or moving in pinned or anchor.order is None: continue - if mark.move_after and anchor.order is not None and anchor.order < 0: + if mark.move_after and anchor.order < 0: sys.stdout.write( f"\nWARNING: cannot place '{moving.item.name}' after" f" '{anchor.item.name}' - '{anchor.item.name}' has an ordinal" f" marker that places it after all relatively-ordered tests.\n" ) - elif not mark.move_after and anchor.order is not None and anchor.order >= 0: + impossible.append(mark) + elif not mark.move_after and anchor.order >= 0: sys.stdout.write( f"\nWARNING: cannot place '{moving.item.name}' before" f" '{anchor.item.name}' - '{anchor.item.name}' has an ordinal" f" marker that places it before all relatively-ordered tests.\n" ) + impossible.append(mark) sys.stdout.flush() + return impossible def sort_by_topology( From 487a10337e73fea25b508bf61e688ea68d1913e5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:15:45 +0000 Subject: [PATCH 07/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/pytest_order/item.py | 46 ++++++++++++++++++++++++---------- tests/test_order_transitive.py | 16 ++++++------ 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/src/pytest_order/item.py b/src/pytest_order/item.py index dfeab94..ac3ff98 100644 --- a/src/pytest_order/item.py +++ b/src/pytest_order/item.py @@ -165,12 +165,20 @@ def _apply_topological_fallback(self, sorted_list: list[Item]) -> bool: for mark in self.rel_marks + self.dep_marks: items_with_rel_constraints.add(mark.item_to_move) - start_items = [item for item in sorted_list if item.order is not None and item.order >= 0] - end_items = [item for item in sorted_list if item.order is not None and item.order < 0] + start_items = [ + item for item in sorted_list if item.order is not None and item.order >= 0 + ] + end_items = [ + item for item in sorted_list if item.order is not None and item.order < 0 + ] middle_items = [item for item in sorted_list if item.order is None] - has_start_constraints = any(item in items_with_rel_constraints for item in start_items) - has_end_constraints = any(item in items_with_rel_constraints for item in end_items) + has_start_constraints = any( + item in items_with_rel_constraints for item in start_items + ) + has_end_constraints = any( + item in items_with_rel_constraints for item in end_items + ) if has_start_constraints: start_pinned: list[Item] = [] @@ -375,8 +383,11 @@ def sort_by_topology( # Add edges for explicit relative constraints for mark in rel_marks + dep_marks: # Normalise to a single "A before B" edge. - a, b = (mark.item, mark.item_to_move) if mark.move_after \ - else (mark.item_to_move, mark.item) + a, b = ( + (mark.item, mark.item_to_move) + if mark.move_after + else (mark.item_to_move, mark.item) + ) if a not in item_set or b not in item_set or a is b: continue successors[a].append(b) @@ -397,10 +408,12 @@ def sort_by_topology( in_degree[b] += 1 # Kahn's algorithm; break ties by input position for stability. - ready = deque(sorted( - (item for item in items if in_degree[item] == 0), - key=lambda x: item_position[x], - )) + ready = deque( + sorted( + (item for item in items if in_degree[item] == 0), + key=lambda x: item_position[x], + ) + ) result: list[Item] = [] while ready: item = ready.popleft() @@ -410,7 +423,11 @@ def sort_by_topology( if in_degree[successor] == 0: # Keep ready sorted by input position. pos = next( - (i for i, r in enumerate(ready) if item_position[r] > item_position[successor]), + ( + i + for i, r in enumerate(ready) + if item_position[r] > item_position[successor] + ), len(ready), ) ready.insert(pos, successor) @@ -419,6 +436,9 @@ def sort_by_topology( if had_cycle: # Append cyclic items in input order; the caller will warn. result.extend( - sorted((item for item in items if in_degree[item] > 0), key=lambda x: item_position[x]) + sorted( + (item for item in items if in_degree[item] > 0), + key=lambda x: item_position[x], + ) ) - return result, had_cycle \ No newline at end of file + return result, had_cycle diff --git a/tests/test_order_transitive.py b/tests/test_order_transitive.py index 88a7d48..6959479 100644 --- a/tests/test_order_transitive.py +++ b/tests/test_order_transitive.py @@ -180,13 +180,15 @@ def test_z(): ) result = test_path.runpytest("-v") result.assert_outcomes(passed=5) - result.stdout.fnmatch_lines([ - "test_mod_extra.py::test_x PASSED", - "test_mod_extra.py::test_y PASSED", - "test_mod_extra.py::test_z PASSED", - "test_mod_base.py::test_anchor PASSED", - "test_mod_base.py::test_last PASSED", - ]) + result.stdout.fnmatch_lines( + [ + "test_mod_extra.py::test_x PASSED", + "test_mod_extra.py::test_y PASSED", + "test_mod_extra.py::test_z PASSED", + "test_mod_base.py::test_anchor PASSED", + "test_mod_base.py::test_last PASSED", + ] + ) assert "cannot place 'test_x' after 'test_anchor'" in result.stdout.str() From f30a16877a451066a990bb2d4014fb9b434e3c06 Mon Sep 17 00:00:00 2001 From: Victor Sergienko Date: Wed, 10 Jun 2026 19:01:33 -0700 Subject: [PATCH 08/14] Vibed a fix for "Combination of absolute and relative ordering" design decision. --- CHANGELOG.md | 8 + docs/source/usage.rst | 35 ++++ src/pytest_order/item.py | 316 ++++++++++++-------------------- tests/test_order_transitive.py | 56 +++--- tests/test_relative_ordering.py | 33 +++- 5 files changed, 215 insertions(+), 233 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c83ddb0..2cd89d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # pytest-order Release Notes +## Unreleased + +### Fixes +- relative order markers (`before`/`after`) now always take preference over + absolute ordinal markers (`index`, `first`, `last`, …); a conflicting ordinal + position is relaxed instead of dropping the relative marker +- transitive relative chains are resolved as a single globally consistent order + ## [Version 1.4.0](https://pypi.org/project/pytest-order/1.4.0/) (2026-04-26) Allows the plugin to run after `--failed-first` and similar options. diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 2c45114..ac57b3b 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -352,6 +352,41 @@ ones. This means that relative ordering always takes preference: In this case, ``test_second`` will be executed before ``test_first``, regardless of the ordinal markers. +This also applies to a relative marker that references an ordinal-pinned +anchor, and to transitive relative chains, which are resolved as a single +globally consistent order: + +.. code:: python + + import pytest + + + def test_setup(): + assert True + + + @pytest.mark.order(after="test_anchor", before="test_last") + def test_middle(): + assert True + + + @pytest.mark.order("second_to_last") + def test_anchor(): + assert True + + + @pytest.mark.order("last") + def test_last(): + assert True + +Here ``test_middle`` is placed after ``test_anchor`` and before ``test_last``, +relaxing the ``second_to_last`` ordinal so that the relative constraints hold:: + + test_setup + test_anchor + test_middle + test_last + Several relationships for the same marker ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you need to order a certain test relative to more than one other test, you diff --git a/src/pytest_order/item.py b/src/pytest_order/item.py index ac3ff98..5a901a7 100644 --- a/src/pytest_order/item.py +++ b/src/pytest_order/item.py @@ -1,6 +1,6 @@ import sys from typing import Optional, Generic, TypeVar -from collections import defaultdict, deque +from collections import defaultdict from _pytest.python import Function @@ -105,51 +105,43 @@ def sort_numbered_items(self) -> list[Item]: def apply_relative_constraints(self, sorted_list: list[Item]) -> bool: """ - Apply relative constraints to sorted_list. - - Three-phase strategy: - 1. Pre-screen impossible marks - constraints whose anchor is pinned by - an absolute ordinal in a section opposite to where the unpinned - mover can land are dropped with a warning, so they do not displace - pinned items during the iterative pass. - 2. Iterative move_item passes - preserves the position of items not - involved in constraints, matching the original algorithm. - 3. Topological fallback - reorders only items whose constraints the - iterative pass could not satisfy (e.g. items with both absolute - and relative markers). - - Returns True if all constraints were satisfied. + Reorder sorted_list to satisfy all relative (before/after and + dependency) constraints. + + The incoming sorted_list already reflects the absolute ordinal markers, + which act only as a baseline preference: relative markers always take + preference, so an ordinal position is relaxed whenever it conflicts with + a relative constraint (see "Combination of absolute and relative + ordering" in the docs). + + Two phases: + 1. Iterative move_item passes satisfy the constraints that can be met by + moving a single item, preserving the position of items not involved + in any constraint and the established tie-breaking. + 2. A stable topological sort over the full constraint set re-validates + the result. It leaves an already-valid baseline unchanged, but + resolves constraints the iterative pass cannot keep on its own + (transitive chains, or a marker relative to an ordinal-pinned anchor + that a later move re-breaks). + + Only a genuine constraint cycle cannot be satisfied; in that case the + caller reports the offending markers. + + Returns True if all constraints were satisfied (no cycle). """ if not self.rel_marks and not self.dep_marks: return True - self._drop_impossible_marks() - if not self.rel_marks and not self.dep_marks: - return True - + rel_marks = list(self.rel_marks) + dep_marks = list(self.dep_marks) self._apply_iterative(sorted_list) - if not self.rel_marks and not self.dep_marks: - return True - - return self._apply_topological_fallback(sorted_list) - def _drop_impossible_marks(self) -> None: - pinned = {item for item in self.items if item.order is not None} - impossible_rel = warn_ordinal_conflicts(self.rel_marks, pinned) - self._remove_marks(self.rel_marks, self.all_rel_marks, impossible_rel) - impossible_dep = warn_ordinal_conflicts(self.dep_marks, pinned) - self._remove_marks(self.dep_marks, self.all_dep_marks, impossible_dep) - - @staticmethod - def _remove_marks( - marks: list["RelativeMark[Item]"], - all_marks: list["RelativeMark[Item]"], - to_remove: list["RelativeMark[Item]"], - ) -> None: - for mark in to_remove: - mark.item_to_move.dec_rel_marks() - marks.remove(mark) - all_marks.remove(mark) + ordered, had_cycle = sort_by_topology(sorted_list, rel_marks, dep_marks) + sorted_list[:] = ordered + if not had_cycle: + self._discard_satisfied_marks(self.rel_marks, self.all_rel_marks) + self._discard_satisfied_marks(self.dep_marks, self.all_dep_marks) + return not had_cycle def _apply_iterative(self, sorted_list: list[Item]) -> None: still_left = 0 @@ -160,52 +152,35 @@ def _apply_iterative(self, sorted_list: list[Item]) -> None: self.handle_dep_marks(sorted_list) length = self.number_of_rel_groups() - def _apply_topological_fallback(self, sorted_list: list[Item]) -> bool: - items_with_rel_constraints = set() - for mark in self.rel_marks + self.dep_marks: - items_with_rel_constraints.add(mark.item_to_move) - - start_items = [ - item for item in sorted_list if item.order is not None and item.order >= 0 - ] - end_items = [ - item for item in sorted_list if item.order is not None and item.order < 0 - ] - middle_items = [item for item in sorted_list if item.order is None] - - has_start_constraints = any( - item in items_with_rel_constraints for item in start_items - ) - has_end_constraints = any( - item in items_with_rel_constraints for item in end_items - ) - - if has_start_constraints: - start_pinned: list[Item] = [] - middle_movable = start_items + middle_items - else: - start_pinned = start_items - middle_movable = middle_items - - if has_end_constraints: - end_pinned: list[Item] = [] - middle_movable += end_items - else: - end_pinned = end_items + def number_of_rel_groups(self) -> int: + return len(self.rel_marks) + len(self.dep_marks) - all_pinned = set(start_pinned + end_pinned) - warn_ordinal_conflicts(self.rel_marks + self.dep_marks, all_pinned) + def handle_rel_marks(self, sorted_list: list[Item]) -> None: + self.handle_relative_marks(self.rel_marks, sorted_list, self.all_rel_marks) - sorted_middle, had_cycle = sort_by_topology( - middle_movable, self.rel_marks, self.dep_marks - ) + def handle_dep_marks(self, sorted_list: list[Item]) -> None: + self.handle_relative_marks(self.dep_marks, sorted_list, self.all_dep_marks) - sorted_list[:] = start_pinned + sorted_middle + end_pinned + @staticmethod + def handle_relative_marks( + marks: list["RelativeMark[Item]"], + sorted_list: list[Item], + all_marks: list["RelativeMark[Item]"], + ) -> None: + for mark in reversed(marks): + if move_item(mark, sorted_list): + marks.remove(mark) + all_marks.remove(mark) - if not had_cycle: - self.rel_marks.clear() - self.dep_marks.clear() - return not had_cycle + @staticmethod + def _discard_satisfied_marks( + marks: list["RelativeMark[Item]"], + all_marks: list["RelativeMark[Item]"], + ) -> None: + for mark in marks: + mark.item_to_move.dec_rel_marks() + all_marks.remove(mark) + marks.clear() def print_unhandled_items(self) -> None: failed_items = [mark.item for mark in self.rel_marks] + [ @@ -223,26 +198,6 @@ def print_unhandled_items(self) -> None: for item in failed_items: item.item.fixturenames.insert(0, "fail_after_cannot_order") - def number_of_rel_groups(self) -> int: - return len(self.rel_marks) + len(self.dep_marks) - - def handle_rel_marks(self, sorted_list: list[Item]) -> None: - self.handle_relative_marks(self.rel_marks, sorted_list, self.all_rel_marks) - - def handle_dep_marks(self, sorted_list: list[Item]) -> None: - self.handle_relative_marks(self.dep_marks, sorted_list, self.all_dep_marks) - - @staticmethod - def handle_relative_marks( - marks: list["RelativeMark[Item]"], - sorted_list: list[Item], - all_marks: list["RelativeMark[Item]"], - ) -> None: - for mark in reversed(marks): - if move_item(mark, sorted_list): - marks.remove(mark) - all_marks.remove(mark) - def group_order(self) -> Optional[int]: if self.start_items: return self.start_items[0][0] @@ -335,110 +290,77 @@ def move_item(mark: RelativeMark[_ItemType], sorted_items: list[_ItemType]) -> b return True -def warn_ordinal_conflicts( - marks: list["RelativeMark[Item]"], - pinned: set["Item"], -) -> list["RelativeMark[Item]"]: - """Warn about relative constraints that reference a pinned anchor in a - direction that cannot be satisfied given the absolute ordering section - boundaries. Returns the list of such impossible marks.""" - impossible: list["RelativeMark[Item]"] = [] - for mark in marks: - anchor, moving = mark.item, mark.item_to_move - if anchor not in pinned or moving in pinned or anchor.order is None: - continue - if mark.move_after and anchor.order < 0: - sys.stdout.write( - f"\nWARNING: cannot place '{moving.item.name}' after" - f" '{anchor.item.name}' - '{anchor.item.name}' has an ordinal" - f" marker that places it after all relatively-ordered tests.\n" - ) - impossible.append(mark) - elif not mark.move_after and anchor.order >= 0: - sys.stdout.write( - f"\nWARNING: cannot place '{moving.item.name}' before" - f" '{anchor.item.name}' - '{anchor.item.name}' has an ordinal" - f" marker that places it before all relatively-ordered tests.\n" - ) - impossible.append(mark) - sys.stdout.flush() - return impossible - - def sort_by_topology( items: list["Item"], rel_marks: list["RelativeMark[Item]"], dep_marks: list["RelativeMark[Item]"], ) -> tuple[list["Item"], bool]: """ - Topologically sort items using relative constraints and ordinal markers. - Returns (sorted_items, had_cycle). - Among items with no constraints between them, ordinal order (if present) is preserved. + Order items so that all relative constraints are satisfied while staying as + close as possible to the incoming order (the absolute-ordinal baseline). + + Relative constraints take preference over the baseline: each item is emitted + right after the items it must follow, so a conflicting ordinal position is + relaxed; items that no constraint orders keep their baseline order. The + incoming order already encodes the absolute ordinals, so no extra ordinal + edges are needed. + + Returns (ordered_items, had_cycle). On a constraint cycle the offending edge + is dropped, all items are still emitted, and had_cycle is True. """ item_set = set(items) - item_position = {item: i for i, item in enumerate(items)} - successors: dict[Item, list[Item]] = defaultdict(list) - in_degree: dict[Item, int] = {item: 0 for item in items} - - # Add edges for explicit relative constraints - for mark in rel_marks + dep_marks: - # Normalise to a single "A before B" edge. - a, b = ( + position = {item: i for i, item in enumerate(items)} + predecessors = _build_predecessors(rel_marks + dep_marks, item_set) + for item in items: + predecessors[item].sort(key=lambda p: position[p]) + + result: list[Item] = [] + placed: set[Item] = set() + on_path: set[Item] = set() + had_cycle = False + + # Iterative post-order DFS: emit unplaced predecessors before each item. + for start in items: + if start in placed: + continue + stack: list[tuple[Item, int]] = [(start, 0)] + on_path.add(start) + while stack: + item, next_pred = stack[-1] + preds = predecessors[item] + while next_pred < len(preds) and preds[next_pred] in placed: + next_pred += 1 + if next_pred < len(preds): + pred = preds[next_pred] + stack[-1] = (item, next_pred + 1) + if pred in on_path: + had_cycle = True + else: + stack.append((pred, 0)) + on_path.add(pred) + continue + stack.pop() + on_path.discard(item) + placed.add(item) + result.append(item) + + return result, had_cycle + + +def _build_predecessors( + marks: list["RelativeMark[Item]"], + item_set: set["Item"], +) -> "defaultdict[Item, list[Item]]": + """Map each item to the items that must run before it, derived from the + relative marks. A mark either places item_to_move after item (move_after) + or before it.""" + predecessors: defaultdict[Item, list[Item]] = defaultdict(list) + for mark in marks: + before, after = ( (mark.item, mark.item_to_move) if mark.move_after else (mark.item_to_move, mark.item) ) - if a not in item_set or b not in item_set or a is b: - continue - successors[a].append(b) - in_degree[b] += 1 - - # Add implicit edges for ordinal ordering - # If two items both have ordinals, the one with smaller ordinal should come first - # BUT don't add an edge that would contradict an explicit relative constraint - ordinal_items = [(item, item.order) for item in items if item.order is not None] - ordinal_items.sort(key=lambda x: x[1]) # Sort by ordinal value - for i in range(len(ordinal_items) - 1): - a, _ = ordinal_items[i] - b, _ = ordinal_items[i + 1] - # Only add edge a→b if there's no path from b to a (which would create a cycle) - # For simplicity, just check if b→a is an explicit edge - if a not in successors[b] and b not in successors[a]: - successors[a].append(b) - in_degree[b] += 1 - - # Kahn's algorithm; break ties by input position for stability. - ready = deque( - sorted( - (item for item in items if in_degree[item] == 0), - key=lambda x: item_position[x], - ) - ) - result: list[Item] = [] - while ready: - item = ready.popleft() - result.append(item) - for successor in sorted(successors[item], key=lambda x: item_position[x]): - in_degree[successor] -= 1 - if in_degree[successor] == 0: - # Keep ready sorted by input position. - pos = next( - ( - i - for i, r in enumerate(ready) - if item_position[r] > item_position[successor] - ), - len(ready), - ) - ready.insert(pos, successor) - - had_cycle = len(result) < len(items) - if had_cycle: - # Append cyclic items in input order; the caller will warn. - result.extend( - sorted( - (item for item in items if in_degree[item] > 0), - key=lambda x: item_position[x], - ) - ) - return result, had_cycle + if before in item_set and after in item_set and before is not after: + predecessors[after].append(before) + return predecessors diff --git a/tests/test_order_transitive.py b/tests/test_order_transitive.py index 6959479..51a46b9 100644 --- a/tests/test_order_transitive.py +++ b/tests/test_order_transitive.py @@ -1,13 +1,14 @@ """ Tests for relative ordering constraints that interact with absolute ordinal markers. -When a test carries both a relative constraint (after=/before=) and the anchor -is pinned to an absolute ordinal position (last, second_to_last, first, …), -the constraint is structurally impossible: relatively-ordered tests always fill -the middle section, between start-ordinal and end-ordinal items. - -pytest-order emits a WARNING for each such impossible constraint and preserves -the absolute ordinal positions unchanged. +Absolute ordinals (first, last, second_to_last, index=N, …) only provide a +baseline order. Relative constraints (after=/before=) always take preference: +when a relative constraint conflicts with an anchor's ordinal position, the +ordinal is relaxed so the relative constraint is satisfied (see "Combination of +absolute and relative ordering" in the docs). + +These tests also cover transitive relative chains, which are resolved as a +single globally-consistent order rather than by greedy pairwise moves. """ @@ -43,16 +44,15 @@ def test_c(): # collection order: test_setup, test_middle, test_anchor (second_to_last), # test_last (last) # markers: -# test_anchor → order("second_to_last") ← end-ordinal, pinned -# test_last → order("last") ← end-ordinal, pinned +# test_anchor → order("second_to_last") +# test_last → order("last") # test_middle → after=test_anchor, before=test_last # -# `after=test_anchor` is impossible: test_middle is relatively-ordered and -# always runs before the end-ordinal section. pytest-order warns and keeps -# absolute positions intact. -# actual order: test_setup, test_middle, test_anchor, test_last +# Relative ordering takes preference: test_middle is placed after test_anchor +# and before test_last, relaxing test_anchor's second_to_last position. +# order: test_setup, test_anchor, test_middle, test_last # --------------------------------------------------------------------------- -def test_between_absolute_anchors(item_names_for, capsys): +def test_between_absolute_anchors(item_names_for): test_content = """ import pytest @@ -73,12 +73,10 @@ def test_last(): """ assert item_names_for(test_content) == [ "test_setup", - "test_middle", "test_anchor", + "test_middle", "test_last", ] - out, _ = capsys.readouterr() - assert "cannot place 'test_middle' after 'test_anchor'" in out # --------------------------------------------------------------------------- @@ -93,11 +91,12 @@ def test_last(): # test_c → before=test_d # test_b → after=test_penultimate, before=test_c # -# `after=test_penultimate` is impossible for the same reason as scenario 2. -# The relative chain test_b→test_c→test_d is resolved correctly. -# actual order: test_a, test_b, test_c, test_d, test_penultimate, test_final +# Relative ordering takes preference: test_b is placed after test_penultimate +# (relaxing its second_to_last position) and the chain test_b→test_c→test_d→ +# test_final is satisfied. +# order: test_a, test_penultimate, test_b, test_c, test_d, test_final # --------------------------------------------------------------------------- -def test_after_absolute_before_relative_chain(item_names_for, capsys): +def test_after_absolute_before_relative_chain(item_names_for): test_content = """ import pytest @@ -126,14 +125,12 @@ def test_final(): """ assert item_names_for(test_content) == [ "test_a", + "test_penultimate", "test_b", "test_c", "test_d", - "test_penultimate", "test_final", ] - out, _ = capsys.readouterr() - assert "cannot place 'test_b' after 'test_penultimate'" in out # --------------------------------------------------------------------------- @@ -144,10 +141,10 @@ def test_final(): # test_y (before=test_z), # test_z (before=test_mod_base::test_last) # -# `after=test_anchor` is impossible (end-ordinal anchor). The relative -# chain test_x→test_y→test_z is satisfied; test_anchor and test_last keep -# their absolute end positions. -# actual order: test_x, test_y, test_z, test_anchor, test_last +# Relative ordering takes preference across modules too: test_x is placed +# after test_anchor (relaxing its second_to_last position) and the chain +# test_x→test_y→test_z→test_last is satisfied. +# order: test_anchor, test_x, test_y, test_z, test_last # --------------------------------------------------------------------------- def test_after_absolute_before_relative_chain_cross_module(test_path): test_path.makepyfile( @@ -182,14 +179,13 @@ def test_z(): result.assert_outcomes(passed=5) result.stdout.fnmatch_lines( [ + "test_mod_base.py::test_anchor PASSED", "test_mod_extra.py::test_x PASSED", "test_mod_extra.py::test_y PASSED", "test_mod_extra.py::test_z PASSED", - "test_mod_base.py::test_anchor PASSED", "test_mod_base.py::test_last PASSED", ] ) - assert "cannot place 'test_x' after 'test_anchor'" in result.stdout.str() # --------------------------------------------------------------------------- diff --git a/tests/test_relative_ordering.py b/tests/test_relative_ordering.py index 1512875..355bd98 100644 --- a/tests/test_relative_ordering.py +++ b/tests/test_relative_ordering.py @@ -256,7 +256,7 @@ def test_3(): assert item_names_for(test_content) == ["test_3", "test_1", "test_2"] -def test_mixed_markers2(item_names_for, capsys): +def test_mixed_markers2(item_names_for): test_content = """ import pytest @@ -272,11 +272,32 @@ def test_2(): def test_3(): pass """ - # test_2 has absolute order(1), so it stays pinned at the start. - # test_3's before="test_2" constraint is impossible and ignored with a warning. - assert item_names_for(test_content) == ["test_2", "test_1", "test_3"] - out, _ = capsys.readouterr() - assert "cannot place 'test_3' before 'test_2'" in out + # Relative ordering takes preference over the absolute ordinals: test_3 is + # placed before test_2, relaxing test_2's ordinal position. test_1 keeps its + # ordinal order relative to test_2. + assert item_names_for(test_content) == ["test_3", "test_2", "test_1"] + + +def test_combination_doc_example(item_names_for): + # The documented combination example (docs/source/usage.rst): an item with + # both an ordinal (index=0) and a relative marker (after another item that + # is pinned later by an ordinal). Relative ordering takes preference, so the + # ordinal index=0 is relaxed and test_second runs before test_first. + test_content = """ + import pytest + + @pytest.mark.order(index=0, after="test_second") + def test_first(): + assert True + + @pytest.mark.order(1) + def test_second(): + assert True + """ + assert item_names_for(test_content) == [ + "test_second", + "test_first", + ] def test_combined_markers1(item_names_for): From 21875a5eee1d5de99bed4a305e30dc6fd7a87e48 Mon Sep 17 00:00:00 2001 From: Victor Sergienko Date: Wed, 10 Jun 2026 19:47:31 -0700 Subject: [PATCH 09/14] This wasn't really needed. --- src/pytest_order/item.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/pytest_order/item.py b/src/pytest_order/item.py index 5a901a7..cfc9909 100644 --- a/src/pytest_order/item.py +++ b/src/pytest_order/item.py @@ -138,9 +138,6 @@ def apply_relative_constraints(self, sorted_list: list[Item]) -> bool: ordered, had_cycle = sort_by_topology(sorted_list, rel_marks, dep_marks) sorted_list[:] = ordered - if not had_cycle: - self._discard_satisfied_marks(self.rel_marks, self.all_rel_marks) - self._discard_satisfied_marks(self.dep_marks, self.all_dep_marks) return not had_cycle def _apply_iterative(self, sorted_list: list[Item]) -> None: @@ -172,16 +169,6 @@ def handle_relative_marks( marks.remove(mark) all_marks.remove(mark) - @staticmethod - def _discard_satisfied_marks( - marks: list["RelativeMark[Item]"], - all_marks: list["RelativeMark[Item]"], - ) -> None: - for mark in marks: - mark.item_to_move.dec_rel_marks() - all_marks.remove(mark) - marks.clear() - def print_unhandled_items(self) -> None: failed_items = [mark.item for mark in self.rel_marks] + [ mark.item for mark in self.dep_marks From 6f88ef61cf1be0378f94af4855bf97e038c39bb7 Mon Sep 17 00:00:00 2001 From: Victor Sergienko Date: Wed, 10 Jun 2026 19:55:51 -0700 Subject: [PATCH 10/14] Restore the failure in test_failed_tests_after_dependency_loop --- tests/test_relative_ordering.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_relative_ordering.py b/tests/test_relative_ordering.py index 355bd98..3f04f38 100644 --- a/tests/test_relative_ordering.py +++ b/tests/test_relative_ordering.py @@ -542,17 +542,25 @@ def test_2(): @pytest.mark.order(before="test_1") def test_3(): pass + + @pytest.mark.order(after="test_1", before="test_3") + def test_4(): + pass """ ) result = test_path.runpytest("-v", "--error-on-failed-ordering") # Since the constraints are consistent (both say test_3 → test_1), # all tests should pass even with --error-on-failed-ordering - result.assert_outcomes(passed=3) + result.assert_outcomes(passed=2, errors=2) + + # This exact order is not a requirement, but we expect 2 of the + # circular dependency tests to fail. result.stdout.fnmatch_lines( [ "test_failed_ordering.py::test_2 PASSED", - "test_failed_ordering.py::test_3 PASSED", - "test_failed_ordering.py::test_1 PASSED", + "test_failed_ordering.py::test_4 PASSED", + "test_failed_ordering.py::test_3 ERROR", + "test_failed_ordering.py::test_1 ERROR", ] ) From d53281e154ace9e20a1139332c065505c27def96 Mon Sep 17 00:00:00 2001 From: Victor Sergienko Date: Thu, 11 Jun 2026 10:33:15 -0700 Subject: [PATCH 11/14] Addressed review comments. --- src/pytest_order/item.py | 119 ++++++++++++++++----------------- tests/test_order_transitive.py | 48 ------------- 2 files changed, 59 insertions(+), 108 deletions(-) diff --git a/src/pytest_order/item.py b/src/pytest_order/item.py index cfc9909..a17570f 100644 --- a/src/pytest_order/item.py +++ b/src/pytest_order/item.py @@ -132,11 +132,9 @@ def apply_relative_constraints(self, sorted_list: list[Item]) -> bool: if not self.rel_marks and not self.dep_marks: return True - rel_marks = list(self.rel_marks) - dep_marks = list(self.dep_marks) self._apply_iterative(sorted_list) - ordered, had_cycle = sort_by_topology(sorted_list, rel_marks, dep_marks) + ordered, had_cycle = self._sort_by_topology(sorted_list) sorted_list[:] = ordered return not had_cycle @@ -192,6 +190,64 @@ def group_order(self) -> Optional[int]: return self.end_items[-1][0] return None + def _sort_by_topology( + self, + items: list["Item"], + ) -> tuple[list["Item"], bool]: + """ + Order items so that all relative constraints are satisfied while staying as + close as possible to the incoming order (the absolute-ordinal baseline). + + Relative constraints take preference over the baseline: each item is emitted + right after the items it must follow, so a conflicting ordinal position is + relaxed; items that no constraint orders keep their baseline order. The + incoming order already encodes the absolute ordinals, so no extra ordinal + edges are needed. + + Returns (ordered_items, had_cycle). On a constraint cycle the offending edge + is dropped, all items are still emitted, and had_cycle is True. + """ + rel_marks = list(self.rel_marks) + dep_marks = list(self.dep_marks) + + item_set = set(items) + position = {item: i for i, item in enumerate(items)} + predecessors = _build_predecessors(rel_marks + dep_marks, item_set) + for item in items: + predecessors[item].sort(key=lambda p: position[p]) + + result: list[Item] = [] + placed: set[Item] = set() + on_path: set[Item] = set() + had_cycle = False + + # Iterative post-order DFS: emit unplaced predecessors before each item. + for start in items: + if start in placed: + continue + stack: list[tuple[Item, int]] = [(start, 0)] + on_path.add(start) + while stack: + item, next_pred = stack[-1] + preds = predecessors[item] + while next_pred < len(preds) and preds[next_pred] in placed: + next_pred += 1 + if next_pred < len(preds): + pred = preds[next_pred] + stack[-1] = (item, next_pred + 1) + if pred in on_path: + had_cycle = True + else: + stack.append((pred, 0)) + on_path.add(pred) + continue + stack.pop() + on_path.discard(item) + placed.add(item) + result.append(item) + + return result, had_cycle + class ItemGroup: """ @@ -277,63 +333,6 @@ def move_item(mark: RelativeMark[_ItemType], sorted_items: list[_ItemType]) -> b return True -def sort_by_topology( - items: list["Item"], - rel_marks: list["RelativeMark[Item]"], - dep_marks: list["RelativeMark[Item]"], -) -> tuple[list["Item"], bool]: - """ - Order items so that all relative constraints are satisfied while staying as - close as possible to the incoming order (the absolute-ordinal baseline). - - Relative constraints take preference over the baseline: each item is emitted - right after the items it must follow, so a conflicting ordinal position is - relaxed; items that no constraint orders keep their baseline order. The - incoming order already encodes the absolute ordinals, so no extra ordinal - edges are needed. - - Returns (ordered_items, had_cycle). On a constraint cycle the offending edge - is dropped, all items are still emitted, and had_cycle is True. - """ - item_set = set(items) - position = {item: i for i, item in enumerate(items)} - predecessors = _build_predecessors(rel_marks + dep_marks, item_set) - for item in items: - predecessors[item].sort(key=lambda p: position[p]) - - result: list[Item] = [] - placed: set[Item] = set() - on_path: set[Item] = set() - had_cycle = False - - # Iterative post-order DFS: emit unplaced predecessors before each item. - for start in items: - if start in placed: - continue - stack: list[tuple[Item, int]] = [(start, 0)] - on_path.add(start) - while stack: - item, next_pred = stack[-1] - preds = predecessors[item] - while next_pred < len(preds) and preds[next_pred] in placed: - next_pred += 1 - if next_pred < len(preds): - pred = preds[next_pred] - stack[-1] = (item, next_pred + 1) - if pred in on_path: - had_cycle = True - else: - stack.append((pred, 0)) - on_path.add(pred) - continue - stack.pop() - on_path.discard(item) - placed.add(item) - result.append(item) - - return result, had_cycle - - def _build_predecessors( marks: list["RelativeMark[Item]"], item_set: set["Item"], diff --git a/tests/test_order_transitive.py b/tests/test_order_transitive.py index 51a46b9..98dd551 100644 --- a/tests/test_order_transitive.py +++ b/tests/test_order_transitive.py @@ -227,51 +227,3 @@ def test_5(): "test_4", ] - -# --------------------------------------------------------------------------- -# Scenario 6 – regression guard: existing simple cases must still work -# (ensures the fix doesn't regress the happy paths) -# --------------------------------------------------------------------------- -def test_simple_before_still_works(item_names_for): - test_content = """ - import pytest - - def test_a(): - pass - - @pytest.mark.order(before="test_a") - def test_b(): - pass - """ - assert item_names_for(test_content) == ["test_b", "test_a"] - - -def test_simple_after_still_works(item_names_for): - test_content = """ - import pytest - - @pytest.mark.order(after="test_b") - def test_a(): - pass - - def test_b(): - pass - """ - assert item_names_for(test_content) == ["test_b", "test_a"] - - -def test_absolute_last_still_works(item_names_for): - test_content = """ - import pytest - - @pytest.mark.order("last") - def test_a(): - pass - - def test_b(): - pass - - def test_c(): - pass - """ - assert item_names_for(test_content) == ["test_b", "test_c", "test_a"] From 4e5530cfdf0a0f33014efd733ccc63a83eb26549 Mon Sep 17 00:00:00 2001 From: Victor Sergienko Date: Thu, 11 Jun 2026 10:44:27 -0700 Subject: [PATCH 12/14] reading self.rel_marks/dep_marks from _sort_by_topology() broke tests - _sort_by_topology() needs the dependencies before _apply_iterative() mutates it. --- src/pytest_order/item.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/pytest_order/item.py b/src/pytest_order/item.py index a17570f..36205a3 100644 --- a/src/pytest_order/item.py +++ b/src/pytest_order/item.py @@ -132,9 +132,11 @@ def apply_relative_constraints(self, sorted_list: list[Item]) -> bool: if not self.rel_marks and not self.dep_marks: return True + rel_marks = list(self.rel_marks) + dep_marks = list(self.dep_marks) self._apply_iterative(sorted_list) - ordered, had_cycle = self._sort_by_topology(sorted_list) + ordered, had_cycle = self._sort_by_topology(sorted_list, rel_marks, dep_marks) sorted_list[:] = ordered return not had_cycle @@ -193,6 +195,8 @@ def group_order(self) -> Optional[int]: def _sort_by_topology( self, items: list["Item"], + rel_marks: list["RelativeMark[Item]"], + dep_marks: list["RelativeMark[Item]"], ) -> tuple[list["Item"], bool]: """ Order items so that all relative constraints are satisfied while staying as @@ -207,9 +211,6 @@ def _sort_by_topology( Returns (ordered_items, had_cycle). On a constraint cycle the offending edge is dropped, all items are still emitted, and had_cycle is True. """ - rel_marks = list(self.rel_marks) - dep_marks = list(self.dep_marks) - item_set = set(items) position = {item: i for i, item in enumerate(items)} predecessors = _build_predecessors(rel_marks + dep_marks, item_set) From 0b0749fe7155e63f26a4139fd55315d395fff09d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:45:06 +0000 Subject: [PATCH 13/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_order_transitive.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_order_transitive.py b/tests/test_order_transitive.py index 98dd551..01f5bad 100644 --- a/tests/test_order_transitive.py +++ b/tests/test_order_transitive.py @@ -226,4 +226,3 @@ def test_5(): "test_2", "test_4", ] - From 125d4856fbf94d86c14e76f9f9c55325fcb2a63a Mon Sep 17 00:00:00 2001 From: Victor Sergienko Date: Thu, 11 Jun 2026 13:40:52 -0700 Subject: [PATCH 14/14] remove excess mention of "relative vs absolute conflict" - this is the way it always worked. --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cd89d6..c80bafc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,6 @@ ## Unreleased ### Fixes -- relative order markers (`before`/`after`) now always take preference over - absolute ordinal markers (`index`, `first`, `last`, …); a conflicting ordinal - position is relaxed instead of dropping the relative marker - transitive relative chains are resolved as a single globally consistent order ## [Version 1.4.0](https://pypi.org/project/pytest-order/1.4.0/) (2026-04-26)