#!/usr/bin/env python3 """ Covert a Pipfile/[.lock] to a pip-tools requirements.(in|txt) file. While it's only slightly annoying to convert the Pipfile to a requirements.in (which we do) the primary goal is to convert the *locked* file with all the versions and hashes, without *updating* any of them. This will allow bisection of problems to the conversion from Pipenv to Pip-tools without confounding from myriad updates to all the libraries used within. To validate/clean up the new file, you can run the build command which will use the lock file and inspect what needs to be updated (hopefully nothing), and should just change the "# via ?" lines and maybe re-order some packages. $ pip-compile --quiet --generate-hashes --output-file=requirements.txt requirements.in After using this to convert the source/lock file, here's a recommended Make target to add: requirements.txt: requirements.in pip-compile \\ --quiet \\ --generate-hashes \\ --output-file=requirements.txt \\ requirements.in """ import argparse import json import pathlib import os import subprocess import sys import typing import toml PIPENV_SOURCE = "Pipfile" PIPENV_LOCK = "Pipfile.lock" TARGET_BASENAME_DEFAULT = "requirements" def transform_source_file( directory: pathlib.Path, *, target_basename: str = TARGET_BASENAME_DEFAULT, force_overwrite: bool = False, dev: bool = False, ) -> typing.Set[str]: """ Returns a set containing the direct dependencies (casefolded for easier lookup) """ with open(directory / PIPENV_SOURCE, mode="r") as f: env_source = toml.load(f) deps = set() lines = [] package_groups = [env_source["packages"]] if dev: package_groups.append(env_source["dev-packages"]) for n, pg in enumerate(package_groups): if n and dev: lines.append("\n# dev-packages\n") for pkg, constraint in pg.items(): if constraint == "*": constraint = "" lines.append(f"{pkg}{constraint}\n") deps.add(pkg.casefold()) mode = "w" if force_overwrite else "x" with open(directory / (target_basename + ".in"), mode=mode) as f: f.writelines(lines) return deps def transform_lock_file( directory: pathlib.Path, *, target_basename: str = TARGET_BASENAME_DEFAULT, direct_deps: typing.Set[str] = None, force_overwrite: bool = False, dev: bool = False, ) -> None: with open(directory / PIPENV_LOCK, mode="r") as f: env_lock = json.load(f) lines = [ f"""\ # # This file is autogenerated by pipenv2tools (NOT pip-compile) # To update, run: # # pip-compile --generate-hashes --output-file={target_basename}.txt {target_basename}.in # """ ] sources = env_lock["_meta"]["sources"] for n, s in enumerate(sources): flag = "--extra-index-url" if n > 0 else "--index-url" assert s["verify_ssl"] lines.append(f"{flag} {s['url']}\n") lines.append("\n") package_groups = [env_lock["default"]] if dev: package_groups.append(env_lock["develop"]) for pg in package_groups: for pkg, info in pg.items(): pl = [] pl.append(f"{pkg}{info['version']}") for hash_ in info["hashes"]: pl.append(f" --hash={hash_}") if direct_deps and pkg.casefold() not in direct_deps: # this could probably directly append to lines and skip the '\\\n' # join, but pip-compile emits reqs.txt with the line-continuation # to the comment. pl.append(" # via ?") lines.append(" \\\n".join(pl) + "\n") lines.append( "\n# WARNING: This file was created via pipenv2tools so may be broken.\n" ) mode = "w" if force_overwrite else "x" with open(directory / (target_basename + ".txt"), mode=mode) as f: f.writelines(lines) class RegenerationError(RuntimeError): pass def regenerate(directory: pathlib.Path): args = [ "pip-compile", "--generate-hashes", "--quiet", "--output-file=requirements.txt", "requirements.in", ] try: proc = subprocess.run(args, check=True, cwd=directory) except FileNotFoundError as exc: errno, msg = exc.args if msg == "No such file or directory: 'pip-compile'": raise RegenerationError("pip-compile not found on the path") from exc raise except subprocess.CalledProcessError as exc: raise RegenerationError( f"pip-compile returned a non-zero exit code {exc.returncode}, see output" ) from exc def main(): parser = argparse.ArgumentParser( formatter_class=argparse.RawDescriptionHelpFormatter, description=__doc__ ) parser.add_argument( "-f", "--force", action="store_true", help="force overwrite destination requirements files (if they exist)", ) parser.add_argument( "-D", "--no-dev", action="store_true", help="Don't include dev dependencies" ) parser.add_argument( "-G", "--no-regenerate", action="store_true", help="By default, the converted output will be run through pip-tools to " "validate and clean it up. this requires the network and a little time, " "however. So this will skip that step", ) parser.add_argument( "-b", "--basename", type=str, default=TARGET_BASENAME_DEFAULT, help="Basename of the output files.", ) args = parser.parse_args() # path = pathlib.Path(__file__).parent # relative to script path = pathlib.Path(os.getcwd()) # pwd try: direct_deps = transform_source_file( path, force_overwrite=args.force, dev=not args.no_dev ) transform_lock_file( path, force_overwrite=args.force, direct_deps=direct_deps, dev=not args.no_dev, ) except FileExistsError as exc: print( "An output file already exists, aborting. Specify --force to overwrite.\n", exc, file=sys.stderr, ) return 1 if not args.no_regenerate: try: regenerate(path) except RegenerationError as exc: print( "Failed to regenerate a 'clean' reqs.txt, " f"but the Pipfile was converted to reqs.(txt|in).\n Reason: {exc}", file=sys.stderr, ) return 1 if __name__ == "__main__": sys.exit(main())