Skip to content

Commit e2fcc01

Browse files
authored
Improve contextual robustness (#3)
* Refactor error handling * Attempt latest tag workaround * fix import path * Improve logging * Functional latest_release workaround * spell out * Add comment about TODO * Reorganize imports
1 parent 0c599da commit e2fcc01

File tree

2 files changed

+151
-44
lines changed

2 files changed

+151
-44
lines changed

src/fprime_bootstrap/__main__.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import logging
1313
import argparse
1414

15+
from fprime_bootstrap.bootstrap_project import bootstrap_project, BootstrapProjectError
16+
1517
logging.basicConfig(
1618
format="[%(levelname)s] %(message)s",
1719
level=logging.INFO,
@@ -37,13 +39,21 @@ def main():
3739
help="Do not create a virtual environment in the project",
3840
default=False,
3941
)
42+
project_parser.add_argument(
43+
"--tag",
44+
type=str,
45+
help="Version of F´ to checkout (default: latest release)",
46+
)
4047

4148
args = parser.parse_args()
4249

43-
if args.command == "project":
44-
from fprime_bootstrap import bootstrap_project
50+
try:
51+
if args.command == "project":
52+
return bootstrap_project(args)
4553

46-
return bootstrap_project.bootstrap_project(args)
54+
except BootstrapProjectError as e:
55+
LOGGER.error(e)
56+
return 1
4757

4858
LOGGER.error("No sub-command supplied")
4959
parser.print_help()

src/fprime_bootstrap/bootstrap_project.py

Lines changed: 138 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import subprocess
1313
import sys
1414
from urllib.request import urlopen
15+
from urllib.error import HTTPError
1516
from pathlib import Path
1617

1718
from typing import TYPE_CHECKING
@@ -26,55 +27,42 @@
2627

2728

2829
def 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+
211281
def 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

Comments
 (0)