Skip to content

Commit 67f0491

Browse files
authored
tests: Add fail_on_changes to toxgen (#4072)
Add `fail_on_changes` to toxgen. The idea is that the script will now have two modes: - **Normal mode** (when `fail_on_changes` is `False`) that is used to actually generate the `tox.ini` file. This [will be](#4050) run in a cron job in CI and create a PR with the updated test setup. - The newly added **fail-on-changes mode** (when `fail_on_changes` is `True`) that is used to detect manual changes to one of the affected files without updating the rest (e.g. making a manual change to `tox.ini` without updating the `tox.jinja` template). This will be run in CI similar to the `fail_on_changes` check of `split-tox-gh-actions`. The problem with detecting manual changes is that if we just reran the script on each PR, chances are it would pull in new releases that are not part of the `tox.ini` on master, making the file look different from what was committed as if it had unrelated manual changes. To counteract this, we now store the timestamp when the file was last generated in `tox.ini`. We use this in fail-on-changes mode to filter out releases that popped up after the file was last generated. This way, the package versions should be the same and if there is anything different in `tox.ini`, it's likely to be the manual changes that we want to detect. Closes #4051
1 parent 3745d9a commit 67f0491

File tree

4 files changed

+130
-11
lines changed

4 files changed

+130
-11
lines changed

.github/workflows/ci.yml

+5-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ jobs:
4444
with:
4545
python-version: 3.12
4646

47-
- run: |
47+
- name: Detect unexpected changes to tox.ini or CI
48+
run: |
49+
pip install -e .
50+
pip install -r scripts/populate_tox/requirements.txt
51+
python scripts/populate_tox/populate_tox.py --fail-on-changes
4852
pip install -r scripts/split_tox_gh_actions/requirements.txt
4953
python scripts/split_tox_gh_actions/split_tox_gh_actions.py --fail-on-changes
5054

scripts/populate_tox/populate_tox.py

+119-8
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@
33
"""
44

55
import functools
6+
import hashlib
67
import os
78
import sys
89
import time
910
from bisect import bisect_left
1011
from collections import defaultdict
12+
from datetime import datetime, timezone
1113
from importlib.metadata import metadata
1214
from packaging.specifiers import SpecifierSet
1315
from packaging.version import Version
1416
from pathlib import Path
17+
from textwrap import dedent
1518
from typing import Optional, Union
1619

1720
# Adding the scripts directory to PATH. This is necessary in order to be able
@@ -106,7 +109,9 @@ def fetch_release(package: str, version: Version) -> dict:
106109
return pypi_data.json()
107110

108111

109-
def _prefilter_releases(integration: str, releases: dict[str, dict]) -> list[Version]:
112+
def _prefilter_releases(
113+
integration: str, releases: dict[str, dict], older_than: Optional[datetime] = None
114+
) -> list[Version]:
110115
"""
111116
Filter `releases`, removing releases that are for sure unsupported.
112117
@@ -135,6 +140,10 @@ def _prefilter_releases(integration: str, releases: dict[str, dict]) -> list[Ver
135140
if meta["yanked"]:
136141
continue
137142

143+
if older_than is not None:
144+
if datetime.fromisoformat(meta["upload_time_iso_8601"]) > older_than:
145+
continue
146+
138147
version = Version(release)
139148

140149
if min_supported and version < min_supported:
@@ -160,19 +169,24 @@ def _prefilter_releases(integration: str, releases: dict[str, dict]) -> list[Ver
160169
return sorted(filtered_releases)
161170

162171

163-
def get_supported_releases(integration: str, pypi_data: dict) -> list[Version]:
172+
def get_supported_releases(
173+
integration: str, pypi_data: dict, older_than: Optional[datetime] = None
174+
) -> list[Version]:
164175
"""
165176
Get a list of releases that are currently supported by the SDK.
166177
167178
This takes into account a handful of parameters (Python support, the lowest
168179
version we've defined for the framework, the date of the release).
180+
181+
If an `older_than` timestamp is provided, no release newer than that will be
182+
considered.
169183
"""
170184
package = pypi_data["info"]["name"]
171185

172186
# Get a consolidated list without taking into account Python support yet
173187
# (because that might require an additional API call for some
174188
# of the releases)
175-
releases = _prefilter_releases(integration, pypi_data["releases"])
189+
releases = _prefilter_releases(integration, pypi_data["releases"], older_than)
176190

177191
# Determine Python support
178192
expected_python_versions = TEST_SUITE_CONFIG[integration].get("python")
@@ -381,7 +395,9 @@ def _render_dependencies(integration: str, releases: list[Version]) -> list[str]
381395
return rendered
382396

383397

384-
def write_tox_file(packages: dict) -> None:
398+
def write_tox_file(
399+
packages: dict, update_timestamp: bool, last_updated: datetime
400+
) -> None:
385401
template = ENV.get_template("tox.jinja")
386402

387403
context = {"groups": {}}
@@ -400,6 +416,11 @@ def write_tox_file(packages: dict) -> None:
400416
}
401417
)
402418

419+
if update_timestamp:
420+
context["updated"] = datetime.now(tz=timezone.utc).isoformat()
421+
else:
422+
context["updated"] = last_updated.isoformat()
423+
403424
rendered = template.render(context)
404425

405426
with open(TOX_FILE, "w") as file:
@@ -453,7 +474,59 @@ def _add_python_versions_to_release(
453474
release.rendered_python_versions = _render_python_versions(release.python_versions)
454475

455476

456-
def main() -> None:
477+
def get_file_hash() -> str:
478+
"""Calculate a hash of the tox.ini file."""
479+
hasher = hashlib.md5()
480+
481+
with open(TOX_FILE, "rb") as f:
482+
buf = f.read()
483+
hasher.update(buf)
484+
485+
return hasher.hexdigest()
486+
487+
488+
def get_last_updated() -> Optional[datetime]:
489+
timestamp = None
490+
491+
with open(TOX_FILE, "r") as f:
492+
for line in f:
493+
if line.startswith("# Last generated:"):
494+
timestamp = datetime.fromisoformat(line.strip().split()[-1])
495+
break
496+
497+
if timestamp is None:
498+
print(
499+
"Failed to find out when tox.ini was last generated; the timestamp seems to be missing from the file."
500+
)
501+
502+
return timestamp
503+
504+
505+
def main(fail_on_changes: bool = False) -> None:
506+
"""
507+
Generate tox.ini from the tox.jinja template.
508+
509+
The script has two modes of operation:
510+
- fail on changes mode (if `fail_on_changes` is True)
511+
- normal mode (if `fail_on_changes` is False)
512+
513+
Fail on changes mode is run on every PR to make sure that `tox.ini`,
514+
`tox.jinja` and this script don't go out of sync because of manual changes
515+
in one place but not the other.
516+
517+
Normal mode is meant to be run as a cron job, regenerating tox.ini and
518+
proposing the changes via a PR.
519+
"""
520+
print(f"Running in {'fail_on_changes' if fail_on_changes else 'normal'} mode.")
521+
last_updated = get_last_updated()
522+
if fail_on_changes:
523+
# We need to make the script ignore any new releases after the `last_updated`
524+
# timestamp so that we don't fail CI on a PR just because a new package
525+
# version was released, leading to unrelated changes in tox.ini.
526+
print(
527+
f"Since we're in fail_on_changes mode, we're only considering releases before the last tox.ini update at {last_updated.isoformat()}."
528+
)
529+
457530
global MIN_PYTHON_VERSION, MAX_PYTHON_VERSION
458531
sdk_python_versions = _parse_python_versions_from_classifiers(
459532
metadata("sentry-sdk").get_all("Classifier")
@@ -480,7 +553,9 @@ def main() -> None:
480553
pypi_data = fetch_package(package)
481554

482555
# Get the list of all supported releases
483-
releases = get_supported_releases(integration, pypi_data)
556+
# If in check mode, ignore releases newer than `last_updated`
557+
older_than = last_updated if fail_on_changes else None
558+
releases = get_supported_releases(integration, pypi_data, older_than)
484559
if not releases:
485560
print(" Found no supported releases.")
486561
continue
@@ -510,8 +585,44 @@ def main() -> None:
510585
}
511586
)
512587

513-
write_tox_file(packages)
588+
if fail_on_changes:
589+
old_file_hash = get_file_hash()
590+
591+
write_tox_file(
592+
packages, update_timestamp=not fail_on_changes, last_updated=last_updated
593+
)
594+
595+
if fail_on_changes:
596+
new_file_hash = get_file_hash()
597+
if old_file_hash != new_file_hash:
598+
raise RuntimeError(
599+
dedent(
600+
"""
601+
Detected that `tox.ini` is out of sync with
602+
`scripts/populate_tox/tox.jinja` and/or
603+
`scripts/populate_tox/populate_tox.py`. This might either mean
604+
that `tox.ini` was changed manually, or the `tox.jinja`
605+
template and/or the `populate_tox.py` script were changed without
606+
regenerating `tox.ini`.
607+
608+
Please don't make manual changes to `tox.ini`. Instead, make the
609+
changes to the `tox.jinja` template and/or the `populate_tox.py`
610+
script (as applicable) and regenerate the `tox.ini` file with:
611+
612+
python -m venv toxgen.env
613+
. toxgen.env/bin/activate
614+
pip install -r scripts/populate_tox/requirements.txt
615+
python scripts/populate_tox/populate_tox.py
616+
"""
617+
)
618+
)
619+
print("Done checking tox.ini. Looking good!")
620+
else:
621+
print(
622+
"Done generating tox.ini. Make sure to also update the CI YAML files to reflect the new test targets."
623+
)
514624

515625

516626
if __name__ == "__main__":
517-
main()
627+
fail_on_changes = len(sys.argv) == 2 and sys.argv[1] == "--fail-on-changes"
628+
main(fail_on_changes)

scripts/populate_tox/tox.jinja

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
# or in the script (if you want to change the auto-generated part).
1010
# The file (and all resulting CI YAMLs) then need to be regenerated via
1111
# "scripts/generate-test-files.sh".
12+
#
13+
# Last generated: {{ updated }}
1214

1315
[tox]
1416
requires =

tox.ini

+4-2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
# or in the script (if you want to change the auto-generated part).
1010
# The file (and all resulting CI YAMLs) then need to be regenerated via
1111
# "scripts/generate-test-files.sh".
12+
#
13+
# Last generated: 2025-02-18T12:57:32.874168+00:00
1214

1315
[tox]
1416
requires =
@@ -290,7 +292,7 @@ envlist =
290292
{py3.6,py3.7,py3.8}-trytond-v5.8.16
291293
{py3.8,py3.10,py3.11}-trytond-v6.8.17
292294
{py3.8,py3.11,py3.12}-trytond-v7.0.9
293-
{py3.8,py3.11,py3.12}-trytond-v7.4.5
295+
{py3.8,py3.11,py3.12}-trytond-v7.4.6
294296

295297
{py3.7,py3.11,py3.12}-typer-v0.15.1
296298

@@ -714,7 +716,7 @@ deps =
714716
trytond-v5.8.16: trytond==5.8.16
715717
trytond-v6.8.17: trytond==6.8.17
716718
trytond-v7.0.9: trytond==7.0.9
717-
trytond-v7.4.5: trytond==7.4.5
719+
trytond-v7.4.6: trytond==7.4.6
718720
trytond: werkzeug
719721
trytond-v4.6.9: werkzeug<1.0
720722
trytond-v4.8.18: werkzeug<1.0

0 commit comments

Comments
 (0)