Skip to content

Commit db0354a

Browse files
feat(python): parallelize checks across packages
Run (package × task) cross-product in parallel using ThreadPoolExecutor and subprocesses. Key changes: - Add scripts/task_runner.py with shared parallel execution engine - Update run_tasks_in_packages_if_exists.py to accept multiple tasks - Update run_tasks_in_changed_packages.py with --files flag and parallel support - Add check-packages poe task (fmt+lint+pyright+mypy in parallel) - Add prek-markdown-code-lint and prek-samples-check with change detection - Split CI code quality workflow into parallel prek and mypy jobs - Update DEV_SETUP.md to document new parallel behavior Core package changes still trigger checks on all packages.
1 parent a9a247f commit db0354a

6 files changed

Lines changed: 256 additions & 146 deletions

File tree

.github/workflows/python-code-quality.yml

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ env:
1313

1414
jobs:
1515
prek:
16-
name: Checks
16+
name: Prek Checks
1717
if: "!cancelled()"
1818
strategy:
1919
fail-fast: false
@@ -47,6 +47,33 @@ jobs:
4747
name: Run Prek Hooks
4848
with:
4949
extra-args: --cd python --all-files
50+
51+
mypy:
52+
name: Mypy Checks
53+
if: "!cancelled()"
54+
strategy:
55+
fail-fast: false
56+
matrix:
57+
python-version: ["3.10", "3.14"]
58+
runs-on: ubuntu-latest
59+
continue-on-error: true
60+
defaults:
61+
run:
62+
working-directory: ./python
63+
env:
64+
UV_PYTHON: ${{ matrix.python-version }}
65+
steps:
66+
- uses: actions/checkout@v6
67+
with:
68+
fetch-depth: 0
69+
- name: Set up python and install the project
70+
id: python-setup
71+
uses: ./.github/actions/python-setup
72+
with:
73+
python-version: ${{ matrix.python-version }}
74+
os: ${{ runner.os }}
75+
env:
76+
UV_CACHE_DIR: /tmp/.uv-cache
5077
- name: Run Mypy
5178
env:
5279
GITHUB_BASE_REF: ${{ github.event.pull_request.base.ref || github.base_ref || 'main' }}

python/DEV_SETUP.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -228,28 +228,28 @@ uv run poe prek-install
228228

229229
### Code Quality and Formatting
230230

231-
Each of the following tasks are designed to run against both the main `agent-framework` package and the extension packages, ensuring consistent code quality across the project.
231+
Each of the following tasks run against both the main `agent-framework` package and the extension packages in parallel, ensuring consistent code quality across the project.
232232

233233
#### `fmt` (format)
234-
Format code using ruff:
234+
Format code using ruff (runs in parallel across all packages):
235235
```bash
236236
uv run poe fmt
237237
```
238238

239239
#### `lint`
240-
Run linting checks and fix issues:
240+
Run linting checks and fix issues (runs in parallel across all packages):
241241
```bash
242242
uv run poe lint
243243
```
244244

245245
#### `pyright`
246-
Run Pyright type checking:
246+
Run Pyright type checking (runs in parallel across all packages):
247247
```bash
248248
uv run poe pyright
249249
```
250250

251251
#### `mypy`
252-
Run MyPy type checking:
252+
Run MyPy type checking (runs in parallel across all packages):
253253
```bash
254254
uv run poe mypy
255255
```
@@ -270,16 +270,22 @@ uv run poe markdown-code-lint
270270

271271
### Comprehensive Checks
272272

273+
#### `check-packages`
274+
Run all package-level quality checks (format, lint, pyright, mypy) in parallel across all packages. This runs the full cross-product of (package × check) concurrently:
275+
```bash
276+
uv run poe check-packages
277+
```
278+
273279
#### `check`
274-
Run all quality checks (format, lint, pyright, mypy, test, markdown lint):
280+
Run all quality checks including package checks, samples, tests and markdown lint:
275281
```bash
276282
uv run poe check
277283
```
278284

279285
### Testing
280286

281287
#### `test`
282-
Run unit tests with coverage by invoking the `test` task in each package sequentially:
288+
Run unit tests with coverage by invoking the `test` task in each package in parallel:
283289
```bash
284290
uv run poe test
285291
```
@@ -327,7 +333,7 @@ uv run poe publish
327333

328334
## Prek Hooks
329335

330-
Prek hooks run automatically on commit and execute a subset of the checks on changed files only. You can also run all checks using prek directly:
336+
Prek hooks run automatically on commit and execute a subset of the checks on changed files only. Package-level checks (fmt, lint, pyright) run in parallel but only for packages with changed files. Markdown and sample checks are skipped when no relevant files were changed. If the `core` package is changed, all packages are checked. You can also run all checks using prek directly:
331337

332338
```bash
333339
uv run prek run -a

python/pyproject.toml

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,8 @@ build-meta = "python -m flit build"
229229
build = ["build-packages", "build-meta"]
230230
publish = "uv publish"
231231
# combined checks
232-
check = ["fmt", "lint", "pyright", "mypy", "samples-lint", "samples-syntax", "test", "markdown-code-lint"]
232+
check-packages = "python scripts/run_tasks_in_packages_if_exists.py fmt lint pyright mypy"
233+
check = ["check-packages", "samples-lint", "samples-syntax", "test", "markdown-code-lint"]
233234

234235
[tool.poe.tasks.all-tests-cov]
235236
cmd = """
@@ -279,7 +280,36 @@ sequence = [
279280
args = [{ name = "python", default = "3.13", options = ['-p', '--python'] }]
280281

281282
[tool.poe.tasks.prek-pyright]
282-
cmd = "uv run python scripts/run_tasks_in_changed_packages.py pyright ${files}"
283+
cmd = "uv run python scripts/run_tasks_in_changed_packages.py pyright --files ${files}"
284+
args = [{ name = "files", default = ".", positional = true, multiple = true }]
285+
286+
[tool.poe.tasks.prek-check-packages]
287+
cmd = "uv run python scripts/run_tasks_in_changed_packages.py fmt lint pyright --files ${files}"
288+
args = [{ name = "files", default = ".", positional = true, multiple = true }]
289+
290+
[tool.poe.tasks.prek-markdown-code-lint]
291+
cmd = """uv run python scripts/check_md_code_blocks.py ${files} --no-glob
292+
--exclude cookiecutter-agent-framework-lab --exclude tau2
293+
--exclude packages/devui/frontend --exclude context_providers/azure_ai_search"""
294+
args = [{ name = "files", default = ".", positional = true, multiple = true }]
295+
296+
[tool.poe.tasks.prek-samples-check]
297+
shell = """
298+
HAS_SAMPLES=false
299+
for f in ${files}; do
300+
case "$f" in
301+
samples/*) HAS_SAMPLES=true; break ;;
302+
esac
303+
done
304+
if [ "$HAS_SAMPLES" = true ]; then
305+
echo "Sample files changed, running samples checks..."
306+
uv run ruff check samples --fix --exclude samples/autogen-migration,samples/semantic-kernel-migration --ignore E501,ASYNC,B901,TD002
307+
uv run pyright -p pyrightconfig.samples.json --warnings
308+
else
309+
echo "No sample files changed, skipping samples checks"
310+
fi
311+
"""
312+
interpreter = "bash"
283313
args = [{ name = "files", default = ".", positional = true, multiple = true }]
284314

285315

@@ -301,18 +331,15 @@ else
301331
echo ".")
302332
fi
303333
echo "Changed files: $CHANGED_FILES"
304-
uv run python scripts/run_tasks_in_changed_packages.py mypy $CHANGED_FILES
334+
uv run python scripts/run_tasks_in_changed_packages.py mypy --files $CHANGED_FILES
305335
"""
306336
interpreter = "bash"
307337

308338
[tool.poe.tasks.prek-check]
309339
sequence = [
310-
{ ref = "fmt" },
311-
{ ref = "lint" },
312-
{ ref = "prek-pyright ${files}" },
313-
{ ref = "markdown-code-lint" },
314-
{ ref = "samples-lint" },
315-
{ ref = "samples-syntax" }
340+
{ ref = "prek-check-packages ${files}" },
341+
{ ref = "prek-markdown-code-lint ${files}" },
342+
{ ref = "prek-samples-check ${files}" }
316343
]
317344
args = [{ name = "files", default = ".", positional = true, multiple = true }]
318345

python/scripts/run_tasks_in_changed_packages.py

Lines changed: 14 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,12 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

3-
"""Run a task only in packages that have changed files."""
3+
"""Run task(s) only in packages that have changed files, in parallel by default."""
44

55
import argparse
6-
import glob
7-
import sys
86
from pathlib import Path
97

10-
import tomli
11-
from poethepoet.app import PoeThePoet
128
from rich import print
13-
14-
15-
def discover_projects(workspace_pyproject_file: Path) -> list[Path]:
16-
with workspace_pyproject_file.open("rb") as f:
17-
data = tomli.load(f)
18-
19-
projects = data["tool"]["uv"]["workspace"]["members"]
20-
exclude = data["tool"]["uv"]["workspace"].get("exclude", [])
21-
22-
all_projects: list[Path] = []
23-
for project in projects:
24-
if "*" in project:
25-
globbed = glob.glob(str(project), root_dir=workspace_pyproject_file.parent)
26-
globbed_paths = [Path(p) for p in globbed]
27-
all_projects.extend(globbed_paths)
28-
else:
29-
all_projects.append(Path(project))
30-
31-
for project in exclude:
32-
if "*" in project:
33-
globbed = glob.glob(str(project), root_dir=workspace_pyproject_file.parent)
34-
globbed_paths = [Path(p) for p in globbed]
35-
all_projects = [p for p in all_projects if p not in globbed_paths]
36-
else:
37-
all_projects = [p for p in all_projects if p != Path(project)]
38-
39-
return all_projects
40-
41-
42-
def extract_poe_tasks(file: Path) -> set[str]:
43-
with file.open("rb") as f:
44-
data = tomli.load(f)
45-
46-
tasks = set(data.get("tool", {}).get("poe", {}).get("tasks", {}).keys())
47-
48-
# Check if there is an include too
49-
include: str | None = data.get("tool", {}).get("poe", {}).get("include", None)
50-
if include:
51-
include_file = file.parent / include
52-
if include_file.exists():
53-
tasks = tasks.union(extract_poe_tasks(include_file))
54-
55-
return tasks
9+
from task_runner import build_work_items, discover_projects, run_tasks
5610

5711

5812
def get_changed_packages(projects: list[Path], changed_files: list[str], workspace_root: Path) -> set[Path]:
@@ -95,38 +49,32 @@ def get_changed_packages(projects: list[Path], changed_files: list[str], workspa
9549

9650

9751
def main() -> None:
98-
parser = argparse.ArgumentParser(description="Run a task only in packages with changed files.")
99-
parser.add_argument("task", help="The task name to run")
100-
parser.add_argument("files", nargs="*", help="Changed files to determine which packages to run")
52+
parser = argparse.ArgumentParser(description="Run task(s) in changed packages, in parallel by default.")
53+
parser.add_argument("tasks", nargs="+", help="Task name(s) to run")
54+
parser.add_argument("--files", nargs="*", default=None, help="Changed files to determine which packages to run")
55+
parser.add_argument("--seq", action="store_true", help="Run sequentially instead of in parallel")
10156
args = parser.parse_args()
10257

10358
pyproject_file = Path(__file__).parent.parent / "pyproject.toml"
10459
workspace_root = pyproject_file.parent
10560
projects = discover_projects(pyproject_file)
10661

107-
# If no files specified, run in all packages (default behavior)
62+
# Determine which packages to check
10863
if not args.files or args.files == ["."]:
109-
print(f"[yellow]No specific files provided, running {args.task} in all packages[/yellow]")
110-
changed_packages = set(projects)
64+
task_list = ", ".join(args.tasks)
65+
print(f"[yellow]No specific files provided, running {task_list} in all packages[/yellow]")
66+
target_packages = sorted(set(projects))
11167
else:
11268
changed_packages = get_changed_packages(projects, args.files, workspace_root)
11369
if changed_packages:
11470
print(f"[cyan]Detected changes in packages: {', '.join(str(p) for p in sorted(changed_packages))}[/cyan]")
11571
else:
116-
print(f"[yellow]No changes detected in any package, skipping {args.task}[/yellow]")
72+
print(f"[yellow]No changes detected in any package, skipping[/yellow]")
11773
return
74+
target_packages = sorted(changed_packages)
11875

119-
# Run the task in changed packages
120-
for project in sorted(changed_packages):
121-
tasks = extract_poe_tasks(project / "pyproject.toml")
122-
if args.task in tasks:
123-
print(f"Running task {args.task} in {project}")
124-
app = PoeThePoet(cwd=project)
125-
result = app(cli_args=[args.task])
126-
if result:
127-
sys.exit(result)
128-
else:
129-
print(f"Task {args.task} not found in {project}")
76+
work_items = build_work_items(target_packages, args.tasks)
77+
run_tasks(work_items, workspace_root, sequential=args.seq)
13078

13179

13280
if __name__ == "__main__":

python/scripts/run_tasks_in_packages_if_exists.py

Lines changed: 14 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,28 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

3-
import glob
3+
"""Run poe task(s) across all workspace packages, in parallel by default."""
4+
5+
import argparse
46
import sys
57
from pathlib import Path
68

7-
import tomli
8-
from poethepoet.app import PoeThePoet
9-
from rich import print
10-
11-
12-
def discover_projects(workspace_pyproject_file: Path) -> list[Path]:
13-
with workspace_pyproject_file.open("rb") as f:
14-
data = tomli.load(f)
15-
16-
projects = data["tool"]["uv"]["workspace"]["members"]
17-
exclude = data["tool"]["uv"]["workspace"].get("exclude", [])
18-
19-
all_projects: list[Path] = []
20-
for project in projects:
21-
if "*" in project:
22-
globbed = glob.glob(str(project), root_dir=workspace_pyproject_file.parent)
23-
globbed_paths = [Path(p) for p in globbed]
24-
all_projects.extend(globbed_paths)
25-
else:
26-
all_projects.append(Path(project))
27-
28-
for project in exclude:
29-
if "*" in project:
30-
globbed = glob.glob(str(project), root_dir=workspace_pyproject_file.parent)
31-
globbed_paths = [Path(p) for p in globbed]
32-
all_projects = [p for p in all_projects if p not in globbed_paths]
33-
else:
34-
all_projects = [p for p in all_projects if p != Path(project)]
35-
36-
return all_projects
37-
38-
39-
def extract_poe_tasks(file: Path) -> set[str]:
40-
with file.open("rb") as f:
41-
data = tomli.load(f)
42-
43-
tasks = set(data.get("tool", {}).get("poe", {}).get("tasks", {}).keys())
44-
45-
# Check if there is an include too
46-
include: str | None = data.get("tool", {}).get("poe", {}).get("include", None)
47-
if include:
48-
include_file = file.parent / include
49-
if include_file.exists():
50-
tasks = tasks.union(extract_poe_tasks(include_file))
51-
52-
return tasks
9+
from task_runner import build_work_items, discover_projects, run_tasks
5310

5411

5512
def main() -> None:
13+
parser = argparse.ArgumentParser(
14+
description="Run poe task(s) across all workspace packages, in parallel by default."
15+
)
16+
parser.add_argument("tasks", nargs="+", help="Task name(s) to run across packages")
17+
parser.add_argument("--seq", action="store_true", help="Run sequentially instead of in parallel")
18+
args = parser.parse_args()
19+
5620
pyproject_file = Path(__file__).parent.parent / "pyproject.toml"
21+
workspace_root = pyproject_file.parent
5722
projects = discover_projects(pyproject_file)
5823

59-
if len(sys.argv) < 2:
60-
print("Please provide a task name")
61-
sys.exit(1)
62-
63-
task_name = sys.argv[1]
64-
for project in projects:
65-
tasks = extract_poe_tasks(project / "pyproject.toml")
66-
if task_name in tasks:
67-
print(f"Running task {task_name} in {project}")
68-
app = PoeThePoet(cwd=project)
69-
result = app(cli_args=sys.argv[1:])
70-
if result:
71-
sys.exit(result)
72-
else:
73-
print(f"Task {task_name} not found in {project}")
24+
work_items = build_work_items(projects, args.tasks)
25+
run_tasks(work_items, workspace_root, sequential=args.seq)
7426

7527

7628
if __name__ == "__main__":

0 commit comments

Comments
 (0)