Skip to content

Commit 08d64aa

Browse files
authored
PYTHON-5890 Automate sync file generation and staging (#2887)
1 parent 16b8f1b commit 08d64aa

9 files changed

Lines changed: 212 additions & 36 deletions

File tree

.github/workflows/test-python.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ jobs:
4646
- name: Run typecheck
4747
run: |
4848
just typing
49+
- name: Run synchro smoke test
50+
run: |
51+
uv run --extra test --with unasync pytest tools/test_synchro.py -v --override-ini="addopts="
4952
- run: |
5053
sudo apt-get install -y cppcheck
5154
- run: |

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ expansion.yml
3030
.evergreen/scripts/test-env.sh
3131
specifications/
3232
results.json
33+
.synchro-modified
3334
.evergreen/atlas_x509_dev_client_certificate.pem
3435

3536
# Lambda temp files

.pre-commit-config.yaml

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,25 +18,36 @@ repos:
1818
exclude: .patch
1919
exclude_types: [json]
2020

21-
- repo: https://github.com/astral-sh/ruff-pre-commit
22-
# Ruff version.
23-
rev: v0.15.16
21+
- repo: local
2422
hooks:
2523
- id: ruff
26-
args: ["--fix", "--show-fixes"]
24+
name: ruff
25+
entry: uv run --group lint ruff check --fix --show-fixes
26+
language: system
27+
types_or: [python, pyi]
28+
require_serial: true
2729
- id: ruff-format
30+
name: ruff-format
31+
entry: uv run --group lint ruff format
32+
language: system
33+
types_or: [python, pyi]
34+
require_serial: true
2835

2936
- repo: local
3037
hooks:
3138
- id: synchro
3239
name: synchro
33-
entry: bash ./tools/synchro.sh
34-
language: python
40+
entry: uv run --group unasync ./tools/synchro.py
41+
language: system
42+
require_serial: true
43+
always_run: true
44+
- id: synchro-stage-files
45+
name: synchro-stage-files
46+
entry: bash -c 'if [ -s .synchro-modified ]; then xargs git add < .synchro-modified || true; fi'
47+
language: system
3548
require_serial: true
36-
fail_fast: true
37-
additional_dependencies:
38-
- ruff==0.15.16
39-
- unasync
49+
always_run: true
50+
pass_filenames: false
4051

4152
- repo: https://github.com/adamchainz/blacken-docs
4253
rev: "1.16.0"

justfile

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ default:
1616
resync:
1717
@uv sync --quiet
1818

19+
[private]
20+
run-synchro:
21+
uv run --group unasync ./tools/synchro.py
22+
1923
# Set up the development environment
2024
install:
2125
bash .evergreen/scripts/setup-dev-env.sh
@@ -66,7 +70,7 @@ lint-manual *args="": && resync
6670

6771
# Run pytest (e.g. just test test/test_uri_parser.py)
6872
[group('test')]
69-
test *args="-v --durations=5 --maxfail=10": && resync
73+
test *args="-v --durations=5 --maxfail=10": run-synchro && resync
7074
#!/usr/bin/env bash
7175
set -euo pipefail
7276
uv run ${USE_ACTIVE_VENV:+--active} --extra test python -m pytest {{args}}
@@ -79,7 +83,7 @@ test-numpy *args="": && resync
7983

8084
# Run tests via the Evergreen test runner script
8185
[group('test')]
82-
run-tests *args: && resync
86+
run-tests *args: run-synchro && resync
8387
bash ./.evergreen/run-tests.sh {{args}}
8488

8589
# Set up the test environment (auth, TLS, etc.)

pyproject.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ Tracker = "https://jira.mongodb.org/projects/PYTHON/issues"
4848

4949
[dependency-groups]
5050
dev = []
51+
lint = [
52+
"ruff==0.15.16",
53+
]
54+
unasync = [
55+
{include-group = "lint"},
56+
"unasync",
57+
]
5158
pip = ["pip>=20.2"]
5259
gevent = ["gevent>=21.12"]
5360
coverage = [
@@ -141,6 +148,10 @@ pretty = true
141148
disable_error_code = ["no-any-return"]
142149
disallow_any_generics = true
143150

151+
[[tool.mypy.overrides]]
152+
module = ["synchro"]
153+
warn_unused_ignores = false
154+
144155
[[tool.mypy.overrides]]
145156
module = ["test.*"]
146157
disable_error_code = ["type-arg", "no-untyped-def", "no-untyped-call"]
@@ -234,6 +245,7 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?)|dummy.*)$"
234245
"RET", "ARG", "F405", "B028", "PGH001", "B018", "F403", "RUF015", "E731", "B007",
235246
"UP031", "F401", "B023", "F811"]
236247
"tools/*.py" = ["T201"]
248+
"tools/test_*.py" = ["E402", "S"]
237249
"hatch_build.py" = ["S"]
238250
"_setup.py" = ["SIM112"]
239251

tools/synchro.py

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
1516
"""Synchronization of asynchronous modules.
1617
1718
Used as part of our build system to generate synchronous code.
@@ -21,10 +22,13 @@
2122

2223
import os
2324
import re
25+
import subprocess
2426
import sys
2527
from pathlib import Path
2628

27-
from unasync import Rule, unasync_files # type: ignore[import-not-found]
29+
from unasync import Rule, unasync_files # type: ignore[import-untyped,import-not-found]
30+
31+
MANIFEST = ".synchro-modified"
2832

2933
replacements = {
3034
"AsyncCollection": "Collection",
@@ -424,6 +428,7 @@ def unasync_directory(files: list[str], src: str, dest: str, replacements: dict[
424428

425429

426430
def main() -> None:
431+
is_ci = bool(os.environ.get("CI"))
427432
modified_files = [f"./{f}" for f in sys.argv[1:]]
428433
errored = False
429434
for fname in async_files + gridfs_files + test_files:
@@ -438,29 +443,77 @@ def main() -> None:
438443
print(f"Refusing to overwrite {test_sync_name}")
439444
errored = True
440445
if errored:
441-
raise ValueError("Aborting synchro due to errors")
442-
443-
unasync_directory(async_files, _pymongo_base, _pymongo_dest_base, replacements)
444-
unasync_directory(gridfs_files, _gridfs_base, _gridfs_dest_base, replacements)
445-
unasync_directory(test_files, _test_base, _test_dest_base, replacements)
446-
447-
sync_files = [
448-
_pymongo_dest_base + f.name for f in Path(_pymongo_dest_base).iterdir() if f.is_file()
449-
]
446+
sys.exit(1)
447+
448+
# When called with specific files, only process those; otherwise process everything.
449+
modified_set = set(modified_files)
450+
filtered_async = [f for f in async_files if not modified_set or f in modified_set]
451+
filtered_gridfs = [f for f in gridfs_files if not modified_set or f in modified_set]
452+
filtered_tests = [f for f in test_files if not modified_set or f in modified_set]
453+
454+
ruff_extra = [] if is_ci else ["--silent"]
455+
456+
# Check async source files for problems before generating sync output.
457+
async_sources = filtered_async + filtered_gridfs + filtered_tests
458+
if async_sources:
459+
subprocess.run( # noqa: S603
460+
[sys.executable, "-m", "ruff", "check", *async_sources, *ruff_extra],
461+
check=True,
462+
)
450463

451-
sync_gridfs_files = [
452-
_gridfs_dest_base + f.name for f in Path(_gridfs_dest_base).iterdir() if f.is_file()
453-
]
454-
sync_test_files = [
455-
_test_dest_base + f for f in converted_tests if (Path(_test_dest_base) / f).is_file()
464+
unasync_directory(filtered_async, _pymongo_base, _pymongo_dest_base, replacements)
465+
unasync_directory(filtered_gridfs, _gridfs_base, _gridfs_dest_base, replacements)
466+
unasync_directory(filtered_tests, _test_base, _test_dest_base, replacements)
467+
468+
# Derive generated output paths directly from filtered source paths.
469+
converted_tests_set = set(converted_tests)
470+
generated_pymongo = [_pymongo_dest_base + Path(f).name for f in filtered_async]
471+
generated_gridfs = [_gridfs_dest_base + Path(f).name for f in filtered_gridfs]
472+
generated_tests = [
473+
_test_dest_base + Path(f).name
474+
for f in filtered_tests
475+
if Path(f).name in converted_tests_set and (Path(_test_dest_base) / Path(f).name).is_file()
456476
]
457477

458-
docstring_translate_files = sync_files + sync_gridfs_files + sync_test_files
478+
docstring_translate_files = generated_pymongo + generated_gridfs + generated_tests
459479

460480
process_files(
461-
sync_files + sync_gridfs_files + sync_test_files, docstring_translate_files, sync_test_files
481+
generated_pymongo + generated_gridfs + generated_tests,
482+
docstring_translate_files,
483+
generated_tests,
462484
)
463485

486+
generated_files = generated_pymongo + generated_gridfs + generated_tests
487+
488+
if is_ci and generated_files:
489+
print(f"Synchro generated {len(generated_files)} file(s):")
490+
for f in generated_files:
491+
print(f" {f}")
492+
493+
subprocess.run( # noqa: S603
494+
[sys.executable, "-m", "ruff", "check", *generated_files, "--fix", *ruff_extra],
495+
check=is_ci,
496+
)
497+
subprocess.run( # noqa: S603
498+
[sys.executable, "-m", "ruff", "format", *generated_files, *ruff_extra],
499+
check=is_ci,
500+
)
501+
502+
if is_ci and generated_files:
503+
result = subprocess.run( # noqa: S603
504+
["git", "diff", "--name-only", "--", *generated_files], # noqa: S607
505+
capture_output=True,
506+
text=True,
507+
check=True,
508+
)
509+
if result.stdout.strip():
510+
print("Sync files are out of date. Run `just lint --all-files synchro` to regenerate:")
511+
for f in result.stdout.strip().splitlines():
512+
print(f" {f}")
513+
sys.exit(1)
514+
515+
Path(MANIFEST).write_text("\n".join(generated_files) + ("\n" if generated_files else ""))
516+
464517

465518
if __name__ == "__main__":
466519
main()

tools/synchro.sh

Lines changed: 0 additions & 7 deletions
This file was deleted.

tools/test_synchro.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Smoke test for tools/synchro.py."""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
from pathlib import Path
7+
from unittest.mock import patch
8+
9+
import pytest
10+
11+
sys.path.insert(0, str(Path(__file__).parent))
12+
13+
14+
def test_synchro(monkeypatch: pytest.MonkeyPatch) -> None:
15+
"""Synchro processes only the async files passed as arguments.
16+
17+
collection.py is a valid async source that would be processed when
18+
synchro runs with no arguments. When mongo_client.py is passed instead,
19+
collection.py must not reach unasync_directory.
20+
"""
21+
monkeypatch.setattr("sys.argv", ["synchro.py", "pymongo/asynchronous/mongo_client.py"])
22+
monkeypatch.delenv("CI", raising=False)
23+
24+
processed: list[str] = []
25+
26+
def capture(files: list[str], *args: object, **kwargs: object) -> None:
27+
processed.extend(files)
28+
29+
with (
30+
patch("synchro.unasync_directory", side_effect=capture),
31+
patch("synchro.process_files"),
32+
patch("subprocess.run"),
33+
patch("pathlib.Path.write_text"),
34+
):
35+
from synchro import main
36+
37+
main()
38+
39+
assert "./pymongo/asynchronous/mongo_client.py" in processed
40+
assert "./pymongo/asynchronous/collection.py" not in processed

0 commit comments

Comments
 (0)