cargo-outdated
1 #!/usr/bin/env python3 2 import json 3 import pickle 4 import subprocess 5 6 from argparse import ArgumentParser 7 from os import chdir, getenv 8 from os.path import exists, join 9 from subprocess import PIPE 10 11 import semver 12 import tomlkit 13 from colorama import Fore, Style 14 from prettytable import PrettyTable 15 16 # Path to git repository holding crates.io index 17 CRATESIO_REPO = "https://github.com/rust-lang/crates.io-index" 18 CRATESIO_INDEX = join(getenv("HOME"), ".cache", "crates.io-index") 19 20 # Path to pickle cache for storing paths to dependency metadata 21 PICKLE_CACHE = join(getenv("HOME"), ".cache", "cargo-outdated.pickle") 22 23 # Set of packages that are ignored by this tool 24 IGNORES = { 25 "darkfi-serial", 26 "darkfi-sdk", 27 "darkfi", 28 "darkfi-derive", 29 "darkfi-derive-internal", 30 "dao-contract", 31 "money-contract", 32 } 33 34 # Yanked releases from crates.io to ignore 35 YANKED = {} 36 37 # Cached paths for metadata to not have to search through the crates index 38 METADATA_PATHS = {} 39 40 if exists(PICKLE_CACHE): 41 with open(PICKLE_CACHE, "rb") as f: 42 json_data = pickle.load(f) 43 METADATA_PATHS = json.loads(json_data) 44 45 46 def parse_toml(filename): 47 with open(filename) as f: 48 content = f.read() 49 50 p = tomlkit.parse(content) 51 deps = p.get("dependencies") 52 devdeps = p.get("dev-dependencies") 53 54 if deps and devdeps: 55 dependencies = deps | devdeps 56 elif deps: 57 dependencies = deps 58 elif devdeps: 59 dependencies = devdeps 60 else: 61 dependencies = None 62 63 return (p, dependencies) 64 65 66 def get_metadata_path(name): 67 find_output = subprocess.run( 68 ["find", CRATESIO_INDEX, "-type", "f", "-name", name], stdout=PIPE) 69 70 metadata_path = find_output.stdout.decode().strip() 71 72 if metadata_path == '': 73 return None 74 75 # Place the path into cache 76 METADATA_PATHS[name] = metadata_path 77 return metadata_path 78 79 80 def check_dep(name, data): 81 if name in IGNORES: 82 return None 83 84 metadata_path = METADATA_PATHS.get(name) 85 86 if not metadata_path: 87 metadata_path = get_metadata_path(name) 88 if not metadata_path: 89 print(f"No crate found for {Fore.YELLOW}{name}{Style.RESET_ALL}") 90 return None 91 92 # Read the metadata. It's split as JSON objects, each in its own line. 93 with open(metadata_path, encoding="utf-8") as f: 94 lines = f.readlines() 95 lines = [i.strip() for i in lines] 96 97 # Latest one is at the end 98 metadata = json.loads(lines[-1]) 99 100 # Get the version from the local data 101 if isinstance(data, str): 102 # This is just the semver 103 local_version = data 104 elif isinstance(data, dict): 105 local_version = data.get("version") 106 if not local_version: 107 # Not a versioned dependency (can be path/git/...) 108 return None 109 else: 110 raise ValueError(f"Invalid dependency: {name}") 111 112 try: 113 if semver.compare(local_version, metadata["vers"]) < 0: 114 name = metadata["name"] 115 vers = metadata["vers"] 116 117 if name in YANKED and vers in YANKED[name]: 118 return None 119 120 return (local_version, vers) 121 except: 122 return None 123 124 return None 125 126 127 def main(): 128 parser = ArgumentParser( 129 description="Prettyprint outdated dependencies in a cargo project") 130 131 parser.add_argument("-u", 132 "--update", 133 action="store_true", 134 help="Prompt to update dependencies") 135 parser.add_argument("-i", 136 "--ignore", 137 type=str, 138 help="Comma-separated list of deps to ignore") 139 140 args = parser.parse_args() 141 142 if args.ignore: 143 for i in args.ignore.split(","): 144 IGNORES.add(i) 145 146 if not exists(CRATESIO_INDEX): 147 print("Cloning crates.io index...") 148 subprocess.run(["git", "clone", CRATESIO_REPO, CRATESIO_INDEX], 149 capture_output=False) 150 151 print("Updating crates.io index...") 152 subprocess.run(["git", "-C", CRATESIO_INDEX, "fetch", "-a"], 153 capture_output=False) 154 155 subprocess.run( 156 ["git", "-C", CRATESIO_INDEX, "reset", "--hard", "origin/master"], 157 capture_output=False) 158 159 # chdir to the root of the project 160 toplevel = subprocess.run(["git", "rev-parse", "--show-toplevel"], 161 capture_output=True) 162 toplevel = toplevel.stdout.decode().strip() 163 chdir(toplevel) 164 165 find_output = subprocess.run( 166 ["find", ".", "-type", "f", "-name", "Cargo.toml"], stdout=PIPE) 167 files = [i.strip() for i in find_output.stdout.decode().split("\n")][:-1] 168 169 x = PrettyTable() 170 x.field_names = ["package", "crate", "current", "latest", "path"] 171 172 for filename in files: 173 ps, deps = parse_toml(filename) 174 package = ps["package"]["name"] 175 print(f"Checking deps for {Fore.GREEN}{package}{Style.RESET_ALL}") 176 for dep in deps: 177 ret = check_dep(dep, deps[dep]) 178 if ret: 179 x.add_row([ 180 package, 181 dep, 182 f"{Fore.YELLOW}{ret[0]}{Style.RESET_ALL}", 183 f"{Fore.GREEN}{ret[1]}{Style.RESET_ALL}", 184 filename, 185 ]) 186 187 if args.update and ret: 188 print(f"Update {dep} from {ret[0]} to {ret[1]}? (y/N): ", 189 end="") 190 choice = input() 191 if choice and (choice == "y" or choice == "Y"): 192 if "dependencies" in ps and dep in ps["dependencies"]: 193 if ps["dependencies"][dep] == ret[0]: 194 ps["dependencies"][dep] = ret[1] 195 else: 196 ps["dependencies"][dep]["version"] = ret[1] 197 elif "dev-dependencies" in ps and dep in ps[ 198 "dev-dependencies"]: 199 if ps["dev-dependencies"][dep] == ret[0]: 200 ps["dev-dependencies"][dep] = ret[1] 201 else: 202 ps["dev-dependencies"][dep]["version"] = ret[1] 203 204 if args.update: 205 with open(filename, "w") as f: 206 f.write(tomlkit.dumps(ps)) 207 208 print(x) 209 210 # Write the pickle 211 with open(PICKLE_CACHE, "wb") as pfile: 212 pickle.dump(json.dumps(METADATA_PATHS).encode(), pfile) 213 214 215 if __name__ == "__main__": 216 main()