pipreqs.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. #!/usr/bin/env python
  2. """pipreqs - Generate pip requirements.txt file based on imports
  3. Usage:
  4. pipreqs [options] [<path>]
  5. Arguments:
  6. <path> The path to the directory containing the application
  7. files for which a requirements file should be
  8. generated (defaults to the current working
  9. directory).
  10. Options:
  11. --use-local Use ONLY local package info instead of querying PyPI.
  12. --pypi-server <url> Use custom PyPi server.
  13. --proxy <url> Use Proxy, parameter will be passed to requests
  14. library. You can also just set the environments
  15. parameter in your terminal:
  16. $ export HTTP_PROXY="http://10.10.1.10:3128"
  17. $ export HTTPS_PROXY="https://10.10.1.10:1080"
  18. --debug Print debug information
  19. --ignore <dirs>... Ignore extra directories, each separated by a comma
  20. --no-follow-links Do not follow symbolic links in the project
  21. --encoding <charset> Use encoding parameter for file open
  22. --savepath <file> Save the list of requirements in the given file
  23. --print Output the list of requirements in the standard
  24. output
  25. --force Overwrite existing requirements.txt
  26. --diff <file> Compare modules in requirements.txt to project
  27. imports
  28. --clean <file> Clean up requirements.txt by removing modules
  29. that are not imported in project
  30. --mode <scheme> Enables dynamic versioning with <compat>,
  31. <gt> or <non-pin> schemes.
  32. <compat> | e.g. Flask~=1.1.2
  33. <gt> | e.g. Flask>=1.1.2
  34. <no-pin> | e.g. Flask
  35. """
  36. from contextlib import contextmanager
  37. import os
  38. import sys
  39. import re
  40. import logging
  41. import ast
  42. import traceback
  43. from docopt import docopt
  44. import requests
  45. from yarg import json2package
  46. from yarg.exceptions import HTTPError
  47. from pipreqs import __version__
  48. REGEXP = [
  49. re.compile(r'^import (.+)$'),
  50. re.compile(r'^from ((?!\.+).*?) import (?:.*)$')
  51. ]
  52. @contextmanager
  53. def _open(filename=None, mode='r'):
  54. """Open a file or ``sys.stdout`` depending on the provided filename.
  55. Args:
  56. filename (str): The path to the file that should be opened. If
  57. ``None`` or ``'-'``, ``sys.stdout`` or ``sys.stdin`` is
  58. returned depending on the desired mode. Defaults to ``None``.
  59. mode (str): The mode that should be used to open the file.
  60. Yields:
  61. A file handle.
  62. """
  63. if not filename or filename == '-':
  64. if not mode or 'r' in mode:
  65. file = sys.stdin
  66. elif 'w' in mode:
  67. file = sys.stdout
  68. else:
  69. raise ValueError('Invalid mode for file: {}'.format(mode))
  70. else:
  71. file = open(filename, mode)
  72. try:
  73. yield file
  74. finally:
  75. if file not in (sys.stdin, sys.stdout):
  76. file.close()
  77. def get_all_imports(
  78. path, encoding=None, extra_ignore_dirs=None, follow_links=True):
  79. imports = set()
  80. raw_imports = set()
  81. candidates = []
  82. ignore_errors = False
  83. ignore_dirs = [".hg", ".svn", ".git", ".tox", "__pycache__", "env", "venv"]
  84. if extra_ignore_dirs:
  85. ignore_dirs_parsed = []
  86. for e in extra_ignore_dirs:
  87. ignore_dirs_parsed.append(os.path.basename(os.path.realpath(e)))
  88. ignore_dirs.extend(ignore_dirs_parsed)
  89. walk = os.walk(path, followlinks=follow_links)
  90. for root, dirs, files in walk:
  91. dirs[:] = [d for d in dirs if d not in ignore_dirs]
  92. candidates.append(os.path.basename(root))
  93. files = [fn for fn in files if os.path.splitext(fn)[1] == ".py"]
  94. candidates += [os.path.splitext(fn)[0] for fn in files]
  95. for file_name in files:
  96. file_name = os.path.join(root, file_name)
  97. with open(file_name, "r", encoding=encoding) as f:
  98. contents = f.read()
  99. try:
  100. tree = ast.parse(contents)
  101. for node in ast.walk(tree):
  102. if isinstance(node, ast.Import):
  103. for subnode in node.names:
  104. raw_imports.add(subnode.name)
  105. elif isinstance(node, ast.ImportFrom):
  106. raw_imports.add(node.module)
  107. except Exception as exc:
  108. if ignore_errors:
  109. traceback.print_exc(exc)
  110. logging.warn("Failed on file: %s" % file_name)
  111. continue
  112. else:
  113. logging.error("Failed on file: %s" % file_name)
  114. raise exc
  115. # Clean up imports
  116. for name in [n for n in raw_imports if n]:
  117. # Sanity check: Name could have been None if the import
  118. # statement was as ``from . import X``
  119. # Cleanup: We only want to first part of the import.
  120. # Ex: from django.conf --> django.conf. But we only want django
  121. # as an import.
  122. cleaned_name, _, _ = name.partition('.')
  123. imports.add(cleaned_name)
  124. packages = imports - (set(candidates) & imports)
  125. logging.debug('Found packages: {0}'.format(packages))
  126. with open(join("stdlib"), "r") as f:
  127. data = {x.strip() for x in f}
  128. return list(packages - data)
  129. def filter_line(line):
  130. return len(line) > 0 and line[0] != "#"
  131. def generate_requirements_file(path, imports, symbol):
  132. with _open(path, "w") as out_file:
  133. logging.debug('Writing {num} requirements: {imports} to {file}'.format(
  134. num=len(imports),
  135. file=path,
  136. imports=", ".join([x['name'] for x in imports])
  137. ))
  138. fmt = '{name}' + symbol + '{version}'
  139. out_file.write('\n'.join(
  140. fmt.format(**item) if item['version'] else '{name}'.format(**item)
  141. for item in imports) + '\n')
  142. def output_requirements(imports, symbol):
  143. generate_requirements_file('-', imports, symbol)
  144. def get_imports_info(
  145. imports, pypi_server="https://pypi.python.org/pypi/", proxy=None):
  146. result = []
  147. for item in imports:
  148. try:
  149. response = requests.get(
  150. "{0}{1}/json".format(pypi_server, item), proxies=proxy)
  151. if response.status_code == 200:
  152. if hasattr(response.content, 'decode'):
  153. data = json2package(response.content.decode())
  154. else:
  155. data = json2package(response.content)
  156. elif response.status_code >= 300:
  157. raise HTTPError(status_code=response.status_code,
  158. reason=response.reason)
  159. except HTTPError:
  160. logging.debug(
  161. 'Package %s does not exist or network problems', item)
  162. continue
  163. result.append({'name': item, 'version': data.latest_release_id})
  164. return result
  165. def get_locally_installed_packages(encoding=None):
  166. packages = {}
  167. ignore = ["tests", "_tests", "egg", "EGG", "info"]
  168. for path in sys.path:
  169. for root, dirs, files in os.walk(path):
  170. for item in files:
  171. if "top_level" in item:
  172. item = os.path.join(root, item)
  173. with open(item, "r", encoding=encoding) as f:
  174. package = root.split(os.sep)[-1].split("-")
  175. try:
  176. package_import = f.read().strip().split("\n")
  177. except: # NOQA
  178. # TODO: What errors do we intend to suppress here?
  179. continue
  180. for i_item in package_import:
  181. if ((i_item not in ignore) and
  182. (package[0] not in ignore)):
  183. version = None
  184. if len(package) > 1:
  185. version = package[1].replace(
  186. ".dist", "").replace(".egg", "")
  187. packages[i_item] = {
  188. 'version': version,
  189. 'name': package[0]
  190. }
  191. return packages
  192. def get_import_local(imports, encoding=None):
  193. local = get_locally_installed_packages()
  194. result = []
  195. for item in imports:
  196. if item.lower() in local:
  197. result.append(local[item.lower()])
  198. # removing duplicates of package/version
  199. result_unique = [
  200. dict(t)
  201. for t in set([
  202. tuple(d.items()) for d in result
  203. ])
  204. ]
  205. return result_unique
  206. def get_pkg_names(pkgs):
  207. """Get PyPI package names from a list of imports.
  208. Args:
  209. pkgs (List[str]): List of import names.
  210. Returns:
  211. List[str]: The corresponding PyPI package names.
  212. """
  213. result = set()
  214. with open(join("mapping"), "r") as f:
  215. data = dict(x.strip().split(":") for x in f)
  216. for pkg in pkgs:
  217. # Look up the mapped requirement. If a mapping isn't found,
  218. # simply use the package name.
  219. result.add(data.get(pkg, pkg))
  220. # Return a sorted list for backward compatibility.
  221. return sorted(result, key=lambda s: s.lower())
  222. def get_name_without_alias(name):
  223. if "import " in name:
  224. match = REGEXP[0].match(name.strip())
  225. if match:
  226. name = match.groups(0)[0]
  227. return name.partition(' as ')[0].partition('.')[0].strip()
  228. def join(f):
  229. return os.path.join(os.path.dirname(__file__), f)
  230. def parse_requirements(file_):
  231. """Parse a requirements formatted file.
  232. Traverse a string until a delimiter is detected, then split at said
  233. delimiter, get module name by element index, create a dict consisting of
  234. module:version, and add dict to list of parsed modules.
  235. Args:
  236. file_: File to parse.
  237. Raises:
  238. OSerror: If there's any issues accessing the file.
  239. Returns:
  240. tuple: The contents of the file, excluding comments.
  241. """
  242. modules = []
  243. # For the dependency identifier specification, see
  244. # https://www.python.org/dev/peps/pep-0508/#complete-grammar
  245. delim = ["<", ">", "=", "!", "~"]
  246. try:
  247. f = open(file_, "r")
  248. except OSError:
  249. logging.error("Failed on file: {}".format(file_))
  250. raise
  251. else:
  252. try:
  253. data = [x.strip() for x in f.readlines() if x != "\n"]
  254. finally:
  255. f.close()
  256. data = [x for x in data if x[0].isalpha()]
  257. for x in data:
  258. # Check for modules w/o a specifier.
  259. if not any([y in x for y in delim]):
  260. modules.append({"name": x, "version": None})
  261. for y in x:
  262. if y in delim:
  263. module = x.split(y)
  264. module_name = module[0]
  265. module_version = module[-1].replace("=", "")
  266. module = {"name": module_name, "version": module_version}
  267. if module not in modules:
  268. modules.append(module)
  269. break
  270. return modules
  271. def compare_modules(file_, imports):
  272. """Compare modules in a file to imported modules in a project.
  273. Args:
  274. file_ (str): File to parse for modules to be compared.
  275. imports (tuple): Modules being imported in the project.
  276. Returns:
  277. tuple: The modules not imported in the project, but do exist in the
  278. specified file.
  279. """
  280. modules = parse_requirements(file_)
  281. imports = [imports[i]["name"] for i in range(len(imports))]
  282. modules = [modules[i]["name"] for i in range(len(modules))]
  283. modules_not_imported = set(modules) - set(imports)
  284. return modules_not_imported
  285. def diff(file_, imports):
  286. """Display the difference between modules in a file and imported modules.""" # NOQA
  287. modules_not_imported = compare_modules(file_, imports)
  288. logging.info(
  289. "The following modules are in {} but do not seem to be imported: "
  290. "{}".format(file_, ", ".join(x for x in modules_not_imported)))
  291. def clean(file_, imports):
  292. """Remove modules that aren't imported in project from file."""
  293. modules_not_imported = compare_modules(file_, imports)
  294. if len(modules_not_imported) == 0:
  295. logging.info("Nothing to clean in " + file_)
  296. return
  297. re_remove = re.compile("|".join(modules_not_imported))
  298. to_write = []
  299. try:
  300. f = open(file_, "r+")
  301. except OSError:
  302. logging.error("Failed on file: {}".format(file_))
  303. raise
  304. else:
  305. try:
  306. for i in f.readlines():
  307. if re_remove.match(i) is None:
  308. to_write.append(i)
  309. f.seek(0)
  310. f.truncate()
  311. for i in to_write:
  312. f.write(i)
  313. finally:
  314. f.close()
  315. logging.info("Successfully cleaned up requirements in " + file_)
  316. def dynamic_versioning(scheme, imports):
  317. """Enables dynamic versioning with <compat>, <gt> or <non-pin> schemes."""
  318. if scheme == "no-pin":
  319. imports = [{"name": item["name"], "version": ""} for item in imports]
  320. symbol = ""
  321. elif scheme == "gt":
  322. symbol = ">="
  323. elif scheme == "compat":
  324. symbol = "~="
  325. return imports, symbol
  326. def init(args):
  327. encoding = args.get('--encoding')
  328. extra_ignore_dirs = args.get('--ignore')
  329. follow_links = not args.get('--no-follow-links')
  330. input_path = args['<path>']
  331. if input_path is None:
  332. input_path = os.path.abspath(os.curdir)
  333. if extra_ignore_dirs:
  334. extra_ignore_dirs = extra_ignore_dirs.split(',')
  335. candidates = get_all_imports(input_path,
  336. encoding=encoding,
  337. extra_ignore_dirs=extra_ignore_dirs,
  338. follow_links=follow_links)
  339. candidates = get_pkg_names(candidates)
  340. logging.debug("Found imports: " + ", ".join(candidates))
  341. pypi_server = "https://pypi.python.org/pypi/"
  342. proxy = None
  343. if args["--pypi-server"]:
  344. pypi_server = args["--pypi-server"]
  345. if args["--proxy"]:
  346. proxy = {'http': args["--proxy"], 'https': args["--proxy"]}
  347. if args["--use-local"]:
  348. logging.debug(
  349. "Getting package information ONLY from local installation.")
  350. imports = get_import_local(candidates, encoding=encoding)
  351. else:
  352. logging.debug("Getting packages information from Local/PyPI")
  353. local = get_import_local(candidates, encoding=encoding)
  354. # Get packages that were not found locally
  355. difference = [x for x in candidates
  356. if x.lower() not in [z['name'].lower() for z in local]]
  357. imports = local + get_imports_info(difference,
  358. proxy=proxy,
  359. pypi_server=pypi_server)
  360. # sort imports based on lowercase name of package, similar to `pip freeze`.
  361. imports = sorted(imports, key=lambda x: x['name'].lower())
  362. path = (args["--savepath"] if args["--savepath"] else
  363. os.path.join(input_path, "requirements.txt"))
  364. if args["--diff"]:
  365. diff(args["--diff"], imports)
  366. return
  367. if args["--clean"]:
  368. clean(args["--clean"], imports)
  369. return
  370. if (not args["--print"]
  371. and not args["--savepath"]
  372. and not args["--force"]
  373. and os.path.exists(path)):
  374. logging.warning("requirements.txt already exists, "
  375. "use --force to overwrite it")
  376. return
  377. if args["--mode"]:
  378. scheme = args.get("--mode")
  379. if scheme in ["compat", "gt", "no-pin"]:
  380. imports, symbol = dynamic_versioning(scheme, imports)
  381. else:
  382. raise ValueError("Invalid argument for mode flag, "
  383. "use 'compat', 'gt' or 'no-pin' instead")
  384. else:
  385. symbol = "=="
  386. if args["--print"]:
  387. output_requirements(imports, symbol)
  388. logging.info("Successfully output requirements")
  389. else:
  390. generate_requirements_file(path, imports, symbol)
  391. logging.info("Successfully saved requirements file in " + path)
  392. def main(): # pragma: no cover
  393. args = docopt(__doc__, version=__version__)
  394. log_level = logging.DEBUG if args['--debug'] else logging.INFO
  395. logging.basicConfig(level=log_level, format='%(levelname)s: %(message)s')
  396. try:
  397. init(args)
  398. except KeyboardInterrupt:
  399. sys.exit(0)
  400. if __name__ == '__main__':
  401. main() # pragma: no cover