3
3
"""
4
4
5
5
import functools
6
+ import hashlib
6
7
import os
7
8
import sys
8
9
import time
9
10
from bisect import bisect_left
10
11
from collections import defaultdict
12
+ from datetime import datetime , timezone
11
13
from importlib .metadata import metadata
12
14
from packaging .specifiers import SpecifierSet
13
15
from packaging .version import Version
14
16
from pathlib import Path
17
+ from textwrap import dedent
15
18
from typing import Optional , Union
16
19
17
20
# 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:
106
109
return pypi_data .json ()
107
110
108
111
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 ]:
110
115
"""
111
116
Filter `releases`, removing releases that are for sure unsupported.
112
117
@@ -135,6 +140,10 @@ def _prefilter_releases(integration: str, releases: dict[str, dict]) -> list[Ver
135
140
if meta ["yanked" ]:
136
141
continue
137
142
143
+ if older_than is not None :
144
+ if datetime .fromisoformat (meta ["upload_time_iso_8601" ]) > older_than :
145
+ continue
146
+
138
147
version = Version (release )
139
148
140
149
if min_supported and version < min_supported :
@@ -160,19 +169,24 @@ def _prefilter_releases(integration: str, releases: dict[str, dict]) -> list[Ver
160
169
return sorted (filtered_releases )
161
170
162
171
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 ]:
164
175
"""
165
176
Get a list of releases that are currently supported by the SDK.
166
177
167
178
This takes into account a handful of parameters (Python support, the lowest
168
179
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.
169
183
"""
170
184
package = pypi_data ["info" ]["name" ]
171
185
172
186
# Get a consolidated list without taking into account Python support yet
173
187
# (because that might require an additional API call for some
174
188
# of the releases)
175
- releases = _prefilter_releases (integration , pypi_data ["releases" ])
189
+ releases = _prefilter_releases (integration , pypi_data ["releases" ], older_than )
176
190
177
191
# Determine Python support
178
192
expected_python_versions = TEST_SUITE_CONFIG [integration ].get ("python" )
@@ -381,7 +395,9 @@ def _render_dependencies(integration: str, releases: list[Version]) -> list[str]
381
395
return rendered
382
396
383
397
384
- def write_tox_file (packages : dict ) -> None :
398
+ def write_tox_file (
399
+ packages : dict , update_timestamp : bool , last_updated : datetime
400
+ ) -> None :
385
401
template = ENV .get_template ("tox.jinja" )
386
402
387
403
context = {"groups" : {}}
@@ -400,6 +416,11 @@ def write_tox_file(packages: dict) -> None:
400
416
}
401
417
)
402
418
419
+ if update_timestamp :
420
+ context ["updated" ] = datetime .now (tz = timezone .utc ).isoformat ()
421
+ else :
422
+ context ["updated" ] = last_updated .isoformat ()
423
+
403
424
rendered = template .render (context )
404
425
405
426
with open (TOX_FILE , "w" ) as file :
@@ -453,7 +474,59 @@ def _add_python_versions_to_release(
453
474
release .rendered_python_versions = _render_python_versions (release .python_versions )
454
475
455
476
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
+
457
530
global MIN_PYTHON_VERSION , MAX_PYTHON_VERSION
458
531
sdk_python_versions = _parse_python_versions_from_classifiers (
459
532
metadata ("sentry-sdk" ).get_all ("Classifier" )
@@ -480,7 +553,9 @@ def main() -> None:
480
553
pypi_data = fetch_package (package )
481
554
482
555
# 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 )
484
559
if not releases :
485
560
print (" Found no supported releases." )
486
561
continue
@@ -510,8 +585,44 @@ def main() -> None:
510
585
}
511
586
)
512
587
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
+ )
514
624
515
625
516
626
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 )
0 commit comments