├── .github └── workflows │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── LICENSE ├── README.md ├── pyproject.toml ├── requirements.txt ├── src ├── __init__.py └── req_update_check │ ├── __init__.py │ ├── cache.py │ ├── cli.py │ ├── core.py │ └── logging_config.py └── tests ├── __init__.py └── test_req_cheq.py /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Create Release 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | uses: softprops/action-gh-release@v1 17 | with: 18 | draft: false 19 | prerelease: false 20 | generate_release_notes: true 21 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Python Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install ruff pytest coverage coveralls 26 | pip install -e . 27 | - name: Check formatting with ruff 28 | run: | 29 | ruff check . 30 | ruff format --check . 31 | - name: Run tests with coverage 32 | run: | 33 | coverage run -m unittest discover 34 | coverage report 35 | coverage xml 36 | - name: Submit to Coveralls 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | run: coveralls --service=github 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | *.pyc 3 | .idea/ 4 | htmlcov/ 5 | .coverage 6 | dist/ 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | #exclude: '^docs/|/migrations/|devcontainer.json' 2 | default_stages: [commit] 3 | 4 | default_language_version: 5 | python: python3.11 6 | 7 | repos: 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v4.6.0 10 | hooks: 11 | - id: trailing-whitespace 12 | - id: end-of-file-fixer 13 | - id: check-json 14 | - id: check-toml 15 | - id: check-xml 16 | - id: check-yaml 17 | - id: debug-statements 18 | - id: check-builtin-literals 19 | - id: check-case-conflict 20 | - id: check-docstring-first 21 | - id: detect-private-key 22 | 23 | # Run the Ruff linter. 24 | - repo: https://github.com/astral-sh/ruff-pre-commit 25 | rev: v0.6.2 26 | hooks: 27 | # Linter 28 | - id: ruff 29 | args: [ --fix, --exit-non-zero-on-fix ] 30 | # Formatter 31 | - id: ruff-format 32 | 33 | # sets up .pre-commit-ci.yaml to ensure pre-commit dependencies stay up to date 34 | ci: 35 | autoupdate_schedule: weekly 36 | skip: [ ] 37 | submodules: false 38 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 Outside, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # req-update-check 2 | 3 | [![Tests](https://github.com/ontherivt/req-update-check/actions/workflows/tests.yml/badge.svg)](https://github.com/ontherivt/req-update-check/actions/workflows/tests.yml) 4 | [![Coverage Status](https://coveralls.io/repos/github/ontherivt/req-update-check/badge.svg?branch=main&t=unEUVF)](https://coveralls.io/github/ontherivt/req-update-check?branch=main) 5 | [![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff) 6 | 7 | 8 | A Python tool to check your requirements.txt file for package updates, with optional file caching for better performance. 9 | 10 | ## Features 11 | 12 | - Check for available updates in your requirements.txt file 13 | - Show update severity (major/minor/patch) 14 | - Display package homepages and changelogs when available 15 | - Optional file caching for faster repeated checks 16 | - Support for comments and inline comments in requirements.txt 17 | - Ignores pre-release versions (alpha, beta, release candidates) 18 | 19 | ## Installation 20 | 21 | Install from PyPI: 22 | 23 | ```bash 24 | pip install req-update-check 25 | ``` 26 | 27 | Or install from the repo directly: 28 | 29 | ```bash 30 | pip install git+https://github.com/ontherivt/req-update-check.git 31 | ``` 32 | 33 | Or install from source: 34 | 35 | ```bash 36 | git clone https://github.com/ontherivt/req-update-check.git 37 | cd req-update-check 38 | pip install -e . 39 | ``` 40 | 41 | ## Usage 42 | 43 | Basic usage: 44 | 45 | ```bash 46 | req-update-check requirements.txt 47 | ``` 48 | 49 | ### Command Line Options 50 | 51 | ```bash 52 | req-update-check [-h] [--no-cache] [--cache-dir CACHE_DIR] requirements_file 53 | ``` 54 | 55 | Arguments: 56 | - `requirements_file`: Path to your requirements.txt file 57 | 58 | Options: 59 | - `--no-cache`: Disable file caching 60 | - `--cache-dir`: Custom cache directory (default: `~/.req-update-check-cache`) 61 | 62 | ### Example Output 63 | 64 | ``` 65 | File caching enabled 66 | The following packages need to be updated: 67 | 68 | requests: 2.28.0 -> 2.31.0 [minor] 69 | Pypi page: https://pypi.python.org/project/requests/ 70 | Homepage: https://requests.readthedocs.io 71 | Changelog: https://requests.readthedocs.io/en/latest/community/updates/#release-history 72 | 73 | redis: 4.5.0 -> 5.0.1 [major] 74 | Pypi page: https://pypi.python.org/project/redis/ 75 | Homepage: https://github.com/redis/redis-py 76 | Changelog: https://github.com/redis/redis-py/blob/master/CHANGES 77 | ``` 78 | 79 | ### Using file Caching 80 | 81 | The tool supports file caching to improve performance when checking multiple times. You can configure the cache storage: 82 | 83 | ```bash 84 | req-update-check --cache-dir ~/.your-cache-dir requirements.txt 85 | ``` 86 | 87 | ## Requirements.txt Format 88 | 89 | The tool supports requirements.txt files with the following formats: 90 | ``` 91 | package==1.2.3 92 | package == 1.2.3 # with spaces 93 | package==1.2.3 # with inline comments 94 | # Full line comments 95 | ``` 96 | 97 | Note: Currently only supports exact version specifiers (`==`). Support for other specifiers (like `>=`, `~=`) is planned for future releases. 98 | 99 | ## Python API 100 | 101 | You can also use req-update-check as a Python library: 102 | 103 | ```python 104 | from req_update_check import Requirements 105 | 106 | # Without file cache 107 | req = Requirements('requirements.txt', allow_cache=False) 108 | req.check_packages() 109 | req.report() 110 | 111 | # With file cache defaults 112 | req = Requirements('requirements.txt') 113 | req.check_packages() 114 | req.report() 115 | ``` 116 | 117 | ## Development 118 | 119 | To set up for development: 120 | 121 | 1. Clone the repository 122 | 2. Create a virtual environment: `python -m venv venv` 123 | 3. Activate the virtual environment: `source venv/bin/activate` (Unix) or `venv\Scripts\activate` (Windows) 124 | 4. Install development dependencies: `pip install -e ".[dev]"` 125 | 126 | To run tests: 127 | 1. `python -m unittest` 128 | 129 | ## Contributing 130 | 131 | Contributions are welcome! Please feel free to submit a Pull Request. 132 | 133 | ## License 134 | 135 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 136 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling >= 1.26"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "req-update-check" 7 | version = "0.1.2" 8 | description = "Check Python package requirements for updates" 9 | readme = "README.md" 10 | requires-python = ">=3.8" 11 | license = "MIT" 12 | license-files = ["LICENSE"] 13 | authors = [ 14 | { name = "PJ Hoberman", email = "phoberman@outsideinc.com" } 15 | ] 16 | dependencies = [ 17 | "requests>=2.31.0" 18 | ] 19 | 20 | [project.urls] 21 | homepage = "https://github.com/ontherivt/req-update-check" 22 | issues = "https://github.com/ontherivt/req-update-check/issues" 23 | 24 | [project.scripts] 25 | req-update-check = "req_update_check.cli:main" 26 | 27 | [tool.hatch.build.targets.wheel] 28 | packages = ["src/req_update_check"] 29 | 30 | # ==== Coverage ==== 31 | [tool.coverage.run] 32 | include = ["src/**"] 33 | #omit = ["*/migrations/*", "*/tests/*"] 34 | #plugins = ["django_coverage_plugin"] 35 | 36 | [tool.ruff] 37 | line-length = 120 38 | 39 | [tool.ruff.lint] 40 | select = [ 41 | "F", 42 | "E", 43 | "W", 44 | "C90", 45 | "I", 46 | "N", 47 | "UP", 48 | "YTT", 49 | # "ANN", # flake8-annotations: we should support this in the future but 100+ errors atm 50 | "ASYNC", 51 | "S", 52 | "BLE", 53 | "FBT", 54 | "B", 55 | "A", 56 | "COM", 57 | "C4", 58 | "DTZ", 59 | "T10", 60 | "DJ", 61 | "EM", 62 | "EXE", 63 | "FA", 64 | 'ISC', 65 | "ICN", 66 | "G", 67 | 'INP', 68 | 'PIE', 69 | "T20", 70 | 'PYI', 71 | 'PT', 72 | "Q", 73 | "RSE", 74 | "RET", 75 | "SLF", 76 | "SLOT", 77 | "SIM", 78 | "TID", 79 | "TCH", 80 | "INT", 81 | # "ARG", # Unused function argument 82 | "PTH", 83 | "ERA", 84 | "PD", 85 | "PGH", 86 | "PL", 87 | "TRY", 88 | "FLY", 89 | # "NPY", 90 | # "AIR", 91 | "PERF", 92 | # "FURB", 93 | # "LOG", 94 | "RUF", 95 | ] 96 | ignore = [ 97 | "S101", # Use of assert detected https://docs.astral.sh/ruff/rules/assert/ 98 | "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` 99 | "SIM102", # sometimes it's better to nest 100 | "UP038", # Checks for uses of isinstance/issubclass that take a tuple 101 | # of types for comparison. 102 | # Deactivated because it can make the code slow: 103 | # https://github.com/astral-sh/ruff/issues/7871 104 | "DJ001", 105 | "PT009", 106 | "FBT001", 107 | "FBT002", 108 | "PTH123", 109 | "G004", # use of f-string in log statements 110 | ] 111 | # The fixes in extend-unsafe-fixes will require 112 | # provide the `--unsafe-fixes` flag when fixing. 113 | extend-unsafe-fixes = [ 114 | "UP038", 115 | ] 116 | 117 | [tool.ruff.lint.isort] 118 | force-single-line = true 119 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.32.3 2 | 3 | # dev + testing 4 | coverage 5 | ruff 6 | pre-commit 7 | coveralls 8 | build 9 | twine 10 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ontherivt/req-update-check/338fe7247eb5bbf21ba009752fe3664c2167d16f/src/__init__.py -------------------------------------------------------------------------------- /src/req_update_check/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.0" 2 | -------------------------------------------------------------------------------- /src/req_update_check/cache.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import time 5 | from pathlib import Path 6 | from typing import Any 7 | 8 | 9 | class FileCache: 10 | def __init__(self, cache_dir: str = ".req-check-cache", expiry: int = 3600): 11 | """Initialize file-based cache. 12 | 13 | Args: 14 | cache_dir: Directory to store cache files (default: .req-check-cache in user's home) 15 | expiry: Cache expiry time in seconds (default: 1 hour) 16 | """ 17 | self.cache_dir = Path.home() / cache_dir 18 | self.cache_dir.mkdir(exist_ok=True) 19 | self.expiry = expiry 20 | 21 | def get(self, key: str) -> Any | None: 22 | """Get value from cache if it exists and hasn't expired.""" 23 | cache_file = self.cache_dir / f"{key}.json" 24 | if cache_file.exists(): 25 | try: 26 | data = json.loads(cache_file.read_text()) 27 | if data["timestamp"] + self.expiry > time.time(): 28 | return data["value"] 29 | # Clean up expired cache 30 | cache_file.unlink(missing_ok=True) 31 | except (json.JSONDecodeError, KeyError): 32 | # Clean up invalid cache 33 | cache_file.unlink(missing_ok=True) 34 | return None 35 | 36 | def set(self, key: str, value: Any) -> None: 37 | """Save value to cache with current timestamp.""" 38 | cache_file = self.cache_dir / f"{key}.json" 39 | data = { 40 | "timestamp": time.time(), 41 | "value": value, 42 | } 43 | cache_file.write_text(json.dumps(data)) 44 | 45 | def clear(self) -> None: 46 | """Clear all cached data.""" 47 | for cache_file in self.cache_dir.glob("*.json"): 48 | cache_file.unlink(missing_ok=True) 49 | -------------------------------------------------------------------------------- /src/req_update_check/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import logging 5 | 6 | from .core import Requirements 7 | from .logging_config import setup_logging 8 | 9 | logger = logging.getLogger("req_update_check") 10 | 11 | 12 | def main(): 13 | setup_logging() 14 | parser = argparse.ArgumentParser( 15 | description="Check Python package requirements for updates.", 16 | ) 17 | parser.add_argument("requirements_file", help="Path to the requirements.txt file") 18 | parser.add_argument("--no-cache", action="store_true", help="Disable file caching") 19 | parser.add_argument( 20 | "--cache-dir", 21 | help="Custom cache directory (default: ~/.req-check-cache)", 22 | ) 23 | 24 | args = parser.parse_args() 25 | 26 | # Handle caching setup 27 | if not args.no_cache: 28 | logger.info("File caching enabled") 29 | 30 | req = Requirements( 31 | args.requirements_file, 32 | allow_cache=not args.no_cache, 33 | cache_dir=args.cache_dir, 34 | ) 35 | req.check_packages() 36 | req.report() 37 | 38 | 39 | if __name__ == "__main__": 40 | main() 41 | -------------------------------------------------------------------------------- /src/req_update_check/core.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import subprocess 5 | import sys 6 | from shutil import which 7 | 8 | import requests 9 | 10 | from .cache import FileCache 11 | 12 | logger = logging.getLogger("req_update_check") 13 | 14 | 15 | def get_pip_path(): 16 | pip_path = which("pip") 17 | if not pip_path: 18 | logger.error("pip executable not found") 19 | return None 20 | return pip_path 21 | 22 | 23 | class Requirements: 24 | pypi_index = "https://pypi.python.org/simple/" 25 | pypi_package_base = "https://pypi.python.org/project/" 26 | headers = {"Content-Type": "json", "Accept": "application/vnd.pypi.simple.v1+json"} 27 | 28 | def __init__( 29 | self, 30 | path: str, 31 | allow_cache: bool = True, 32 | cache_dir: str | None = None, 33 | ): 34 | self._index = False 35 | self._get_packages = False 36 | 37 | self.path = path 38 | self.packages = None 39 | self.package_index = set() 40 | self.allow_cache = allow_cache 41 | self.updates = [] 42 | cache_dir = cache_dir or ".req-check-cache" 43 | self.cache = FileCache(cache_dir) if allow_cache else None 44 | 45 | def get_index(self): 46 | if self._index: 47 | return 48 | self._index = True 49 | if self.allow_cache and self.cache: 50 | package_index = self.cache.get("package-index") 51 | if package_index: 52 | self.package_index = set(package_index) 53 | return 54 | 55 | res = requests.get(self.pypi_index, headers=self.headers, timeout=10) 56 | package_index = res.json()["projects"] 57 | for package in package_index: 58 | self.package_index.add(package["name"]) 59 | 60 | if self.cache: 61 | self.cache.set("package-index", list(self.package_index)) 62 | 63 | def get_packages(self): 64 | if self._get_packages: 65 | return None 66 | self._get_packages = True 67 | self.get_index() 68 | try: 69 | with open(self.path) as file: 70 | requirements = file.readlines() 71 | except FileNotFoundError: 72 | msg = f"File {self.path} not found." 73 | logger.info(msg) 74 | sys.exit(1) 75 | 76 | packages = [] 77 | for req in requirements: 78 | if req.startswith("#") or req in ["", "\n"]: 79 | continue 80 | # remove inline comments 81 | req_ = req.split("#")[0] 82 | packages.append(req_.strip().split("==")) 83 | 84 | self.packages = packages 85 | return packages 86 | 87 | def get_latest_version(self, package_name): 88 | if self.allow_cache and self.cache: 89 | latest_version = self.cache.get(f"package:{package_name}") 90 | if latest_version: 91 | return latest_version 92 | 93 | res = requests.get(f"{self.pypi_index}{package_name}/", headers=self.headers, timeout=10) 94 | versions = res.json()["versions"] 95 | # start from the end and find the first version that is not a pre-release 96 | for version in reversed(versions): 97 | if not any(x in version for x in ["a", "b", "rc"]): 98 | if self.cache: 99 | self.cache.set(f"package:{package_name}", version) 100 | return version 101 | return None 102 | 103 | def check_packages(self): 104 | self.get_packages() 105 | for package in self.packages: 106 | self.check_package(package) 107 | 108 | def check_package(self, package: list[str, str]): 109 | expected_length = 2 110 | if len(package) == expected_length: 111 | package_name, package_version = package 112 | else: 113 | return 114 | 115 | # check for optional dependencies 116 | if "[" in package_name: 117 | package_name, optional_deps = package_name.split("[") 118 | logger.info(f"Skipping optional packages '{optional_deps.replace(']', '')}' from {package_name}") 119 | 120 | # check if package is in the index 121 | if package_name not in self.package_index: 122 | msg = f"Package {package_name} not found in the index." 123 | logger.info(msg) 124 | return 125 | 126 | latest_version = self.get_latest_version(package_name) 127 | if latest_version != package_version: 128 | level = self.check_major_minor(package_version, latest_version) 129 | self.updates.append( 130 | (package_name, package_version, latest_version, level), 131 | ) 132 | 133 | def report(self): 134 | if not self.updates: 135 | logger.info("All packages are up to date.") 136 | return 137 | 138 | logger.info("The following packages need to be updated:\n") 139 | for package in self.updates: 140 | package_name, current_version, latest_version, level = package 141 | msg = f"{package_name}: {current_version} -> {latest_version} [{level}]" 142 | msg += f"\n\tPypi page: {self.pypi_package_base}{package_name}/" 143 | links = self.get_package_info(package_name) 144 | if links: 145 | if links.get("homepage"): 146 | msg += f"\n\tHomepage: {links['homepage']}" 147 | if links.get("changelog"): 148 | msg += f"\n\tChangelog: {links['changelog']}" 149 | msg += "\n" 150 | logger.info(msg) 151 | 152 | def get_package_info(self, package_name: str) -> dict: 153 | """Get package information using pip show command.""" 154 | if self.allow_cache and self.cache: 155 | info = self.cache.get(f"package-info:{package_name}") 156 | if info: 157 | return info 158 | pip_path = get_pip_path() 159 | if not pip_path: 160 | return {} 161 | try: 162 | # ruff: noqa: S603 163 | result = subprocess.run( 164 | [pip_path, "show", package_name, "--verbose"], 165 | capture_output=True, 166 | text=True, 167 | check=True, 168 | ) 169 | 170 | info = {} 171 | 172 | for line in result.stdout.split("\n"): 173 | if "Home-page: " in line: 174 | info["homepage"] = line.split(": ", 1)[1].strip() 175 | 176 | if "Changelog," in line or "change-log, " in line.lower(): 177 | info["changelog"] = line.split(", ", 1)[1].strip() 178 | 179 | if "Homepage, " in line: 180 | info["homepage"] = line.split(", ", 1)[1].strip() 181 | if self.cache: 182 | self.cache.set(f"package-info:{package_name}", info) 183 | else: 184 | return info 185 | except subprocess.CalledProcessError: 186 | return {} 187 | 188 | def check_major_minor(self, current_version, latest_version): 189 | current_major, current_minor, current_patch, *_ = current_version.split(".") + ["0"] * 3 190 | latest_major, latest_minor, latest_patch, *_ = latest_version.split(".") + ["0"] * 3 191 | 192 | if current_major != latest_major: 193 | return "major" 194 | if current_minor != latest_minor: 195 | return "minor" 196 | return "patch" 197 | -------------------------------------------------------------------------------- /src/req_update_check/logging_config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | 5 | def setup_logging(level=logging.INFO): 6 | logger = logging.getLogger("req_update_check") 7 | logger.setLevel(level) 8 | 9 | # Console handler 10 | console = logging.StreamHandler(sys.stdout) 11 | console.setLevel(level) 12 | formatter = logging.Formatter("%(message)s") 13 | console.setFormatter(formatter) 14 | logger.addHandler(console) 15 | 16 | return logger 17 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ontherivt/req-update-check/338fe7247eb5bbf21ba009752fe3664c2167d16f/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_req_cheq.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import mock_open 3 | from unittest.mock import patch 4 | 5 | from src.req_update_check.cache import FileCache 6 | from src.req_update_check.cli import main 7 | from src.req_update_check.core import Requirements 8 | 9 | 10 | class TestFileCache(unittest.TestCase): 11 | def setUp(self): 12 | self.cache = FileCache(cache_dir=".test-cache") 13 | self.test_key = "test-key" 14 | self.test_value = {"data": "test"} 15 | 16 | def tearDown(self): 17 | self.cache.clear() 18 | 19 | def test_set_and_get(self): 20 | self.cache.set(self.test_key, self.test_value) 21 | result = self.cache.get(self.test_key) 22 | self.assertEqual(result, self.test_value) 23 | 24 | def test_expired_cache(self): 25 | with patch("time.time", return_value=100): 26 | self.cache.set(self.test_key, self.test_value) 27 | 28 | with patch("time.time", return_value=5000): 29 | result = self.cache.get(self.test_key) 30 | self.assertIsNone(result) 31 | 32 | def test_invalid_cache(self): 33 | cache_file = self.cache.cache_dir / f"{self.test_key}.json" 34 | cache_file.write_text("invalid json") 35 | result = self.cache.get(self.test_key) 36 | self.assertIsNone(result) 37 | 38 | 39 | class TestRequirements(unittest.TestCase): 40 | def setUp(self): 41 | self.req_content = """ 42 | requests==2.26.0 43 | flask==2.0.1 44 | # comment line 45 | pytest==6.2.4 # inline comment 46 | """ 47 | self.mock_index = { 48 | "projects": [ 49 | {"name": "requests"}, 50 | {"name": "flask"}, 51 | {"name": "pytest"}, 52 | ], 53 | } 54 | self.mock_versions = { 55 | "versions": ["2.26.0", "2.27.0", "2.28.0"], 56 | } 57 | 58 | self.requirements = Requirements("requirements.txt", allow_cache=False) 59 | 60 | @patch.object(Requirements, "get_index") 61 | @patch("builtins.open", new_callable=mock_open) 62 | def test_get_packages(self, mock_file, mock_get_index): 63 | mock_file.return_value.readlines.return_value = self.req_content.split("\n") 64 | req = Requirements("requirements.txt", allow_cache=False) 65 | req.check_packages() 66 | expected = [ 67 | ["requests", "2.26.0"], 68 | ["flask", "2.0.1"], 69 | ["pytest", "6.2.4"], 70 | ] 71 | self.assertEqual(req.packages, expected) 72 | 73 | @patch("requests.get") 74 | def test_get_index(self, mock_get): 75 | mock_get.return_value.json.side_effect = [self.mock_index] + [self.mock_versions] * 3 76 | with patch("builtins.open", new_callable=mock_open) as mock_file: 77 | mock_file.return_value.readlines.return_value = self.req_content.split("\n") 78 | req = Requirements("requirements.txt", allow_cache=False) 79 | req.check_packages() 80 | self.assertEqual(req.package_index, {"requests", "flask", "pytest"}) 81 | 82 | @patch.object(Requirements, "get_index") 83 | @patch("requests.get") 84 | def test_get_latest_version(self, mock_get, mock_get_index): 85 | mock_get.return_value.json.return_value = self.mock_versions 86 | with patch("builtins.open", new_callable=mock_open) as mock_file: 87 | mock_file.return_value.readlines.return_value = self.req_content.split("\n") 88 | req = Requirements("requirements.txt", allow_cache=False) 89 | latest = req.get_latest_version("requests") 90 | self.assertEqual(latest, "2.28.0") 91 | 92 | @patch.object(Requirements, "get_index") 93 | def test_check_major_minor(self, mock_get_index): 94 | with patch("builtins.open", new_callable=mock_open) as mock_file: 95 | mock_file.return_value.readlines.return_value = self.req_content.split("\n") 96 | req = Requirements("requirements.txt", allow_cache=False) 97 | 98 | self.assertEqual(req.check_major_minor("1.0.0", "2.0.0"), "major") 99 | self.assertEqual(req.check_major_minor("1.0.0", "1.1.0"), "minor") 100 | self.assertEqual(req.check_major_minor("1.0.0", "1.0.1"), "patch") 101 | 102 | def test_optional_dependencies(self): 103 | package = ["psycopg2[binary]", "2.9.1"] 104 | with self.assertLogs("req_update_check", level="INFO") as cm: 105 | self.requirements.check_package(package) 106 | self.assertIn("Skipping optional packages 'binary' from psycopg2", cm.output[0]) 107 | 108 | package = ["psycopg2", "2.9.1"] 109 | with self.assertLogs("req_update_check", level="INFO") as cm: 110 | self.requirements.check_package(package) 111 | 112 | self.assertNotIn("Skipping optional packages", cm.output[0]) 113 | 114 | 115 | class TestCLI(unittest.TestCase): 116 | def setUp(self): 117 | self.req_content = "requests==2.26.0\nflask==2.0.1\n" 118 | self.requirements_file = "requirements.txt" 119 | 120 | @patch("sys.argv", ["req-check", "requirements.txt"]) 121 | @patch("builtins.print") 122 | @patch("src.req_update_check.cli.Requirements") 123 | def test_main_default_args(self, mock_requirements, mock_print): 124 | mock_instance = mock_requirements.return_value 125 | main() 126 | mock_requirements.assert_called_with( 127 | "requirements.txt", 128 | allow_cache=True, 129 | cache_dir=None, 130 | ) 131 | mock_instance.check_packages.assert_called_once() 132 | mock_instance.report.assert_called_once() 133 | 134 | @patch("sys.argv", ["req-check", "requirements.txt", "--no-cache"]) 135 | @patch("builtins.print") 136 | @patch("src.req_update_check.cli.Requirements") 137 | def test_main_no_cache(self, mock_requirements, mock_print): 138 | main() 139 | mock_requirements.assert_called_with( 140 | "requirements.txt", 141 | allow_cache=False, 142 | cache_dir=None, 143 | ) 144 | 145 | @patch( 146 | "sys.argv", 147 | ["req-check", "requirements.txt", "--cache-dir", "/custom/cache"], 148 | ) 149 | @patch("builtins.print") 150 | @patch("src.req_update_check.cli.Requirements") 151 | def test_main_custom_cache_dir(self, mock_requirements, mock_print): 152 | main() 153 | mock_requirements.assert_called_with( 154 | "requirements.txt", 155 | allow_cache=True, 156 | cache_dir="/custom/cache", 157 | ) 158 | 159 | 160 | if __name__ == "__main__": 161 | unittest.main() 162 | --------------------------------------------------------------------------------