├── .github └── workflows │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── all.html ├── build.sh ├── crontask.sh ├── deploy.sh ├── favicon.ico ├── generate.py ├── history-requirements.txt ├── history_get.py ├── history_plot.py ├── index.html ├── package.json ├── pyproject.toml ├── requirements.txt ├── run.sh ├── style.css ├── svg_wheel.py ├── template.py ├── template └── index.html ├── test_utils.py ├── update.sh ├── utils.py └── wheel.css /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | env: 6 | FORCE_COLOR: 1 7 | PIP_DISABLE_PIP_VERSION_CHECK: 1 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.x" 21 | cache: pip 22 | - uses: pre-commit/action@v3.0.1 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | FORCE_COLOR: 1 10 | 11 | jobs: 12 | test: 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: ["pypy3.10", "3.9", "3.10", "3.11", "3.12", "3.13"] 18 | os: [ubuntu-latest] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | allow-prereleases: true 30 | cache: pip 31 | cache-dependency-path: | 32 | .github/workflows/test.yml 33 | requirements.txt 34 | 35 | - name: Set up node 36 | uses: actions/setup-node@v4 37 | with: 38 | node-version: 16 39 | cache: 'npm' 40 | cache-dependency-path: ".github/workflows/test.yml" 41 | 42 | - name: Install dependencies 43 | run: | 44 | python -m pip install -U pip 45 | python -m pip install -U wheel 46 | python -m pip install -Ur requirements.txt --prefer-binary 47 | npm install svgexport 48 | 49 | - name: Unit tests 50 | run: | 51 | python test_utils.py 52 | 53 | - name: Test run 54 | run: | 55 | ./build.sh 56 | 57 | - name: History charts 58 | run: | 59 | git config remote.origin.fetch +refs/heads/*:refs/remotes/origin/* 60 | git fetch origin 61 | python history_get.py -n 10 62 | python history_plot.py 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | node_modules 3 | 4 | # Generated files 5 | history.jsonl 6 | history.png 7 | python-eol.json 8 | requests-cache.sqlite 9 | results*.json 10 | top-pypi-packages.json 11 | wheel*.png 12 | wheel*.svg 13 | 14 | # Build dir 15 | build 16 | 17 | # IDE 18 | .idea 19 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.11.4 4 | hooks: 5 | - id: ruff 6 | args: [--exit-non-zero-on-fix] 7 | 8 | - repo: https://github.com/psf/black-pre-commit-mirror 9 | rev: 25.1.0 10 | hooks: 11 | - id: black 12 | 13 | - repo: https://github.com/pre-commit/pre-commit-hooks 14 | rev: v5.0.0 15 | hooks: 16 | - id: check-added-large-files 17 | - id: check-case-conflict 18 | - id: check-merge-conflict 19 | - id: check-json 20 | - id: check-toml 21 | - id: check-yaml 22 | - id: debug-statements 23 | - id: end-of-file-fixer 24 | - id: forbid-submodules 25 | - id: requirements-txt-fixer 26 | - id: trailing-whitespace 27 | 28 | - repo: https://github.com/python-jsonschema/check-jsonschema 29 | rev: 0.32.1 30 | hooks: 31 | - id: check-github-workflows 32 | 33 | - repo: https://github.com/rhysd/actionlint 34 | rev: v1.7.7 35 | hooks: 36 | - id: actionlint 37 | 38 | - repo: https://github.com/tox-dev/pyproject-fmt 39 | rev: v2.5.1 40 | hooks: 41 | - id: pyproject-fmt 42 | 43 | - repo: https://github.com/abravalheri/validate-pyproject 44 | rev: v0.24.1 45 | hooks: 46 | - id: validate-pyproject 47 | - repo: meta 48 | hooks: 49 | - id: check-hooks-apply 50 | - id: check-useless-excludes 51 | 52 | ci: 53 | autoupdate_schedule: quarterly 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Charlie Denton 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 21 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Drop Python 2 | 3 | [![Test](https://github.com/hugovk/drop-python/actions/workflows/test.yml/badge.svg)](https://github.com/hugovk/drop-python/actions/workflows/test.yml) 4 | [![Code style: Black](https://img.shields.io/badge/code%20style-Black-000000.svg)](https://github.com/psf/black) 5 | 6 | It's about time to drop support for old Pythons. 7 | 8 | ## How to use 9 | 10 | ```bash 11 | usage: generate.py [-h] [-n NUMBER] [-v VERSION [VERSION ...]] 12 | 13 | Generate 14 | 15 | optional arguments: 16 | -h, --help show this help message and exit 17 | -n NUMBER, --number NUMBER 18 | Number of packages to chart (default: 360) 19 | -v VERSION [VERSION ...], --version VERSION [VERSION ...] 20 | Python version or versions to check (default: ['2.6', 21 | '3.2', '3.3']) 22 | ``` 23 | 24 | For example: 25 | ```bash 26 | $ python3 generate.py 27 | 28 | $ python3 generate.py -v 3.2 -n 100 29 | 30 | $ python3 generate.py -v 2.6 31 | ``` 32 | See also `build.sh`. 33 | 34 | Gets list of packages from [Top PyPI Packages](https://hugovk.github.io/top-pypi-packages/). 35 | 36 | ## How to test locally 37 | 38 | In another terminal: 39 | ```bash 40 | $ python3 -m http.server 8000 41 | ``` 42 | 43 | Then visit http://localhost:8000/ 44 | 45 | ## How to deploy 46 | 47 | Make sure we're on `main` and run `crontask.sh` daily from cron. 48 | 49 | ## Thanks 50 | 51 | This is derivative work from [Python Wheels](https://pythonwheels.com), a site that tracks progress in the new Python package distribution standard called [Wheels](https://pypi.org/project/wheel). Thanks also to [Python 3 Wall of Superpowers](https://python3wos.appspot.com/) for the concept and making their code open source, and see also [Python 3 Readiness](http://py3readiness.org). 52 | -------------------------------------------------------------------------------- /all.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Drop Python 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 |

Drop Python

22 |
23 |
24 |

Python 2.0

25 | 26 |
27 |
28 |

Python 2.1

29 | 30 |
31 |
32 |

Python 2.2

33 | 34 |
35 |
36 |

Python 2.3

37 | 38 |
39 |
40 |
41 |
42 |

Python 2.4

43 | 44 |
45 |
46 |

Python 2.5

47 | 48 |
49 |
50 |

Python 2.6

51 | 52 |
53 |
54 |

Python 2.7

55 | 56 |
57 |
58 |
59 |
60 |

Python 3.0

61 | 62 |
63 |
64 |

Python 3.1

65 | 66 |
67 |
68 |

Python 3.2

69 | 70 |
71 |
72 |

Python 3.3

73 | 74 |
75 |
76 |
77 |
78 |

Python 3.4

79 | 80 |
81 |
82 |

Python 3.5

83 | 84 |
85 |
86 |

Python 3.6

87 | 88 |
89 |
90 |

Python 3.7

91 | 92 |
93 |
94 |
95 |
96 | 97 |

What is this about?

98 |

Old Python versions have reached end of life. It's about time to drop support for them.

99 | 100 |

This site shows the top 360 most-downloaded packages on PyPI showing which have dropped support for a Python version.

101 |
    102 |
  • Green packages have dropped support,
  • 103 |
  • White packages may still support it.
  • 104 |
105 |

Packages that are backports (for example, enum34) or known to be deprecated are not included (for example, distribute). If your package is incorrectly listed, please create a ticket.

106 |

This is not an official website, just a nice visual way to measure progress. To see the authoritative guide on wheels and other aspects of python packaging, see the Python Packaging User Guide.

107 | 108 |
109 |
110 | 111 |

Something's wrong with this page!

112 |

Fantastic, a problem found is a problem fixed. Please create a ticket!

113 |

You can also submit a pull request.

114 |

Thanks

115 |

Thanks to Python Wheels and Python 3 Wall of Superpowers for the concept and making their code open source.

116 | 117 |
118 | 119 |
120 |
121 |
122 | 123 | 124 |
125 |
126 | 130 |
131 | 132 | 141 | 151 | 152 | 153 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Prevents script from running if there are any errors 4 | set -e 5 | 6 | # Info 7 | python3 --version 8 | 9 | # Install dependencies 10 | python3 -m pip install -r requirements.txt 11 | 12 | # Update 13 | git pull origin main 14 | 15 | # Fetch fresh copy of top packages 16 | wget https://hugovk.github.io/top-pypi-packages/top-pypi-packages.min.json -O top-pypi-packages.json 17 | 18 | # Fetch Python EOL dates 19 | wget https://endoflife.date/api/python.json -O python-eol.json 20 | 21 | # TEMP workaround 22 | # https://github.com/nodejs/node/issues/43132#issuecomment-1130503287 23 | export OPENSSL_CONF=/dev/null 24 | 25 | # Generate the files 26 | python3 generate.py --version 2.{0,1,2,3,4,5,6,7} 3.{0,1,2,3,4,5,6,7,8,9} 27 | 28 | # Create index.html files from the template 29 | python3 template.py --version 2.{0,1,2,3,4,5,6,7} 3.{0,1,2,3,4,5,6,7,8,9} 30 | 31 | # Make output directory, don't fail if it exists 32 | mkdir -p build 33 | 34 | # Copy to output directory 35 | cp -R {2.{0,1,2,3,4,5,6,7},3.{0,1,2,3,4,5,6,7,8,9},all.html,index.html,results.json,style.css,wheel.css} build 36 | 37 | # Remove templated index.html files 38 | rm {2.{0,1,2,3,4,5,6,7},3.{0,1,2,3,4,5,6,7,8,9}}/index.html 39 | -------------------------------------------------------------------------------- /crontask.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | cd ~/github/drop-python 5 | ./update.sh 6 | ./run.sh 7 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Adapted from https://zellwk.com/blog/deploy-static-site/ 4 | 5 | 6 | # Prevents script from running if there are any errors 7 | set -e 8 | 9 | # Gets commit hash as message 10 | REV=`git rev-parse HEAD` 11 | 12 | git checkout --force gh-pages # Step 3 13 | 14 | git rm -rf . # Step 4 15 | 16 | git checkout gh-pages -- .gitignore # Step 5 17 | 18 | cp -R build/* . && rm -rf build # Step 6 19 | 20 | git add . # Step 7 21 | 22 | git commit -m "Deploy $REV" # Step 8 23 | 24 | git push --force origin gh-pages # Step 9 25 | 26 | git checkout main # Step 10 27 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugovk/drop-python/7056aaca910edb64db800955640e72c5a7413136/favicon.ico -------------------------------------------------------------------------------- /generate.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from svg_wheel import generate_svg_wheel 4 | from utils import ( 5 | annotate_support, 6 | get_top_packages, 7 | remove_irrelevant_packages, 8 | save_to_file, 9 | ) 10 | 11 | 12 | def main(to_chart=360, versions=["2.6"]): 13 | packages = remove_irrelevant_packages(get_top_packages(), to_chart) 14 | annotate_support(packages, versions) 15 | save_to_file(packages, "results.json") 16 | generate_svg_wheel(packages, to_chart, versions) 17 | 18 | 19 | if __name__ == "__main__": 20 | parser = argparse.ArgumentParser( 21 | description="Generate", formatter_class=argparse.ArgumentDefaultsHelpFormatter 22 | ) 23 | parser.add_argument( 24 | "-n", "--number", default=360, type=int, help="Number of packages to chart" 25 | ) 26 | parser.add_argument( 27 | "-v", 28 | "--version", 29 | default=["2.6", "3.2", "3.3"], 30 | nargs="+", 31 | help="Python version or versions to check", 32 | ) 33 | args = parser.parse_args() 34 | 35 | main(args.number, args.version) 36 | -------------------------------------------------------------------------------- /history-requirements.txt: -------------------------------------------------------------------------------- 1 | gitpython 2 | jsonlines 3 | matplotlib 4 | termcolor 5 | tqdm 6 | -------------------------------------------------------------------------------- /history_get.py: -------------------------------------------------------------------------------- 1 | """ 2 | Go through the drop-python commit history and create history.jsonl of lines like this: 3 | 4 | {"date": "2017-10-22 18:44:21+03:00", "drop_totals": {"3.2":204, "3.3":102, "2.6":105}} 5 | {"date": "2017-10-22 17:44:22+03:00", "drop_totals": {"3.2":203, "3.3":102, "2.6":105}} 6 | 7 | Usage: 8 | pip install -r history-requirements.txt 9 | python3 history_get.py # creates/updates history.jsonl 10 | """ 11 | 12 | import argparse 13 | import json 14 | 15 | import git # pip install gitpython 16 | import jsonlines # pip install jsonlines 17 | from tqdm import tqdm # pip install tqdm 18 | 19 | # from pprint import pprint 20 | 21 | 22 | def load_from_file(file_name): 23 | try: 24 | with open(file_name) as f: 25 | packages = json.load(f) 26 | return packages 27 | 28 | except json.decoder.JSONDecodeError: 29 | return None 30 | 31 | 32 | def do_json_file(): 33 | data = load_from_file("results.json") 34 | do_json(data) 35 | 36 | 37 | def do_json(data): 38 | packages = data["data"] 39 | 40 | drop_totals = {} 41 | for package in packages: 42 | for key, value in package.items(): 43 | if key in ["downloads", "name", "value"]: 44 | continue 45 | thingy = int(value["dropped_support"] == "yes") 46 | try: 47 | drop_totals[key] += thingy 48 | except KeyError: 49 | drop_totals[key] = thingy 50 | 51 | return drop_totals 52 | 53 | 54 | def load_jsonlines(file_name): 55 | with jsonlines.open(file_name) as reader: 56 | lines = list(reader) 57 | return lines 58 | 59 | 60 | def append_jsonlines(file_name, lines): 61 | with jsonlines.open(file_name, mode="a") as writer: 62 | for line in lines: 63 | writer.write(line) 64 | 65 | 66 | DATE_TIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z" 67 | 68 | 69 | def main(): 70 | parser = argparse.ArgumentParser(description=__doc__) 71 | parser.add_argument( 72 | "-n", 73 | "--number", 74 | type=int, 75 | help="Number of commits to process. For testing, default: all", 76 | ) 77 | args = parser.parse_args() 78 | print(args.number) 79 | 80 | try: 81 | old_lines = load_jsonlines("history.jsonl") 82 | except FileNotFoundError: 83 | old_lines = [] 84 | old_dates = {d["date"] for d in old_lines} 85 | 86 | repo = git.Repo(".") 87 | origin = repo.remote("origin") 88 | print("Fetch origin/gh-pages...") 89 | origin.fetch("gh-pages") 90 | 91 | print("Get data...") 92 | new_lines = [] 93 | 94 | commits = list(repo.iter_commits("origin/gh-pages")) 95 | if args.number: 96 | commits = commits[: args.number] 97 | commits.reverse() # oldest first 98 | for commit in tqdm(commits): 99 | if str(commit.authored_datetime) in old_dates: 100 | continue 101 | 102 | try: 103 | target_file = commit.tree / "results.json" 104 | except KeyError: 105 | # eg. "Blob or Tree named 'results.json' not found" 106 | continue 107 | data = target_file.data_stream.read() 108 | data = json.loads(data) 109 | drop_totals = do_json(data) 110 | new_lines.append( 111 | {"date": str(commit.authored_datetime), "drop_totals": drop_totals} 112 | ) 113 | 114 | append_jsonlines("history.jsonl", new_lines) 115 | print(f"Updated with {len(new_lines)} commits") 116 | 117 | 118 | if __name__ == "__main__": 119 | main() 120 | -------------------------------------------------------------------------------- /history_plot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Take history.jsonl from history_get.py and plot it 3 | 4 | Usage: 5 | 6 | # Prep 7 | pip install -r history-requirements.txt 8 | python3 history_get.py # creates/updates history.jsonl 9 | 10 | # All available data 11 | python3 history_plot.py && open history.png 12 | 13 | # Subset 14 | python3 history_plot.py -v 2.6 2.7 3.3 3.4 3.5 3.6 3.7 && open history.png 15 | """ 16 | 17 | import argparse 18 | import hashlib 19 | from pprint import pprint # noqa: F401 20 | 21 | from termcolor import colored # pip install termcolor 22 | 23 | from history_get import load_jsonlines 24 | 25 | EOL = { 26 | "2.7": "2020-01-01", 27 | "3.4": "2019-03-18", 28 | "3.5": "2020-09-30", 29 | "3.6": "2021-12-23", 30 | "3.7": "2023-06-27", 31 | } 32 | 33 | 34 | def dopplr(name): 35 | """ 36 | Take the MD5 digest of a name, 37 | convert it to hex and take the 38 | first 6 characters as an RGB value. 39 | """ 40 | # Tweak "2.8" because it's too close in colour to "3.5" 41 | if name == "2.8": 42 | name = "python 2.8" 43 | 44 | return "#" + hashlib.sha224(name.encode()).hexdigest()[:6] 45 | 46 | 47 | def make_chart(dates, totals): 48 | # x: list of dates 49 | # y: totals for each version 50 | import matplotlib.pyplot as plt # pip install matplotlib 51 | import matplotlib.ticker as plticker 52 | 53 | # "2020-01-26 22:35:22+02:00" -> "2020-01-26" 54 | dates = [date.split()[0] for date in dates] 55 | 56 | fig, ax = plt.subplots() 57 | 58 | print("Plot...") 59 | for version, v in totals.items(): 60 | print(version) 61 | 62 | if version in EOL and EOL[version] in dates: 63 | # Add a vertical line to zero at EOL 64 | eol_pos = dates.index(EOL[version]) 65 | # dates.insert(eol_pos, EOL[version]) 66 | totals[version][eol_pos] = 0 67 | # breakpoint() 68 | 69 | ax.plot(dates, v, label=version, color=dopplr(version)) 70 | 71 | ax.set_ylim(ymin=0, ymax=360) 72 | 73 | plt.xticks(fontsize=8, rotation=90) 74 | 75 | # Tweak spacing to prevent clipping of tick-labels 76 | plt.subplots_adjust(bottom=0.2) 77 | 78 | # Shrink current axis by 20% so legend is outside chart 79 | box = ax.get_position() 80 | ax.set_position([box.x0, box.y0, box.width * 0.9, box.height]) 81 | 82 | # Put a legend to the right of the current axis 83 | ax.legend( 84 | loc="center left", 85 | bbox_to_anchor=(1, 0.5), 86 | ) 87 | 88 | # This locator puts ticks at regular intervals 89 | loc = plticker.MultipleLocator(base=50) 90 | ax.xaxis.set_major_locator(loc) 91 | loc = plticker.MultipleLocator(base=60) 92 | ax.yaxis.set_major_locator(loc) 93 | 94 | plt.suptitle("Dropped Python versions") 95 | plt.title("By the top 360 packages downloaded from PyPI", fontsize=10) 96 | plt.ylabel("Packages") 97 | 98 | outfile = "history.png" 99 | print(colored(outfile, "green")) 100 | plt.savefig(outfile, dpi=96 * 2.5) 101 | 102 | 103 | def main(): 104 | parser = argparse.ArgumentParser( 105 | description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter 106 | ) 107 | parser.add_argument("-v", "--versions", nargs="+", help="Show only these versions") 108 | args = parser.parse_args() 109 | 110 | # Each line looks like: 111 | # { 112 | # "date": "2020-01-26 23:35:22+02:00", 113 | # "drop_totals": {"2.0": 348, "2.1": 348, "2.2": 348, "2.3": 348, "2.4": 342, ...} 114 | # } 115 | lines = load_jsonlines("history.jsonl") 116 | 117 | # First 3-4 days of data is junk, ditch it 118 | lines = [line for line in lines if line["date"] > "2017-10-11 20:44:23+03:00"] 119 | 120 | # Ditch first few days for some due to bug fixes causing a sudden dip 121 | for line in lines: 122 | if line["date"] <= "2017-10-24 23:44:27+03:00": 123 | line["drop_totals"].pop("2.6", None) 124 | line["drop_totals"].pop("3.2", None) 125 | if line["date"] <= "2018-09-06 13:35:20+03:00": 126 | line["drop_totals"].pop("3.4", None) 127 | 128 | # Sort by date 129 | lines = sorted(lines, key=lambda k: k["date"]) 130 | print("Number of lines:", len(lines)) 131 | 132 | # First get a list of all versions, as not every data point has every version 133 | if args.versions: 134 | all_versions = args.versions 135 | else: 136 | all_versions = set() 137 | for line in lines: 138 | all_versions.update(line["drop_totals"].keys()) 139 | all_versions = sorted(all_versions) 140 | print("All versions: ", all_versions) 141 | 142 | print("Prep data...") 143 | dates = [] 144 | totals = {} 145 | for line in lines: 146 | dates.append(line["date"]) 147 | 148 | for version in all_versions: 149 | try: 150 | version_total = line["drop_totals"][version] 151 | except KeyError: 152 | version_total = None 153 | 154 | try: 155 | totals[version].append(version_total) 156 | except KeyError: 157 | totals[version] = [version_total] 158 | 159 | make_chart(dates, totals) 160 | 161 | 162 | if __name__ == "__main__": 163 | main() 164 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Drop Python 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 |

Drop Python

22 |
23 |
24 |

Python 2.7

25 | 26 |
27 |
28 |

Python 3.5

29 | 30 |
31 |
32 |
33 |
34 |

Python 3.6

35 | 36 |
37 |
38 |

Python 3.7

39 | 40 |
41 |
42 |
43 |
44 | 45 |

What is this about?

46 |

Old Python versions have reached end of life. It's about time to drop support for them.

47 | 48 |

This site shows the top 360 most-downloaded packages on PyPI (source) showing which have dropped support for a Python version.

49 |
    50 |
  • Green packages have dropped support,
  • 51 |
  • White packages may still support it.
  • 52 |
53 |

Packages that are backports (for example, enum34) or known to be deprecated are not included (for example, distribute). If your package is incorrectly listed, please create a ticket.

54 |

See them all.

55 |

This is not an official website, just a nice visual way to measure progress. To see the authoritative guide on wheels and other aspects of python packaging, see the Python Packaging User Guide.

56 | 57 |

Something's wrong with this page!

58 |

Fantastic, a problem found is a problem fixed. Please create a ticket!

59 |

You can also submit a pull request.

60 |

Thanks

61 |

Thanks to Python Wheels and Python 3 Wall of Superpowers for the concept and making their code open source.

62 | 63 |
64 |
65 |

Python 3.8

66 | 67 |
68 | 69 |
70 | 74 |
75 | 76 | 85 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "drop-python", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "dependencies": { 7 | "svgexport": "^0.3.2" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/hugovk/drop-python.git" 15 | }, 16 | "author": "Charles Denton and Contributors", 17 | "license": "ISC", 18 | "bugs": { 19 | "url": "https://github.com/hugovk/drop-python/issues" 20 | }, 21 | "homepage": "https://github.com/hugovk/drop-python" 22 | } 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target_version = [ "py39" ] 3 | 4 | [tool.ruff] 5 | fix = true 6 | 7 | lint.select = [ 8 | "C4", # flake8-comprehensions 9 | "E", # pycodestyle errors 10 | "EM", # flake8-errmsg 11 | "F", # pyflakes errors 12 | "I", # isort 13 | "ICN", # flake8-import-conventions 14 | "ISC", # flake8-implicit-str-concat 15 | "LOG", # flake8-logging 16 | "PGH", # pygrep-hooks 17 | "PYI", # flake8-pyi 18 | "RUF022", # unsorted-dunder-all 19 | "RUF100", # unused noqa (yesqa) 20 | "UP", # pyupgrade 21 | "W", # pycodestyle warnings 22 | "YTT", # flake8-2020 23 | ] 24 | lint.ignore = [ 25 | "E203", # Whitespace before ':' 26 | "E221", # Multiple spaces before operator 27 | "E226", # Missing whitespace around arithmetic operator 28 | "E241", # Multiple spaces after ',' 29 | ] 30 | lint.flake8-import-conventions.aliases.datetime = "dt" 31 | lint.flake8-import-conventions.banned-from = [ 32 | "datetime", 33 | ] 34 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | gitpython 2 | jsonlines 3 | matplotlib 4 | packaging 5 | requests-cache 6 | termcolor 7 | tqdm 8 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | ./build.sh 2 | ./deploy.sh 3 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin-top: 15px; 3 | } 4 | 5 | a.btn, h1 { 6 | text-align: center; 7 | } 8 | 9 | a.btn:last-child, canvas, body { 10 | margin-bottom: 15px; 11 | } 12 | 13 | a.btn { 14 | border-bottom-width: 0; 15 | border-radius: 0; 16 | width: 100% 17 | } 18 | 19 | a.btn:first-child { 20 | border-top-left-radius: 5px; 21 | border-top-right-radius: 5px; 22 | } 23 | 24 | a.btn:last-child { 25 | border-bottom-width: 1px; 26 | border-bottom-left-radius: 5px; 27 | border-bottom-right-radius: 5px; 28 | } 29 | 30 | pre { 31 | text-align: left; 32 | } 33 | 34 | footer { 35 | text-align: center; 36 | } 37 | 38 | .github-fork-ribbon:before { 39 | background-color: #090; 40 | } 41 | 42 | .github-fork-ribbon.no-box-sizing:before, 43 | .github-fork-ribbon.no-box-sizing:after { 44 | box-sizing: content-box; 45 | } 46 | 47 | .text-default { 48 | color: #777; 49 | } 50 | 51 | @media (prefers-color-scheme: dark) { 52 | body { 53 | color: #ccc; 54 | background: black; 55 | } 56 | code, pre { 57 | color: #ccc; 58 | background: #222; 59 | } 60 | a { 61 | color: #5bf; 62 | } 63 | a:hover, 64 | a:hover div { 65 | color: black; 66 | background-color: #5bf; 67 | outline: 0.05em solid #5bf; 68 | } 69 | .btn-default { 70 | color: #ccc; 71 | background: black; 72 | border-color: #222; 73 | } 74 | .btn-default:hover, 75 | .btn-default:focus, 76 | .btn-default:active, 77 | .btn-default.active, 78 | .open.dropdown-toggle.btn-default { 79 | color: #ccc; 80 | background: #222; 81 | border-color: #333; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /svg_wheel.py: -------------------------------------------------------------------------------- 1 | import math 2 | import os 3 | import xml.etree.ElementTree as et 4 | 5 | from utils import create_dir 6 | 7 | HEADERS = b""" 8 | 9 | 11 | """ 12 | 13 | PATH_TEMPLATE = """ 14 | M {start_outer_x},{start_outer_y} 15 | A{outer_radius},{outer_radius} 0 0 1 {end_outer_x},{end_outer_y} 16 | L {start_inner_x},{start_inner_y} 17 | A{inner_radius},{inner_radius} 0 0 0 {end_inner_x},{end_inner_y} 18 | Z 19 | """ 20 | 21 | FRACTION_LINE = 80 22 | OFFSET = 20 23 | PADDING = 10 24 | OUTER_RADIUS = 180 25 | INNER_RADIUS = OUTER_RADIUS / 2 26 | CENTER = PADDING + OUTER_RADIUS 27 | 28 | 29 | def annular_sector_path(start, stop): 30 | cos_stop = math.cos(stop) 31 | cos_start = math.cos(start) 32 | sin_stop = math.sin(stop) 33 | sin_start = math.sin(start) 34 | 35 | points = { 36 | "inner_radius": INNER_RADIUS, 37 | "outer_radius": OUTER_RADIUS, 38 | "start_outer_x": CENTER + OUTER_RADIUS * cos_start, 39 | "start_outer_y": CENTER + OUTER_RADIUS * sin_start, 40 | "end_outer_x": CENTER + OUTER_RADIUS * cos_stop, 41 | "end_outer_y": CENTER + OUTER_RADIUS * sin_stop, 42 | "start_inner_x": CENTER + INNER_RADIUS * cos_stop, 43 | "start_inner_y": CENTER + INNER_RADIUS * sin_stop, 44 | "end_inner_x": CENTER + INNER_RADIUS * cos_start, 45 | "end_inner_y": CENTER + INNER_RADIUS * sin_start, 46 | } 47 | return PATH_TEMPLATE.format(**points) 48 | 49 | 50 | def add_annular_sector(wheel, start, stop, style_class): 51 | return et.SubElement( 52 | wheel, 53 | "path", 54 | d=annular_sector_path(start=start, stop=stop), 55 | attrib={"class": style_class}, 56 | ) 57 | 58 | 59 | def angles(index, total): 60 | start = index * math.tau / total 61 | stop = (index + 1) * math.tau / total 62 | 63 | return start - math.tau / 4, stop - math.tau / 4 64 | 65 | 66 | def add_fraction(wheel, packages, total, version): 67 | text_attributes = { 68 | "class": "wheel-text", 69 | "text-anchor": "middle", 70 | "dominant-baseline": "central", 71 | "font-size": str(2 * OFFSET), 72 | "font-family": '"Helvetica Neue",Helvetica,Arial,sans-serif', 73 | } 74 | 75 | # Packages with some sort of wheel 76 | wheel_packages = sum( 77 | 1 if package[version]["dropped_support"] == "yes" else 0 for package in packages 78 | ) 79 | 80 | packages_with_wheels = et.SubElement( 81 | wheel, 82 | "text", 83 | x=str(CENTER), 84 | y=str(CENTER - OFFSET), 85 | attrib=text_attributes, 86 | ) 87 | packages_with_wheels.text = f"{wheel_packages}" 88 | 89 | title = et.SubElement(packages_with_wheels, "title") 90 | percentage = f"{wheel_packages / float(total):.0%}" 91 | title.text = percentage 92 | 93 | # Dividing line 94 | et.SubElement( 95 | wheel, 96 | "line", 97 | x1=str(CENTER - FRACTION_LINE // 2), 98 | y1=str(CENTER), 99 | x2=str(CENTER + FRACTION_LINE // 2), 100 | y2=str(CENTER), 101 | attrib={"class": "wheel-line", "stroke-width": "2"}, 102 | ) 103 | 104 | # Total packages 105 | total_packages = et.SubElement( 106 | wheel, 107 | "text", 108 | x=str(CENTER), 109 | y=str(CENTER + OFFSET), 110 | attrib=text_attributes, 111 | ) 112 | total_packages.text = f"{total}" 113 | 114 | title = et.SubElement(total_packages, "title") 115 | title.text = percentage 116 | 117 | 118 | def generate_svg_wheel(packages, total, versions): 119 | for version in versions: 120 | wheel = et.Element( 121 | "svg", 122 | viewBox=f"0 0 {2 * CENTER} {2 * CENTER}", 123 | version="1.1", 124 | xmlns="http://www.w3.org/2000/svg", 125 | ) 126 | 127 | for index, result in enumerate(packages): 128 | start, stop = angles(index, total) 129 | sector = add_annular_sector( 130 | wheel, start=start, stop=stop, style_class=result[version]["css_class"] 131 | ) 132 | title = et.SubElement(sector, "title") 133 | title.text = f"{result['name']} {result[version]['icon']}" 134 | 135 | add_fraction(wheel, packages, total, version) 136 | 137 | create_dir(version) 138 | wheel_svg = os.path.join(version, "wheel.svg") 139 | wheel_png = os.path.join(version, "wheel.png") 140 | wheel_og_png = os.path.join(version, "wheel-og.png") 141 | with open(wheel_svg, "wb") as svg: 142 | svg.write(HEADERS) 143 | svg.write(et.tostring(wheel)) 144 | 145 | # Install with: npm install svgexport 146 | os.system(f"./node_modules/.bin/svgexport {wheel_svg} {wheel_png} 32:32") 147 | os.system(f"./node_modules/.bin/svgexport {wheel_svg} {wheel_og_png} 630:630") 148 | -------------------------------------------------------------------------------- /template.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import datetime as dt 3 | import json 4 | import os 5 | from string import Template 6 | 7 | from utils import create_dir 8 | 9 | SUBSTITUTIONS = { 10 | "2.0": {"template_eol": "2001-06-22"}, 11 | "2.1": {"template_eol": "2002-04-08"}, 12 | "2.2": {"template_eol": "2003-05-30"}, 13 | "2.3": {"template_eol": "2008-03-11"}, 14 | "2.4": {"template_eol": "19 December 2008"}, 15 | "2.5": {"template_eol": "26 May 2011"}, 16 | "2.6": { 17 | "template_eol": "29 October 2013", 18 | "reasons": """ 19 |
  • pip no longer supports Python {{version}}
  • 20 |
  • Stop using Python 2.6 please
  • 21 |
  • Stop Supporting Python 2.6 (For Free)
  • 22 |
  • Scientific Python moving to require Python 3
  • 23 |
  • Not much PyPI traffic (June 2016)
  • 24 |
  • Virtually no PyPI traffic (June 2018)
  • 25 | """, # noqa: E501 26 | "remove": """ 27 |

    For example, no need to install or import unittest2 any more. This:

    28 |
     29 | try:
     30 |     import unittest2 as unittest  # Python 2.6
     31 |  except ImportError:
     32 |     import unittest
    33 |

    …can be replaced with: 34 |

     35 | import unittest
    36 | """, # noqa: E501 37 | "new_features": """ 38 |

    Use new Python features

    39 |

    See what's new. For example: 40 | 41 |

    58 | 59 | """, # noqa: E501 60 | }, 61 | "2.7": { 62 | "reasons": """ 63 |
  • Sunsetting Python 2 support
  • 64 |
  • Why Python 3?
  • 65 |
  • Python 2.7 Countdown
  • 66 | """, # noqa: E501 67 | "remove": """ 68 |

    Follow this guide: https://python3statement.org/practicalities/ 69 | """, 70 | }, 71 | "3.0": {"template_eol": "27 June 2009"}, 72 | "3.1": {"template_eol": "9 April 2012"}, 73 | "3.2": {"template_eol": "27 February 2016"}, 74 | "3.3": { 75 | "reasons": """ 76 |

  • pip 10 deprecated Python 3.3 support, pip 11 won't support it
  • 77 |
  • Very little PyPI traffic (June 2016)
  • 78 |
  • Virtually no PyPI traffic (June 2018)
  • 79 | """, # noqa: E501 80 | }, 81 | "3.4": {"reasons": "
  • It's EOL
  • "}, 82 | "3.5": { 83 | "reasons": """ 84 |
  • f-strings in 3.6!
  • 85 | """, # noqa: E501 86 | }, 87 | "3.6": { 88 | "reasons": """ 89 |
  • Future typing
  • 90 |
  • Guaranteed dict sort order
  • 91 |
  • breakpoint()
  • 92 |
  • Data classes
  • 93 |
  • And more!
  • 94 | """ # noqa: E501 95 | }, 96 | "3.7": { 97 | "reasons": """ 98 |
  • Walrus operator
  • 99 |
  • And more!
  • 100 | """ # noqa: E501 101 | }, 102 | "3.8": { 103 | "reasons": """ 104 |
  • use list and dict for type annotations
  • 105 |
  • And more!
  • 106 | """ # noqa: E501 107 | }, 108 | "3.9": { 109 | "reasons": """ 110 |
  • use match statement and write union types as X | Y
  • 111 |
  • And more!
  • 112 | """ # noqa: E501 113 | }, 114 | } 115 | 116 | REASONS = """ 117 |
  • pip no longer supports Python {{version}}
  • 118 |
  • Coverage no longer supports Python {{version}}
  • 119 |
  • Requests no longer supports Python {{version}}
  • 120 |
  • Virtually no PyPI traffic (June 2016)
  • 121 |
  • Virtually no PyPI traffic (June 2018)
  • 122 | 123 | """ # noqa: E501 124 | 125 | 126 | def get_eols() -> dict: 127 | with open("python-eol.json") as data_file: 128 | data = json.load(data_file) 129 | 130 | return {version["cycle"]: version["eol"] for version in data} 131 | 132 | 133 | if __name__ == "__main__": 134 | parser = argparse.ArgumentParser( 135 | description="Template", formatter_class=argparse.ArgumentDefaultsHelpFormatter 136 | ) 137 | parser.add_argument( 138 | "-v", 139 | "--version", 140 | default=["2.6", "3.2", "3.3", "3.4", "3.5", "3.6", "3.7"], 141 | nargs="+", 142 | help="Python version or versions to check", 143 | ) 144 | args = parser.parse_args() 145 | 146 | eols = get_eols() 147 | 148 | # Open the file 149 | with open("template/index.html") as infile: 150 | # Read it 151 | src = Template(infile.read()) 152 | 153 | now = dt.datetime.utcnow() 154 | for version in args.version: 155 | # Document data 156 | print(version) 157 | major, minor = version.split(".") 158 | next_minor = int(minor) + 1 159 | next_version = f"{major}.{next_minor}" 160 | substitutions = SUBSTITUTIONS[version] 161 | 162 | try: 163 | eol_date = eols[version] 164 | except KeyError: 165 | eol_date = substitutions["template_eol"] 166 | 167 | try: 168 | # Convert "1 January 2020" string to datetime 169 | eol_datetime = dt.datetime.strptime(eol_date, "%d %B %Y") 170 | except ValueError: 171 | # Convert "2020-01-01" string to datetime 172 | eol_datetime = dt.datetime.strptime(eol_date, "%Y-%m-%d") 173 | 174 | # Convert to "1 January 2020" string 175 | eol_date = f"{eol_datetime:%-d %B %Y}" 176 | 177 | d = { 178 | "template_version": version, 179 | "template_eol": eol_date, 180 | "template_major": major, 181 | "template_minor": minor, 182 | "template_next_minor": next_minor, 183 | "template_next_version": next_version, 184 | "template_reasons": substitutions.get("reasons", REASONS), 185 | "template_remove_examples": substitutions.get("remove", ""), 186 | "template_new_features": substitutions.get("new_features", ""), 187 | } 188 | 189 | # Do the substitution 190 | result = src.safe_substitute(d) 191 | 192 | # EOL in the future? 193 | if now < eol_datetime: 194 | result = result.replace("about time", "soon time") 195 | result = result.replace(" reached the ", " reaches the ") 196 | # print(result) 197 | 198 | # Save it 199 | outfile = os.path.join(version, "index.html") 200 | print(outfile) 201 | create_dir(version) 202 | with open(outfile, "w") as f: 203 | f.write(result) 204 | -------------------------------------------------------------------------------- /template/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Drop Python $template_version 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
    21 |
    22 |
    23 |

    Drop Python $template_version

    24 | 25 |

    What is this about?

    26 |

    Python $template_version reached the end of its life on $template_eol. It's about time to drop support for Python $template_version.

    27 |

    Reasons for dropping

    28 |
      29 | $template_reasons 30 |
    31 |

    What is this list?

    32 |

    This site shows the top 360 most-downloaded packages on PyPI (source) showing which have dropped support for Python $template_version.

    33 |
      34 |
    • Green packages have dropped Python $template_version,
    • 35 |
    • White packages may still support Python $template_version.
    • 36 |
    37 |

    Packages that are backports (for example, enum34) or known to be deprecated are not included (for example, distribute). If your package is incorrectly listed, please create a ticket.

    38 |

    This is not an official website, just a nice visual way to measure progress. To see the authoritative guide on wheels and other aspects of python packaging, see the Python Packaging User Guide.

    39 |

    My package is white. What can I do?

    40 |

    Remove the classifier

    41 |

    Remove the Trove classifier from setup.py.

    42 |
     43 | 'Programming Language :: Python :: $template_version'
    44 | 45 |

    Stop testing $template_version

    46 |

    Remove Python $template_version from your CI. For example Travis CI's .travis.yml:

    47 |
     48 | python:
     49 |  - $template_version
    50 |

    And for example from Appveyor's appveyor.yml: 51 |

     52 | C:\Python${template_major}${template_minor}
     53 | C:\Python${template_major}${template_minor}-x64
    54 |

    And tox.ini: 55 |

     56 | envlist=py${template_major}${template_minor}
    57 | 58 |

    Remove old code and documentation

    59 |

    Remove old Python $template_version-specific code and documentation. Common files to check: 60 |

      61 |
    • .travis.yml 62 |
    • appveyor.yml 63 |
    • README.md 64 |
    • setup.py 65 |
    • tox.ini 66 |
    67 | 68 | $template_remove_examples 69 | 70 |

    Search your code for stuff like: 71 |

     72 | if sys.version_info < ($template_major, $template_next_minor):
     73 |     # Python $template_version stuff
     74 | 
     75 | if platform.python_version == "$template_version":
     76 |     # Python $template_version stuff
     77 | 
     78 | ver = platform.python_version_tuple()
     79 |     if float('{0}.{1}'.format(*ver[:2])) < $template_next_version:
     80 |     # Python $template_version stuff
     81 | 
     82 | try:
     83 |     # Python $template_version
     84 |     import something
     85 | except ImportError:
     86 |     # Python $template_next_version+
     87 |     import something_else
     88 | 
     89 | // In C code
     90 | #if PY_VERSION_HEX < 0x0${template_major}0${template_next_minor}0000
     91 | -#endif
     92 | 
    93 | 94 |

    Also search for $template_version and ${template_major}${template_minor}. 95 | 96 |

    If you test with coverage, look for code which was tested before removing $template_version from your CI. 97 | 98 |

    Finally, consider dropping support for Python 2.6 and 3.3, which reached EOL on 2013-10-29 and 2017-09-29 respectively. 99 | 100 | $template_new_features 101 | 102 |

    Something's wrong with this page!

    103 |

    Fantastic, a problem found is a problem fixed. Please create a ticket!

    104 |

    You can also submit a pull request.

    105 |

    Thanks

    106 |

    Thanks to Python Wheels and Python 3 Wall of Superpowers for the concept and making their code open source.

    107 |
    108 |
    109 |
    110 | drop-python requires JavaScript to be enabled to display the list of packages. 111 | 112 | 113 | 114 | 115 |
    116 |
    117 |
    118 | 122 |
    123 | 124 | 133 | 143 | 144 | 145 | -------------------------------------------------------------------------------- /test_utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Unit tests for utils.py 4 | """ 5 | import unittest 6 | 7 | import utils 8 | 9 | 10 | class TestClassifiersSupport(unittest.TestCase): 11 | def test_has_support(self): 12 | # Arrange 13 | classifiers = [ 14 | "Programming Language :: Python", 15 | "Programming Language :: Python :: 2", 16 | "Programming Language :: Python :: 2.6", 17 | "Programming Language :: Python :: 2.7", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.3", 20 | "Programming Language :: Python :: 3.4", 21 | "Programming Language :: Python :: 3.5", 22 | "Programming Language :: Python :: 3.6", 23 | ] 24 | 25 | # Act 26 | has_support = utils.classifiers_support(classifiers, "2.6") 27 | 28 | # Assert 29 | self.assertEqual(has_support, "yes") 30 | 31 | def test_no_support_but_others_are(self): 32 | # Arrange 33 | classifiers = [ 34 | "Programming Language :: Python", 35 | "Programming Language :: Python :: 2", 36 | "Programming Language :: Python :: 2.7", 37 | "Programming Language :: Python :: 3", 38 | "Programming Language :: Python :: 3.4", 39 | "Programming Language :: Python :: 3.5", 40 | "Programming Language :: Python :: 3.6", 41 | ] 42 | 43 | # Act 44 | has_support = utils.classifiers_support(classifiers, "2.6") 45 | 46 | # Assert 47 | self.assertEqual(has_support, "no") 48 | 49 | def test_no_support_but_other_2x_are(self): 50 | # Arrange 51 | classifiers = [ 52 | "Programming Language :: Python", 53 | "Programming Language :: Python :: 2", 54 | "Programming Language :: Python :: 2.7", 55 | ] 56 | 57 | # Act 58 | has_support = utils.classifiers_support(classifiers, "2.6") 59 | 60 | # Assert 61 | self.assertEqual(has_support, "no") 62 | 63 | def test_no_support_but_other_3x_are(self): 64 | # Arrange 65 | classifiers = [ 66 | "Programming Language :: Python", 67 | "Programming Language :: Python :: 3", 68 | "Programming Language :: Python :: 3.4", 69 | "Programming Language :: Python :: 3.5", 70 | "Programming Language :: Python :: 3.6", 71 | ] 72 | 73 | # Act 74 | has_support = utils.classifiers_support(classifiers, "2.6") 75 | 76 | # Assert 77 | self.assertEqual(has_support, "no") 78 | 79 | def test_maybe_support_or_any_major_minor(self): 80 | # Arrange 81 | # No major.minor classifiers 82 | classifiers = [ 83 | "Programming Language :: Python :: 2", 84 | "Programming Language :: Python :: 3", 85 | ] 86 | 87 | # Act 88 | has_support = utils.classifiers_support(classifiers, "2.6") 89 | 90 | # Assert 91 | self.assertEqual(has_support, "maybe") 92 | 93 | def test_maybe_support_for_empty(self): 94 | # Arrange 95 | # No classifiers 96 | classifiers = [] 97 | 98 | # Act 99 | has_support = utils.classifiers_support(classifiers, "2.6") 100 | 101 | # Assert 102 | self.assertEqual(has_support, "maybe") 103 | 104 | def test_maybe_support_for_3x(self): 105 | # Arrange 106 | # We have major but no major.minor 107 | classifiers = [ 108 | "Programming Language :: Python :: 2.4", 109 | "Programming Language :: Python :: 2.5", 110 | "Programming Language :: Python :: 2.6", 111 | "Programming Language :: Python :: 2.7", 112 | "Programming Language :: Python :: 3", 113 | ] 114 | 115 | # Act 116 | has_support = utils.classifiers_support(classifiers, "3.4") 117 | 118 | # Assert 119 | self.assertEqual(has_support, "maybe") 120 | 121 | def test_maybe_support_for_2x(self): 122 | # Arrange 123 | # We have major but no major.minor 124 | classifiers = [ 125 | "Programming Language :: Python", 126 | "Programming Language :: Python :: 3", 127 | ] 128 | 129 | # Act 130 | has_support = utils.classifiers_support(classifiers, "2.6") 131 | 132 | # Assert 133 | self.assertEqual(has_support, "maybe") 134 | 135 | 136 | class TestRequiresPythonSupports(unittest.TestCase): 137 | def test_has_support(self): 138 | # Arrange 139 | python_requires = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 140 | 141 | # Act 142 | has_support = utils.requires_python_supports(python_requires, "2.6") 143 | 144 | # Assert 145 | self.assertEqual(has_support, "yes") 146 | 147 | def test_no_support_but_others_are(self): 148 | # Arrange 149 | python_requires = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 150 | 151 | # Act 152 | has_support = utils.requires_python_supports(python_requires, "2.6") 153 | 154 | # Assert 155 | self.assertEqual(has_support, "no") 156 | 157 | def test_no_support_but_other_2x_are(self): 158 | # Arrange 159 | python_requires = "==2.7" 160 | 161 | # Act 162 | has_support = utils.requires_python_supports(python_requires, "2.6") 163 | 164 | # Assert 165 | self.assertEqual(has_support, "no") 166 | 167 | def test_no_support_but_other_3x_are(self): 168 | # Arrange 169 | python_requires = ">=3.4" 170 | 171 | # Act 172 | has_support = utils.requires_python_supports(python_requires, "2.6") 173 | 174 | # Assert 175 | self.assertEqual(has_support, "no") 176 | 177 | def test_maybe_support_for_none(self): 178 | # Arrange 179 | # No python_requires 180 | python_requires = None 181 | 182 | # Act 183 | has_support = utils.requires_python_supports(python_requires, "2.6") 184 | 185 | # Assert 186 | self.assertEqual(has_support, "maybe") 187 | 188 | def test_maybe_support_for_empty(self): 189 | # Arrange 190 | # No python_requires 191 | python_requires = "" 192 | 193 | # Act 194 | has_support = utils.requires_python_supports(python_requires, "2.6") 195 | 196 | # Assert 197 | self.assertEqual(has_support, "maybe") 198 | 199 | 200 | if __name__ == "__main__": 201 | unittest.main() 202 | 203 | # End of file 204 | -------------------------------------------------------------------------------- /update.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | date 5 | 6 | # Install dependencies 7 | python3 -m pip install -r requirements.txt 8 | 9 | git checkout main 10 | git pull origin main 11 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import json 3 | import os 4 | from zoneinfo import ZoneInfo 5 | 6 | import requests_cache 7 | from packaging import specifiers 8 | 9 | BASE_URL = "https://pypi.org/pypi" 10 | 11 | EXCLUDED_PACKAGES = { 12 | # backports 13 | "argparse", 14 | "backports-abc", 15 | "backports-entry-points-selectable", 16 | "backports-functools-lru-cache", 17 | "backports-shutil-get-terminal-size", 18 | "backports-ssl-match-hostname", 19 | "backports-tempfile", 20 | "backports-weakref", 21 | "backports-zoneinfo", 22 | "configparser", 23 | "contextlib2", 24 | "enum-compat", 25 | "enum34", 26 | "funcsigs", 27 | "functools32", 28 | "future", 29 | "futures", 30 | "importlib-metadata", 31 | "ipaddress", 32 | "linecache2", 33 | "mock", 34 | "monotonic", 35 | "ordereddict", 36 | "pathlib", 37 | "pathlib2", 38 | "scandir", 39 | "simplejson", 40 | "singledispatch", 41 | "six", 42 | "subprocess32", 43 | "traceback2", 44 | "typing", 45 | "typing-extensions", 46 | "unicodecsv", 47 | "unittest2", 48 | "zipp", 49 | # deprecated 50 | "BeautifulSoup", 51 | "boto", 52 | "bs4", 53 | "distribute", 54 | "django-social-auth", 55 | "gitdb2", 56 | "google-gax", 57 | "jws", 58 | "letsencrypt", 59 | "lockfile", 60 | "msgpack-python", 61 | "nose", 62 | "oauth2client", 63 | "pep8", 64 | "py", 65 | "pycrypto", 66 | "raven", 67 | "retrying", 68 | "sklearn", 69 | "simplegeneric", 70 | "smmap2", 71 | "tensorflow-tensorboard", 72 | # deleted after violating PyPI AUP 73 | "pypular", 74 | } 75 | 76 | # Keep responses for one hour 77 | SESSION = requests_cache.CachedSession("requests-cache", expire_after=60 * 60) 78 | 79 | CLASSIFIER = "Programming Language :: Python :: {}" 80 | 81 | 82 | def create_dir(dir): 83 | if not os.path.isdir(dir): 84 | os.mkdir(dir) 85 | 86 | 87 | def get_json_url(package_name): 88 | return BASE_URL + "/" + package_name + "/json" 89 | 90 | 91 | def requires_python_supports(requires_python, version): 92 | """ 93 | Check if a given Python version matches the `requires_python` specifier. 94 | 95 | Returns "yes" if the version of Python matches the requirement. 96 | Returns "no" if the version of Python does not matches the requirement. 97 | Returns "maybe" if there's no requirement. 98 | 99 | Raises an InvalidSpecifier if `requires_python` have an invalid format. 100 | """ 101 | if requires_python is None or requires_python == "": 102 | # The package provides no information 103 | return "maybe" 104 | requires_python_specifier = specifiers.SpecifierSet(requires_python) 105 | 106 | return "yes" if version in requires_python_specifier else "no" 107 | 108 | 109 | def classifiers_support(classifiers, version): 110 | """Do these classifiers support this Python version?""" 111 | desired_classifier = CLASSIFIER.format(version) 112 | major, minor = version.split(".") 113 | 114 | # Explicit major.minor support 115 | if desired_classifier in classifiers: 116 | return "yes" 117 | 118 | # Check if classifiers are explicit. 119 | # Only report "no" when at least one other major.minor version is explicitly 120 | # supported (but not the desired one). 121 | # ie. major and major.other present, but major.minor is missing. 122 | if any(f"{major}." in c for c in classifiers): 123 | return "no" 124 | 125 | python_any = any("Programming Language :: Python ::" in c for c in classifiers) 126 | python = "Programming Language :: Python" in classifiers 127 | # python2 = "Programming Language :: Python :: 2" in classifiers 128 | python3 = "Programming Language :: Python :: 3" in classifiers 129 | python2x = any("Programming Language :: Python :: 2." in c for c in classifiers) 130 | python3x = any("Programming Language :: Python :: 3." in c for c in classifiers) 131 | 132 | # No major.minor listed 133 | if major == "2" and python and python3 and not python3x: 134 | return "maybe" 135 | 136 | if major == "2" and python3x and not python2x: 137 | return "no" 138 | 139 | # We have at least some version listed, but not even this major 140 | if python_any and CLASSIFIER.format(major) not in classifiers: 141 | return "no" 142 | 143 | # Otherwise? 144 | return "maybe" 145 | 146 | 147 | def annotate_support(packages, versions=["2.6"]): 148 | print("Getting support data...") 149 | num_packages = len(packages) 150 | for index, package in enumerate(packages): 151 | print(index + 1, num_packages, package["name"]) 152 | url = get_json_url(package["name"]) 153 | response = SESSION.get(url) 154 | if response.status_code != 200: 155 | print(" ! Skipping " + package["name"]) 156 | continue 157 | data = response.json() 158 | 159 | for version in versions: 160 | # Init 161 | package[version] = {} 162 | 163 | # First try with requires_python 164 | has_support = requires_python_supports( 165 | data["info"]["requires_python"], version 166 | ) 167 | 168 | # Second try with classifers 169 | if has_support == "maybe": 170 | has_support = classifiers_support(data["info"]["classifiers"], version) 171 | 172 | if has_support == "yes": 173 | package[version]["dropped_support"] = "no" 174 | if has_support == "no": 175 | package[version]["dropped_support"] = "yes" 176 | if has_support == "maybe": 177 | package[version]["dropped_support"] = "maybe" 178 | 179 | # Display logic. I know, I'm sorry. 180 | package["value"] = 1 181 | if has_support == "no": 182 | package[version]["css_class"] = "success" 183 | package[version]["icon"] = "\u2713" # Check mark 184 | title = "This package doesn't support Python {}." 185 | elif has_support == "yes": 186 | package[version]["css_class"] = "default" 187 | package[version]["icon"] = "\u2717" # Ballot X 188 | title = "This package supports Python {}." 189 | else: # "maybe" 190 | package[version]["css_class"] = "default" 191 | package[version]["icon"] = "?" 192 | title = "This package may support Python {}." 193 | package[version]["title"] = title.format(version) 194 | 195 | 196 | def get_top_packages(): 197 | print("Getting packages...") 198 | 199 | with open("top-pypi-packages.json") as data_file: 200 | packages = json.load(data_file)["rows"] 201 | 202 | # Rename keys 203 | for package in packages: 204 | package["downloads"] = package.pop("download_count") 205 | package["name"] = package.pop("project") 206 | 207 | return packages 208 | 209 | 210 | def not_excluded(package): 211 | return package["name"] not in EXCLUDED_PACKAGES 212 | 213 | 214 | def remove_irrelevant_packages(packages, limit): 215 | print("Removing cruft...") 216 | active_packages = list(filter(not_excluded, packages)) 217 | return active_packages[:limit] 218 | 219 | 220 | def save_to_file(packages, file_name): 221 | now = dt.datetime.utcnow().replace(tzinfo=ZoneInfo("UTC")) 222 | with open(file_name, "w") as f: 223 | f.write( 224 | json.dumps( 225 | {"data": packages, "last_update": now.strftime("%A, %d %B %Y, %X %Z")} 226 | ) 227 | ) 228 | -------------------------------------------------------------------------------- /wheel.css: -------------------------------------------------------------------------------- 1 | .success { 2 | stroke: #198754; 3 | stroke-width: 1; 4 | fill: #198754; 5 | } 6 | 7 | .default { 8 | stroke: #cccccc; 9 | stroke-width: 1; 10 | fill: #ffffff; 11 | } 12 | 13 | line.wheel-line { 14 | stroke: #333; 15 | } 16 | 17 | text.wheel-text { 18 | fill: #333; 19 | } 20 | 21 | @media (prefers-color-scheme: dark) { 22 | .default { 23 | stroke: #222; 24 | stroke-width: 1; 25 | fill: black; 26 | } 27 | line.wheel-line { 28 | stroke: #ccc; 29 | } 30 | text.wheel-text { 31 | fill: #ccc; 32 | } 33 | 34 | } 35 | --------------------------------------------------------------------------------