Coverage for vibefpmgui/new_project.py: 96%

129 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""File → New fpm Project…: ``fpm new`` with its options chosen in a dialog. 

2 

3`new_command` makes the command, `name_problem` checks a name as fpm does; 

4`NewProjectDialog` asks for them, showing the command it will run. fpm 

5makes one of three kinds of project (its options for them can't be mixed): 

6*full* (``--full``, the default here: an annotated ``fpm.toml`` and every 

7folder), the *folders chosen* (``--lib``, ``--app``…, with a minimal 

8``fpm.toml``), or *bare* (``--bare``: ``fpm.toml`` and ``README.md`` only). 

9""" 

10 

11from __future__ import annotations 

12 

13import html 

14import re 

15from pathlib import Path 

16 

17from qtpy.QtCore import Qt 

18from qtpy.QtWidgets import ( 

19 QButtonGroup, 

20 QCheckBox, 

21 QDialog, 

22 QDialogButtonBox, 

23 QFileDialog, 

24 QFormLayout, 

25 QHBoxLayout, 

26 QLabel, 

27 QLineEdit, 

28 QPushButton, 

29 QRadioButton, 

30 QVBoxLayout, 

31 QWidget, 

32) 

33 

34NO_NAME = "Give the project a name." 

35NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,62}$") # (fpm's rule: up to 63, starting with a letter) 

36# The parts a project can be made with: (flag, what it is, its folder). 

37FULL, CHOSEN, BARE = "full", "chosen", "bare" # the kinds of project 

38PARTS = (("--lib", "Library", "src/"), ("--app", "Application", "app/"), ("--test", "Tests", "test/"), 

39 ("--example", "Example", "example/")) 

40 

41 

42def name_problem(name: str, folder: Path, backfill: bool = False) -> str: 

43 """Why ``name`` can't be a new project in ``folder`` ("" if it can).""" 

44 if not name: 

45 return NO_NAME 

46 if not NAME_RE.match(name): 

47 return ("A name is up to 63 letters, digits, underscores or hyphens, starting with a letter " 

48 "(no spaces).") 

49 if not folder.is_dir(): 

50 return f"There's no folder {folder}." 

51 target = folder / name 

52 if target.exists() and not backfill: 

53 return f"{target} already exists (Backfill adds only what's missing to it)." 

54 if target.exists() and not target.is_dir(): 54 ↛ 55line 54 didn't jump to line 55 because the condition on line 54 was never true

55 return f"{target} is a file." 

56 return "" 

57 

58 

59def new_command(fpm: str, name: str, kind: str = FULL, parts: set[str] = frozenset(), 

60 backfill: bool = False) -> list[str]: 

61 """``fpm new NAME`` for a kind of project: --full, the ``parts`` chosen 

62 (their flags, in fpm's order), or --bare; and --backfill.""" 

63 command = [fpm, "new", name] 

64 if kind == FULL: 

65 command.append("--full") 

66 elif kind == BARE: 

67 command.append("--bare") 

68 else: 

69 command += [flag for flag, _what, _folder in PARTS if flag in parts] 

70 if backfill: 

71 command.append("--backfill") 

72 return command 

73 

74 

75class NewProjectDialog(QDialog): 

76 """The new project's name, folder and parts; Create runs nothing itself 

77 (see ``command`` and ``project_folder``: the window runs fpm).""" 

78 

79 def __init__(self, fpm: str, folder: Path, parent=None): 

80 super().__init__(parent) 

81 self.fpm = fpm 

82 self.setWindowTitle("New fpm Project") 

83 self.name_edit = QLineEdit() 

84 self.name_edit.setPlaceholderText("my_project") 

85 self.folder_edit = QLineEdit(str(folder)) 

86 browse = QPushButton("Browse…") 

87 browse.clicked.connect(self._browse) 

88 where = QHBoxLayout() 

89 where.addWidget(self.folder_edit, 1) 

90 where.addWidget(browse) 

91 # The kind of project (fpm's options for them can't be mixed). 

92 self.kind_buttons: dict[str, QRadioButton] = {} 

93 self.kinds = QButtonGroup(self) 

94 kinds = QVBoxLayout() 

95 kinds.setSpacing(2) 

96 for kind, text, tip in ( 

97 (FULL, "Full: an annotated fpm.toml, and src/, app/, test/ and example/", 

98 ("--full: a fpm.toml with every setting explained (and links to the documentation), and every " 

99 "folder with a placeholder Fortran file")), 

100 (CHOSEN, "Only the folders chosen, with a minimal fpm.toml:", 

101 "--lib, --app, --test, --example: the folders ticked below"), 

102 (BARE, "Bare: fpm.toml and README.md only", "--bare: no folders or Fortran files")): 

103 button = QRadioButton(text) 

104 button.setToolTip(tip) 

105 button.toggled.connect(self._update) 

106 self.kinds.addButton(button) 

107 self.kind_buttons[kind] = button 

108 kinds.addWidget(button) 

109 if kind == CHOSEN: 

110 self.part_checks: dict[str, QCheckBox] = {} 

111 for flag, what, folder_name in PARTS: 

112 check = QCheckBox(f"{what} ({folder_name})") 

113 check.setChecked(flag != "--example") # (fpm's own choice without --full) 

114 check.setToolTip(f"{flag}: create {folder_name} with a placeholder Fortran file") 

115 check.toggled.connect(self._update) 

116 row = QHBoxLayout() 

117 row.setContentsMargins(22, 0, 0, 0) 

118 row.addWidget(check) 

119 kinds.addLayout(row) 

120 self.part_checks[flag] = check 

121 self.kind_buttons[FULL].setChecked(True) 

122 parts_box = QWidget() 

123 parts_box.setLayout(kinds) 

124 self.backfill_check = QCheckBox("Backfill an existing folder (add only what's missing)") 

125 self.backfill_check.setToolTip("--backfill: the folder may exist already; only the files and folders " 

126 "it doesn't have are created") 

127 self.backfill_check.toggled.connect(self._update) 

128 self.command_label = QLabel() 

129 self.command_label.setTextInteractionFlags(Qt.TextSelectableByMouse) 

130 self.command_label.setWordWrap(True) 

131 self.problem_label = QLabel() 

132 self.problem_label.setWordWrap(True) 

133 self.problem_label.setStyleSheet("color: #e03131;") 

134 form = QFormLayout() 

135 form.addRow("Name:", self.name_edit) 

136 form.addRow("Create in:", where) 

137 form.addRow("Make:", parts_box) 

138 form.addRow("", self.backfill_check) 

139 note = QLabel("<span style='color:gray'>fpm also makes it a git repository; it opens here once it's " 

140 "made.</span>") 

141 note.setWordWrap(True) 

142 self.buttons = QDialogButtonBox(QDialogButtonBox.Cancel) 

143 self.create_button = self.buttons.addButton("Create", QDialogButtonBox.AcceptRole) 

144 self.buttons.accepted.connect(self.accept) 

145 self.buttons.rejected.connect(self.reject) 

146 layout = QVBoxLayout(self) 

147 layout.addLayout(form) 

148 layout.addWidget(note) 

149 layout.addWidget(self.command_label) 

150 layout.addWidget(self.problem_label) 

151 layout.addWidget(self.buttons) 

152 self.name_edit.textChanged.connect(self._update) 

153 self.folder_edit.textChanged.connect(self._update) 

154 self.setMinimumWidth(520) 

155 self._update() 

156 

157 def _browse(self): 

158 folder = QFileDialog.getExistingDirectory(self, "Create the project in", self.folder_edit.text()) 

159 if folder: 

160 self.folder_edit.setText(folder) 

161 

162 def kind(self) -> str: 

163 return next(kind for kind, button in self.kind_buttons.items() if button.isChecked()) 

164 

165 def parts(self) -> set[str]: 

166 return {flag for flag, check in self.part_checks.items() if check.isChecked()} 

167 

168 def project_folder(self) -> Path: 

169 return Path(self.folder_edit.text().strip()).expanduser() / self.name_edit.text().strip() 

170 

171 def command(self) -> list[str]: 

172 return new_command(self.fpm, self.name_edit.text().strip(), self.kind(), self.parts(), 

173 self.backfill_check.isChecked()) 

174 

175 def problem(self) -> str: 

176 """What's wrong (the name's first: it's the first field), or "".""" 

177 problem = name_problem(self.name_edit.text().strip(), Path(self.folder_edit.text().strip()).expanduser(), 

178 self.backfill_check.isChecked()) 

179 if not problem and self.kind() == CHOSEN and not self.parts(): 

180 problem = "Choose at least one folder." 

181 return problem 

182 

183 def _update(self, *_): 

184 if not hasattr(self, "create_button"): 

185 return # (being built) 

186 for check in self.part_checks.values(): 

187 check.setEnabled(self.kind() == CHOSEN) 

188 problem = self.problem() 

189 self.problem_label.setText("" if problem == NO_NAME else problem) # (not before anything's typed) 

190 self.create_button.setEnabled(not problem) 

191 shown = " ".join(["fpm", *self.command()[1:]]) 

192 self.command_label.setText(f"<span style='color:gray'>Runs, in {html.escape(self.folder_edit.text().strip())}" 

193 f":</span> <code>{html.escape(shown)}</code>")