1212import subprocess
1313import sys
1414from urllib .request import urlopen
15+ from urllib .error import HTTPError
1516from pathlib import Path
1617
1718from typing import TYPE_CHECKING
2627
2728
2829def bootstrap_project (parsed_args : "argparse.Namespace" ):
29- """Creates a new F' project"""
30+ """Creates a new F´ project"""
3031
31- # Check Python version
32- if sys .version_info < (3 , 8 ):
33- LOGGER .error (
34- "Python 3.8 or higher is required to use the F´ Python tooling suite. "
35- "Please install Python 3.8 or higher and try again."
36- )
37- return 1
38-
39- # Check if Git is installed and available - needed for cloning F' as submodule
40- if not shutil .which ("git" ):
41- LOGGER .error (
42- "Git is not installed or in PATH. Please install Git and try again." ,
43- )
44- return 1
32+ # Runs system checks such as Python version, OS requirements etc...
33+ run_system_checks ()
34+ # Run contextual checks, such as parent path and project name
35+ run_context_checks (parsed_args .path )
4536
4637 target_dir = Path (parsed_args .path )
4738 # Ask user for project name
48- project_name = input (f"Project name ({ DEFAULT_PROJECT_NAME } ): " )
49- if not is_valid_name (project_name ):
50- return 1
51- elif not project_name :
52- project_name = DEFAULT_PROJECT_NAME
39+ project_name = (
40+ input (f"Project name ({ DEFAULT_PROJECT_NAME } ): " ) or DEFAULT_PROJECT_NAME
41+ )
42+ check_project_name (project_name )
5343
5444 project_path = target_dir / project_name
5545
5646 try :
5747 generate_boilerplate_project (project_path , project_name )
58- setup_git_repo (project_path )
48+ setup_git_repo (project_path , parsed_args . tag )
5949 if not parsed_args .no_venv :
6050 setup_venv (project_path )
6151
6252 print_success_message (project_name )
6353
6454 except (PermissionError , FileExistsError ) as out_directory_error :
65- LOGGER . error (
55+ raise OutDirectoryError (
6656 f"{ out_directory_error } . Please select a different project name or remove the existing directory."
6757 )
68- return 1
6958 except FileNotFoundError as e :
70- LOGGER . error (
59+ raise OutDirectoryError (
7160 f"{ e } . Permission denied to write to the directory." ,
7261 )
73- return 1
7462 return 0
7563
7664
77- def is_valid_name (project_name : str ) -> bool :
65+ def check_project_name (project_name : str ) -> bool :
7866 """Checks if a project name is valid"""
7967 invalid_characters = [
8068 "#" ,
@@ -102,24 +90,64 @@ def is_valid_name(project_name: str) -> bool:
10290 ]
10391 for char in project_name :
10492 if char in invalid_characters :
105- LOGGER . error ( "Invalid character in project name: {}" . format ( char ))
106- LOGGER . error ( "Invalid project name. " )
107- return False
108- return True
93+ raise InvalidProjectName (
94+ f "Invalid character in project name: { char } . "
95+ "Project name cannot contain special characters or spaces."
96+ )
10997
11098
111- def setup_git_repo (project_path : Path ):
112- """Sets up a new git project"""
113- # Retrieve latest F' release
114- with urlopen ("https://api.github.com/repos/nasa/fprime/releases/latest" ) as url :
115- fprime_latest_release = json .loads (url .read ().decode ())
116- latest_tag_name = fprime_latest_release ["tag_name" ]
99+ def run_system_checks ():
100+ """Runs system checks"""
101+ # Check Python version
102+ if sys .version_info < (3 , 8 ):
103+ raise UnsupportedPythonVersion (
104+ "Python 3.8 or higher is required to use the F´ Python tooling suite. "
105+ "Please install Python 3.8 or higher and try again."
106+ )
107+
108+ # Check if Git is installed and available - needed for cloning F´ as submodule
109+ if not shutil .which ("git" ):
110+ raise GitNotInstalled (
111+ "Git is not installed or in PATH. Please install Git and try again."
112+ )
113+
114+ # Check if running on Windows
115+ if sys .platform == "win32" :
116+ raise UnsupportedPlatform (
117+ "F´ does not currently support Windows. Please use WSL (https://learn.microsoft.com/en-us/windows/wsl/about), "
118+ "or a Linux or macOS system. If you are using WSL, please ensure you are running this script from WSL."
119+ )
120+ return 0
117121
122+
123+ def run_context_checks (project_path : Path ):
124+ real_path = Path (project_path ).resolve ()
125+
126+ # Check that no ' " and spaces are in the path and its parents
127+ if any (char in str (real_path .name ) for char in ['"' , "'" , "´" , " " ]):
128+ raise InvalidProjectName (
129+ f"Special characters such as single or double quotes and spaces are not allowed in the project path: { real_path } ."
130+ )
131+
132+ # TODO:
133+ # 1) Ideally we would check that the path is not a symlink here, but it doesn't seem to be doable in Python... ?
134+ # 2) Elegant way of dealing with line endings in Windows (see https://github.com/nasa/fprime/issues/2566)
135+ return 0
136+
137+
138+ def setup_git_repo (project_path : Path , tag : str ):
139+ """Sets up a new git project"""
118140 # Initialize git repository
119141 subprocess .run (["git" , "init" ], cwd = project_path )
120142
121- # Add F' as a submodule
122- LOGGER .info (f"Checking out F´ submodule at latest release: { latest_tag_name } " )
143+ # Retrieve latest F´ release
144+ if tag :
145+ tag_name = tag
146+ else :
147+ tag_name = get_latest_fprime_release ()
148+
149+ # Add F´ as a submodule
150+ LOGGER .info (f"Checking out F´ submodule at version: { tag_name } " )
123151 subprocess .run (
124152 [
125153 "git" ,
@@ -145,19 +173,22 @@ def setup_git_repo(project_path: Path):
145173 fprime_path = project_path / "fprime"
146174
147175 subprocess .run (
148- ["git" , "fetch" , "origin" , "--depth" , "1" , "tag" , latest_tag_name ],
176+ ["git" , "fetch" , "origin" , "--depth" , "1" , "tag" , tag_name ],
149177 cwd = fprime_path ,
150178 capture_output = True ,
151179 )
152180
153181 # Checkout requested branch/tag
154182 res = subprocess .run (
155- ["git" , "checkout" , latest_tag_name ],
183+ ["git" , "checkout" , tag_name ],
156184 cwd = fprime_path ,
157185 capture_output = True ,
158186 )
159187 if res .returncode != 0 :
160- LOGGER .error (f"Unable to checkout tag: { latest_tag_name } . Exit..." )
188+ LOGGER .error (f"Unable to checkout tag: { tag_name } ." )
189+ LOGGER .error (
190+ "Please set the --tag environment variable to a valid F´ release tag and try again."
191+ )
161192 sys .exit (1 )
162193
163194
@@ -208,6 +239,45 @@ def generate_boilerplate_project(project_path: Path, project_name: str):
208239 file .rename (file .parent / file .name .replace ("-template" , "" ))
209240
210241
242+ def get_latest_fprime_release () -> str :
243+ """Retrieves the latest F´ release from GitHub
244+
245+ Note: Using the GitHub API is the simplest and most reliable way to get the
246+ latest release. However, in some cases the API may not be respond (e.g. rate
247+ limit exceeded). In these cases, we fall back to using `git ls-remote` to get
248+ the latest tag. This approach seems fragile (will the format of the output change?),
249+ but it's the best we can do without the API.
250+ """
251+ try :
252+ with urlopen (
253+ "https://api.github.com/repos/nasa/fprime/releases/latestee"
254+ ) as url :
255+ fprime_latest_release = json .loads (url .read ().decode ())
256+ return fprime_latest_release ["tag_name" ]
257+ except HTTPError :
258+ stdout = subprocess .Popen (
259+ [
260+ "git" ,
261+ "ls-remote" ,
262+ "--tags" ,
263+ "--refs" ,
264+ "https://github.com/nasa/fprime" ,
265+ ],
266+ stdout = subprocess .PIPE ,
267+ ).stdout .readlines ()
268+
269+ import re
270+
271+ # This regex only matches tags in the format v1.2.3, and NOT v1.2.3-rc1 or v1.2.3a1 etc...
272+ tags = re .findall (r"v\d+\.\d+\.\d+\b" , "" .join (map (str , stdout )))
273+
274+ # Used to compare semantic versions, e.g. v3.11.0 > v3.7.0
275+ def version_tuple (version ):
276+ return tuple (map (int , version .lstrip ("v" ).split ("." )))
277+
278+ return max (tags , key = version_tuple )
279+
280+
211281def print_success_message (project_name : str ):
212282 """Prints a success message"""
213283 print (
@@ -234,3 +304,30 @@ def print_success_message(project_name: str):
234304################################################################
235305"""
236306 )
307+
308+
309+ #################### Exceptions ####################
310+
311+
312+ class BootstrapProjectError (Exception ):
313+ pass
314+
315+
316+ class UnsupportedPythonVersion (BootstrapProjectError ):
317+ pass
318+
319+
320+ class GitNotInstalled (BootstrapProjectError ):
321+ pass
322+
323+
324+ class UnsupportedPlatform (BootstrapProjectError ):
325+ pass
326+
327+
328+ class InvalidProjectName (BootstrapProjectError ):
329+ pass
330+
331+
332+ class OutDirectoryError (BootstrapProjectError ):
333+ pass
0 commit comments