Skip to content

Commit 99363ca

Browse files
authored
v0.2.0 (#2)
1 parent b6a704e commit 99363ca

File tree

8 files changed

+268
-51
lines changed

8 files changed

+268
-51
lines changed

.github/workflows/test.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ jobs:
2525
run: pre-commit run --show-diff-on-failure --color=always --all-files
2626

2727
test:
28+
name: Python ${{matrix.python-version}} + Pytest ${{matrix.pytest-version}}
2829
strategy:
2930
fail-fast: true
3031
matrix:
@@ -34,6 +35,9 @@ jobs:
3435
- "3.11"
3536
- "3.12"
3637
- "3.13"
38+
pytest-version:
39+
- "7"
40+
- "8"
3741
runs-on: ubuntu-latest
3842
steps:
3943
- name: Check out repository
@@ -44,6 +48,10 @@ jobs:
4448
python-version: ${{ matrix.python-version }}
4549
- name: Install uv
4650
uses: astral-sh/setup-uv@v5
51+
- name: Install dependencies
52+
run: |
53+
uv sync
54+
uv pip install pytest=="${{matrix.pytest-version}}"
4755
- name: Test
4856
run: uv run pytest
4957

README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,70 @@ jobs:
4040
- name: Run pytest
4141
run: pytest --cdist-group=${{ matrix.cdist-group }}/4
4242
```
43+
44+
## Usage
45+
46+
### Configuration
47+
48+
Pytest-cdist comes with several CLI and pytest-ini options:
49+
50+
| CLI | Ini | Allowed values | Default |
51+
|-------------------------|-----------------------|-------------------------------|---------|
52+
| `--cdist-justify-items` | `cdist-justify-items` | `none`, `file`, `scope` | `none` |
53+
| `--cdist-group-steal` | `--cdist-group-steal` | `<group number>:<percentage>` | - |
54+
| `--cdist-report` | - | - | false |
55+
| `--cdist-report-dir` | `cdist-report-dir` | | `.` |
56+
57+
58+
### Controlling how items are split up
59+
60+
By default, pytest-cdist will split up the items into groups as evenly as possible.
61+
Sometimes this may not be desired, for example if there's some costly fixtures requested
62+
by multiple tests, which should ideally only run once.
63+
64+
To solve this, the `cdist-justify-items` option can be used to configure how items are
65+
split up. It can take two possible values:
66+
67+
- `file`: Ensure all items inside a file end up in the same group
68+
- `scope`: Ensure all items in the same pytest scope end up in the same group
69+
70+
```ini
71+
[pytest]
72+
cdist-justify-items=file
73+
```
74+
75+
```bash
76+
pytest --cdist-group=1/2 --cdist-justify-items=file
77+
```
78+
79+
80+
### Skewing the group sizes
81+
82+
Normally, items are distributed evenly among groups, which is a good default, but there
83+
may be cases where this will result in an uneven execution time, if one group contains
84+
a number of slower tests than the other ones.
85+
86+
To work around this, the `cdist-group-steal` option can be used. It allows to specific
87+
a certain percentage of items a group will "steal" from other groups. For example
88+
`--cdist-group-steal=2:30` will cause group `2` to steal 30% of items from all other
89+
groups.
90+
91+
```ini
92+
[pytest]
93+
cdist-group-steal=2:30
94+
```
95+
96+
```bash
97+
pytest --cdist-group=1/2 --cdist-group-steal=2:30
98+
```
99+
100+
### With pytest-xdist
101+
102+
When running under pytest-xdist, pytest-cdist will honour tests marked with
103+
`xdist_group`, and group them together in the same cdist group.
104+
105+
106+
### With pytest-randomly
107+
108+
At the moment, pytest-cdist does not work out of the box with pytest randomly's test
109+
reordering, unless an explicit seed is passed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pytest-cdist"
3-
version = "0.1.0"
3+
version = "0.2.0"
44
description = "Add your description here"
55
readme = "README.md"
66
requires-python = ">=3.9"
@@ -31,6 +31,7 @@ dev = [
3131
"pytest-xdist>=3.6.1",
3232
"mypy>=1.14.0",
3333
"pre-commit>=4.0.1",
34+
"typing-extensions>=4.12.2",
3435
]
3536

3637
[project.entry-points.pytest11]

pytest_cdist/plugin.py

Lines changed: 138 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,22 @@
11
from __future__ import annotations
2+
23
import collections
4+
import dataclasses
35
import json
6+
import os
47
import pathlib
5-
from typing import TypeVar, Literal
8+
from typing import TypeVar, Literal, TYPE_CHECKING
69

710
import pytest
11+
from _pytest.stash import StashKey
812

9-
T = TypeVar("T")
13+
if TYPE_CHECKING:
14+
from typing_extensions import TypeAlias
1015

16+
T = TypeVar("T")
17+
JustifyItemsStrategy: TypeAlias = Literal["none", "file", "scope"]
1118

12-
@pytest.hookimpl
13-
def pytest_addoption(parser: pytest.Parser) -> None:
14-
group = parser.getgroup("cdist")
15-
group.addoption("--cdist-group", action="store", default=None)
16-
group.addoption("--cdist-report", action="store_true", default=False)
17-
group.addoption(
18-
"--cdist-report-dir", action="store", default=".", type=pathlib.Path
19-
)
20-
group.addoption("--cdist-justify-items", action="store", default="none")
21-
group.addoption(
22-
"--cdist-group-steal",
23-
action="store",
24-
default=None,
25-
help="make a group steal a percentage of items from other groups. '1:30' would "
26-
"make group 1 steal 30 % of items from all other groups)",
27-
)
19+
_CDIST_CONFIG_KEY = StashKey["CdistConfig | None"]()
2820

2921

3022
def _partition_list(items: list[T], chunk_size: int) -> list[list[T]]:
@@ -83,6 +75,9 @@ def _justify_items(
8375

8476
last_file = get_boundary(items[-1])
8577
next_group = groups[i + 1 if i < (len(groups) - 1) else 0]
78+
if not next_group:
79+
continue
80+
8681
next_file = get_boundary(next_group[0])
8782

8883
if last_file == next_file:
@@ -118,53 +113,143 @@ def _justify_xdist_groups(groups: list[list[pytest.Item]]) -> list[list[pytest.I
118113
return groups
119114

120115

116+
@dataclasses.dataclass
117+
class CdistConfig:
118+
current_group: int
119+
total_groups: int
120+
justify_items_strategy: JustifyItemsStrategy = "none"
121+
group_steal: tuple[int, int] | None = None
122+
write_report: bool = False
123+
report_dir: pathlib.Path = pathlib.Path(".")
124+
125+
def cli_options(self, config: pytest.Config) -> str:
126+
opts = [f"--cdist-group={self.current_group + 1}/{self.total_groups}"]
127+
128+
if (
129+
self.justify_items_strategy != "none"
130+
and "cdist-justify-items" not in config.inicfg
131+
):
132+
opts.append(f"--cdist-justify-items={self.justify_items_strategy}")
133+
134+
if self.group_steal and "cdist-group-steal" not in config.inicfg:
135+
opts.append(
136+
f"--cdist-group-steal={self.group_steal[0] + 1}:{self.group_steal[1]}"
137+
)
138+
139+
if self.write_report:
140+
opts.append("--cdist-report")
141+
142+
if (
143+
self.report_dir != pathlib.Path(".")
144+
and "cdist-report-dir" not in config.inicfg
145+
):
146+
opts.append(f"--cdist-report-dir={str(self.report_dir)}")
147+
148+
return " ".join(opts)
149+
150+
@classmethod
151+
def from_pytest_config(cls, config: pytest.Config) -> CdistConfig | None:
152+
cdist_option = config.getoption("cdist_group")
153+
154+
if cdist_option is None:
155+
return None
156+
157+
report_dir = pathlib.Path(
158+
config.getoption("cdist_report_dir", None)
159+
or config.getini("cdist-report-dir")
160+
)
161+
162+
write_report: bool = config.getoption("cdist_report")
163+
164+
justify_items_strategy: JustifyItemsStrategy = config.getoption(
165+
"cdist_justify_items",
166+
default=None,
167+
) or config.getini("cdist-justify-items")
168+
169+
group_steal = _get_group_steal_opt(
170+
config.getoption("cdist_group_steal") or config.getini("cdist-group-steal")
171+
)
172+
173+
current_group, total_groups = map(int, cdist_option.split("/"))
174+
if not 0 < current_group <= total_groups:
175+
raise pytest.UsageError(f"Unknown group {current_group}")
176+
177+
# using whole numbers (2/2) is more intuitive for the CLI,
178+
# but here we want to use the group numbers for zero-based indexing
179+
current_group -= 1
180+
181+
return cls(
182+
total_groups=total_groups,
183+
current_group=current_group,
184+
report_dir=report_dir,
185+
write_report=write_report,
186+
justify_items_strategy=justify_items_strategy,
187+
group_steal=group_steal,
188+
)
189+
190+
191+
@pytest.hookimpl
192+
def pytest_addoption(parser: pytest.Parser) -> None:
193+
group = parser.getgroup("cdist")
194+
group.addoption("--cdist-group", action="store", default=None)
195+
group.addoption("--cdist-report", action="store_true", default=False)
196+
group.addoption("--cdist-report-dir", action="store")
197+
group.addoption("--cdist-justify-items", action="store")
198+
group.addoption(
199+
"--cdist-group-steal",
200+
action="store",
201+
default=None,
202+
help="make a group steal a percentage of items from other groups. '1:30' would "
203+
"make group 1 steal 30%% of items from all other groups)",
204+
)
205+
206+
parser.addini("cdist-justify-items", help="justify items strategy", default="none")
207+
parser.addini(
208+
"cdist-report-dir", help="cdist report dir", default=".", type="paths"
209+
)
210+
parser.addini("cdist-group-steal", help="cdist group steal", default=None)
211+
212+
213+
def pytest_configure(config: pytest.Config) -> None:
214+
cdist_config = CdistConfig.from_pytest_config(config)
215+
config.stash[_CDIST_CONFIG_KEY] = cdist_config
216+
217+
121218
def pytest_collection_modifyitems(
122219
session: pytest.Session, config: pytest.Config, items: list[pytest.Item]
123220
) -> None:
124-
cdist_option = config.getoption("cdist_group")
221+
cdist_config = config.stash.get(_CDIST_CONFIG_KEY, None)
125222

126-
if cdist_option is None:
223+
if cdist_config is None:
127224
return
128225

129-
report_dir: pathlib.Path = config.getoption("cdist_report_dir")
130-
write_report: bool = config.getoption("cdist_report")
131-
justify_items_strategy: Literal["none", "file", "scope"] = config.getoption(
132-
"cdist_justify_items"
133-
)
134-
group_steal = _get_group_steal_opt(config.getoption("cdist_group_steal"))
135-
136-
current_group, total_groups = map(int, cdist_option.split("/"))
137-
if not 0 < current_group <= total_groups:
138-
raise pytest.UsageError(f"Unknown group {current_group}")
226+
groups = _partition_list(items, cdist_config.total_groups)
139227

140-
# using whole numbers (2/2) is more intuitive for the CLI,
141-
# but here we want to use the group numbers for zero-based indexing
142-
current_group -= 1
228+
if cdist_config.justify_items_strategy != "none":
229+
groups = _justify_items(groups, strategy=cdist_config.justify_items_strategy)
143230

144-
groups = _partition_list(items, total_groups)
145-
if justify_items_strategy != "none":
146-
groups = _justify_items(groups, strategy=justify_items_strategy)
231+
if os.getenv("PYTEST_XDIST_WORKER"):
232+
groups = _justify_xdist_groups(groups)
147233

148-
# if os.getenv("PYTEST_XDIST_WORKER"):
149-
groups = _justify_xdist_groups(groups)
150-
151-
if group_steal is not None:
152-
target_group, amount_to_steal = group_steal
234+
if cdist_config.group_steal is not None:
235+
target_group, amount_to_steal = cdist_config.group_steal
153236
groups = _distribute_with_bias(
154237
groups,
155238
target=target_group,
156239
bias=amount_to_steal,
157240
)
158241

159-
new_items = groups.pop(current_group)
242+
new_items = groups.pop(cdist_config.current_group)
160243
deselect = [item for group in groups for item in group]
161244

162-
if write_report:
163-
report_dir.joinpath(f"pytest_cdist_report_{current_group + 1}.json").write_text(
245+
if cdist_config.write_report:
246+
cdist_config.report_dir.joinpath(
247+
f"pytest_cdist_report_{cdist_config.current_group + 1}.json"
248+
).write_text(
164249
json.dumps(
165250
{
166-
"group": current_group + 1,
167-
"total_groups": total_groups,
251+
"group": cdist_config.current_group + 1,
252+
"total_groups": cdist_config.total_groups,
168253
"collected": [i.nodeid for i in items],
169254
"selected": [i.nodeid for i in new_items],
170255
}
@@ -177,3 +262,10 @@ def pytest_collection_modifyitems(
177262

178263
if deselect:
179264
config.hook.pytest_deselected(items=deselect)
265+
266+
267+
def pytest_report_header(config: pytest.Config) -> list[str]:
268+
cdist_config = config.stash.get(_CDIST_CONFIG_KEY, None)
269+
if cdist_config is None:
270+
return []
271+
return ["cdist options: " + cdist_config.cli_options(config)]

pytest_cdist/py.typed

Whitespace-only changes.

test.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
def test_one():
2+
assert True
3+
4+
5+
def test_two():
6+
assert False

0 commit comments

Comments
 (0)