├── .github └── workflows │ └── python-publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── pip_upgrade ├── __init__.py ├── dependencies_base.py ├── dev.py ├── main.py ├── packages │ ├── __init__.py │ ├── package.py │ └── package_set.py ├── repositories │ └── pypi_repository.py ├── solver │ └── solver.py ├── store.py ├── tool.py ├── tools │ ├── __init__.py │ ├── clear_cache.py │ ├── config.py │ └── cprint.py └── version_checker.py ├── pyproject.toml └── test └── run_test.py /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | git-repos 3 | colab/ 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | pip-wheel-metadata/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 99 | __pypackages__/ 100 | 101 | # Celery stuff 102 | celerybeat-schedule 103 | celerybeat.pid 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | .env 110 | .venv 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Onur Cetinkol 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pip-upgrade 🎉 2 | The purpose of pip-upgrade is to be a simple yet robust and reliable tool for upgrading all of your packages while not breaking dependencies. 3 | 4 | ## Installation 5 | 6 | pip install pip-upgrade-tool 7 | 8 | or 9 | 10 | pip install git+https://github.com/realiti4/pip-upgrade.git@master --upgrade 11 | 12 | ## Usage 13 | Just run `pip-upgrade` in your terminal while virtualenv is active. 14 | 15 | $ pip-upgrade 16 | 17 | ``` 18 | Checking outdated packages... 19 | These packages will be upgraded: ['colorama', 'isort'] 20 | Continue? (y/n): y 21 | ... 22 | All packages are up to date! 🎉 23 | ``` 24 | 25 | **Tip:** You can use `-e` to exclude some packages and continue in `Continue? (y/n):` after seeing which packages are going to be upgraded. This is super useful for packages like gohlke's Numpy+mkl for example. `-r` to repeat the previous excluded packages. It'll show if there is a saved repeatable action. 26 | ``` 27 | These packages will be upgraded: ['hypothesis', 'Pillow', 'pytest', 'setuptools'] 28 | (-r, --repeat : -e pytest) 29 | Continue? (y/n or -e/-r/--help): -e pytest hypothesis 30 | ``` 31 | 32 | You can also exclude packages beforehand. Use `-e` or `--exclude`. The tool won't upgrade dependency breaking packages already, this is extra for packages that you want to keep it at a version. You can also add packages to config file for this to persist until you remove. This combined with `pip-upgrade -y` that accepts and skips user prompt can be used for automated environments. 33 | 34 | $ pip-upgrade -e numpy pandas 35 | ### Options 36 | - `pip-upgrade -e` Exclude packages you don't want to upgrade. Can take multiple or single value. 37 | - `pip-upgrade -y` Accept all upgrades and skip user prompt. 38 | - `pip-upgrade --clean` Clear pip's cache. 39 | - `pip-upgrade --local` By default locally installed editable packages (installed with `pip install -e .`) won't be upgraded. Use this option to upgrade everything. 40 | - `pip-upgrade --novenv` By default the tool won't work if virtualenv is not active. Use this if you want use it globally and pass the assertion error. 41 | - `pip-upgrade --reset-config` Reset config file located in `~/.pipupgrade.ini` to it's default. 42 | 43 | ### Permanent Configuration 44 | When `pip-upgrade` is run for the first time, it will create a file in the user's home directory named `.pipupgrade.ini`. This file can be manually edited by the user for permanent configuration options. The configuration file current consists of two options under the `conf` section, `exclude` and `novenv`. `novenv` is false by default, but if set to true, the `pip-upgrade` command will not require you to be in a virtualenv, which is the same function as the `--novenv` argument. The second option, `exclude`, will take the same values as the `--exclude` argument, but these excluded packages will persist forever until removed. 45 | 46 | ### Contributing 47 | Any contribution is appreciated, please feel free to send pull requests. 48 | 49 | -------------------------------------------------------------------------------- /pip_upgrade/__init__.py: -------------------------------------------------------------------------------- 1 | from pip_upgrade.main import main 2 | -------------------------------------------------------------------------------- /pip_upgrade/dependencies_base.py: -------------------------------------------------------------------------------- 1 | from pip._vendor import pkg_resources 2 | from pip_upgrade.version_checker import version_check, not_equal_check 3 | from pip_upgrade.store import Store 4 | 5 | 6 | class DependenciesBase: 7 | def __init__(self): 8 | self.self_check = False 9 | 10 | self.packages = [dist.project_name for dist in pkg_resources.working_set] 11 | self.be_upgraded = {} 12 | self.wont_upgrade = {} 13 | 14 | self.dict = self.create_dict(self.packages) 15 | self.outdated = None 16 | 17 | def create_dict(self, packages): 18 | """ 19 | Creates a dict of Stores class for packages 20 | """ 21 | return {x: Store(x) for x in packages} 22 | 23 | def get_dependencies(self): 24 | """ 25 | The main func for getting dependencies and comparing them to output a final list 26 | """ 27 | 28 | self.retrieve_dependencies() 29 | 30 | for pkg_dict in self.outdated: 31 | pkg_name = pkg_dict["name"] 32 | current_version = pkg_dict["version"] 33 | latest_version = pkg_dict["latest_version"] 34 | 35 | try: 36 | pkg_store = self.dict[pkg_name] 37 | except: 38 | try: 39 | pkg_store = self.dict[pkg_name.lower()] 40 | except Exception as e: 41 | if "_" in pkg_name: # Fix for '_' 42 | pkg_name = pkg_name.replace("_", "-") 43 | try: 44 | pkg_store = self.dict[pkg_name] 45 | except Exception as e: 46 | raise e 47 | 48 | pkg_store.current_version = current_version 49 | pkg_store.latest_version = latest_version 50 | 51 | self.compare_deps(pkg_store) 52 | 53 | def compare_deps(self, pkg_store): 54 | """ 55 | Compares dependencies in a list and decides what packages' final version should be 56 | """ 57 | result = [] 58 | done = False 59 | 60 | for key in ["==", "~=", "<", "<="]: 61 | if len(pkg_store.data[key]) > 0: 62 | result = [key, min(pkg_store.data[key])] 63 | done = True 64 | break 65 | if not done: 66 | for key in [">", ">="]: 67 | if len(pkg_store.data[key]) > 0: 68 | result = [key, max(pkg_store.data[key])] 69 | done = True 70 | break 71 | # Nonequal Check 72 | for item in pkg_store.data["!="]: 73 | not_equal_check(item, pkg_store.latest_version) 74 | result = ["!=", pkg_store.latest_version] 75 | 76 | if version_check(result, pkg_store.latest_version): 77 | self.be_upgraded[pkg_store.name] = result 78 | 79 | def retrieve_dependencies(self): 80 | """ 81 | Retrieves dependencies pkg_main requires, and puts all dependent packages in self.dict with their version. 82 | """ 83 | for pkg_main in self.packages: 84 | try: 85 | dep_list = pkg_resources.working_set.by_key[pkg_main].requires() 86 | except: 87 | dep_list = pkg_resources.working_set.by_key[pkg_main.lower()].requires() 88 | 89 | for i in dep_list: 90 | name = i.name # Name of dependency 91 | specs = i.specs # Specs of dependency 92 | 93 | if len(specs) != 0: 94 | try: 95 | self.dict[name] += specs 96 | except: 97 | for key in self.dict: 98 | if key.lower() == name.lower(): 99 | name = key 100 | try: 101 | self.dict[name] += specs 102 | except Exception as e: 103 | if "_" in name: # Fix for '_' 104 | name = name.replace("_", "-") 105 | try: 106 | self.dict[name] += specs 107 | except Exception as e: 108 | # raise e 109 | print( 110 | f"Skipping {name}, warning: Name mismatch. This will be improved. Manually upgrade if needed" 111 | ) 112 | 113 | def check_name_in_dict(self): 114 | """ 115 | Case and '_', '-' checks. Returns the true name on dict 116 | """ 117 | return 118 | -------------------------------------------------------------------------------- /pip_upgrade/dev.py: -------------------------------------------------------------------------------- 1 | from pkg_resources import working_set 2 | 3 | from pip_upgrade.packages import Package, PackageSet 4 | from pip_upgrade.repositories.pypi_repository import PyPiRepository 5 | 6 | from poetry.console.commands.update import UpdateCommand 7 | from packaging.utils import canonicalize_name 8 | 9 | 10 | def get_installed_packages() -> list[Package]: 11 | installed_packages = PackageSet.from_working_set(working_set) 12 | 13 | installed_packages = [i for i in working_set] 14 | 15 | print("debug") 16 | 17 | installed_packages = [ 18 | Package( 19 | package.project_name, 20 | package.version, 21 | package.location, 22 | package.key, 23 | package.precedence, 24 | ) 25 | for package in installed_packages 26 | ] 27 | 28 | return installed_packages 29 | 30 | 31 | def main() -> None: 32 | repo = PyPiRepository() 33 | 34 | installed_packages = get_installed_packages() 35 | 36 | print("debug") 37 | 38 | # package_info = repo.get_package_info("matplotlib") 39 | # package_info = repo._find_packages("matplotlib", "1.1") 40 | 41 | # last_version = package_info["versions"][-1] 42 | 43 | # # we can get required_dist from here 44 | # test = repo._get_release_info("matplotlib", last_version) 45 | 46 | print("Done") 47 | 48 | 49 | if __name__ == "__main__": 50 | main() 51 | -------------------------------------------------------------------------------- /pip_upgrade/main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import argparse 3 | import logging 4 | 5 | from pip_upgrade.tool import PipUpgrade 6 | from pip_upgrade.tools import Config, cprint, clear_cache 7 | 8 | parser = argparse.ArgumentParser() 9 | parser.add_argument('-e', '--exclude', nargs='+', help="Exclude packages you don't want to upgrade") 10 | parser.add_argument('--local', action='store_true', help="Upgrades local packages as well") 11 | parser.add_argument('--novenv', action='store_true', help="Disables venv check") 12 | parser.add_argument('--clear', action='store_true', help="Clears pip's cache") # Deprecated 13 | parser.add_argument('--clean', action='store_true', help="Clears pip's cache") 14 | parser.add_argument('-y', '--yes', action='store_true', help="Accept all upgrades and skip user prompt") 15 | parser.add_argument('--reset-config', action='store_true', help='Reset config file to default') 16 | parser.add_argument('--dev', action='store_true', help="Doesn't actually call upgrade at the end") 17 | parser.add_argument('-q', '--query', help="Query package dependency info from pypi") 18 | 19 | args = parser.parse_args() 20 | 21 | 22 | 23 | def check_venv(config): 24 | """ 25 | Checks if virtualenv is active, throws an asssertion error if not 26 | """ 27 | if not args.novenv and config['conf']['novenv'] == 'false': 28 | assert not sys.prefix == sys.base_prefix, 'Please use pip-upgrade in a virtualenv. If you would like to surpass this use pip-upgrade --novenv' 29 | 30 | def main(dev=False): 31 | config = Config() 32 | 33 | if dev: 34 | print('Developer Mode') 35 | args.dev = True 36 | 37 | if args.reset_config: 38 | config._reset() 39 | sys.exit() 40 | 41 | check_venv(config) 42 | 43 | if args.clear or args.clean: 44 | return clear_cache() 45 | 46 | pip_upgrade = PipUpgrade(args, config) 47 | 48 | try: 49 | pip_upgrade.get_dependencies() 50 | pip_upgrade.upgrade() 51 | except BaseException: 52 | logging.exception("An exception was thrown!") 53 | 54 | # Print upgrade info if there available upgrades after an exception 55 | if pip_upgrade.self_check: 56 | cprint("\nThere is an upgrade for pip-upgrade-tool!", color='green') 57 | cprint("Please first manually upgrade the tool using 'python -m pip install -U pip-upgrade-tool'\nIf this doesn't fix your issue, please consider opening an issue at https://github.com/realiti4/pip-upgrade", disabled=True) 58 | 59 | if __name__ == "__main__": 60 | main(dev=False) 61 | -------------------------------------------------------------------------------- /pip_upgrade/packages/__init__.py: -------------------------------------------------------------------------------- 1 | from pip_upgrade.packages.package_set import PackageSet 2 | from pip_upgrade.packages.package import Package -------------------------------------------------------------------------------- /pip_upgrade/packages/package.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Self 2 | 3 | from packaging.utils import canonicalize_name 4 | 5 | 6 | class Package: 7 | def __init__(self, name: str, version: str, location: str, key: str, precedence: int) -> None: 8 | self.name: str = name 9 | self.version: str = version 10 | self.location: str = location 11 | self.key: str = key 12 | self.precedence: int = precedence 13 | 14 | def __str__(self) -> str: 15 | return f"{self.name}=={self.version}" 16 | 17 | def __repr__(self) -> str: 18 | return f"Package({self.name!r}, {self.version!r})" 19 | 20 | def __eq__(self, other: Any) -> bool: 21 | if not isinstance(other, Package): 22 | return NotImplemented 23 | return self.name == other.name and self.version == other.version 24 | 25 | def __hash__(self) -> int: 26 | return hash((self.name, self.version)) 27 | 28 | @classmethod 29 | def from_string(cls, string: str) -> Self: 30 | name, version = string.split("==") 31 | return cls(name, version) 32 | -------------------------------------------------------------------------------- /pip_upgrade/packages/package_set.py: -------------------------------------------------------------------------------- 1 | 2 | from typing import Self 3 | 4 | from .package import Package 5 | 6 | 7 | class PackageSet: 8 | def __init__(self, packages: list[Package]): 9 | self.working_set = packages 10 | 11 | @classmethod 12 | def from_working_set(cls, working_set) -> Self: 13 | installed_packages = [ 14 | Package( 15 | package.project_name, 16 | package.version, 17 | package.location, 18 | package.key, 19 | package.precedence, 20 | ) 21 | for package in working_set 22 | ] 23 | 24 | return cls(installed_packages) 25 | 26 | def __iter__(self): 27 | return iter(self.working_set) 28 | 29 | def __len__(self): 30 | return len(self.working_set) 31 | 32 | # def __getitem__(self, name): 33 | # return self.working_set[name] 34 | 35 | # def get_by_key(self, key): 36 | # return self._package_by_key[key] 37 | 38 | # def get_by_filename(self, filename): 39 | # return self._package_by_file[filename] 40 | 41 | # def keys(self): 42 | # return self.working_set.keys() 43 | 44 | # def items(self): 45 | # return self.working_set.items() 46 | 47 | # def values(self): 48 | # return self.working_set.values() 49 | 50 | # def __contains__(self, name): 51 | # return name in self.working_set 52 | 53 | # def __eq__(self, other): 54 | # return self.working_set == other._package_by_name 55 | 56 | # def __ne__(self, other): 57 | # return self.working_set != other._package_by_name 58 | 59 | # def difference(self, other): 60 | # return PackageSet(set(self.values()) - set(other.values())) 61 | 62 | # def intersection(self, other): 63 | # return PackageSet(set(self.values()) & set(other.values())) 64 | 65 | # def union(self, other): 66 | # return PackageSet(set(self.values()) | set(other.values())) 67 | 68 | # def __repr__(self): 69 | # return "".format(self.working_set) 70 | 71 | # def __hash__(self): 72 | # return hash(self.working_set) 73 | -------------------------------------------------------------------------------- /pip_upgrade/repositories/pypi_repository.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018-present Sébastien Eustace 2 | # Source: Poetry (https://github.com/python-poetry/poetry) 3 | # Modified from the original 4 | 5 | from __future__ import annotations 6 | 7 | import logging 8 | 9 | from typing import TYPE_CHECKING 10 | from typing import Any 11 | 12 | import requests 13 | import requests.adapters 14 | 15 | from cachecontrol.controller import logger as cache_control_logger 16 | from poetry.core.packages.package import Package 17 | from poetry.core.packages.utils.link import Link 18 | from poetry.core.version.exceptions import InvalidVersion 19 | 20 | from poetry.repositories.exceptions import PackageNotFound 21 | from poetry.repositories.http_repository import HTTPRepository 22 | from poetry.repositories.link_sources.json import SimpleJsonPage 23 | from poetry.repositories.parsers.pypi_search_parser import SearchResultParser 24 | from poetry.utils.constants import REQUESTS_TIMEOUT 25 | 26 | 27 | cache_control_logger.setLevel(logging.ERROR) 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | if TYPE_CHECKING: 32 | from packaging.utils import NormalizedName 33 | from poetry.core.constraints.version import Version 34 | from poetry.core.constraints.version import VersionConstraint 35 | 36 | SUPPORTED_PACKAGE_TYPES = {"sdist", "bdist_wheel"} 37 | 38 | 39 | class PyPiRepository(HTTPRepository): 40 | def __init__( 41 | self, 42 | url: str = "https://pypi.org/", 43 | disable_cache: bool = False, 44 | fallback: bool = True, 45 | pool_size: int = requests.adapters.DEFAULT_POOLSIZE, 46 | ) -> None: 47 | super().__init__( 48 | "PyPI", 49 | url.rstrip("/") + "/simple/", 50 | disable_cache=disable_cache, 51 | pool_size=pool_size, 52 | ) 53 | 54 | self._base_url = url 55 | self._fallback = fallback 56 | 57 | def search(self, query: str) -> list[Package]: 58 | results = [] 59 | 60 | response = requests.get( 61 | self._base_url + "search", params={"q": query}, timeout=REQUESTS_TIMEOUT 62 | ) 63 | parser = SearchResultParser() 64 | parser.feed(response.text) 65 | 66 | for result in parser.results: 67 | try: 68 | package = Package(result.name, result.version) 69 | package.description = result.description.strip() 70 | results.append(package) 71 | except InvalidVersion: 72 | self._log( 73 | f'Unable to parse version "{result.version}" for the' 74 | f" {result.name} package, skipping", 75 | level="debug", 76 | ) 77 | 78 | return results 79 | 80 | def get_package_info(self, name: NormalizedName) -> dict[str, Any]: 81 | """ 82 | Return the package information given its name. 83 | 84 | The information is returned from the cache if it exists 85 | or retrieved from the remote server. 86 | """ 87 | return self._get_package_info(name) 88 | 89 | def _find_packages( 90 | self, name: NormalizedName, constraint: VersionConstraint 91 | ) -> list[Package]: 92 | """ 93 | Find packages on the remote server. 94 | """ 95 | try: 96 | json_page = self.get_page(name) 97 | except PackageNotFound: 98 | self._log(f"No packages found for {name}", level="debug") 99 | return [] 100 | 101 | versions = [ 102 | (version, json_page.yanked(name, version)) 103 | for version in json_page.versions(name) 104 | if constraint.allows(version) 105 | ] 106 | 107 | return [Package(name, version, yanked=yanked) for version, yanked in versions] 108 | 109 | def _get_package_info(self, name: NormalizedName) -> dict[str, Any]: 110 | headers = {"Accept": "application/vnd.pypi.simple.v1+json"} 111 | info = self._get(f"simple/{name}/", headers=headers) 112 | if info is None: 113 | raise PackageNotFound(f"Package [{name}] not found.") 114 | 115 | return info 116 | 117 | def find_links_for_package(self, package: Package) -> list[Link]: 118 | json_data = self._get(f"pypi/{package.name}/{package.version}/json") 119 | if json_data is None: 120 | return [] 121 | 122 | links = [] 123 | for url in json_data["urls"]: 124 | if url["packagetype"] in SUPPORTED_PACKAGE_TYPES: 125 | h = f"sha256={url['digests']['sha256']}" 126 | links.append(Link(url["url"] + "#" + h, yanked=self._get_yanked(url))) 127 | 128 | return links 129 | 130 | def _get_release_info( 131 | self, name: NormalizedName, version: Version 132 | ) -> dict[str, Any]: 133 | from poetry.inspection.info import PackageInfo 134 | 135 | self._log(f"Getting info for {name} ({version}) from PyPI", "debug") 136 | 137 | json_data = self._get(f"pypi/{name}/{version}/json") 138 | if json_data is None: 139 | raise PackageNotFound(f"Package [{name}] not found.") 140 | 141 | info = json_data["info"] 142 | 143 | data = PackageInfo( 144 | name=info["name"], 145 | version=info["version"], 146 | summary=info["summary"], 147 | requires_dist=info["requires_dist"], 148 | requires_python=info["requires_python"], 149 | yanked=self._get_yanked(info), 150 | cache_version=str(self.CACHE_VERSION), 151 | ) 152 | 153 | try: 154 | version_info = json_data["urls"] 155 | except KeyError: 156 | version_info = [] 157 | 158 | files = info.get("files", []) 159 | for file_info in version_info: 160 | if file_info["packagetype"] in SUPPORTED_PACKAGE_TYPES: 161 | files.append( 162 | { 163 | "file": file_info["filename"], 164 | "hash": "sha256:" + file_info["digests"]["sha256"], 165 | } 166 | ) 167 | data.files = files 168 | 169 | if self._fallback and data.requires_dist is None: 170 | self._log( 171 | "No dependencies found, downloading metadata and/or archives", 172 | level="debug", 173 | ) 174 | # No dependencies set (along with other information) 175 | # This might be due to actually no dependencies 176 | # or badly set metadata when uploading. 177 | # So, we need to make sure there is actually no 178 | # dependencies by introspecting packages. 179 | page = self.get_page(name) 180 | links = list(page.links_for_version(name, version)) 181 | info = self._get_info_from_links(links) 182 | 183 | data.requires_dist = info.requires_dist 184 | 185 | if not data.requires_python: 186 | data.requires_python = info.requires_python 187 | 188 | return data.asdict() 189 | 190 | def _get_page(self, name: NormalizedName) -> SimpleJsonPage: 191 | source = self._base_url + f"simple/{name}/" 192 | info = self.get_package_info(name) 193 | return SimpleJsonPage(source, info) 194 | 195 | def _get( 196 | self, endpoint: str, headers: dict[str, str] | None = None 197 | ) -> dict[str, Any] | None: 198 | try: 199 | json_response = self.session.get( 200 | self._base_url + endpoint, 201 | raise_for_status=False, 202 | timeout=REQUESTS_TIMEOUT, 203 | headers=headers, 204 | ) 205 | except requests.exceptions.TooManyRedirects: 206 | # Cache control redirect loop. 207 | # We try to remove the cache and try again 208 | self.session.delete_cache(self._base_url + endpoint) 209 | json_response = self.session.get( 210 | self._base_url + endpoint, 211 | raise_for_status=False, 212 | timeout=REQUESTS_TIMEOUT, 213 | headers=headers, 214 | ) 215 | 216 | if json_response.status_code != 200: 217 | return None 218 | 219 | json: dict[str, Any] = json_response.json() 220 | return json 221 | 222 | @staticmethod 223 | def _get_yanked(json_data: dict[str, Any]) -> str | bool: 224 | if json_data.get("yanked", False): 225 | return json_data.get("yanked_reason") or True 226 | return False -------------------------------------------------------------------------------- /pip_upgrade/solver/solver.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/realiti4/pip-upgrade/b9861a5f15426ce50b915dd52e9b2598ba422757/pip_upgrade/solver/solver.py -------------------------------------------------------------------------------- /pip_upgrade/store.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from packaging import version 3 | 4 | 5 | class Store: 6 | def __init__(self, pkg_name: str, current: str = None, latest: str = None): 7 | self.name = pkg_name 8 | self.current = current 9 | self.latest = latest 10 | 11 | self.data = {"==": [], "~=": [], "<": [], "<=": [], ">": [], ">=": [], "!=": []} 12 | 13 | self.ready = False 14 | 15 | def __iadd__(self, item): 16 | self.add(item) 17 | return self 18 | 19 | def __getitem__(self, key): 20 | return self.data[key] 21 | 22 | def __setitem__(self, key, item): 23 | assert key in self.data, "Item is not in keys" 24 | self.data[key] = item 25 | 26 | def __len__(self): 27 | return sum([len(self.data[key]) for key in self.data]) 28 | 29 | def __repr__(self): 30 | output = "" 31 | for key in self.data: 32 | output += key + ":\n" + " " + str(self.data[key]) + "\n" 33 | return output 34 | 35 | def type_check(self, sign): 36 | """ 37 | Returns a function to compare version 38 | """ 39 | if sign in ["==", "~=", "<", "<="]: 40 | group = "less" 41 | return min, group 42 | elif sign in [">", ">="]: 43 | group = "greater" 44 | return max, group 45 | else: 46 | group = "nonequal" 47 | return None, group 48 | 49 | def add(self, item): 50 | assert isinstance(item, list) 51 | assert len(item[0]) == 2, "It only accepts list of (sign, version)" 52 | for sub_item in item: 53 | sign_, version_ = sub_item 54 | self.data[sign_].append(version_) 55 | 56 | def add__(self, deps): 57 | """ 58 | Adds value to store while applying min or max 59 | """ 60 | assert isinstance(deps, list), "It takes list of deps [(sign, version), ..]" 61 | 62 | for _sign, _version in deps: 63 | compare, group = self.type_check(_sign) 64 | _version = version.parse(_version) 65 | 66 | self.data[group]["sign"].append(_sign) 67 | self.data[group]["data"].append(_version) 68 | 69 | # if compare is None: # Nonequal 70 | # self.data[group]['sign'] = '!=' 71 | # self.data[group]['data'] += _version 72 | # else: 73 | # self.data[group]['sign'] = '!=' 74 | 75 | print("de") 76 | 77 | def get(self): 78 | """ 79 | Runs compare_ and gets final data. It has to be called after all adding is done. 80 | """ 81 | if not self.ready: # Disabled, always prepare the data for now 82 | self.prepare_() 83 | return self.get_data 84 | 85 | def prepare_(self): 86 | self.get_data = deepcopy(self.data) 87 | for key in ["==", "~=", "<", "<="]: 88 | self.get_data[key] = [min(self.get_data[key])] 89 | for key in [">", ">="]: 90 | self.get_data[key] = [max(self.get_data[key])] 91 | 92 | def compare_(self): 93 | """ 94 | Compares versions when all items are added 95 | """ 96 | print("Debug: running compare..") 97 | for key in self.data: 98 | sign = self.data[key]["sign"] 99 | data = self.data[key]["data"] 100 | func = self.data[key]["compare_func"] 101 | if not len(data) == 0 and func is not None: 102 | index = data.index(func(data)) 103 | self.data[key]["sign"] = sign[index] 104 | self.data[key]["data"] = data[index] 105 | # self.ready = True 106 | -------------------------------------------------------------------------------- /pip_upgrade/tool.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | import subprocess 5 | 6 | from pip_upgrade.dependencies_base import DependenciesBase 7 | from pip_upgrade.tools import cprint 8 | 9 | 10 | class PipUpgrade(DependenciesBase): 11 | def __init__(self, args, config): 12 | super(PipUpgrade, self).__init__() 13 | self.args = args 14 | self.config = config 15 | self.colored = "\033[32m" if config["conf"]["disable_colors"] == "false" else "\033[m" 16 | self.restorable = False 17 | if "restore" in config.config: 18 | if len(config["restore"]["last_exclude"]) != 0: 19 | self.restorable = True 20 | 21 | # Upgrade if it is first run 22 | if config.first_run: 23 | self._upgrade_pip() 24 | 25 | # Exclude editable and user defined packages 26 | self.excluded_pkgs = [] if self.args.exclude is None else self.args.exclude 27 | self.excluded_pkgs += self.config["conf"]["exclude"].split(" ") 28 | if not self.args.local: # Exclude editable packages 29 | self.excluded_pkgs = self.get_packages(args=["--editable"]) + self.excluded_pkgs 30 | 31 | self.self_check = False 32 | self.outdated = self.check_outdated() 33 | 34 | # Packages info 35 | 36 | def get_packages(self, args=[]): 37 | """ 38 | This gets packages from pip, but it might be slower. Maybe use this later. 39 | """ 40 | arg_list = [sys.executable, "-m", "pip", "list", "--format=json"] + args 41 | 42 | packages = subprocess.check_output(arg_list) 43 | packages = packages.decode("utf-8").replace("\n", "").split("\r")[0] 44 | packages = json.loads(packages) 45 | 46 | packages_list = [] 47 | 48 | for pkg in packages: 49 | packages_list.append(pkg["name"]) 50 | 51 | return packages_list 52 | 53 | def check_outdated(self): 54 | print("Checking outdated packages...") 55 | reqs = subprocess.check_output([sys.executable, "-m", "pip", "list", "--format=json", "--outdated"]) 56 | reqs = reqs.decode("utf-8").replace("\n", "").split("\r")[0] 57 | 58 | outdated = json.loads(reqs) # List 59 | 60 | # Exclude package itself 61 | for i, item in enumerate(outdated): 62 | if item["name"] == "pip-upgrade-tool": 63 | self.self_check = True 64 | outdated.pop(i) 65 | 66 | # Exclude other packages 67 | # print(f'Excluding locally installed packages: {self.excluded_pkgs}') 68 | outdated_return = [] 69 | for i, item in enumerate(outdated): 70 | if not item["name"] in self.excluded_pkgs: 71 | outdated_return.append(item) 72 | 73 | return outdated_return 74 | 75 | # Upgrade 76 | 77 | def clear_list(self, main, subtract, check_input_error=False): 78 | """ 79 | Removes subtract's elements from main 80 | """ 81 | for item in subtract: 82 | if item in main: 83 | main.pop(item) 84 | else: 85 | if check_input_error: 86 | raise Exception(f"{item} is not in upgradable packages. This error is for safety incase of typos") 87 | return main 88 | 89 | def user_prompt(self, packages): 90 | if self.args.yes: 91 | cont_upgrade = "y" 92 | else: 93 | cont_upgrade = input("Continue? (y/n or -e/-r/--help): ") 94 | 95 | if cont_upgrade.lower() == "y": 96 | cont_upgrade = True 97 | self.config["restore"]["last_exclude"] = "" 98 | self.config._save() 99 | elif cont_upgrade.lower() == "n": 100 | cont_upgrade = False 101 | elif cont_upgrade.startswith("-e"): 102 | exclude = cont_upgrade.split(" ") 103 | exclude.remove("-e") 104 | self.clear_list(packages, exclude, check_input_error=True) 105 | cont_upgrade = True if len(packages) > 0 else False 106 | self.config["restore"]["last_exclude"] = " ".join(str(x) for x in exclude) 107 | self.config._save() 108 | elif cont_upgrade.lower() == "-r" or cont_upgrade.lower() == "--repeat": 109 | if self.restorable: 110 | repeat = self.config["restore"]["last_exclude"] 111 | 112 | exclude = repeat.split(" ") 113 | self.clear_list(packages, exclude, check_input_error=False) 114 | cont_upgrade = True if len(packages) > 0 else False 115 | else: 116 | print("No previous setting to repeat...") 117 | cont_upgrade = self.user_prompt(packages) 118 | elif cont_upgrade.lower() == "-h" or cont_upgrade.lower() == "--help": 119 | self._help() 120 | cont_upgrade = self.user_prompt(packages) 121 | else: 122 | print("Please use one of the accepted inputs (y/n or -e PackageNames)\nCanceling...") 123 | cont_upgrade = False 124 | 125 | return cont_upgrade 126 | 127 | def upgrade(self): 128 | be_upgraded = self.be_upgraded 129 | packages = {} 130 | for name, value in be_upgraded.items(): 131 | if not value: 132 | packages[name] = "" 133 | pkg = name 134 | else: 135 | packages[name] = value[0] + value[1] 136 | 137 | packages = self.clear_list(packages, self.wont_upgrade) 138 | 139 | if len(packages) > 0: 140 | # Info 141 | cprint("These packages will be upgraded: ", list(packages.keys()), color="green") 142 | if self.restorable: 143 | restore = self.config["restore"]["last_exclude"] 144 | print(f"(-r, --repeat : -e {restore})") 145 | 146 | # User input 147 | cont_upgrade = self.user_prompt(packages) 148 | 149 | # Prepare packages dict 150 | packages = list(packages.items()) 151 | packages = ["".join(x) for x in packages] 152 | 153 | if cont_upgrade and not self.args.dev: 154 | subprocess.check_call([sys.executable, "-m", "pip", "install", "-U", *packages]) 155 | 156 | print("All packages are up to date! 🎉") 157 | 158 | if self.self_check: 159 | print( 160 | "A new update avaliable for pip-upgrade-tool.\nPlease manually upgrade the tool using 'python -m pip install -U pip-upgrade-tool'" 161 | ) 162 | # cprint("A new update avaliable for pip-upgrade-tool.\nPlease manually upgrade the tool using 'python -m pip install -U pip-upgrade-tool'", color='yellow') 163 | 164 | def _upgrade_pip(self): 165 | print("Checking pip version for the first run...") 166 | subprocess.check_call([sys.executable, "-m", "pip", "install", "-U", "pip"]) 167 | 168 | def _help(self): 169 | print("") 170 | print("y : Continue") 171 | print("n : Abort") 172 | print("-e, --exclude : Exclude packages and continue. Example: -e pytest hypothesis") 173 | print("-r, --repeat : Repeat previous excluded pkgs") 174 | print("") 175 | -------------------------------------------------------------------------------- /pip_upgrade/tools/__init__.py: -------------------------------------------------------------------------------- 1 | from pip_upgrade.tools.cprint import ColoredPrint 2 | from pip_upgrade.tools.config import Config 3 | from pip_upgrade.tools.clear_cache import clear_cache 4 | 5 | # Initialize cprint 6 | cprint = ColoredPrint() 7 | -------------------------------------------------------------------------------- /pip_upgrade/tools/clear_cache.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import subprocess 3 | import shutil 4 | 5 | from pathlib import Path 6 | 7 | 8 | def clear_cache() -> None: 9 | """ 10 | Clears pip cache 11 | """ 12 | arg_list = [sys.executable, "-m", "pip", "cache", "dir"] 13 | output = subprocess.check_output(arg_list) 14 | output = output.decode("utf-8").replace("\n", "").replace("\r", "") 15 | 16 | # Print folder size 17 | dev_path = Path(output) 18 | cache_size = sum(f.stat().st_size for f in dev_path.glob("**/*") if f.is_file()) 19 | cache_size = int((cache_size / 1024) / 1024) 20 | 21 | print(f"Folder will be deleted: {output} Size: {cache_size}MB") 22 | confirm = input("Continue? (y/n): ") 23 | 24 | if confirm.lower() == "y": 25 | try: 26 | shutil.rmtree(output) 27 | print("Cache is cleared..") 28 | except Exception as e: 29 | print(e) 30 | else: 31 | print("Aborted, if the folder was wrong, please fill an issue.") 32 | -------------------------------------------------------------------------------- /pip_upgrade/tools/config.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | import copy 4 | 5 | 6 | class Config(configparser.ConfigParser): 7 | def __init__(self): 8 | self.home = os.path.expanduser("~") 9 | self.name = ".pipupgrade.ini" 10 | self.config = configparser.ConfigParser() 11 | self.first_run = True 12 | 13 | self._init() 14 | self._check_validity() 15 | 16 | def _save(self): 17 | with open(os.path.join(self.home, self.name), "w") as f: 18 | self.config.write(f) 19 | 20 | def _read(self): 21 | self.config.read(os.path.join(self.home, self.name)) 22 | 23 | def _init(self): 24 | if not os.path.isfile(os.path.join(self.home, self.name)): 25 | # Conf 26 | self.config.add_section("conf") 27 | self.config["conf"]["exclude"] = "" 28 | self.config["conf"]["novenv"] = "false" 29 | self.config["conf"]["max_cache"] = "false" 30 | self.config["conf"]["disable_colors"] = "false" 31 | # Restore 32 | self.config.add_section("restore") 33 | self.config["restore"]["last_exclude"] = "" 34 | # Save 35 | self._save() 36 | else: 37 | self.first_run = False 38 | self._read() 39 | 40 | def _check_validity(self): 41 | # Check config validity 42 | check_changes = copy.deepcopy(self.config) 43 | # Conf 44 | if not self.config.has_section("conf"): 45 | print("Invalid config (no `conf` section), config will be ignored.") 46 | self.config.add_section("conf") 47 | self.config["conf"]["novenv"] = "false" 48 | self.config["conf"]["exclude"] = "" 49 | if not self.config.has_option("conf", "novenv"): 50 | self.config["conf"]["novenv"] = "false" 51 | if not self.config.has_option("conf", "exclude"): 52 | self.config["conf"]["exclude"] = "" 53 | if not self.config.has_option("conf", "max_cache"): 54 | self.config["conf"]["max_cache"] = "false" 55 | if not self.config.has_option("conf", "disable_colors"): 56 | self.config["conf"]["disable_colors"] = "false" 57 | # Restore 58 | if not self.config.has_section("restore"): 59 | self.config.add_section("restore") 60 | self.config["restore"]["last_exclude"] = "" 61 | if not self.config.has_option("conf", "novenv"): 62 | self.config["restore"]["last_exclude"] = "" 63 | 64 | if not check_changes == self.config: 65 | self._save() 66 | 67 | def _reset(self): 68 | if input("Are you sure you want to completely reset the config file? (y/n): ") == "y": 69 | if os.path.isfile(os.path.join(self.home, self.name)): 70 | os.remove(os.path.join(self.home, self.name)) 71 | self.config = configparser.ConfigParser() 72 | self._init() 73 | else: 74 | print("Aborted, not resetting config.") 75 | 76 | def __getitem__(self, key): 77 | return self.config[key] 78 | 79 | def __repr__(self): 80 | return self.config 81 | -------------------------------------------------------------------------------- /pip_upgrade/tools/cprint.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class ColoredPrint: 5 | def __init__(self): 6 | self.enabled = True 7 | self.color_dict = {"green": "\033[32m", "yellow": "notimplemented", "default": "\033[m"} 8 | if self.terminal_check(): 9 | self.enabled = False 10 | 11 | def terminal_check(self): 12 | """Don't print colored if it is cmd""" 13 | return "$P$G" in os.getenv("PROMPT", "") 14 | 15 | def __call__(self, *input, color="green", disabled=False): 16 | if disabled or not self.enabled: 17 | print(*input) 18 | else: 19 | if isinstance(input, tuple): 20 | print(f"{self.color_dict[color]}{input[0]}{self.color_dict['default']}", *input[1:]) 21 | else: 22 | print(f"{self.color_dict[color]}{input}{self.color_dict['default']}") 23 | 24 | 25 | if __name__ == "__main__": 26 | cprint = ColoredPrint() 27 | cprint("heey", "ha") 28 | print("de") 29 | -------------------------------------------------------------------------------- /pip_upgrade/version_checker.py: -------------------------------------------------------------------------------- 1 | from packaging import version 2 | 3 | 4 | def version_check(dep, latest_version): 5 | if len(dep) == 0: 6 | return True 7 | 8 | sign_dict = {"==": 0, "~=": 1, "<": 2, "<=": 3, ">": 4, ">=": 5, "!=": 6} 9 | 10 | sign, apply_dep = dep 11 | key = sign_dict[sign] 12 | 13 | if key == 0: 14 | if ".*" in apply_dep: 15 | apply_dep, latest_version = any_version_control(apply_dep, latest_version) 16 | 17 | result = version.parse(apply_dep) == version.parse(latest_version) 18 | elif key == 1: 19 | result = version.parse(apply_dep).minor == version.parse(latest_version).minor 20 | elif key == 2: 21 | result = version.parse(latest_version) < version.parse(apply_dep) 22 | elif key == 3: 23 | result = version.parse(latest_version) <= version.parse(apply_dep) 24 | elif key == 4: 25 | result = version.parse(latest_version) > version.parse(apply_dep) 26 | elif key == 5: 27 | result = version.parse(latest_version) >= version.parse(apply_dep) 28 | elif key == 6: 29 | result = version.parse(latest_version) != version.parse(apply_dep) 30 | else: 31 | print(f"version check error: {sign}") 32 | 33 | return result 34 | 35 | 36 | def not_equal_check(version_, latest_version): 37 | if ".*" in version_: 38 | version_, _ = version_.split(".*") 39 | 40 | if latest_version[: len(version_)] == version_: 41 | return True 42 | else: 43 | return False 44 | 45 | 46 | def any_version_control(version_, latest_version): 47 | dot_count = version_.split(".*")[0].count(".") 48 | 49 | i = 0 50 | output_latest_version = "" 51 | 52 | for item in latest_version.split("."): 53 | output_latest_version += item 54 | 55 | if i == dot_count: 56 | break 57 | else: 58 | output_latest_version += "." 59 | 60 | i += 1 61 | 62 | return version_.split(".*")[0], output_latest_version 63 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=62.6", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pip-upgrade-tool" 7 | version = "1.0.0" 8 | authors = [ 9 | {name = "Onur Cetinkol", email = "realiti44@gmail.com"}, 10 | ] 11 | description = "An easy tool for upgrading all of your packages while not breaking dependencies" 12 | readme = "README.md" 13 | requires-python = ">=3.9" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "License :: OSI Approved :: MIT License", 17 | "Operating System :: OS Independent", 18 | ] 19 | dependencies = [ 20 | "packaging", 21 | ] 22 | 23 | [project.urls] 24 | Homepage = "https://github.com/realiti4/pip-upgrade" 25 | 26 | [project.scripts] 27 | pip-upgrade = "pip_upgrade:main" 28 | 29 | [tool.ruff] 30 | line-length = 120 31 | 32 | [tool.setuptools] 33 | packages = ["pip_upgrade", "pip_upgrade.tools"] 34 | -------------------------------------------------------------------------------- /test/run_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import argparse 3 | 4 | from pip_upgrade.tool import PipUpgrade 5 | from pip_upgrade.utils.config import Config 6 | 7 | 8 | parser = argparse.ArgumentParser() 9 | parser.add_argument('-e', '--exclude', nargs='+', help="Exclude packages you don't want to upgrade") 10 | parser.add_argument('--local', action='store_true', help="Upgrades local packages as well") 11 | parser.add_argument('--novenv', action='store_true', help="Disables venv check") 12 | parser.add_argument('--clear', action='store_true', help="Clears pip's cache") # Deprecated 13 | parser.add_argument('--clean', action='store_true', help="Clears pip's cache") 14 | parser.add_argument('-y', '--yes', action='store_true', help="Accept all upgrades and skip user prompt") 15 | parser.add_argument('--reset-config', action='store_true', help='Reset config file to default') 16 | parser.add_argument('--dev', action='store_true', help="Doesn't actually call upgrade at the end") 17 | 18 | args = parser.parse_args() 19 | 20 | def test_main(): 21 | config = Config() 22 | 23 | args.dev = True 24 | args.yes = True 25 | 26 | pip_upgrade = PipUpgrade(args, config) 27 | 28 | pip_upgrade.get_dependencies() 29 | 30 | pip_upgrade.upgrade() 31 | 32 | --------------------------------------------------------------------------------