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
@@ -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
516626if __name__ == "__main__" :
517- main ()
627+ fail_on_changes = len (sys .argv ) == 2 and sys .argv [1 ] == "--fail-on-changes"
628+ main (fail_on_changes )
0 commit comments