/ contrib / cargo-outdated
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()