pyproject_toml.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. # Copyright (C) 2025 The Qt Company Ltd.
  2. # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
  3. from __future__ import annotations
  4. import os
  5. import sys
  6. # TODO: Remove this import when Python 3.11 is the minimum supported version
  7. if sys.version_info >= (3, 11):
  8. import tomllib
  9. from pathlib import Path
  10. from . import PYPROJECT_JSON_PATTERN
  11. from .pyproject_parse_result import PyProjectParseResult
  12. from .pyproject_json import parse_pyproject_json
  13. def _parse_toml_content(content: str) -> dict:
  14. """
  15. Parse TOML content for project name and files list only.
  16. """
  17. result = {"project": {}, "tool": {"pyside6-project": {}}}
  18. current_section = None
  19. for line in content.splitlines():
  20. line = line.strip()
  21. if not line or line.startswith('#'):
  22. continue
  23. if line == '[project]':
  24. current_section = 'project'
  25. elif line == '[tool.pyside6-project]':
  26. current_section = 'tool.pyside6-project'
  27. elif '=' in line and current_section:
  28. key, value = [part.strip() for part in line.split('=', 1)]
  29. # Handle string values - name of the project
  30. if value.startswith('"') and value.endswith('"'):
  31. value = value[1:-1]
  32. # Handle array of strings - files names
  33. elif value.startswith('[') and value.endswith(']'):
  34. items = value[1:-1].split(',')
  35. value = [item.strip().strip('"') for item in items if item.strip()]
  36. if current_section == 'project':
  37. result['project'][key] = value
  38. else: # tool.pyside6-project
  39. result['tool']['pyside6-project'][key] = value
  40. return result
  41. def _write_base_toml_content(data: dict) -> str:
  42. """
  43. Write minimal TOML content with project and tool.pyside6-project sections.
  44. """
  45. lines = []
  46. if data.get('project'):
  47. lines.append('[project]')
  48. for key, value in sorted(data['project'].items()):
  49. if isinstance(value, str):
  50. lines.append(f'{key} = "{value}"')
  51. if data.get("tool") and data['tool'].get('pyside6-project'):
  52. lines.append('\n[tool.pyside6-project]')
  53. for key, value in sorted(data['tool']['pyside6-project'].items()):
  54. if isinstance(value, list):
  55. items = [f'"{item}"' for item in sorted(value)]
  56. lines.append(f'{key} = [{", ".join(items)}]')
  57. else:
  58. lines.append(f'{key} = "{value}"')
  59. return '\n'.join(lines)
  60. def parse_pyproject_toml(pyproject_toml_file: Path) -> PyProjectParseResult:
  61. """
  62. Parse a pyproject.toml file and return a PyProjectParseResult object.
  63. """
  64. result = PyProjectParseResult()
  65. try:
  66. content = pyproject_toml_file.read_text(encoding='utf-8')
  67. # TODO: Remove the manual parsing when Python 3.11 is the minimum supported version
  68. if sys.version_info >= (3, 11):
  69. root_table = tomllib.loads(content) # Use tomllib for Python >= 3.11
  70. print("Using tomllib for parsing TOML content")
  71. else:
  72. root_table = _parse_toml_content(content) # Fallback to manual parsing
  73. except Exception as e:
  74. result.errors.append(str(e))
  75. return result
  76. pyside_table = root_table.get("tool", {}).get("pyside6-project", {})
  77. if not pyside_table:
  78. result.errors.append("Missing [tool.pyside6-project] table")
  79. return result
  80. files = pyside_table.get("files", [])
  81. if not isinstance(files, list):
  82. result.errors.append("Missing or invalid files list")
  83. return result
  84. # Convert paths
  85. for file in files:
  86. if not isinstance(file, str):
  87. result.errors.append(f"Invalid file: {file}")
  88. return result
  89. file_path = Path(file)
  90. if not file_path.is_absolute():
  91. file_path = (pyproject_toml_file.parent / file).resolve()
  92. result.files.append(file_path)
  93. return result
  94. def write_pyproject_toml(pyproject_file: Path, project_name: str, project_files: list[str]):
  95. """
  96. Create or overwrite a pyproject.toml file with the specified content.
  97. """
  98. data = {
  99. "project": {"name": project_name},
  100. "tool": {
  101. "pyside6-project": {"files": sorted(project_files)}
  102. }
  103. }
  104. content = _write_base_toml_content(data)
  105. try:
  106. pyproject_file.write_text(content, encoding='utf-8')
  107. except Exception as e:
  108. raise ValueError(f"Error writing TOML file: {str(e)}")
  109. def robust_relative_to_posix(target_path: Path, base_path: Path) -> str:
  110. """
  111. Calculates the relative path from base_path to target_path.
  112. Uses Path.relative_to first, falls back to os.path.relpath if it fails.
  113. Returns the result as a POSIX path string.
  114. """
  115. # Ensure both paths are absolute for reliable calculation, although in this specific code,
  116. # project_folder and paths in output_files are expected to be resolved/absolute already.
  117. abs_target = target_path.resolve() if not target_path.is_absolute() else target_path
  118. abs_base = base_path.resolve() if not base_path.is_absolute() else base_path
  119. try:
  120. return abs_target.relative_to(abs_base).as_posix()
  121. except ValueError:
  122. # Fallback to os.path.relpath which is more robust for paths that are not direct subpaths.
  123. relative_str = os.path.relpath(str(abs_target), str(abs_base))
  124. # Convert back to Path temporarily to get POSIX format
  125. return Path(relative_str).as_posix()
  126. def migrate_pyproject(pyproject_file: Path | str = None) -> int:
  127. """
  128. Migrate a project *.pyproject JSON file to the new pyproject.toml format.
  129. The containing subprojects are migrated recursively.
  130. :return: 0 if successful, 1 if an error occurred.
  131. """
  132. project_name = None
  133. # Transform the user input string into a Path object
  134. if isinstance(pyproject_file, str):
  135. pyproject_file = Path(pyproject_file)
  136. if pyproject_file:
  137. if not pyproject_file.match(PYPROJECT_JSON_PATTERN):
  138. print(f"Cannot migrate non \"{PYPROJECT_JSON_PATTERN}\" file:", file=sys.stderr)
  139. print(f"\"{pyproject_file}\"", file=sys.stderr)
  140. return 1
  141. project_files = [pyproject_file]
  142. project_name = pyproject_file.stem
  143. else:
  144. # Get the existing *.pyproject files in the current directory
  145. project_files = list(Path().glob(PYPROJECT_JSON_PATTERN))
  146. if not project_files:
  147. print(f"No project file found in the current directory: {Path()}", file=sys.stderr)
  148. return 1
  149. if len(project_files) > 1:
  150. print("Multiple pyproject files found in the project folder:")
  151. print('\n'.join(str(project_file) for project_file in project_files))
  152. response = input("Continue? y/n: ")
  153. if response.lower().strip() not in {"yes", "y"}:
  154. return 0
  155. else:
  156. # If there is only one *.pyproject file in the current directory,
  157. # use its file name as the project name
  158. project_name = project_files[0].stem
  159. # The project files that will be written to the pyproject.toml file
  160. output_files: set[Path] = set()
  161. for project_file in project_files:
  162. project_data = parse_pyproject_json(project_file)
  163. if project_data.errors:
  164. print(f"Invalid project file: {project_file}. Errors found:", file=sys.stderr)
  165. print('\n'.join(project_data.errors), file=sys.stderr)
  166. return 1
  167. output_files.update(project_data.files)
  168. project_folder = project_files[0].parent.resolve()
  169. if project_name is None:
  170. # If a project name has not resolved, use the name of the parent folder
  171. project_name = project_folder.name
  172. pyproject_toml_file = project_folder / "pyproject.toml"
  173. relative_files = sorted(
  174. robust_relative_to_posix(p, project_folder) for p in output_files
  175. )
  176. if not (already_existing_file := pyproject_toml_file.exists()):
  177. # Create new pyproject.toml file
  178. data = {
  179. "project": {"name": project_name},
  180. "tool": {
  181. "pyside6-project": {"files": relative_files}
  182. }
  183. }
  184. updated_content = _write_base_toml_content(data)
  185. else:
  186. # For an already existing file, append our tool.pyside6-project section
  187. # If the project section is missing, add it
  188. try:
  189. content = pyproject_toml_file.read_text(encoding='utf-8')
  190. except Exception as e:
  191. print(f"Error processing existing TOML file: {str(e)}", file=sys.stderr)
  192. return 1
  193. append_content = []
  194. if '[project]' not in content:
  195. # Add project section if needed
  196. append_content.append('\n[project]')
  197. append_content.append(f'name = "{project_name}"')
  198. if '[tool.pyside6-project]' not in content:
  199. # Add tool.pyside6-project section
  200. append_content.append('\n[tool.pyside6-project]')
  201. items = [f'"{item}"' for item in relative_files]
  202. append_content.append(f'files = [{", ".join(items)}]')
  203. if append_content:
  204. updated_content = content.rstrip() + '\n' + '\n'.join(append_content)
  205. else:
  206. # No changes needed
  207. print("pyproject.toml already contains [project] and [tool.pyside6-project] sections")
  208. return 0
  209. print(f"WARNING: A pyproject.toml file already exists at \"{pyproject_toml_file}\"")
  210. print("The file will be updated with the following content:")
  211. print(updated_content)
  212. response = input("Proceed? [Y/n] ")
  213. if response.lower().strip() not in {"yes", "y"}:
  214. return 0
  215. try:
  216. pyproject_toml_file.write_text(updated_content, encoding='utf-8')
  217. except Exception as e:
  218. print(f"Error writing to \"{pyproject_toml_file}\": {str(e)}", file=sys.stderr)
  219. return 1
  220. if not already_existing_file:
  221. print(f"Created \"{pyproject_toml_file}\"")
  222. else:
  223. print(f"Updated \"{pyproject_toml_file}\"")
  224. # Recursively migrate the subprojects
  225. for sub_project_file in filter(lambda f: f.match(PYPROJECT_JSON_PATTERN), output_files):
  226. result = migrate_pyproject(sub_project_file)
  227. if result != 0:
  228. return result
  229. return 0