split_paths_by_spaces.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. #!/usr/bin/env python
  2. # coding=utf-8
  3. #
  4. # SPDX-FileCopyrightText: 2021-2022 Espressif Systems (Shanghai) CO LTD
  5. #
  6. # SPDX-License-Identifier: Apache-2.0
  7. #
  8. # This script converts space-separated EXTRA_COMPONENT_DIRS and COMPONENT_DIRS
  9. # CMake variables into semicolon-separated lists.
  10. #
  11. # IDF versions <=v4.4 didn't support spaces in paths to ESP-IDF or projects.
  12. # Therefore it was okay to use spaces as separators in EXTRA_COMPONENT_DIRS,
  13. # same as it was done in the legacy GNU Make based build system.
  14. # CMake build system used 'spaces2list' function to convert space-separated
  15. # variables into semicolon-separated lists, replacing every space with a
  16. # semicolon.
  17. #
  18. # In IDF 5.0 and later, spaces in project path and ESP-IDF path are supported.
  19. # This means that EXTRA_COMPONENT_DIRS and COMPONENT_DIRS variables now should
  20. # be semicolon-separated CMake lists.
  21. #
  22. # To provide compatibility with the projects written for older ESP-IDF versions,
  23. # this script attempts to convert these space-separated variables into semicolon-
  24. # separated ones. Note that in general this cannot be done unambiguously, so this
  25. # script will still report an error if there are multiple ways to interpret the
  26. # variable, and ask the user to fix the project CMakeLists.txt file.
  27. #
  28. import argparse
  29. import os
  30. import pprint
  31. import sys
  32. import textwrap
  33. import typing
  34. import unittest
  35. class PathSplitError(RuntimeError):
  36. pass
  37. def main() -> None:
  38. parser = argparse.ArgumentParser()
  39. parser.add_argument('--var-name', required=True, help='Name of CMake variable, for printing errors and warnings')
  40. parser.add_argument('in_variable', help='Input variable, may contain a mix of spaces and semicolons as separators')
  41. args = parser.parse_args()
  42. # Initially split the paths by semicolons
  43. semicolon_separated_parts = args.in_variable.split(';')
  44. # Every resulting part may contain space separators. Handle each part:
  45. paths = []
  46. ctx = dict(warnings=False)
  47. errors = False
  48. for part in semicolon_separated_parts:
  49. def warning_cb(warning_str: str) -> None:
  50. print('\n '.join(
  51. textwrap.wrap('Warning: in CMake variable {}: {}'.format(args.var_name, warning_str), width=120,
  52. break_on_hyphens=False)), file=sys.stderr)
  53. ctx['warnings'] = True
  54. try:
  55. paths += split_paths_by_spaces(part, warning_cb=warning_cb)
  56. except PathSplitError as e:
  57. print('\n '.join(textwrap.wrap('Error: in CMake variable {}: {}'.format(args.var_name, str(e)), width=120,
  58. break_on_hyphens=False)), file=sys.stderr)
  59. errors = True
  60. if errors or ctx['warnings']:
  61. print(textwrap.dedent("""
  62. Note: In ESP-IDF v5.0 and later, COMPONENT_DIRS and EXTRA_COMPONENT_DIRS should be defined
  63. as CMake lists, not as space separated strings.
  64. Examples:
  65. * set(EXTRA_COMPONENT_DIRS path/to/components path/to/more/components)
  66. # Correct, EXTRA_COMPONENT_DIRS is defined as a CMake list, with two paths added
  67. * list(APPEND EXTRA_COMPONENT_DIRS path/to/component)
  68. list(APPEND EXTRA_COMPONENT_DIRS path/to/more/components)
  69. # Correct, use when building EXTRA_COMPONENT_DIRS incrementally
  70. * set(EXTRA_COMPONENT_DIRS path/to/components "another/path with space/components")
  71. # Literal path with spaces has to be quoted
  72. * set(EXTRA_COMPONENT_DIRS $ENV{MY_PATH}/components dir/more_components)
  73. # Correct, even if MY_PATH contains spaces
  74. * set(EXTRA_COMPONENT_DIRS ${ROOT}/component1 ${ROOT}/component2 ${ROOT}/component3)
  75. # Correct, even if ROOT contains spaces
  76. Avoid string concatenation!
  77. set(EXTRA_COMPONENT_DIRS "${EXTRA_COMPONENT_DIRS} component1")
  78. set(EXTRA_COMPONENT_DIRS "${EXTRA_COMPONENT_DIRS} component2")
  79. # Incorrect. String "component1 component2" may indicate a single directory
  80. # name with a space, or two directory names separated by space.
  81. Instead use:
  82. list(APPEND component1)
  83. list(APPEND component2)
  84. Defining COMPONENT_DIRS and EXTRA_COMPONENT_DIRS as CMake lists is backwards compatible
  85. with ESP-IDF 4.4 and below.
  86. (If you think these variables are defined correctly in your project and this message
  87. is not relevant, please report this as an issue.)
  88. """), file=sys.stderr)
  89. print('Diagnostic info: {} was invoked in {} with arguments: {}'.format(
  90. sys.argv[0], os.getcwd(), sys.argv[1:]
  91. ), file=sys.stderr)
  92. if errors:
  93. raise SystemExit(1)
  94. sys.stdout.write(';'.join(paths))
  95. sys.stdout.flush()
  96. def split_paths_by_spaces(src: str, path_exists_cb: typing.Callable[[str], bool] = os.path.exists,
  97. warning_cb: typing.Optional[typing.Callable[[str], None]] = None) -> typing.List[str]:
  98. if ' ' not in src:
  99. # no spaces, complete string should be the path
  100. return [src]
  101. def path_exists_or_empty(path: str) -> bool:
  102. return path == '' or path_exists_cb(path)
  103. # remove leading and trailing spaces
  104. delayed_warnings = []
  105. trimmed = src.lstrip(' ')
  106. if trimmed != src:
  107. delayed_warnings.append("Path component '{}' contains leading spaces".format(src))
  108. src = trimmed
  109. trimmed = src.rstrip(' ')
  110. if trimmed != src:
  111. delayed_warnings.append("Path component '{}' contains trailing spaces".format(src))
  112. src = trimmed
  113. # Enumerate all possible ways to split the string src into paths by spaces.
  114. # The number of these ways is equal to sum(C(n, k), 0<=k<n) == 2^n
  115. # (where n is the number of spaces, k is the number of splits, C(n, k) are binomial coefficients)
  116. #
  117. # We do this by associating every space with a bit of an integer in the range [0, 2^n - 1],
  118. # such that when the bit is 0 there is no split in the given space, and bit is 1 when there is a split.
  119. parts = src.split(' ')
  120. num_spaces = len(parts) - 1
  121. valid_ways_to_split = []
  122. all_ways_to_split = [selective_join(parts, i) for i in range(2 ** num_spaces)]
  123. for paths_list in all_ways_to_split:
  124. nonempty_paths = list(filter(bool, paths_list))
  125. if all(map(path_exists_or_empty, nonempty_paths)):
  126. valid_ways_to_split.append(nonempty_paths)
  127. num_candidates = len(valid_ways_to_split)
  128. if num_candidates == 1:
  129. # Success, found only one correct way to split.
  130. result = valid_ways_to_split[0]
  131. # Report warnings
  132. if warning_cb:
  133. if len(result) > 1:
  134. warning_cb("Path component '{}' contains a space separator. It was automatically split into {}".format(
  135. src, pprint.pformat(result)
  136. ))
  137. for w in delayed_warnings:
  138. warning_cb(w)
  139. return result
  140. if num_candidates == 0:
  141. raise PathSplitError(("Didn't find a valid way to split path '{}'. "
  142. 'This error may be reported if one or more paths '
  143. "are separated with spaces, and at least one path doesn't exist.").format(src))
  144. # if num_candidates > 1
  145. raise PathSplitError("Found more than one valid way to split path '{}':{}".format(
  146. src, ''.join('\n\t- ' + pprint.pformat(p) for p in valid_ways_to_split)
  147. ))
  148. def selective_join(parts: typing.List[str], n: int) -> typing.List[str]:
  149. """
  150. Given the list of N+1 strings, and an integer n in [0, 2**N - 1] range,
  151. concatenate i-th and (i+1)-th string with space inbetween if bit i is not set in n.
  152. Examples:
  153. selective_join(['a', 'b', 'c'], 0b00) == ['a b c']
  154. selective_join(['a', 'b', 'c'], 0b01) == ['a', 'b c']
  155. selective_join(['a', 'b', 'c'], 0b10) == ['a b', 'c']
  156. selective_join(['a', 'b', 'c'], 0b11) == ['a', 'b', 'c']
  157. This function is used as part of finding all the ways to split a string by spaces.
  158. :param parts: Strings to join
  159. :param n: Integer (bit map) to set the positions to join
  160. :return: resulting list of strings
  161. """
  162. result = []
  163. concatenated = [parts[0]]
  164. for part in parts[1:]:
  165. if n & 1:
  166. result.append(' '.join(concatenated))
  167. concatenated = [part]
  168. else:
  169. concatenated.append(part)
  170. n >>= 1
  171. if concatenated:
  172. result.append(' '.join(concatenated))
  173. return result
  174. class HelperTests(unittest.TestCase):
  175. def test_selective_join(self) -> None:
  176. self.assertListEqual(['a b c'], selective_join(['a', 'b', 'c'], 0b00))
  177. self.assertListEqual(['a', 'b c'], selective_join(['a', 'b', 'c'], 0b01))
  178. self.assertListEqual(['a b', 'c'], selective_join(['a', 'b', 'c'], 0b10))
  179. self.assertListEqual(['a', 'b', 'c'], selective_join(['a', 'b', 'c'], 0b11))
  180. class SplitTests(unittest.TestCase):
  181. def test_split_paths_absolute(self) -> None:
  182. self.check_paths_concatenated('/absolute/path/one', '/absolute/path/two')
  183. def test_split_paths_absolute_spaces(self) -> None:
  184. self.check_paths_concatenated('/absolute/path with spaces')
  185. self.check_paths_concatenated('/absolute/path with more spaces')
  186. self.check_paths_concatenated('/absolute/path with spaces/one', '/absolute/path with spaces/two')
  187. self.check_paths_concatenated('/absolute/path with spaces/one',
  188. '/absolute/path with spaces/two',
  189. '/absolute/path with spaces/three')
  190. def test_split_paths_absolute_relative(self) -> None:
  191. self.check_paths_concatenated('/absolute/path/one', 'two')
  192. def test_split_paths_relative(self) -> None:
  193. self.check_paths_concatenated('one', 'two')
  194. def test_split_paths_absolute_spaces_relative(self) -> None:
  195. self.check_paths_concatenated('/absolute/path with spaces/one', 'two')
  196. def test_split_paths_ambiguous(self) -> None:
  197. self.check_paths_concatenated_ambiguous('/absolute/path one', 'two',
  198. additional_paths_exist=['/absolute/path', 'one'])
  199. self.check_paths_concatenated_ambiguous('/path ', '/path',
  200. additional_paths_exist=['/path /path'])
  201. def test_split_paths_nonexistent(self) -> None:
  202. self.check_paths_concatenated_nonexistent('one', 'two')
  203. def test_split_paths_extra_whitespace(self) -> None:
  204. paths = ['/path']
  205. path_exists = self.path_exists_by_list(paths)
  206. self.assertListEqual(paths, split_paths_by_spaces(' /path', path_exists_cb=path_exists))
  207. self.assertListEqual(paths, split_paths_by_spaces('/path ', path_exists_cb=path_exists))
  208. self.assertListEqual(paths + paths, split_paths_by_spaces('/path /path', path_exists_cb=path_exists))
  209. def test_split_paths_warnings(self) -> None:
  210. paths = ['/path']
  211. ctx = {'warnings': []} # type: typing.Dict[str, typing.List[str]]
  212. def add_warning(warning: str) -> None:
  213. ctx['warnings'].append(warning)
  214. path_exists = self.path_exists_by_list(paths)
  215. self.assertListEqual(paths,
  216. split_paths_by_spaces(' /path', path_exists_cb=path_exists, warning_cb=add_warning))
  217. self.assertEqual(1, len(ctx['warnings']))
  218. self.assertIn('leading', ctx['warnings'][0])
  219. ctx['warnings'] = []
  220. self.assertListEqual(paths,
  221. split_paths_by_spaces('/path ', path_exists_cb=path_exists, warning_cb=add_warning))
  222. self.assertEqual(1, len(ctx['warnings']))
  223. self.assertIn('trailing', ctx['warnings'][0])
  224. ctx['warnings'] = []
  225. self.assertListEqual(paths + paths,
  226. split_paths_by_spaces('/path /path', path_exists_cb=path_exists, warning_cb=add_warning))
  227. self.assertEqual(1, len(ctx['warnings']))
  228. self.assertIn('contains a space separator', ctx['warnings'][0])
  229. @staticmethod
  230. def path_exists_by_list(paths_which_exist: typing.List[str]) -> typing.Callable[[str], bool]:
  231. """
  232. Returns a function to check whether a path exists, similar to os.path.exists, but instead of checking
  233. for files on the real filesystem it considers only the paths provided in 'paths_which_exist' argument.
  234. :param paths_which_exist: list of paths which should be considered as existing
  235. :return: function to check if path exists
  236. """
  237. all_paths = set()
  238. for path in paths_which_exist or []:
  239. # for path /a/b/c, add it and also add components of the path: /a, /a/b
  240. end = len(path)
  241. while end > 0:
  242. all_paths.add(path[0:end])
  243. end = path.rfind('/', 0, end)
  244. def path_exists(path: str) -> bool:
  245. return path in all_paths
  246. return path_exists
  247. def split_paths_concatenated_base(self, paths_to_concatentate: typing.List[str],
  248. paths_existing: typing.List[str]) -> typing.List[str]:
  249. concatenated = ' '.join(paths_to_concatentate)
  250. path_exists = self.path_exists_by_list(paths_existing)
  251. return split_paths_by_spaces(concatenated, path_exists_cb=path_exists)
  252. def check_paths_concatenated(self, *args: str) -> None:
  253. paths = [*args]
  254. paths_split = self.split_paths_concatenated_base(paths_to_concatentate=paths, paths_existing=paths)
  255. self.assertListEqual(paths, paths_split)
  256. def check_paths_concatenated_ambiguous(self, *args: str,
  257. additional_paths_exist: typing.Optional[typing.List[str]] = None) -> None:
  258. paths = [*args]
  259. self.assertRaises(PathSplitError, self.split_paths_concatenated_base, paths_to_concatentate=paths,
  260. paths_existing=paths + (additional_paths_exist or []))
  261. def check_paths_concatenated_nonexistent(self, *args: str,
  262. additional_paths_exist: typing.List[str] = None) -> None:
  263. paths = [*args]
  264. self.assertRaises(PathSplitError, self.split_paths_concatenated_base, paths_to_concatentate=paths,
  265. paths_existing=additional_paths_exist)
  266. if __name__ == '__main__':
  267. main()