33"""
44
55import functools
6+ import hashlib
67import os
78import sys
89import time
910from bisect import bisect_left
1011from collections import defaultdict
12+ from datetime import datetime , timezone
1113from importlib .metadata import metadata
1214from packaging .specifiers import SpecifierSet
1315from packaging .version import Version
1416from pathlib import Path
17+ from textwrap import dedent
1518from typing import Optional , Union
1619
1720# Adding the scripts directory to PATH. This is necessary in order to be able
@@ -107,7 +110,7 @@ def fetch_release(package: str, version: Version) -> dict:
107110
108111
109112def _prefilter_releases (
110- integration : str , releases : dict [str , dict ]
113+ integration : str , releases : dict [str , dict ], older_than : Optional [ datetime ] = None
111114) -> tuple [list [Version ], Optional [Version ]]:
112115 """
113116 Filter `releases`, removing releases that are for sure unsupported.
@@ -148,6 +151,10 @@ def _prefilter_releases(
148151 if meta ["yanked" ]:
149152 continue
150153
154+ if older_than is not None :
155+ if datetime .fromisoformat (meta ["upload_time_iso_8601" ]) > older_than :
156+ continue
157+
151158 version = Version (release )
152159
153160 if min_supported and version < min_supported :
@@ -188,7 +195,7 @@ def _prefilter_releases(
188195
189196
190197def get_supported_releases (
191- integration : str , pypi_data : dict
198+ integration : str , pypi_data : dict , older_than : Optional [ datetime ] = None
192199) -> tuple [list [Version ], Optional [Version ]]:
193200 """
194201 Get a list of releases that are currently supported by the SDK.
@@ -199,14 +206,17 @@ def get_supported_releases(
199206 We return the list of supported releases and optionally also the newest
200207 prerelease, if it should be tested (meaning it's for a version higher than
201208 the current stable version).
209+
210+ If an `older_than` timestamp is provided, no release newer than that will be
211+ considered.
202212 """
203213 package = pypi_data ["info" ]["name" ]
204214
205215 # Get a consolidated list without taking into account Python support yet
206216 # (because that might require an additional API call for some
207217 # of the releases)
208218 releases , latest_prerelease = _prefilter_releases (
209- integration , pypi_data ["releases" ]
219+ integration , pypi_data ["releases" ], older_than
210220 )
211221
212222 # Determine Python support
@@ -424,7 +434,9 @@ def _render_dependencies(integration: str, releases: list[Version]) -> list[str]
424434 return rendered
425435
426436
427- def write_tox_file (packages : dict ) -> None :
437+ def write_tox_file (
438+ packages : dict , update_timestamp : bool , last_updated : datetime
439+ ) -> None :
428440 template = ENV .get_template ("tox.jinja" )
429441
430442 context = {"groups" : {}}
@@ -443,6 +455,11 @@ def write_tox_file(packages: dict) -> None:
443455 }
444456 )
445457
458+ if update_timestamp :
459+ context ["updated" ] = datetime .now (tz = timezone .utc ).isoformat ()
460+ else :
461+ context ["updated" ] = last_updated .isoformat ()
462+
446463 rendered = template .render (context )
447464
448465 with open (TOX_FILE , "w" ) as file :
@@ -496,7 +513,59 @@ def _add_python_versions_to_release(
496513 release .rendered_python_versions = _render_python_versions (release .python_versions )
497514
498515
499- def main () -> None :
516+ def get_file_hash () -> str :
517+ """Calculate a hash of the tox.ini file."""
518+ hasher = hashlib .md5 ()
519+
520+ with open (TOX_FILE , "rb" ) as f :
521+ buf = f .read ()
522+ hasher .update (buf )
523+
524+ return hasher .hexdigest ()
525+
526+
527+ def get_last_updated () -> Optional [datetime ]:
528+ timestamp = None
529+
530+ with open (TOX_FILE , "r" ) as f :
531+ for line in f :
532+ if line .startswith ("# Last generated:" ):
533+ timestamp = datetime .fromisoformat (line .strip ().split ()[- 1 ])
534+ break
535+
536+ if timestamp is None :
537+ print (
538+ "Failed to find out when tox.ini was last generated; the timestamp seems to be missing from the file."
539+ )
540+
541+ return timestamp
542+
543+
544+ def main (fail_on_changes : bool = False ) -> None :
545+ """
546+ Generate tox.ini from the tox.jinja template.
547+
548+ The script has two modes of operation:
549+ - fail on changes mode (if `fail_on_changes` is True)
550+ - normal mode (if `fail_on_changes` is False)
551+
552+ Fail on changes mode is run on every PR to make sure that `tox.ini`,
553+ `tox.jinja` and this script don't go out of sync because of manual changes
554+ in one place but not the other.
555+
556+ Normal mode is meant to be run as a cron job, regenerating tox.ini and
557+ proposing the changes via a PR.
558+ """
559+ print (f"Running in { 'fail_on_changes' if fail_on_changes else 'normal' } mode." )
560+ last_updated = get_last_updated ()
561+ if fail_on_changes :
562+ # We need to make the script ignore any new releases after the `last_updated`
563+ # timestamp so that we don't fail CI on a PR just because a new package
564+ # version was released, leading to unrelated changes in tox.ini.
565+ print (
566+ f"Since we're in fail_on_changes mode, we're only considering releases before the last tox.ini update at { last_updated .isoformat ()} ."
567+ )
568+
500569 global MIN_PYTHON_VERSION , MAX_PYTHON_VERSION
501570 sdk_python_versions = _parse_python_versions_from_classifiers (
502571 metadata ("sentry-sdk" ).get_all ("Classifier" )
@@ -523,7 +592,14 @@ def main() -> None:
523592 pypi_data = fetch_package (package )
524593
525594 # Get the list of all supported releases
526- releases , latest_prerelease = get_supported_releases (integration , pypi_data )
595+
596+ # If in fail-on-changes mode, ignore releases newer than `last_updated`
597+ older_than = last_updated if fail_on_changes else None
598+
599+ releases , latest_prerelease = get_supported_releases (
600+ integration , pypi_data , older_than
601+ )
602+
527603 if not releases :
528604 print (" Found no supported releases." )
529605 continue
@@ -553,8 +629,44 @@ def main() -> None:
553629 }
554630 )
555631
556- write_tox_file (packages )
632+ if fail_on_changes :
633+ old_file_hash = get_file_hash ()
634+
635+ write_tox_file (
636+ packages , update_timestamp = not fail_on_changes , last_updated = last_updated
637+ )
638+
639+ if fail_on_changes :
640+ new_file_hash = get_file_hash ()
641+ if old_file_hash != new_file_hash :
642+ raise RuntimeError (
643+ dedent (
644+ """
645+ Detected that `tox.ini` is out of sync with
646+ `scripts/populate_tox/tox.jinja` and/or
647+ `scripts/populate_tox/populate_tox.py`. This might either mean
648+ that `tox.ini` was changed manually, or the `tox.jinja`
649+ template and/or the `populate_tox.py` script were changed without
650+ regenerating `tox.ini`.
651+
652+ Please don't make manual changes to `tox.ini`. Instead, make the
653+ changes to the `tox.jinja` template and/or the `populate_tox.py`
654+ script (as applicable) and regenerate the `tox.ini` file with:
655+
656+ python -m venv toxgen.env
657+ . toxgen.env/bin/activate
658+ pip install -r scripts/populate_tox/requirements.txt
659+ python scripts/populate_tox/populate_tox.py
660+ """
661+ )
662+ )
663+ print ("Done checking tox.ini. Looking good!" )
664+ else :
665+ print (
666+ "Done generating tox.ini. Make sure to also update the CI YAML files to reflect the new test targets."
667+ )
557668
558669
559670if __name__ == "__main__" :
560- main ()
671+ fail_on_changes = len (sys .argv ) == 2 and sys .argv [1 ] == "--fail-on-changes"
672+ main (fail_on_changes )
0 commit comments