|
4 | 4 |
|
5 | 5 | import functools
|
6 | 6 | import hashlib
|
| 7 | +import json |
7 | 8 | import os
|
8 | 9 | import sys
|
9 | 10 | import time
|
|
34 | 35 | # CUTOFF = datetime.now(tz=timezone.utc) - timedelta(days=365 * 5)
|
35 | 36 |
|
36 | 37 | TOX_FILE = Path(__file__).resolve().parent.parent.parent / "tox.ini"
|
| 38 | +RELEASES_CACHE_FILE = Path(__file__).resolve().parent / "releases.jsonl" |
37 | 39 | ENV = Environment(
|
38 | 40 | loader=FileSystemLoader(Path(__file__).resolve().parent),
|
39 | 41 | trim_blocks=True,
|
40 | 42 | lstrip_blocks=True,
|
41 | 43 | )
|
42 | 44 |
|
43 |
| -PYPI_COOLDOWN = 0.1 # seconds to wait between requests to PyPI |
| 45 | +PYPI_COOLDOWN = 0.05 # seconds to wait between requests to PyPI |
44 | 46 |
|
45 | 47 | PYPI_PROJECT_URL = "https://pypi.python.org/pypi/{project}/json"
|
46 | 48 | PYPI_VERSION_URL = "https://pypi.python.org/pypi/{project}/{version}/json"
|
47 | 49 | CLASSIFIER_PREFIX = "Programming Language :: Python :: "
|
48 | 50 |
|
| 51 | +CACHE = defaultdict(dict) |
49 | 52 |
|
50 | 53 | IGNORE = {
|
51 | 54 | # Do not try auto-generating the tox entries for these. They will be
|
@@ -94,9 +97,32 @@ def fetch_package(package: str) -> Optional[dict]:
|
94 | 97 |
|
95 | 98 | @functools.cache
|
96 | 99 | def fetch_release(package: str, version: Version) -> Optional[dict]:
|
97 |
| - """Fetch release metadata from PyPI.""" |
| 100 | + """Fetch release metadata from cache or, failing that, PyPI.""" |
| 101 | + release = _fetch_from_cache(package, version) |
| 102 | + if release is not None: |
| 103 | + return release |
| 104 | + |
98 | 105 | url = PYPI_VERSION_URL.format(project=package, version=version)
|
99 |
| - return fetch_url(url) |
| 106 | + release = fetch_url(url) |
| 107 | + if release is not None: |
| 108 | + _save_to_cache(package, version, release) |
| 109 | + return release |
| 110 | + |
| 111 | + |
| 112 | +def _fetch_from_cache(package: str, version: Version) -> Optional[dict]: |
| 113 | + package = _normalize_name(package) |
| 114 | + if package in CACHE and str(version) in CACHE[package]: |
| 115 | + CACHE[package][str(version)]["_accessed"] = True |
| 116 | + return CACHE[package][str(version)] |
| 117 | + |
| 118 | + return None |
| 119 | + |
| 120 | + |
| 121 | +def _save_to_cache(package: str, version: Version, release: Optional[dict]) -> None: |
| 122 | + with open(RELEASES_CACHE_FILE, "a") as releases_cache: |
| 123 | + releases_cache.write(json.dumps(_normalize_release(release)) + "\n") |
| 124 | + |
| 125 | + CACHE[_normalize_name(package)][str(version)] = release |
100 | 126 |
|
101 | 127 |
|
102 | 128 | def _prefilter_releases(
|
@@ -600,6 +626,24 @@ def get_last_updated() -> Optional[datetime]:
|
600 | 626 | return timestamp
|
601 | 627 |
|
602 | 628 |
|
| 629 | +def _normalize_name(package: str) -> str: |
| 630 | + return package.lower().replace("-", "_") |
| 631 | + |
| 632 | + |
| 633 | +def _normalize_release(release: dict) -> dict: |
| 634 | + """Filter out unneeded parts of the release JSON.""" |
| 635 | + normalized = { |
| 636 | + "info": { |
| 637 | + "classifiers": release["info"]["classifiers"], |
| 638 | + "name": release["info"]["name"], |
| 639 | + "requires_python": release["info"]["requires_python"], |
| 640 | + "version": release["info"]["version"], |
| 641 | + "yanked": release["info"]["yanked"], |
| 642 | + }, |
| 643 | + } |
| 644 | + return normalized |
| 645 | + |
| 646 | + |
603 | 647 | def main(fail_on_changes: bool = False) -> None:
|
604 | 648 | """
|
605 | 649 | Generate tox.ini from the tox.jinja template.
|
@@ -636,6 +680,20 @@ def main(fail_on_changes: bool = False) -> None:
|
636 | 680 | f"The SDK supports Python versions {MIN_PYTHON_VERSION} - {MAX_PYTHON_VERSION}."
|
637 | 681 | )
|
638 | 682 |
|
| 683 | + # Load file cache |
| 684 | + global CACHE |
| 685 | + |
| 686 | + with open(RELEASES_CACHE_FILE) as releases_cache: |
| 687 | + for line in releases_cache: |
| 688 | + release = json.loads(line) |
| 689 | + name = _normalize_name(release["info"]["name"]) |
| 690 | + version = release["info"]["version"] |
| 691 | + CACHE[name][version] = release |
| 692 | + CACHE[name][version][ |
| 693 | + "_accessed" |
| 694 | + ] = False # for cleaning up unused cache entries |
| 695 | + |
| 696 | + # Process packages |
639 | 697 | packages = defaultdict(list)
|
640 | 698 |
|
641 | 699 | for group, integrations in GROUPS.items():
|
@@ -701,6 +759,21 @@ def main(fail_on_changes: bool = False) -> None:
|
701 | 759 | packages, update_timestamp=not fail_on_changes, last_updated=last_updated
|
702 | 760 | )
|
703 | 761 |
|
| 762 | + # Sort the release cache file |
| 763 | + releases = [] |
| 764 | + with open(RELEASES_CACHE_FILE) as releases_cache: |
| 765 | + releases = [json.loads(line) for line in releases_cache] |
| 766 | + releases.sort(key=lambda r: (r["info"]["name"], r["info"]["version"])) |
| 767 | + with open(RELEASES_CACHE_FILE, "w") as releases_cache: |
| 768 | + for release in releases: |
| 769 | + if ( |
| 770 | + CACHE[_normalize_name(release["info"]["name"])][ |
| 771 | + release["info"]["version"] |
| 772 | + ]["_accessed"] |
| 773 | + is True |
| 774 | + ): |
| 775 | + releases_cache.write(json.dumps(release) + "\n") |
| 776 | + |
704 | 777 | if fail_on_changes:
|
705 | 778 | new_file_hash = get_file_hash()
|
706 | 779 | if old_file_hash != new_file_hash:
|
|
0 commit comments