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
« 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.
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"""
11from __future__ import annotations
13import html
14import re
15from pathlib import Path
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)
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/"))
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 ""
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
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)."""
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()
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)
162 def kind(self) -> str:
163 return next(kind for kind, button in self.kind_buttons.items() if button.isChecked())
165 def parts(self) -> set[str]:
166 return {flag for flag, check in self.part_checks.items() if check.isChecked()}
168 def project_folder(self) -> Path:
169 return Path(self.folder_edit.text().strip()).expanduser() / self.name_edit.text().strip()
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())
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
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>")