check_python_dependencies.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. #!/usr/bin/env python
  2. #
  3. # SPDX-FileCopyrightText: 2018-2023 Espressif Systems (Shanghai) CO LTD
  4. # SPDX-License-Identifier: Apache-2.0
  5. import argparse
  6. import os
  7. import re
  8. import sys
  9. try:
  10. from packaging.requirements import Requirement
  11. from packaging.version import Version
  12. except ImportError:
  13. print('packaging cannot be imported. '
  14. 'If you\'ve installed a custom Python then this package is provided separately and have to be installed as well. '
  15. 'Please refer to the Get Started section of the ESP-IDF Programming Guide for setting up the required packages.')
  16. sys.exit(1)
  17. try:
  18. from importlib.metadata import PackageNotFoundError, requires
  19. from importlib.metadata import version as get_version
  20. except ImportError:
  21. # compatibility for python <=3.7
  22. from importlib_metadata import PackageNotFoundError, requires # type: ignore
  23. from importlib_metadata import version as get_version # type: ignore
  24. try:
  25. from typing import Set
  26. except ImportError:
  27. # This is a script run during the early phase of setting up the environment. So try to avoid failure caused by
  28. # Python version incompatibility. The supported Python version is checked elsewhere.
  29. pass
  30. PYTHON_PACKAGE_RE = re.compile(r'[^<>=~]+')
  31. if __name__ == '__main__':
  32. parser = argparse.ArgumentParser(description='ESP-IDF Python package dependency checker')
  33. parser.add_argument('--requirements', '-r',
  34. help='Path to a requirements file (can be used multiple times)',
  35. action='append', default=[])
  36. parser.add_argument('--constraints', '-c', default=[],
  37. help='Path to a constraints file (can be used multiple times)',
  38. action='append')
  39. args = parser.parse_args()
  40. required_set = set()
  41. for req_path in args.requirements:
  42. with open(req_path) as f:
  43. required_set |= set(i for i in map(str.strip, f.readlines()) if len(i) > 0 and not i.startswith('#'))
  44. constr_dict = {} # for example package_name -> package_name==1.0
  45. for const_path in args.constraints:
  46. with open(const_path) as f:
  47. for con in [i for i in map(str.strip, f.readlines()) if len(i) > 0 and not i.startswith('#')]:
  48. if con.startswith('file://'):
  49. con = os.path.basename(con)
  50. elif con.startswith('--only-binary'):
  51. continue
  52. elif con.startswith('-e') and '#egg=' in con: # version control URLs, take the egg= part at the end only
  53. con_m = re.search(r'#egg=([^\s]+)', con)
  54. if not con_m:
  55. print('Malformed input. Cannot find name in {}'.format(con))
  56. sys.exit(1)
  57. con = con_m[1]
  58. name_m = PYTHON_PACKAGE_RE.search(con)
  59. if not name_m:
  60. print('Malformed input. Cannot find name in {}'.format(con))
  61. sys.exit(1)
  62. constr_dict[name_m[0]] = con.partition(' #')[0] # remove comments
  63. not_satisfied = [] # in string form which will be printed
  64. # already_checked set is used in order to avoid circular checks which would cause looping.
  65. already_checked = set() # type: Set[Requirement]
  66. # required_set contains package names in string form without version constraints. If the package has a constraint
  67. # specification (package name + version requirement) then use that instead. new_req_list is used to store
  68. # requirements to be checked on each level of breath-first-search of the package dependency tree. The initial
  69. # version is the direct dependencies deduced from the requirements arguments of the script.
  70. new_req_list = [Requirement(constr_dict.get(i, i)) for i in required_set]
  71. def version_check(requirement: Requirement) -> None:
  72. # compare installed version with required
  73. version = Version(get_version(requirement.name))
  74. if not requirement.specifier.contains(version, prereleases=True):
  75. not_satisfied.append(f"Requirement '{requirement}' was not met. Installed version: {version}")
  76. # evaluate markers and check versions of direct requirements
  77. for req in new_req_list[:]:
  78. if not req.marker or req.marker.evaluate():
  79. try:
  80. version_check(req)
  81. except PackageNotFoundError as e:
  82. not_satisfied.append(f"'{e}' - was not found and is required by the application")
  83. new_req_list.remove(req)
  84. else:
  85. new_req_list.remove(req)
  86. while new_req_list:
  87. req_list = new_req_list
  88. new_req_list = []
  89. already_checked.update(req_list)
  90. for requirement in req_list: # check one level of the dependency tree
  91. try:
  92. dependency_requirements = set()
  93. extras = list(requirement.extras) or ['']
  94. # `requires` returns all sub-requirements including all extras - we need to filter out just required ones
  95. for name in requires(requirement.name) or []:
  96. sub_req = Requirement(name)
  97. # check extras e.g. esptool[hsm]
  98. for extra in extras:
  99. # evaluate markers if present
  100. if not sub_req.marker or sub_req.marker.evaluate(environment={'extra': extra}):
  101. dependency_requirements.add(sub_req)
  102. version_check(sub_req)
  103. # dependency_requirements are the direct dependencies of "requirement". They belong to the next level
  104. # of the dependency tree. They will be checked only if they haven't been already. Note that the
  105. # version is taken into account as well because packages can have different requirements for a given
  106. # Python package. The dependencies need to be checked for all of them because they can be different.
  107. new_req_list.extend(dependency_requirements - already_checked)
  108. except PackageNotFoundError as e:
  109. not_satisfied.append(f"'{e}' - was not found and is required by the application")
  110. if len(not_satisfied) > 0:
  111. print('The following Python requirements are not satisfied:')
  112. print(os.linesep.join(not_satisfied))
  113. if 'IDF_PYTHON_ENV_PATH' in os.environ:
  114. # We are running inside a private virtual environment under IDF_TOOLS_PATH,
  115. # ask the user to run install.bat again.
  116. install_script = 'install.bat' if sys.platform == 'win32' else 'install.sh'
  117. print('To install the missing packages, please run "{}"'.format(install_script))
  118. else:
  119. print('Please follow the instructions found in the "Set up the tools" section of '
  120. 'ESP-IDF Getting Started Guide.')
  121. print('Diagnostic information:')
  122. idf_python_env_path = os.environ.get('IDF_PYTHON_ENV_PATH')
  123. print(' IDF_PYTHON_ENV_PATH: {}'.format(idf_python_env_path or '(not set)'))
  124. print(' Python interpreter used: {}'.format(sys.executable))
  125. if not idf_python_env_path or idf_python_env_path not in sys.executable:
  126. print(' Warning: python interpreter not running from IDF_PYTHON_ENV_PATH')
  127. print(' PATH: {}'.format(os.getenv('PATH')))
  128. sys.exit(1)
  129. print('Python requirements are satisfied.')