├── src └── pypi_changes │ ├── py.typed │ ├── __main__.py │ ├── _print │ ├── __init__.py │ ├── json.py │ └── tree.py │ ├── __init__.py │ ├── _distributions.py │ ├── _pkg.py │ ├── _cli.py │ └── _info.py ├── .github ├── CODEOWNERS ├── FUNDING.yaml ├── release.yaml ├── dependabot.yaml ├── SECURITY.md ├── CONTRIBUTING.md └── workflows │ ├── release.yaml │ └── check.yaml ├── .gitignore ├── tests ├── test_version.py ├── __init__.py ├── test_main.py ├── test_pypi_changes.py ├── test_pkg.py ├── conftest.py ├── test_cli.py ├── test_distributions.py ├── test_print_json.py ├── test_print_rich.py ├── test_info.py ├── pypi_info_self.yaml └── pypi_info_missing_package.yaml ├── README.md ├── LICENSE ├── .pre-commit-config.yaml ├── tox.ini ├── CODE_OF_CONDUCT.md └── pyproject.toml /src/pypi_changes/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @gaborbernat 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yaml: -------------------------------------------------------------------------------- 1 | tidelift: pypi/pypi-changes 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | /.tox 4 | /docs/_draft.rst 5 | /src/pypi_changes/_version.py 6 | /.*_cache 7 | -------------------------------------------------------------------------------- /.github/release.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot[bot] 5 | - pre-commit-ci[bot] 6 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def test_version() -> None: 5 | from pypi_changes import __version__ # noqa: PLC0415 6 | 7 | assert __version__ 8 | -------------------------------------------------------------------------------- /src/pypi_changes/__main__.py: -------------------------------------------------------------------------------- 1 | """Main module.""" 2 | 3 | from __future__ import annotations 4 | 5 | from pypi_changes import main 6 | 7 | if __name__ == "__main__": 8 | raise SystemExit(main()) 9 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | from importlib.metadata import PathDistribution 5 | from pathlib import Path 6 | from unittest.mock import MagicMock 7 | 8 | MakeDist = Callable[[Path, str, str], MagicMock] 9 | 10 | __all__ = [ 11 | "MakeDist", 12 | "PathDistribution", 13 | ] 14 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 1.2.1 + | :white_check_mark: | 8 | | < 1.2.1 | :x: | 9 | 10 | ## Reporting a Vulnerability 11 | 12 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift 13 | will coordinate the fix and disclosure. 14 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import subprocess # noqa: S404 4 | import sys 5 | from pathlib import Path 6 | 7 | 8 | def test_help_module() -> None: 9 | subprocess.check_call([sys.executable, "-m", "pypi_changes", "--help"]) 10 | 11 | 12 | def test_help_console() -> None: 13 | cli = Path(sys.executable).parent / f"python{'.exe' if sys.platform == 'win32' else ''}" 14 | subprocess.check_call([str(cli), "--help"]) 15 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `pypi_changes` 2 | 3 | Thank you for your interest in contributing to `pypi_changes`! There are many ways to contribute, and we appreciate all 4 | of them. As a reminder, all contributors are expected to follow our [Code of Conduct][coc]. 5 | 6 | [coc]: https://www.pypa.io/en/latest/code-of-conduct/ 7 | 8 | ## Development Documentation 9 | 10 | Our [development documentation](http://tox.readthedocs.org/en/latest/development.html#development) contains details on 11 | how to get started with contributing to `tox`, and details of our development processes. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pypi_changes 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/pypi-changes?style=flat-square)](https://pypi.org/project/pypi-changes/) 4 | [![Supported Python 5 | versions](https://img.shields.io/pypi/pyversions/pypi-changes.svg)](https://pypi.org/project/pypi-changes/) 6 | [![check](https://github.com/gaborbernat/pypi_changes/actions/workflows/check.yaml/badge.svg)](https://github.com/gaborbernat/pypi_changes/actions/workflows/check.yaml) 7 | [![Downloads](https://static.pepy.tech/badge/pypi-changes/month)](https://pepy.tech/project/pypi-changes) 8 | 9 | [![asciicast](https://asciinema.org/a/446966.svg)](https://asciinema.org/a/446966) 10 | -------------------------------------------------------------------------------- /tests/test_pypi_changes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from virtualenv import cli_run 6 | 7 | from pypi_changes import main 8 | 9 | if TYPE_CHECKING: 10 | from pathlib import Path 11 | 12 | 13 | def test_pypi_changes_self_output_default(tmp_path: Path) -> None: 14 | venv = cli_run([str(tmp_path / "venv")], setup_logging=False) 15 | main([str(venv.creator.exe), "--cache-path", str(tmp_path / "a.sqlite")]) 16 | 17 | 18 | def test_pypi_changes_self_output_json(tmp_path: Path) -> None: 19 | venv = cli_run([str(tmp_path / "venv")], setup_logging=False) 20 | main([str(venv.creator.exe), "--output", "json"]) 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a 2 | copy of this software and associated documentation files (the 3 | "Software"), to deal in the Software without restriction, including 4 | without limitation the rights to use, copy, modify, merge, publish, 5 | distribute, sublicense, and/or sell copies of the Software, and to 6 | permit persons to whom the Software is furnished to do so, subject to 7 | the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included 10 | in all copies or substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 13 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 14 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 15 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 16 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 17 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 18 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /src/pypi_changes/_print/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from collections.abc import Iterable 7 | from datetime import datetime 8 | 9 | from pypi_changes._cli import Options 10 | from pypi_changes._pkg import Package 11 | 12 | 13 | class _Reversor: # noqa: PLW1641 14 | def __init__(self, obj: str) -> None: 15 | self.obj = obj 16 | 17 | def __eq__(self, other: object) -> bool: 18 | return isinstance(other, _Reversor) and other.obj == self.obj 19 | 20 | def __lt__(self, other: _Reversor) -> bool: 21 | return other.obj < self.obj 22 | 23 | 24 | def get_sorted_pkg_list(distributions: Iterable[Package], options: Options, now: datetime) -> Iterable[Package]: 25 | if options.sort in {"a", "alphabetic"}: 26 | return sorted(distributions, key=lambda v: v.name.lower()) 27 | return sorted(distributions, key=lambda v: (v.last_release_at or now, _Reversor(v.name)), reverse=True) 28 | 29 | 30 | __all__ = [ 31 | "get_sorted_pkg_list", 32 | ] 33 | -------------------------------------------------------------------------------- /src/pypi_changes/__init__.py: -------------------------------------------------------------------------------- 1 | """Detect and visualize PyPI changes.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from ._cli import parse_cli_arguments 8 | from ._distributions import collect_distributions 9 | from ._info import pypi_info 10 | from ._print.json import print_json 11 | from ._print.tree import print_tree 12 | from ._version import version 13 | 14 | if TYPE_CHECKING: 15 | from collections.abc import Sequence 16 | 17 | #: semantic version of the package 18 | __version__ = version 19 | 20 | 21 | def main(args: Sequence[str] | None = None) -> int: 22 | """ 23 | Execute the entry point. 24 | 25 | :param args: CLI arguments 26 | :return: exit code 27 | """ 28 | options = parse_cli_arguments(args) 29 | distributions = collect_distributions(options) 30 | info = pypi_info(distributions, options) 31 | 32 | if options.output == "tree": 33 | print_tree(info, options) 34 | else: # output == "json" 35 | print_json(info, options) 36 | return 0 37 | 38 | 39 | __all__ = [ 40 | "__version__", 41 | "main", 42 | ] 43 | -------------------------------------------------------------------------------- /tests/test_pkg.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from pypi_changes._pkg import Package 6 | 7 | if TYPE_CHECKING: 8 | from pathlib import Path 9 | 10 | from tests import MakeDist 11 | 12 | 13 | def test_ignore_dev_release(make_dist: MakeDist, tmp_path: Path) -> None: 14 | releases = {"releases": {"1.0.0dev1": [{"version": "1.0.0dev1"}], "0.9.0": [{"version": "0.9.0"}]}} 15 | pkg = Package(make_dist(tmp_path, "a", "1.0.0"), info=releases) 16 | assert pkg.last_release == {"version": "0.9.0"} 17 | 18 | 19 | def test_fallback_to_rc_release(make_dist: MakeDist, tmp_path: Path) -> None: 20 | pkg = Package(make_dist(tmp_path, "a", "1.0.0"), info={"releases": {"1.0.0rc1": [{"version": "1.0.0rc1"}]}}) 21 | assert pkg.last_release == {"version": "1.0.0rc1"} 22 | 23 | 24 | def test_current_release_parse_ok(make_dist: MakeDist, tmp_path: Path) -> None: 25 | pkg = Package(make_dist(tmp_path, "a", "1.0.0"), info={"releases": {"1.0.0": [{"version": "1.0.0"}]}}) 26 | assert pkg.current_release == {"version": "1.0.0"} 27 | 28 | 29 | def test_current_release_empty(make_dist: MakeDist, tmp_path: Path) -> None: 30 | pkg = Package(make_dist(tmp_path, "a", "1.0.0"), info=None) 31 | assert pkg.current_release == {} 32 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - repo: https://github.com/python-jsonschema/check-jsonschema 8 | rev: 0.35.0 9 | hooks: 10 | - id: check-github-workflows 11 | args: ["--verbose"] 12 | - repo: https://github.com/codespell-project/codespell 13 | rev: v2.4.1 14 | hooks: 15 | - id: codespell 16 | additional_dependencies: ["tomli>=2.2.1"] 17 | - repo: https://github.com/tox-dev/tox-ini-fmt 18 | rev: "1.7.1" 19 | hooks: 20 | - id: tox-ini-fmt 21 | args: ["-p", "fix"] 22 | - repo: https://github.com/tox-dev/pyproject-fmt 23 | rev: "v2.11.1" 24 | hooks: 25 | - id: pyproject-fmt 26 | - repo: https://github.com/astral-sh/ruff-pre-commit 27 | rev: "v0.14.9" 28 | hooks: 29 | - id: ruff-format 30 | - id: ruff 31 | args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"] 32 | - repo: https://github.com/rbubley/mirrors-prettier 33 | rev: "v3.7.4" 34 | hooks: 35 | - id: prettier 36 | args: ["--print-width=120", "--prose-wrap=always"] 37 | - repo: meta 38 | hooks: 39 | - id: check-hooks-apply 40 | - id: check-useless-excludes 41 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import TYPE_CHECKING 5 | from unittest.mock import MagicMock, create_autospec 6 | 7 | import pytest 8 | 9 | from pypi_changes._cli import Options 10 | 11 | if TYPE_CHECKING: 12 | from pathlib import Path 13 | 14 | from _pytest.monkeypatch import MonkeyPatch 15 | 16 | from tests import MakeDist 17 | 18 | 19 | @pytest.fixture(autouse=True) 20 | def _no_index(monkeypatch: MonkeyPatch) -> None: 21 | monkeypatch.delenv("PIP_INDEX_URL", raising=False) 22 | 23 | 24 | @pytest.fixture(autouse=True) 25 | def _no_proxy(monkeypatch: MonkeyPatch) -> None: 26 | monkeypatch.delenv("https_proxy", raising=False) 27 | monkeypatch.delenv("http_proxy", raising=False) 28 | monkeypatch.delenv("no_proxy", raising=False) 29 | 30 | 31 | @pytest.fixture 32 | def option_simple(tmp_path: Path) -> Options: 33 | return Options(cache_path=tmp_path / "a.sqlite", jobs=1, cache_duration=0.01) 34 | 35 | 36 | @pytest.fixture 37 | def make_dist() -> MakeDist: 38 | def func(path: Path, name: str, version: str) -> MagicMock: 39 | of_type = f"importlib{'.' if sys.version_info >= (3, 8) else '_'}metadata.PathDistribution" 40 | dist: MagicMock = create_autospec(of_type) 41 | dist.metadata = {"Name": name} 42 | dist._path = path / "dist" # noqa: SLF001 43 | dist.version = version 44 | return dist 45 | 46 | return func 47 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release to PyPI 2 | on: 3 | push: 4 | tags: ["*"] 5 | 6 | env: 7 | dists-artifact-name: python-package-distributions 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v6 14 | with: 15 | fetch-depth: 0 16 | - name: Install the latest version of uv 17 | uses: astral-sh/setup-uv@v7 18 | with: 19 | enable-cache: true 20 | cache-dependency-glob: "pyproject.toml" 21 | github-token: ${{ secrets.GITHUB_TOKEN }} 22 | - name: Build package 23 | run: uv build --python 3.14 --python-preference only-managed --sdist --wheel . --out-dir dist 24 | - name: Store the distribution packages 25 | uses: actions/upload-artifact@v6 26 | with: 27 | name: ${{ env.dists-artifact-name }} 28 | path: dist/* 29 | 30 | release: 31 | needs: 32 | - build 33 | runs-on: ubuntu-latest 34 | environment: 35 | name: release 36 | url: https://pypi.org/project/pypi-changes/${{ github.ref_name }} 37 | permissions: 38 | id-token: write 39 | steps: 40 | - name: Download all the dists 41 | uses: actions/download-artifact@v7 42 | with: 43 | name: ${{ env.dists-artifact-name }} 44 | path: dist/ 45 | - name: Publish to PyPI 46 | uses: pypa/gh-action-pypi-publish@v1.13.0 47 | with: 48 | attestations: true 49 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from unittest.mock import call 5 | 6 | import pytest 7 | 8 | from pypi_changes import __version__ 9 | from pypi_changes._cli import Options, parse_cli_arguments 10 | 11 | if TYPE_CHECKING: 12 | from pathlib import Path 13 | 14 | from _pytest.capture import CaptureFixture 15 | from pytest_mock import MockerFixture 16 | 17 | 18 | def test_cli_ok_default(tmp_path: Path, mocker: MockerFixture) -> None: 19 | user_cache_path = mocker.patch("pypi_changes._cli.user_cache_path", return_value=tmp_path / "cache") 20 | 21 | options = parse_cli_arguments([str(tmp_path)]) 22 | 23 | assert isinstance(options, Options) 24 | assert options.__dict__ == { 25 | "jobs": 10, 26 | "cache_path": tmp_path / "cache" / "requests.sqlite", 27 | "cache_duration": 3600, 28 | "python": tmp_path, 29 | "sort": "updated", 30 | "output": "tree", 31 | } 32 | assert user_cache_path.call_args == call(appname="pypi_changes", appauthor="gaborbernat", version=__version__) 33 | 34 | 35 | def test_cli_python_not_exist(tmp_path: Path, capsys: CaptureFixture[str]) -> None: 36 | with pytest.raises(SystemExit) as context: 37 | parse_cli_arguments([str(tmp_path / "missing")]) 38 | 39 | assert context.value.code == 2 40 | out, err = capsys.readouterr() 41 | assert not out 42 | assert f"pypi-changes: error: argument PYTHON_EXE: path {tmp_path / 'missing'} does not exist" in err 43 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: check 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: ["main"] 6 | tags-ignore: ["**"] 7 | pull_request: 8 | schedule: 9 | - cron: "0 8 * * *" 10 | 11 | concurrency: 12 | group: check-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | env: 22 | - "3.14" 23 | - "3.13" 24 | - "3.12" 25 | - "3.11" 26 | - "3.10" 27 | - type 28 | - dev 29 | - pkg_meta 30 | steps: 31 | - uses: actions/checkout@v6 32 | with: 33 | fetch-depth: 0 34 | - name: Install the latest version of uv 35 | uses: astral-sh/setup-uv@v7 36 | with: 37 | enable-cache: true 38 | cache-dependency-glob: "pyproject.toml" 39 | github-token: ${{ secrets.GITHUB_TOKEN }} 40 | - name: Install tox 41 | run: uv tool install --python-preference only-managed --python 3.14 tox --with tox-uv 42 | - name: Install Python 43 | if: startsWith(matrix.env, '3.') && matrix.env != '3.14' 44 | run: uv python install --python-preference only-managed ${{ matrix.env }} 45 | - name: Setup test suite 46 | run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.env }} 47 | - name: Run test suite 48 | run: tox run --skip-pkg-install -e ${{ matrix.env }} 49 | env: 50 | PYTEST_ADDOPTS: "-vv --durations=5" 51 | -------------------------------------------------------------------------------- /tests/test_distributions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from pathlib import Path 5 | from typing import TYPE_CHECKING 6 | 7 | from pypi_changes._cli import Options 8 | from pypi_changes._distributions import collect_distributions 9 | from tests import PathDistribution 10 | 11 | if TYPE_CHECKING: 12 | from pytest_mock import MockerFixture 13 | 14 | 15 | def test_distributions() -> None: 16 | distributions = list(collect_distributions(Options(python=Path(sys.executable)))) 17 | assert all(isinstance(i, PathDistribution) for i in distributions) 18 | 19 | 20 | def _make_dist(path: Path, name: str) -> Path: 21 | dist = path / f"{name}.dist-info" 22 | dist.mkdir(parents=True) 23 | (dist / "METADATA").write_text(f"Name: {name}") 24 | return dist 25 | 26 | 27 | def test_distribution_duplicate_path(mocker: MockerFixture, tmp_path: Path) -> None: 28 | dist = _make_dist(tmp_path, "a") 29 | mocker.patch("pypi_changes._distributions._get_py_info", return_value=[dist.parent] * 2) 30 | distributions = list(collect_distributions(Options(python=Path(sys.executable)))) 31 | assert len(distributions) == 1 32 | assert distributions[0].metadata["Name"] == "a" 33 | 34 | 35 | def test_distribution_duplicate_pkg(mocker: MockerFixture, tmp_path: Path) -> None: 36 | dist_1, dist_2 = _make_dist(tmp_path / "1", "a"), _make_dist(tmp_path / "2", "a") 37 | mocker.patch("pypi_changes._distributions._get_py_info", return_value=[dist_1.parent, dist_2.parent]) 38 | distributions = list(collect_distributions(Options(python=Path(sys.executable)))) 39 | assert len(distributions) == 1 40 | assert distributions[0].metadata["Name"] == "a" 41 | assert distributions[0]._path == dist_1 # noqa: SLF001 42 | -------------------------------------------------------------------------------- /src/pypi_changes/_print/json.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from datetime import datetime, timezone 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from humanize import naturaldelta 8 | 9 | from . import get_sorted_pkg_list 10 | 11 | if TYPE_CHECKING: 12 | from collections.abc import Iterable 13 | 14 | from pypi_changes._cli import Options 15 | from pypi_changes._pkg import Package 16 | 17 | 18 | def release_info(release: dict[str, Any] | None, now: datetime) -> dict[str, Any]: 19 | if release is None: 20 | return {} 21 | release_at = release.get("upload_time_iso_8601") 22 | release_since = naturaldelta(now - release_at) if release_at else None 23 | return { 24 | "version": release.get("version"), 25 | "date": release_at.isoformat() if release_at is not None else None, 26 | "since": release_since, 27 | } 28 | 29 | 30 | def print_json(distributions: Iterable[Package], options: Options) -> None: 31 | now = datetime.now(timezone.utc) 32 | pkg_list = [] 33 | 34 | for pkg in get_sorted_pkg_list(distributions, options, now): 35 | current_release = {"version": pkg.version, **release_info(pkg.current_release, now)} 36 | latest_release = release_info(pkg.last_release, now) 37 | pkg_list.append( 38 | { 39 | "name": pkg.name, 40 | "version": pkg.version, 41 | "up_to_date": pkg.version == latest_release.get("version") if latest_release is not None else True, 42 | "current": current_release, 43 | "latest": latest_release, 44 | }, 45 | ) 46 | print(json.dumps(pkg_list, indent=2)) # noqa: T201 47 | 48 | 49 | __all__ = [ 50 | "print_json", 51 | ] 52 | -------------------------------------------------------------------------------- /src/pypi_changes/_print/tree.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime, timezone 4 | from typing import TYPE_CHECKING 5 | 6 | from humanize import naturaldelta 7 | from rich import print as rich_print 8 | from rich.markup import escape 9 | from rich.text import Text 10 | from rich.tree import Tree 11 | 12 | from . import get_sorted_pkg_list 13 | 14 | if TYPE_CHECKING: 15 | from collections.abc import Iterable 16 | 17 | from pypi_changes._cli import Options 18 | from pypi_changes._pkg import Package 19 | 20 | 21 | def print_tree(distributions: Iterable[Package], options: Options) -> None: 22 | now = datetime.now(timezone.utc) 23 | tree = Tree(f"🐍 Distributions within {escape(str(options.python))}", guide_style="cyan") 24 | for pkg in get_sorted_pkg_list(distributions, options, now): 25 | text = Text(pkg.name, "yellow") 26 | text.stylize(f"link https://pypi.org/project/{pkg.name}/#history") 27 | text.append(" ", "white") 28 | text.append(pkg.version, "blue") 29 | 30 | current_release = pkg.current_release 31 | current_release_at = current_release.get("upload_time_iso_8601") 32 | last_release = pkg.last_release or {} 33 | last_release_at = pkg.last_release_at 34 | 35 | if current_release_at is not None: 36 | text.append(" ") # pragma: no cover 37 | text.append(naturaldelta(now - current_release_at), "green") # pragma: no cover 38 | if pkg.version != last_release.get("version"): 39 | text.append(f" remote {last_release.get('version')}", "red") 40 | if last_release_at is not None: # pragma: no branch 41 | text.append(" ", "white") 42 | text.append(naturaldelta(now - last_release_at), "green") 43 | tree.add(text) 44 | rich_print(tree) 45 | 46 | 47 | __all__ = [ 48 | "print_tree", 49 | ] 50 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4.30.3 4 | tox-uv>=1.28 5 | env_list = 6 | fix 7 | 3.14 8 | 3.13 9 | 3.12 10 | 3.11 11 | 3.10 12 | type 13 | pkg_meta 14 | skip_missing_interpreters = true 15 | 16 | [testenv] 17 | description = run the unit tests with pytest under {base_python} 18 | package = wheel 19 | wheel_build_env = .pkg 20 | extras = 21 | testing 22 | pass_env = 23 | PYTEST_* 24 | set_env = 25 | COVERAGE_FILE = {work_dir}/.coverage.{env_name} 26 | commands = 27 | python -m pytest {tty:--color=yes} {posargs: \ 28 | --cov {env_site_packages_dir}{/}pypi_changes --cov {tox_root}{/}tests \ 29 | --cov-config=pyproject.toml --no-cov-on-fail --cov-report term-missing:skip-covered --cov-context=test \ 30 | --cov-report html:{env_tmp_dir}{/}htmlcov --cov-report xml:{work_dir}{/}coverage.{env_name}.xml \ 31 | --junitxml {work_dir}{/}junit.{env_name}.xml \ 32 | tests} 33 | 34 | [testenv:fix] 35 | description = format the code base to adhere to our styles, and complain about what we cannot do automatically 36 | skip_install = true 37 | deps = 38 | pre-commit-uv>=4.1.5 39 | commands = 40 | pre-commit run --all-files --show-diff-on-failure 41 | 42 | [testenv:type] 43 | description = run type check on code base 44 | deps = 45 | mypy==1.18.2 46 | types-requests>=2.32.4.20250913 47 | commands = 48 | mypy src tests {posargs} 49 | 50 | [testenv:pkg_meta] 51 | description = check that the long description is valid 52 | skip_install = true 53 | deps = 54 | check-wheel-contents>=0.6.3 55 | twine>=6.2 56 | uv>=0.8.22 57 | commands = 58 | uv build --sdist --wheel --out-dir {env_tmp_dir} . 59 | twine check {env_tmp_dir}{/}* 60 | check-wheel-contents --no-config {env_tmp_dir} 61 | 62 | [testenv:dev] 63 | description = generate a DEV environment 64 | package = editable 65 | commands = 66 | uv tree 67 | python -c 'import sys; print(sys.executable)' 68 | -------------------------------------------------------------------------------- /src/pypi_changes/_distributions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import re 5 | from importlib.metadata import Distribution, PathDistribution 6 | from pathlib import Path 7 | from subprocess import check_output # noqa: S404 8 | from typing import TYPE_CHECKING 9 | 10 | from rich.console import Console 11 | 12 | if TYPE_CHECKING: 13 | from collections.abc import Generator, Iterable 14 | 15 | from ._cli import Options 16 | 17 | _PKG_REGEX = re.compile(r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])(\.egg-info|\.dist-info)$", flags=re.IGNORECASE) 18 | 19 | 20 | def collect_distributions(options: Options) -> list[PathDistribution]: 21 | distributions: list[PathDistribution] = [] 22 | with Console().status("Discovering distributions") as status: 23 | paths = _get_py_info(str(options.python)) 24 | for dist in _iter_distributions(paths): 25 | status.update(f"Discovering distributions {len(distributions)}") 26 | distributions.append(dist) 27 | return distributions 28 | 29 | 30 | def _get_py_info(python: str) -> list[Path]: 31 | cmd = [python, "-c", "import sys, json; print(json.dumps(sys.path))"] 32 | return [Path(i) for i in json.loads(check_output(cmd, text=True))] # noqa: S603 33 | 34 | 35 | def _iter_distributions(paths: Iterable[Path]) -> Generator[PathDistribution, None, None]: 36 | found: set[str] = set() 37 | done_paths: set[Path] = set() 38 | for raw_path in paths: 39 | if not raw_path.exists(): 40 | continue 41 | path = raw_path.resolve() 42 | if path not in done_paths: 43 | done_paths.add(path) 44 | for candidate in path.iterdir(): 45 | if not candidate.is_dir(): 46 | continue 47 | match = _PKG_REGEX.match(candidate.name) 48 | if match: 49 | dist = Distribution.at(candidate) 50 | name = dist.metadata["Name"] 51 | if name is not None and name not in found: 52 | found.add(name) 53 | yield dist 54 | 55 | 56 | __all__ = [ 57 | "collect_distributions", 58 | ] 59 | -------------------------------------------------------------------------------- /src/pypi_changes/_pkg.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime, timezone 4 | from typing import TYPE_CHECKING, Any, cast 5 | 6 | from packaging.version import Version 7 | 8 | if TYPE_CHECKING: 9 | from importlib.metadata import PathDistribution 10 | from pathlib import Path 11 | 12 | 13 | class Package: 14 | def __init__(self, dist: PathDistribution, info: dict[str, Any] | Exception | None) -> None: 15 | self.dist: PathDistribution = dist 16 | self.info: dict[str, Any] | None = None if isinstance(info, Exception) else info 17 | self.exc = info if isinstance(info, Exception) else None 18 | 19 | @property 20 | def last_release_at(self) -> datetime: 21 | last_release = self.last_release 22 | if last_release is None: 23 | return datetime.now(timezone.utc) 24 | return self.last_release["upload_time_iso_8601"] # type: ignore[no-any-return,index] # Any instead of datetime 25 | 26 | @property 27 | def last_release(self) -> dict[str, Any] | None: 28 | if self.info is None or not self.info["releases"]: 29 | return None 30 | for version_str, releases in self.info["releases"].items(): 31 | version = Version(version_str) 32 | if not version.is_devrelease and not version.is_prerelease: 33 | return releases[0] # type: ignore[no-any-return] 34 | return next(iter(self.info["releases"].values()))[0] # type: ignore[no-any-return] 35 | 36 | @property 37 | def name(self) -> str: 38 | return self.dist.metadata["Name"] 39 | 40 | @property 41 | def version(self) -> str: 42 | return self.dist.version 43 | 44 | @property 45 | def path(self) -> Path: 46 | return cast("Path", self.dist._path) # noqa: SLF001 47 | 48 | @property 49 | def current_release(self) -> dict[str, Any]: 50 | if self.info is not None: 51 | release_info = self.info["releases"].get(self.version) 52 | if release_info is not None: 53 | return release_info[0] or {} 54 | return {} # return empty version info if not matching 55 | 56 | def __repr__(self) -> str: 57 | return f"{self.__class__.__name__}(name={self.name!r}, path={self.path!r})" 58 | 59 | 60 | __all__ = [ 61 | "Package", 62 | ] 63 | -------------------------------------------------------------------------------- /tests/test_print_json.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import sys 5 | from datetime import datetime, timezone 6 | from pathlib import Path 7 | from typing import TYPE_CHECKING 8 | from unittest.mock import create_autospec 9 | 10 | from pypi_changes._pkg import Package 11 | from pypi_changes._print.json import print_json, release_info 12 | from tests import PathDistribution 13 | 14 | if TYPE_CHECKING: 15 | from _pytest.capture import CaptureFixture 16 | from pytest_mock import MockerFixture 17 | 18 | from pypi_changes._cli import Options 19 | 20 | 21 | def test_print_json(capsys: CaptureFixture[str], option_simple: Options, mocker: MockerFixture) -> None: 22 | mocked_datetime = mocker.patch("pypi_changes._print.json.datetime") 23 | mocked_datetime.now.return_value = datetime(2021, 11, 6, 10, tzinfo=timezone.utc) 24 | option_simple.python = Path(sys.executable) 25 | option_simple.sort = "unsorted" 26 | packages = [ 27 | Package( 28 | create_autospec(PathDistribution, spec_set=True, version=v_cur, metadata={"Name": n}), 29 | info={ 30 | "releases": { 31 | v_last: [{"version": v_last, "upload_time_iso_8601": t_last}], 32 | v_cur: [{"version": v_cur, "upload_time_iso_8601": t_cur}], 33 | }, 34 | }, 35 | ) 36 | for n, (v_last, t_last), (v_cur, t_cur) in [ 37 | ( 38 | "a", 39 | ("2", datetime(2021, 10, 5, 10, tzinfo=timezone.utc)), 40 | ("1", datetime(2020, 3, 8, 10, tzinfo=timezone.utc)), 41 | ), 42 | ( 43 | "b", 44 | ("2", None), 45 | ("1", None), 46 | ), 47 | ] 48 | ] 49 | 50 | print_json(packages, option_simple) 51 | 52 | out, err = capsys.readouterr() 53 | assert not err 54 | output = [i.strip() for i in out.splitlines()] 55 | parsed_output = json.loads("".join(output)) 56 | assert parsed_output == [ 57 | { 58 | "name": "b", 59 | "version": "1", 60 | "up_to_date": False, 61 | "current": {"version": "1", "date": None, "since": None}, 62 | "latest": {"version": "2", "date": None, "since": None}, 63 | }, 64 | { 65 | "name": "a", 66 | "version": "1", 67 | "up_to_date": False, 68 | "current": { 69 | "version": "1", 70 | "date": "2020-03-08T10:00:00+00:00", 71 | "since": "1 year, 7 months", 72 | }, 73 | "latest": { 74 | "version": "2", 75 | "date": "2021-10-05T10:00:00+00:00", 76 | "since": "a month", 77 | }, 78 | }, 79 | ] 80 | 81 | 82 | def test_release_info_no_release() -> None: 83 | result = release_info(None, datetime.now(timezone.utc)) 84 | 85 | assert result == {} 86 | -------------------------------------------------------------------------------- /src/pypi_changes/_cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from argparse import Action, ArgumentDefaultsHelpFormatter, ArgumentError, ArgumentParser, Namespace 4 | from pathlib import Path 5 | from typing import TYPE_CHECKING 6 | 7 | from platformdirs import user_cache_path 8 | 9 | from ._version import version 10 | 11 | if TYPE_CHECKING: 12 | from collections.abc import Sequence 13 | 14 | 15 | class Options(Namespace): 16 | python: Path 17 | jobs: int 18 | cache_path: Path 19 | cache_duration: int 20 | sort: str 21 | 22 | 23 | def parse_cli_arguments(args: Sequence[str] | None = None) -> Options: 24 | parser = _define_cli_arguments() 25 | options = Options() 26 | parser.parse_args(args, options) 27 | return options 28 | 29 | 30 | def _define_cli_arguments() -> ArgumentParser: 31 | epilog = f"running {version} at {Path(__file__).parent}" 32 | parser = ArgumentParser(prog="pypi-changes", formatter_class=_HelpFormatter, epilog=epilog) 33 | 34 | parallel_help = "maximum number of parallel requests when loading distribution information from PyPI" 35 | parser.add_argument("--jobs", "-j", default=10, type=int, help=parallel_help, metavar="COUNT") 36 | 37 | path = user_cache_path(appname="pypi_changes", appauthor="gaborbernat", version=version) / "requests.sqlite" 38 | parser.add_argument( 39 | "--cache-path", 40 | "-c", 41 | default=path, 42 | type=Path, 43 | help="requests are cached to disk to this sqlite file", 44 | metavar="PATH", 45 | dest="cache_path", 46 | ) 47 | cache_help = "seconds how long requests should be cached (pass 0 to bypass the cache, -1 to cache forever)" 48 | parser.add_argument("--cache-duration", "-d", default=3600, type=int, help=cache_help, metavar="SEC") 49 | 50 | parser.add_argument( 51 | "--sort", 52 | "-s", 53 | help="sorting method", 54 | choices=["a", "alphabetic", "u", "updated"], 55 | default="updated", 56 | dest="sort", 57 | const="updated", 58 | nargs="?", 59 | ) 60 | 61 | parser.add_argument( 62 | "--output", 63 | "-o", 64 | help="Choose output format", 65 | choices=["tree", "json"], 66 | default="tree", 67 | dest="output", 68 | ) 69 | 70 | parser.add_argument("python", help="python interpreter to inspect", metavar="PYTHON_EXE", action=_Python) 71 | 72 | return parser 73 | 74 | 75 | class _Python(Action): 76 | def __call__( 77 | self, 78 | parser: ArgumentParser, # noqa: ARG002 79 | namespace: Namespace, 80 | values: str | Sequence[str] | None, 81 | option_string: str | None = None, # noqa: ARG002 82 | ) -> None: 83 | assert isinstance(values, str) # noqa: S101 84 | path = Path(values).absolute() 85 | if not path.exists(): 86 | raise ArgumentError(self, f"path {path} does not exist") 87 | setattr(namespace, self.dest, path) 88 | 89 | 90 | class _HelpFormatter(ArgumentDefaultsHelpFormatter): 91 | def __init__(self, prog: str) -> None: 92 | super().__init__(prog, max_help_position=35, width=190) 93 | 94 | 95 | __all__ = [ 96 | "Options", 97 | "parse_cli_arguments", 98 | ] 99 | -------------------------------------------------------------------------------- /tests/test_print_rich.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from datetime import datetime, timezone 5 | from pathlib import Path 6 | from typing import TYPE_CHECKING 7 | from unittest.mock import create_autospec 8 | 9 | from pypi_changes._pkg import Package 10 | from pypi_changes._print.tree import print_tree 11 | from tests import PathDistribution 12 | 13 | if TYPE_CHECKING: 14 | from _pytest.capture import CaptureFixture 15 | from pytest_mock import MockerFixture 16 | 17 | from pypi_changes._cli import Options 18 | 19 | 20 | def test_print(capsys: CaptureFixture[str], option_simple: Options, mocker: MockerFixture) -> None: 21 | mocked_datetime = mocker.patch("pypi_changes._print.tree.datetime") 22 | mocked_datetime.now.return_value = datetime(2021, 11, 6, 10, tzinfo=timezone.utc) 23 | option_simple.python = Path(sys.executable) 24 | option_simple.sort = "updated" 25 | packages = [ 26 | Package( 27 | create_autospec(PathDistribution, spec_set=True, version=v_l, metadata={"Name": n}), 28 | info={"releases": {v_u: [{"version": v_u, "upload_time_iso_8601": t}]}}, 29 | ) 30 | for n, v_l, v_u, t in [ 31 | ("a", "1", "2", datetime(2021, 10, 5, 10, tzinfo=timezone.utc)), 32 | ("b", "3", "3", datetime(2021, 11, 5, 10, tzinfo=timezone.utc)), 33 | ("d", "1", "1", None), 34 | ("c", "1", "2", None), 35 | ] 36 | ] 37 | 38 | print_tree(packages, option_simple) 39 | 40 | out, err = capsys.readouterr() 41 | assert not err 42 | output = [i.strip() for i in out.splitlines()] 43 | assert output[0].startswith("🐍 Distributions within") 44 | assert output[1] == sys.executable or output[0].endswith(sys.executable) 45 | assert output[-4:] == [ 46 | "├── c 1 remote 2", 47 | "├── d 1", 48 | "├── b 3 a day", 49 | "└── a 1 remote 2 a month", 50 | ] 51 | 52 | 53 | def test_print_alphabetical(capsys: CaptureFixture[str], option_simple: Options, mocker: MockerFixture) -> None: 54 | mocked_datetime = mocker.patch("pypi_changes._print.tree.datetime") 55 | mocked_datetime.now.return_value = datetime(2021, 11, 6, 10, tzinfo=timezone.utc) 56 | option_simple.python = Path(sys.executable) 57 | option_simple.sort = "alphabetic" 58 | packages = [ 59 | Package( 60 | create_autospec(PathDistribution, spec_set=True, version=v_l, metadata={"Name": n}), 61 | info={"releases": {v_u: [{"version": v_u, "upload_time_iso_8601": t}]}}, 62 | ) 63 | for n, v_l, v_u, t in [ 64 | ("a", "1", "2", datetime(2021, 10, 5, 10, tzinfo=timezone.utc)), 65 | ("b", "3", "3", datetime(2021, 11, 5, 10, tzinfo=timezone.utc)), 66 | ("d", "1", "1", None), 67 | ("c", "1", "2", None), 68 | ] 69 | ] 70 | 71 | print_tree(packages, option_simple) 72 | 73 | out, err = capsys.readouterr() 74 | assert not err 75 | output = [i.strip() for i in out.splitlines()] 76 | assert output[0].startswith("🐍 Distributions within") 77 | assert output[1] == sys.executable or output[0].endswith(sys.executable) 78 | assert output[-4:] == [ 79 | "├── a 1 remote 2 a month", 80 | "├── b 3 a day", 81 | "├── c 1 remote 2", 82 | "└── d 1", 83 | ] 84 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making 6 | participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, 7 | disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, 8 | religion, or sexual identity and orientation. 9 | 10 | ## Our Standards 11 | 12 | Examples of behavior that contributes to creating a positive environment include: 13 | 14 | - Using welcoming and inclusive language 15 | - Being respectful of differing viewpoints and experiences 16 | - Gracefully accepting constructive criticism 17 | - Focusing on what is best for the community 18 | - Showing empathy towards other community members 19 | 20 | Examples of unacceptable behavior by participants include: 21 | 22 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 23 | - Trolling, insulting/derogatory comments, and personal or political attacks 24 | - Public or private harassment 25 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 26 | - Other conduct which could reasonably be considered inappropriate in a professional setting 27 | 28 | ## Our Responsibilities 29 | 30 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take 31 | appropriate and fair corrective action in response to any instances of unacceptable behavior. 32 | 33 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, 34 | issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any 35 | contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 36 | 37 | ## Scope 38 | 39 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the 40 | project or its community. Examples of representing a project or community include using an official project e-mail 41 | address, posting via an official social media account, or acting as an appointed representative at an online or offline 42 | event. Representation of a project may be further defined and clarified by project maintainers. 43 | 44 | ## Enforcement 45 | 46 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at 47 | `gaborjbernat@gmail.com` deems appropriate to the circumstances. The project team is obligated to maintain 48 | confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be 49 | posted separately. 50 | 51 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent 52 | repercussions as determined by other members of the project's leadership. 53 | 54 | ## Attribution 55 | 56 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at 57 | [https://www.contributor-covenant.org/version/1/4/code-of-conduct.html][version] 58 | 59 | [homepage]: https://www.contributor-covenant.org/ 60 | [version]: https://www.contributor-covenant.org/version/1/4/ 61 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatch-vcs>=0.5", 5 | "hatchling>=1.27", 6 | ] 7 | 8 | [project] 9 | name = "pypi-changes" 10 | description = "check out when packages changed" 11 | readme.content-type = "text/markdown" 12 | readme.file = "README.md" 13 | keywords = [ 14 | "environments", 15 | "isolated", 16 | "testing", 17 | "virtual", 18 | ] 19 | license = "MIT" 20 | maintainers = [ 21 | { name = "Bernát Gábor", email = "gaborjbernat@gmail.com" }, 22 | ] 23 | authors = [ 24 | { name = "Bernát Gábor", email = "gaborjbernat@gmail.com" }, 25 | ] 26 | requires-python = ">=3.9" 27 | classifiers = [ 28 | "Development Status :: 5 - Production/Stable", 29 | "Framework :: tox", 30 | "Intended Audience :: Developers", 31 | "License :: OSI Approved :: MIT License", 32 | "Operating System :: MacOS :: MacOS X", 33 | "Operating System :: Microsoft :: Windows", 34 | "Operating System :: POSIX", 35 | "Programming Language :: Python :: 3 :: Only", 36 | "Programming Language :: Python :: 3.9", 37 | "Programming Language :: Python :: 3.10", 38 | "Programming Language :: Python :: 3.11", 39 | "Programming Language :: Python :: 3.12", 40 | "Programming Language :: Python :: 3.13", 41 | "Programming Language :: Python :: 3.14", 42 | "Topic :: Software Development :: Libraries", 43 | "Topic :: Software Development :: Testing", 44 | "Topic :: Utilities", 45 | ] 46 | dynamic = [ 47 | "version", 48 | ] 49 | dependencies = [ 50 | "humanize>=4.13", 51 | "packaging>=25", 52 | "platformdirs>=4.4", 53 | "pypi-simple>=1.8", 54 | "requests>=2.32.5", 55 | "requests-cache>=1.2.1", 56 | "rich>=14.1", 57 | ] 58 | optional-dependencies.testing = [ 59 | "covdefaults>=2.3", 60 | "pytest>=8.4.2", 61 | "pytest-cov>=7", 62 | "pytest-mock>=3.15.1", 63 | "urllib3<2", 64 | "vcrpy>=7", 65 | "virtualenv>=20.34", 66 | ] 67 | urls.Homepage = "https://github.com/gaborbernat/pypi_changes" 68 | urls.Source = "https://github.com/gaborbernat/pypi_changes" 69 | urls.Tracker = "https://github.com/gaborbernat/pypi_changes/issues" 70 | scripts.pypi-changes = "pypi_changes.__main__:main" 71 | 72 | [tool.hatch] 73 | build.hooks.vcs.version-file = "src/pypi_changes/_version.py" 74 | version.source = "vcs" 75 | 76 | [tool.ruff] 77 | line-length = 120 78 | format.preview = true 79 | format.docstring-code-line-length = 100 80 | format.docstring-code-format = true 81 | lint.select = [ 82 | "ALL", 83 | ] 84 | lint.ignore = [ 85 | "ANN401", # allow Any as type annotation 86 | "COM812", # Conflict with formatter 87 | "CPY", # No copyright statements 88 | "D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible 89 | "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible 90 | "DOC", # no support 91 | "ISC001", # Conflict with formatter 92 | "S104", # Possible binding to all interface 93 | ] 94 | lint.per-file-ignores."tests/**/*.py" = [ 95 | "D", # don't care about documentation in tests 96 | "FBT", # don't care about booleans as positional arguments in tests 97 | "INP001", # no implicit namespace 98 | "PLC2701", # allow private import 99 | "PLR2004", # Magic value used in comparison, consider replacing with a constant variable 100 | "S101", # asserts allowed in tests≈ 101 | "S603", # `subprocess` call: check for execution of untrusted input 102 | ] 103 | lint.isort = { known-first-party = [ 104 | "pypi_changes", 105 | ], required-imports = [ 106 | "from __future__ import annotations", 107 | ] } 108 | lint.preview = true 109 | 110 | [tool.codespell] 111 | builtin = "clear,usage,en-GB_to_en-US" 112 | write-changes = true 113 | count = true 114 | skip = "*.yaml" 115 | 116 | [tool.coverage] 117 | html.show_contexts = true 118 | html.skip_covered = false 119 | paths.source = [ 120 | "src", 121 | ".tox*/*/lib/python*/site-packages", 122 | ".tox*/pypy*/site-packages", 123 | ".tox*\\*\\Lib\\site-packages", 124 | "*/src", 125 | "*\\src", 126 | ] 127 | report.fail_under = 98 128 | report.omit = [ 129 | ] 130 | run.parallel = true 131 | run.plugins = [ 132 | "covdefaults", 133 | ] 134 | 135 | [tool.mypy] 136 | python_version = "3.11" 137 | show_error_codes = true 138 | strict = true 139 | overrides = [ 140 | { module = [ 141 | "virtualenv.*", 142 | "vcr.*", 143 | ], ignore_missing_imports = true }, 144 | ] 145 | -------------------------------------------------------------------------------- /tests/test_info.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from datetime import datetime, timezone 5 | from pathlib import Path 6 | from typing import TYPE_CHECKING 7 | from unittest.mock import create_autospec 8 | 9 | import pytest 10 | from pypi_simple import DistributionPackage, ProjectPage, PyPISimple 11 | from vcr import use_cassette 12 | 13 | from pypi_changes._info import _merge_with_index_server, pypi_info 14 | from pypi_changes._pkg import Package 15 | 16 | if TYPE_CHECKING: 17 | from pytest_mock import MockerFixture 18 | 19 | from pypi_changes._cli import Options 20 | from tests import MakeDist 21 | 22 | 23 | @pytest.fixture 24 | def _force_pypi_index(mocker: MockerFixture, _no_index: None) -> None: 25 | mocker.patch("pypi_changes._info.PYPI_INDEX", "") 26 | mocker.patch.dict(os.environ, {"PIP_INDEX_URL": "https://pypi.org/simple"}) 27 | 28 | 29 | @pytest.mark.usefixtures("_force_pypi_index") 30 | def test_info_self(tmp_path: Path, option_simple: Options, make_dist: MakeDist) -> None: 31 | dist = make_dist(tmp_path, "pypi-changes", "1.0.0") 32 | distributions = [dist] 33 | 34 | with use_cassette(str(Path(__file__).parent / "pypi_info_self.yaml"), mode="once"): 35 | packages = list(pypi_info(distributions, option_simple)) 36 | 37 | assert isinstance(packages, list) 38 | assert len(packages) == 1 39 | pkg = packages[0] 40 | assert isinstance(pkg, Package) 41 | assert repr(pkg) == f"Package(name='pypi-changes', path={tmp_path / 'dist'!r})" 42 | 43 | 44 | @pytest.mark.usefixtures("_force_pypi_index") 45 | @pytest.mark.usefixtures("_no_proxy") 46 | def test_info_missing(tmp_path: Path, option_simple: Options, make_dist: MakeDist) -> None: 47 | dist = make_dist(tmp_path, "missing-package", "1.0.0") 48 | distributions = [dist] 49 | 50 | with use_cassette(str(Path(__file__).parent / "pypi_info_missing_package.yaml"), mode="once"): 51 | packages = list(pypi_info(distributions, option_simple)) 52 | 53 | assert isinstance(packages, list) 54 | assert len(packages) == 1 55 | pkg = packages[0] 56 | assert pkg.info is None 57 | current = datetime.now(timezone.utc) 58 | last_release_at = pkg.last_release_at 59 | assert current <= last_release_at 60 | 61 | 62 | @pytest.mark.usefixtures("_no_proxy") 63 | def test_info_pypi_server_invalid_version(tmp_path: Path, option_simple: Options, make_dist: MakeDist) -> None: 64 | dist = make_dist(tmp_path, "pytz", "1.0") 65 | 66 | with use_cassette(str(Path(__file__).parent / "pypi_info_pytz.yaml"), mode="once"): 67 | packages = list(pypi_info([dist], option_simple)) 68 | 69 | assert isinstance(packages, list) 70 | assert len(packages) == 1 71 | pkg = packages[0] 72 | assert pkg.exc is None 73 | assert pkg.info is not None 74 | assert "2004b" in pkg.info["releases"] # this is an invalid version 75 | 76 | 77 | def test_info_pypi_server_timeout( 78 | tmp_path: Path, 79 | mocker: MockerFixture, 80 | option_simple: Options, 81 | make_dist: MakeDist, 82 | ) -> None: 83 | dist = make_dist(tmp_path, "a", "1.0") 84 | mock_cached_session = mocker.patch("pypi_changes._info.CachedSession") 85 | mock_cached_session.return_value.__enter__.return_value.get.side_effect = TimeoutError 86 | 87 | packages = list(pypi_info([dist], option_simple)) 88 | 89 | assert isinstance(packages, list) 90 | assert len(packages) == 1 91 | pkg = packages[0] 92 | assert pkg.info is None 93 | assert isinstance(pkg.exc, TimeoutError) 94 | 95 | 96 | def test_merge_with_pypi() -> None: 97 | versions = [("2", "sdist"), ("1", "sdist"), (None, None), ("3", "wheel")] 98 | packages = [create_autospec(DistributionPackage, version=v, package_type=t) for v, t in versions] 99 | page = create_autospec(ProjectPage, packages=packages) 100 | client = create_autospec(PyPISimple, spec_set=True) 101 | client.get_project_page.return_value = page 102 | 103 | start = {"0": [{"packagetype": "sdist", "upload_time_iso_8601": None, "version": "0"}]} 104 | result = _merge_with_index_server("a", client, start) 105 | assert result == { 106 | "0": [{"packagetype": "sdist", "upload_time_iso_8601": None, "version": "0"}], 107 | "1": [{"packagetype": "sdist", "upload_time_iso_8601": None, "version": "1"}], 108 | "2": [{"packagetype": "sdist", "upload_time_iso_8601": None, "version": "2"}], 109 | "3": [{"packagetype": "wheel", "upload_time_iso_8601": None, "version": "3"}], 110 | } 111 | -------------------------------------------------------------------------------- /src/pypi_changes/_info.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from collections import defaultdict 5 | from concurrent.futures import ThreadPoolExecutor, as_completed 6 | from contextlib import ExitStack, contextmanager 7 | from datetime import datetime, timedelta, timezone 8 | from typing import TYPE_CHECKING, Any 9 | 10 | from packaging.version import InvalidVersion, Version 11 | from pypi_simple import PyPISimple 12 | from requests_cache import CachedSession 13 | from rich.progress import BarColumn, Progress, Task, TextColumn, TimeRemainingColumn 14 | from rich.text import Text 15 | 16 | from ._pkg import Package 17 | 18 | if TYPE_CHECKING: 19 | from collections.abc import Generator, Iterator, Sequence 20 | from importlib.metadata import PathDistribution 21 | 22 | from requests import Session 23 | 24 | from ._cli import Options 25 | 26 | PYPI_INDEX = "https://pypi.org/simple" 27 | 28 | 29 | def pypi_info(distributions: Sequence[PathDistribution], options: Options) -> Generator[Package, None, None]: 30 | with ExitStack() as stack: 31 | enter = stack.enter_context 32 | session = enter(CachedSession(str(options.cache_path), backend="sqlite", expire_after=options.cache_duration)) 33 | session.cache.delete(expired=True) # cleanup old entries 34 | 35 | client = enter(_pypi_client(session)) 36 | 37 | progress = Progress( 38 | "[progress.description]{task.description}", 39 | BarColumn(), 40 | TextColumn("[bold magenta] {task.completed}/{task.total}"), 41 | "[progress.percentage]{task.percentage:>3.0f}%", 42 | SpeedColumn(), 43 | TimeRemainingColumn(), 44 | transient=True, 45 | ) 46 | enter(progress) 47 | task = progress.add_task("[red]Acquire release information", total=len(distributions)) 48 | 49 | executor = enter(ThreadPoolExecutor(max_workers=options.jobs, thread_name_prefix="version-getter")) 50 | future_to_url = {executor.submit(one_info, client, session, dist): dist for dist in distributions} 51 | for future in as_completed(future_to_url): 52 | dist = future_to_url[future] 53 | progress.update(task, advance=1) 54 | try: 55 | result: Exception | dict[str, Any] | None = future.result() 56 | except Exception as exc: # noqa: BLE001 57 | result = exc 58 | yield Package(dist, result) 59 | 60 | 61 | class SpeedColumn(TextColumn): 62 | def __init__(self) -> None: 63 | super().__init__("[bold cyan]") 64 | 65 | def render(self, task: Task) -> Text: # noqa: PLR6301 66 | if task.speed is None: 67 | return Text("no speed") 68 | return Text(f"{task.speed:.3f} steps/s") 69 | 70 | 71 | @contextmanager 72 | def _pypi_client(session: Session) -> Iterator[PyPISimple | None]: 73 | url = os.environ.get("PIP_INDEX_URL") 74 | if url is not None and url.lstrip("/") != PYPI_INDEX: 75 | with PyPISimple(endpoint=url, session=session) as client: 76 | yield client 77 | else: 78 | yield None 79 | 80 | 81 | def one_info(pypi_client: PyPISimple | None, session: CachedSession, dist: PathDistribution) -> dict[str, Any] | None: 82 | name: str = dist.metadata["Name"] 83 | result = _load_from_pypi_json_api(name, session) 84 | if pypi_client is not None: 85 | result["releases"] = _merge_with_index_server(name, pypi_client, result["releases"]) 86 | return result 87 | 88 | 89 | def _load_from_pypi_json_api(name: str, session: CachedSession) -> dict[str, Any]: 90 | # ask PyPi - e.g. https://pypi.org/pypi/pip/json, see https://warehouse.pypa.io/api-reference/json/ for more details 91 | response = session.get(f"https://pypi.org/pypi/{name}/json") 92 | result: dict[str, Any] = response.json() if response.ok else {"releases": {}} 93 | 94 | # normalize response 95 | prev_release_at = datetime.now(timezone.utc) 96 | for a_version, artifact_release in sorted(result["releases"].items(), reverse=True): 97 | if artifact_release: # enrich into releases version and transform upload time to python datetime 98 | for release in artifact_release: 99 | upload_time = datetime.fromisoformat(release.get("upload_time_iso_8601").replace("Z", "+00:00")) 100 | release.update({"version": a_version, "upload_time_iso_8601": upload_time}) 101 | prev_release_at = artifact_release[0]["upload_time_iso_8601"] 102 | else: # if no releases make up a release time and enrich version 103 | prev_release_at -= timedelta(seconds=1) 104 | release = {"packagetype": "sdist", "version": a_version, "upload_time_iso_8601": prev_release_at} 105 | artifact_release.append(release) 106 | result["releases"] = dict(sorted(result["releases"].items(), key=sort_by_version_release, reverse=True)) 107 | return result 108 | 109 | 110 | def sort_by_version_release(value: tuple[str, list[dict[str, Any]]]) -> tuple[Version, datetime]: 111 | try: 112 | version = Version(value[0]) 113 | except InvalidVersion: 114 | version = Version("0.0.1") 115 | return version, value[1][0]["upload_time_iso_8601"] 116 | 117 | 118 | def _merge_with_index_server( 119 | name: str, 120 | pypi_client: PyPISimple, 121 | releases: dict[str, list[dict[str, Any]]], 122 | ) -> dict[str, list[dict[str, Any]]]: 123 | index_info = pypi_client.get_project_page(name) 124 | index_releases = defaultdict(list) 125 | for pkg in index_info.packages: 126 | release = {"packagetype": pkg.package_type, "version": pkg.version, "upload_time_iso_8601": None} 127 | if pkg.version is not None: # some Artifactory might not set this for .egg-info uploads, ignore those 128 | index_releases[pkg.version].append(release) 129 | 130 | missing = {ver: values for ver, values in index_releases.items() if ver not in releases} 131 | if missing: 132 | missing.update(releases) 133 | return dict(sorted(missing.items(), key=sort_by_version_release, reverse=True)) 134 | return releases 135 | 136 | 137 | __all__ = [ 138 | "Package", 139 | "pypi_info", 140 | ] 141 | -------------------------------------------------------------------------------- /tests/pypi_info_self.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - "*/*" 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.26.0 13 | method: GET 14 | uri: https://pypi.org/pypi/pypi-changes/json 15 | response: 16 | body: 17 | string: !!binary | 18 | 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 47 | vv4C1A1YrbYVAAA= 48 | headers: 49 | Accept-Ranges: 50 | - bytes 51 | Access-Control-Allow-Headers: 52 | - Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since 53 | Access-Control-Allow-Methods: 54 | - GET 55 | Access-Control-Allow-Origin: 56 | - "*" 57 | Access-Control-Expose-Headers: 58 | - X-PyPI-Last-Serial 59 | Access-Control-Max-Age: 60 | - "86400" 61 | Cache-Control: 62 | - max-age=900, public 63 | Connection: 64 | - keep-alive 65 | Content-Encoding: 66 | - gzip 67 | Content-Length: 68 | - "1664" 69 | Content-Security-Policy: 70 | - base-uri 'self'; block-all-mixed-content; connect-src 'self' https://api.github.com/repos/ 71 | *.fastly-insights.com sentry.io https://api.pwnedpasswords.com https://2p66nmmycsj3.statuspage.io; 72 | default-src 'none'; font-src 'self' fonts.gstatic.com; form-action 'self'; frame-ancestors 'none'; frame-src 73 | 'none'; img-src 'self' https://warehouse-camo.ingress.cmh1.psfhosted.org/ www.google-analytics.com 74 | *.fastly-insights.com; script-src 'self' www.googletagmanager.com www.google-analytics.com 75 | *.fastly-insights.com https://cdn.ravenjs.com; style-src 'self' fonts.googleapis.com; worker-src 76 | *.fastly-insights.com 77 | Content-Type: 78 | - application/json 79 | Date: 80 | - Fri, 05 Nov 2021 08:05:47 GMT 81 | ETag: 82 | - '"SQujkLcwtvpILHqrbKZiyg"' 83 | Referrer-Policy: 84 | - origin-when-cross-origin 85 | Server: 86 | - nginx/1.13.9 87 | Strict-Transport-Security: 88 | - max-age=31536000; includeSubDomains; preload 89 | Vary: 90 | - Accept-Encoding 91 | X-Cache: 92 | - HIT, HIT 93 | X-Cache-Hits: 94 | - 1, 1 95 | X-Content-Type-Options: 96 | - nosniff 97 | X-Frame-Options: 98 | - deny 99 | X-Permitted-Cross-Domain-Policies: 100 | - none 101 | X-PyPI-Last-Serial: 102 | - "11924395" 103 | X-Served-By: 104 | - cache-bwi5127-BWI, cache-lhr7369-LHR 105 | X-Timer: 106 | - S1636099548.605431,VS0,VE1 107 | X-XSS-Protection: 108 | - 1; mode=block 109 | status: 110 | code: 200 111 | message: OK 112 | - request: 113 | body: null 114 | headers: 115 | Accept: 116 | - "*/*" 117 | Accept-Encoding: 118 | - gzip, deflate 119 | Connection: 120 | - keep-alive 121 | User-Agent: 122 | - python-requests/2.26.0 123 | method: GET 124 | uri: https://pypi.org/simple/pypi-changes/ 125 | response: 126 | body: 127 | string: !!binary | 128 | H4sIAAAAAAAC/52TTW/UMBCG7/yKNEjcEn9/lWQlBD0gVaICLpzQxB5vou4mITGg8OvxKtsLAgn1 129 | 5LHGr+edZ+zm5t2Ht5+/PNwVfTqfDi+afSmKpkcIlyCHZ0xQjHDGtpy3ebhdcJ7WIU3LVv3AZR2m 130 | sSz8NCYcU1uympZXXRrSCQ/3w/i4FnFaiou48j2MR1wbsmcvpchTraabwnYV9+yfypzaz0DRLxjb 131 | sk9pXm8JicMJ13reUj+N/bQmDPW0HMkM/hGykggklhEMACBodDoqYVF5DdprVCCjo1wqwyLwYBkG 132 | J5xlGhmjzlHpZWfIxcnXq5Mq91rTat5ENU4jVjBu9c/+9HLtgSvddkpqpb1XlBqRrzRMUSmUpgIM 133 | 00zKzgrgLsrIObdOYGe1CAE8p7IDxsoiQIJqwW/fhyVX2/tqy1fH9LoVtSkP/2GmIXBouoU8l1gk 134 | VJFMxILyQXJvpQ4ickOlArA872LGh8LTyNBogd55bb0U6ABURPUXYnWCpT7+euLkBTdOavBWo/aU 135 | o44afaYUo6VesGCFCiojVFJGxrETXR5gHowxgQdPn8PpauEPOmR/f/mF7Z/hpqo+3X18/+a+YMxx 136 | KZyqqsNvhHHyojMDAAA= 137 | headers: 138 | Accept-Ranges: 139 | - bytes 140 | Cache-Control: 141 | - max-age=600, public 142 | Connection: 143 | - keep-alive 144 | Content-Encoding: 145 | - gzip 146 | Content-Length: 147 | - "470" 148 | Content-Security-Policy: 149 | - default-src 'none'; sandbox allow-top-navigation 150 | Content-Type: 151 | - text/html; charset=UTF-8 152 | Date: 153 | - Fri, 05 Nov 2021 08:05:47 GMT 154 | ETag: 155 | - '"NS4CK+gfAbMDa105cHQfyg"' 156 | Referrer-Policy: 157 | - origin-when-cross-origin 158 | Server: 159 | - nginx/1.13.9 160 | Strict-Transport-Security: 161 | - max-age=31536000; includeSubDomains; preload 162 | Vary: 163 | - Accept-Encoding 164 | X-Cache: 165 | - HIT, HIT 166 | X-Cache-Hits: 167 | - 1, 1 168 | X-Content-Type-Options: 169 | - nosniff 170 | X-Frame-Options: 171 | - deny 172 | X-Permitted-Cross-Domain-Policies: 173 | - none 174 | X-PyPI-Last-Serial: 175 | - "11924395" 176 | X-Served-By: 177 | - cache-bwi5161-BWI, cache-lhr7369-LHR 178 | X-Timer: 179 | - S1636099548.622362,VS0,VE1 180 | X-XSS-Protection: 181 | - 1; mode=block 182 | status: 183 | code: 200 184 | message: OK 185 | version: 1 186 | -------------------------------------------------------------------------------- /tests/pypi_info_missing_package.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - "*/*" 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.26.0 13 | method: GET 14 | uri: https://pypi.org/pypi/missing-package/json 15 | response: 16 | body: 17 | string: 18 | "\n \n 301 Moved Permanently\n \n \n

301 Moved 19 | Permanently

\n The resource has been moved to /pypi/missing-package/json/; you should be redirected 20 | automatically.\n\n\n \n" 21 | headers: 22 | Accept-Ranges: 23 | - bytes 24 | Connection: 25 | - keep-alive 26 | Content-Length: 27 | - "224" 28 | Content-Security-Policy: 29 | - base-uri 'self'; block-all-mixed-content; connect-src 'self' https://api.github.com/repos/ 30 | *.fastly-insights.com sentry.io https://api.pwnedpasswords.com https://2p66nmmycsj3.statuspage.io; 31 | default-src 'none'; font-src 'self' fonts.gstatic.com; form-action 'self'; frame-ancestors 'none'; frame-src 32 | 'none'; img-src 'self' https://warehouse-camo.ingress.cmh1.psfhosted.org/ www.google-analytics.com 33 | *.fastly-insights.com; script-src 'self' www.googletagmanager.com www.google-analytics.com 34 | *.fastly-insights.com https://cdn.ravenjs.com; style-src 'self' fonts.googleapis.com; worker-src 35 | *.fastly-insights.com 36 | Content-Type: 37 | - text/html; charset=UTF-8 38 | Date: 39 | - Fri, 05 Nov 2021 08:54:19 GMT 40 | Location: 41 | - https://pypi.org/pypi/missing-package/json/ 42 | Referrer-Policy: 43 | - origin-when-cross-origin 44 | Server: 45 | - nginx/1.13.9 46 | Strict-Transport-Security: 47 | - max-age=31536000; includeSubDomains; preload 48 | Vary: 49 | - Accept-Encoding 50 | X-Cache: 51 | - MISS, MISS 52 | X-Cache-Hits: 53 | - 0, 0 54 | X-Content-Type-Options: 55 | - nosniff 56 | X-Frame-Options: 57 | - deny 58 | X-Permitted-Cross-Domain-Policies: 59 | - none 60 | X-Served-By: 61 | - cache-bwi5177-BWI, cache-lhr7332-LHR 62 | X-Timer: 63 | - S1636102460.677741,VS0,VE106 64 | X-XSS-Protection: 65 | - 1; mode=block 66 | status: 67 | code: 301 68 | message: Moved Permanently 69 | - request: 70 | body: null 71 | headers: 72 | Accept: 73 | - "*/*" 74 | Accept-Encoding: 75 | - gzip, deflate 76 | Connection: 77 | - keep-alive 78 | User-Agent: 79 | - python-requests/2.26.0 80 | method: GET 81 | uri: https://pypi.org/pypi/missing-package/json/ 82 | response: 83 | body: 84 | string: !!binary | 85 | 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 118 | /w39F4HK9rsxEgAA 119 | headers: 120 | Accept-Ranges: 121 | - bytes 122 | Connection: 123 | - keep-alive 124 | Content-Encoding: 125 | - gzip 126 | Content-Length: 127 | - "1893" 128 | Content-Security-Policy: 129 | - base-uri 'self'; block-all-mixed-content; connect-src 'self' https://api.github.com/repos/ 130 | *.fastly-insights.com sentry.io https://api.pwnedpasswords.com https://2p66nmmycsj3.statuspage.io; 131 | default-src 'none'; font-src 'self' fonts.gstatic.com; form-action 'self'; frame-ancestors 'none'; frame-src 132 | 'none' https://www.youtube-nocookie.com; img-src 'self' https://warehouse-camo.ingress.cmh1.psfhosted.org/ 133 | www.google-analytics.com *.fastly-insights.com; script-src 'self' www.googletagmanager.com 134 | www.google-analytics.com *.fastly-insights.com https://cdn.ravenjs.com https://www.youtube.com 135 | https://s.ytimg.com; style-src 'self' fonts.googleapis.com; worker-src *.fastly-insights.com 136 | Content-Type: 137 | - text/html; charset=UTF-8 138 | Date: 139 | - Fri, 05 Nov 2021 08:54:19 GMT 140 | ETag: 141 | - '"iC0+fkUPr9ioHXlCCoAlCw"' 142 | Referrer-Policy: 143 | - origin-when-cross-origin 144 | Server: 145 | - nginx/1.13.9 146 | Strict-Transport-Security: 147 | - max-age=31536000; includeSubDomains; preload 148 | Vary: 149 | - Accept-Encoding 150 | X-Cache: 151 | - MISS, MISS 152 | X-Cache-Hits: 153 | - 0, 0 154 | X-Content-Type-Options: 155 | - nosniff 156 | X-Frame-Options: 157 | - deny 158 | X-Permitted-Cross-Domain-Policies: 159 | - none 160 | X-Served-By: 161 | - cache-bwi5162-BWI, cache-lhr7332-LHR 162 | X-Timer: 163 | - S1636102460.800731,VS0,VE110 164 | X-XSS-Protection: 165 | - 1; mode=block 166 | status: 167 | code: 404 168 | message: Not Found 169 | - request: 170 | body: null 171 | headers: 172 | Accept: 173 | - "*/*" 174 | Accept-Encoding: 175 | - gzip, deflate 176 | Connection: 177 | - keep-alive 178 | User-Agent: 179 | - python-requests/2.26.0 180 | method: GET 181 | uri: https://pypi.org/simple/missing-package/ 182 | response: 183 | body: 184 | string: 404 Not Found 185 | headers: 186 | Accept-Ranges: 187 | - bytes 188 | Connection: 189 | - keep-alive 190 | Content-Length: 191 | - "13" 192 | Content-Security-Policy: 193 | - default-src 'none'; sandbox allow-top-navigation 194 | Content-Type: 195 | - text/plain; charset=UTF-8 196 | Date: 197 | - Fri, 05 Nov 2021 08:54:20 GMT 198 | Referrer-Policy: 199 | - origin-when-cross-origin 200 | Server: 201 | - nginx/1.13.9 202 | Strict-Transport-Security: 203 | - max-age=31536000; includeSubDomains; preload 204 | Vary: 205 | - Accept-Encoding 206 | X-Cache: 207 | - HIT, MISS 208 | X-Cache-Hits: 209 | - 1, 0 210 | X-Content-Type-Options: 211 | - nosniff 212 | X-Frame-Options: 213 | - deny 214 | X-Permitted-Cross-Domain-Policies: 215 | - none 216 | X-Served-By: 217 | - cache-bwi5166-BWI, cache-lhr7332-LHR 218 | X-Timer: 219 | - S1636102460.929001,VS0,VE80 220 | X-XSS-Protection: 221 | - 1; mode=block 222 | status: 223 | code: 404 224 | message: Not Found 225 | version: 1 226 | --------------------------------------------------------------------------------