├── .version ├── pipup ├── __init__.py ├── exceptions.py ├── freeze.py ├── req_files.py └── main.py ├── MANIFEST.in ├── requirements.txt ├── tests ├── requirements_files │ └── req1.txt ├── test_setup.py ├── test_reqfile.py ├── test_freeze.py └── conftest.py ├── .gitignore ├── pytest.ini ├── bin └── pip-up ├── setup.cfg ├── AUTHORS.txt ├── CHANGELOG.md ├── Makefile ├── setup.py ├── LICENSE.txt └── README.md /.version: -------------------------------------------------------------------------------- 1 | 0.3.1 2 | -------------------------------------------------------------------------------- /pipup/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include .version 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Click==7.0 2 | pytest==5.0.0 -------------------------------------------------------------------------------- /tests/requirements_files/req1.txt: -------------------------------------------------------------------------------- 1 | Django=2.2.4 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | *.egg-info/ 4 | *.eggs/ 5 | -------------------------------------------------------------------------------- /tests/test_setup.py: -------------------------------------------------------------------------------- 1 | def test_reqs(requirements_files): 2 | assert requirements_files.get("req1.txt") 3 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = .git .eggs build dist 3 | python_files = tests/test_*.py tests/*/test_*.py 4 | -------------------------------------------------------------------------------- /bin/pip-up: -------------------------------------------------------------------------------- 1 | #!/usr/local/Cellar/python/2.7.6/bin/python 2 | 3 | from pip_up.main import main_entry 4 | main_entry() 5 | -------------------------------------------------------------------------------- /pipup/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class ReqFileNotFound(Exception): 4 | pass 5 | 6 | 7 | class ReqFileNotReadable(Exception): 8 | pass 9 | 10 | 11 | class ReqFileNotWritable(Exception): 12 | pass 13 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.3.1 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:.version] 7 | 8 | [bdist_wheel] 9 | universal = 1 10 | 11 | [aliases] 12 | test = pytest 13 | 14 | -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | Originally written by Frank Wiles 2 | 3 | ====================================================================== 4 | Contributors 5 | ====================================================================== 6 | 7 | - Tim Hatch http://timhatch.com/ 8 | 9 | -------------------------------------------------------------------------------- /tests/test_reqfile.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from pipup.req_files import ReqFile 3 | 4 | 5 | def test_find_package_requirements(): 6 | """ Find the requirements file in this package """ 7 | r = ReqFile(auto_read=False) 8 | 9 | assert r.exists 10 | 11 | # Our valid path 12 | p = Path(__file__).parent / "../requirements.txt" 13 | p = p.resolve() 14 | 15 | assert r.file_path == str(p) 16 | -------------------------------------------------------------------------------- /tests/test_freeze.py: -------------------------------------------------------------------------------- 1 | from pipup.freeze import Freeze 2 | 3 | 4 | def test_freeze(): 5 | f = Freeze() 6 | f.lines.append("Django==2.2") 7 | f.lines.append("pytest==5.0.0") 8 | f.lines.append("Click==7.0") 9 | 10 | # We should be able to find upper and lower case versions 11 | assert f.find("Django") 12 | assert f.find("django") 13 | 14 | # We should be able to find versions with and without the version numbers 15 | assert f.find("pytest") 16 | assert f.find("pytest==5.0.0") 17 | 18 | assert not f.find("not-found") 19 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | 4 | from pathlib import Path 5 | 6 | 7 | @pytest.fixture 8 | def requirements_files(): 9 | """ 10 | Load all of our requirements files as strings in a dict for use in tests 11 | """ 12 | # Return data 13 | requirements_strings = {} 14 | 15 | # Path to our files 16 | req_dir = Path(__file__).parent / "requirements_files" 17 | 18 | for file_path in os.listdir(str(req_dir)): 19 | with open(req_dir / file_path) as f: 20 | requirements_strings[str(file_path)] = f.read() 21 | 22 | return requirements_strings 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Version 0.3.1 May 23rd, 2020 4 | 5 | - Fixed missing MANIFEST.in file (thanks to Tim Hatch) 6 | 7 | ## Version 0.3.0 June 29th, 2019 8 | 9 | - Removed unnecessary requirements 10 | - Removed "copy to clipboard" feature that only works on OSX 11 | - Upgrade to Click 7 12 | - Fixed argument case inconsistency between pip and pipup 13 | - Updated setup and PyPI description 14 | - Added tests 15 | 16 | ## Version 0.2.0 released June 25th, 2016 17 | 18 | - Fix unhelpful error message when pipup can't find your requirements.txt file. Thanks Melissa Hill for finding and reporting the bug 19 | 20 | ## Version 0.1.0 released June 25th, 2016 21 | 22 | - Initial version 23 | 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: version dist 2 | 3 | clean-build: ## remove build artifacts 4 | rm -fr build/ 5 | rm -fr dist/ 6 | rm -fr .eggs/ 7 | find . -name '*.egg-info' -exec rm -fr {} + 8 | find . -name '*.egg' -exec rm -f {} + 9 | 10 | clean-pyc: ## remove Python file artifacts 11 | find . -name '*.pyc' -exec rm -f {} + 12 | find . -name '*.pyo' -exec rm -f {} + 13 | find . -name '*~' -exec rm -f {} + 14 | find . -name '__pycache__' -exec rm -fr {} + 15 | 16 | clean-test: ## remove test and coverage artifacts 17 | rm -fr .tox/ 18 | rm -f .coverage 19 | rm -fr htmlcov/ 20 | rm -fr .pytest_cache 21 | 22 | clean: clean-test clean-build clean-pyc 23 | 24 | 25 | version: 26 | bumpversion patch 27 | git push 28 | git push --tags 29 | 30 | dist: 31 | python setup.py sdist bdist_wheel 32 | 33 | release: 34 | twine upload dist/* 35 | -------------------------------------------------------------------------------- /pipup/freeze.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import warnings 4 | 5 | warnings.filterwarnings("ignore") 6 | 7 | 8 | class Freeze(object): 9 | """ 10 | Handle getting information from 'pip freeze' 11 | """ 12 | 13 | def __init__(self): 14 | self.lines = [] 15 | 16 | def get(self): 17 | output = subprocess.check_output(args=["pip", "freeze"], cwd=os.getcwd()) 18 | 19 | for line in output.split(b"\n"): 20 | line = line.decode("utf-8").strip() 21 | self.lines.append(line) 22 | 23 | def find(self, pattern): 24 | """ Find a pattern or package in pip list """ 25 | FOUND = [] 26 | 27 | # Get a pip freeze if we haven't already 28 | if not self.lines: 29 | self.get() 30 | 31 | for l in self.lines: 32 | if pattern.lower() in l.lower(): 33 | FOUND.append(l) 34 | 35 | return FOUND 36 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from os import path 3 | 4 | here = path.abspath(path.dirname(__file__)) 5 | 6 | # Get the long description from the README file 7 | with open(path.join(here, "README.md"), encoding="utf-8") as f: 8 | long_description = f.read() 9 | 10 | # Grab the version from bumpversion 11 | with open(path.join(here, ".version"), encoding="utf-8") as f: 12 | version = f.read() 13 | version = version.strip() 14 | 15 | setup( 16 | name="pipup", 17 | version=version, 18 | description="Install or update pip dependency and save it to requirements.txt", 19 | long_description=long_description, 20 | long_description_content_type="text/markdown", 21 | author="Frank Wiles", 22 | author_email="frank@revsys.com", 23 | url="https://github.com/revsys/pipup", 24 | packages=find_packages(), 25 | include_package_data=True, 26 | install_requires=["click==7.0"], 27 | setup_requires=["pytest-runner"], 28 | tests_require=["pytest"], 29 | entry_points=""" 30 | [console_scripts] 31 | pipup=pipup.main:cli 32 | """, 33 | classifiers=[ 34 | "Development Status :: 4 - Beta", 35 | "Environment :: Console", 36 | "Intended Audience :: Developers", 37 | "License :: OSI Approved :: BSD License", 38 | "Operating System :: OS Independent", 39 | "Programming Language :: Python :: 3", 40 | ], 41 | ) 42 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) Revolution Systems, LLC and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of pipup nor the names of its contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pipup - Better requirements.txt management 2 | 3 | So why pipup you ask? It's a silly small utility, but it solves some real issues 4 | I have on a daily basis. The 3 most common things I need to do with pip are: 5 | 6 | 1. See if a package is installed and, if so, which version is installed 7 | 2. Install a package and then save the installed version info to requirements.txt 8 | 3. Upgrade a package and change the entry in requirements.txt 9 | 10 | Sadly, pip doesn't help us here so this is why I've created pipup. Running just 11 | `pipup ` or `pipup -U ` *just does what I want*. No 12 | more forgetting to include or update a requirements.txt entry for me! 13 | 14 | ## Installation 15 | 16 | pipup is installed via pip: 17 | 18 | pip install pipup 19 | 20 | ## Usage 21 | 22 | Using pipup is easy: 23 | 24 | $ pipup Django 25 | 26 | If Django is already installed, pipup will display the current version for you 27 | like this: 28 | 29 | $ pipup Django 30 | Looking for 'Django' 31 | Already installed: 32 | Django==1.9.7 33 | No changes to save, skipping save. 34 | 35 | If Django isn't installed, pipup will install it and save the pinned version of 36 | the package to the requirements.txt in your current directory: 37 | 38 | $ pipup Django 39 | Looking for 'Django' 40 | Installing 'Django'... 41 | Django==1.9.7 42 | Changes saved to /Users/frank/work/src/pipup/requirements.txt 43 | 44 | If we have an older version of Django installed, say `Django==1.8.4` we can use 45 | the `--upgrade` or `-U` option to upgrade Django and update our requirements: 46 | 47 | $ pipup -U Django 48 | Looking for 'Django' 49 | Already installed: 50 | Django==1.8.4 51 | Upgrading: 52 | Django==1.9.7 53 | Changes saved to /Users/frank/work/src/pipup/requirements.txt 54 | 55 | ## Detailed options 56 | 57 | `--upgrade` or `-U` install or upgrade the requested package(s) 58 | `--skip` or `-s` install or upgrade, but don't save the changes into your requirements file 59 | `--requirements` or `-r` path to the requirements file you wish to update 60 | 61 | **NOTE:** Originally we tried to be *smart* and walk your file system backwards until we found a requirements.txt, but this can easily write the pip changes to a random requirements.txt on your system if you use a certain, fairly common, directory structure for your Python projects. To avoid this confusion, we're going to be explicit and require that you run pipup from the top of a project or specify the requirements path directly yourself. 62 | 63 | 64 | ## Need help? 65 | 66 | [REVSYS](http://www.revsys.com?utm_medium=github&utm_source=pipup) can help with your Python, Django, and infrastructure projects. If you have a question about this project, please open a GitHub issue. If you love us and want to keep track of our goings-on, here's where you can find us online: 67 | 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /pipup/req_files.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | import click 5 | 6 | from .exceptions import ReqFileNotFound, ReqFileNotReadable, ReqFileNotWritable 7 | 8 | UNPINNED_RE = re.compile(r"^[0-9a-zA-Z_\-]+$") 9 | 10 | 11 | class ReqFile(object): 12 | """ 13 | Class to manage a requirements file 14 | """ 15 | 16 | file_path = None 17 | 18 | def __init__(self, path=None, file_name="requirements.txt", auto_read=True): 19 | self.file_name = file_name 20 | self.exists = False 21 | 22 | if path is None: 23 | self.file_path = self.find_requirements_file() 24 | else: 25 | self.file_path = path 26 | 27 | if self.file_path is not None: 28 | self.exists = True 29 | 30 | # Store requirements lines 31 | self.lines = [] 32 | self.packages = {} 33 | 34 | if auto_read: 35 | self.read(self.file_path) 36 | 37 | def find_requirements_file(self): 38 | """ 39 | Find the first requirements file matching file_name 40 | """ 41 | for dirname, subdirs, files in os.walk(os.getcwd()): 42 | for fname in files: 43 | if fname == self.file_name: 44 | return os.path.join(dirname, fname) 45 | 46 | def read(self, path): 47 | """ 48 | Read in requirements file 49 | """ 50 | # File doesn't exist, so just move along 51 | if not self.exists: 52 | return 53 | 54 | if not os.path.exists(path): 55 | raise ReqFileNotFound("{} not found".format(path)) 56 | 57 | if not os.access(path, os.R_OK): 58 | raise ReqFileNotReadable("{} not readable".format(path)) 59 | 60 | if not os.access(path, os.W_OK): 61 | raise ReqFileNotWritable("{} not writeable".format(path)) 62 | 63 | # Clear out any any existing lines 64 | self.lines = [] 65 | 66 | with open(path) as f: 67 | for i, line in enumerate(f): 68 | self.parse_line(line, i) 69 | 70 | def parse_line(self, line, line_number): 71 | """ 72 | Parse a line of our requirements file for later use 73 | """ 74 | # Save line untouched to rewrite it 75 | self.lines.append(line.strip()) 76 | 77 | if "==" in line: 78 | package, version = line.split("==") 79 | self.packages[package] = version 80 | 81 | if UNPINNED_RE.match(line): 82 | 83 | click.secho( 84 | "WARNING: Found unpinned package '{}' at line {}.".format( 85 | line.strip(), line_number 86 | ), 87 | fg="red", 88 | ) 89 | 90 | def save(self, lines): 91 | """ 92 | Save these lines to the requirements.txt file 93 | """ 94 | # Don't do anything if there isn't anything to do 95 | if not lines: 96 | return False 97 | 98 | # Don't do anything if there isn't a file to update 99 | if not self.exists: 100 | return False 101 | 102 | # Always re-read in case something has changed 103 | self.read(self.file_path) 104 | 105 | new_lines = [] 106 | 107 | for r in lines: 108 | FOUND = False 109 | 110 | for l in self.lines: 111 | l = l.strip() 112 | 113 | # Skip lines we can't handle 114 | if "==" not in l: 115 | new_lines.append(l) 116 | continue 117 | 118 | pkg, version = l.split("==", 1) 119 | 120 | if pkg in r: 121 | new_lines.append(r) 122 | FOUND = True 123 | else: 124 | new_lines.append(l) 125 | 126 | if not FOUND: 127 | new_lines.append(r) 128 | 129 | with open(self.file_path, "w") as f: 130 | for l in new_lines: 131 | f.write("{}\n".format(l)) 132 | 133 | return True 134 | -------------------------------------------------------------------------------- /pipup/main.py: -------------------------------------------------------------------------------- 1 | import click 2 | import copy 3 | import subprocess 4 | import sys 5 | import os 6 | import warnings 7 | 8 | from .req_files import ReqFile 9 | from .freeze import Freeze 10 | 11 | warnings.filterwarnings("ignore") 12 | 13 | freeze = Freeze() 14 | 15 | 16 | def handle_find(req, packages, clipboard=False): 17 | """ Find one or more packages """ 18 | values = [] 19 | 20 | for p in packages: 21 | res = freeze.find(p) 22 | if res: 23 | values.extend(res) 24 | print("\n".join(res)) 25 | else: 26 | click.secho(" *** Could not find '{}' ***".format(p), fg="red") 27 | 28 | return values 29 | 30 | 31 | def handle_install(req, packages, upgrade=False): 32 | """ Install or upgrade a package """ 33 | originals = [] 34 | values = [] 35 | f = Freeze() 36 | 37 | # Get all of the original matching lines to output only those lines that 38 | # changed 39 | for p in packages: 40 | 41 | found = f.find(p) 42 | 43 | if not upgrade and found: 44 | click.secho("ERROR: '{}' seems to exist:".format(p), fg="red") 45 | click.echo("\n".join(found)) 46 | click.secho("exiting...", fg="red") 47 | sys.exit(-1) 48 | 49 | originals.extend(found) 50 | 51 | cmd = ["pip", "install"] 52 | if upgrade: 53 | cmd.append("-U") 54 | 55 | for p in packages: 56 | command = copy.copy(cmd) 57 | command.append(p) 58 | 59 | output = subprocess.check_output(args=command, cwd=os.getcwd()) 60 | 61 | f = Freeze() 62 | val = f.find(p) 63 | 64 | for v in val: 65 | if v not in originals: 66 | values.append(v) 67 | 68 | if not values: 69 | click.secho("No packages changed.", fg="red") 70 | 71 | return values 72 | 73 | 74 | @click.command() 75 | @click.option( 76 | "--upgrade", 77 | "-U", 78 | default=False, 79 | help="Upgrade if package already exists", 80 | is_flag=True, 81 | ) 82 | @click.option( 83 | "--skip", "-s", default=False, help="Skip saving to requirements.txt", is_flag=True 84 | ) 85 | @click.option( 86 | "--requirements", 87 | "-r", 88 | default=None, 89 | help="Path to requirements.txt file to update", 90 | type=click.Path(exists=True), 91 | ) 92 | @click.argument("packages", nargs=-1) 93 | def cli(upgrade, skip, requirements, packages): 94 | """ 95 | Smart management of requirements.txt files 96 | """ 97 | if not packages: 98 | click.secho("No packages listed, try pipup --help") 99 | sys.exit(-1) 100 | 101 | # Grab current requirements 102 | if requirements is not None: 103 | req = ReqFile(path=requirements) 104 | else: 105 | req = ReqFile() 106 | 107 | found_packages = [] 108 | upgraded_packages = [] 109 | installed_packages = [] 110 | 111 | click.secho("Looking for '{}'".format(", ".join(packages)), fg="green") 112 | for pkg in packages: 113 | found = freeze.find(pkg) 114 | 115 | if found: 116 | found_packages.extend(found) 117 | click.secho("Already installed:", fg="green") 118 | for f in found: 119 | click.secho(f) 120 | 121 | if upgrade: 122 | click.secho("Upgrading:", fg="green") 123 | upgrades = handle_install(req=req, packages=[pkg], upgrade=upgrade) 124 | 125 | for u in upgrades: 126 | click.secho(u) 127 | 128 | upgraded_packages.extend(upgrades) 129 | else: 130 | click.secho("Installing '{}'...".format(pkg), fg="green") 131 | 132 | installs = handle_install(req=req, packages=[pkg], upgrade=upgrade) 133 | 134 | for i in installs: 135 | click.secho(i) 136 | 137 | installed_packages.extend(installs) 138 | 139 | # Save unless we're told otherwise 140 | if not skip: 141 | all_packages = installed_packages + upgraded_packages 142 | if not req.exists: 143 | click.secho( 144 | "Can't find a requirements.txt! You'll need to either create one or update yours manually.", 145 | fg="red", 146 | ) 147 | 148 | if req.save(all_packages): 149 | click.secho("Changes saved to {}".format(req.file_path), fg="green") 150 | else: 151 | click.secho("No changes to save, skipping save.", fg="green") 152 | --------------------------------------------------------------------------------