Coverage for src/lektor_ng/quickstart.py: 80%

194 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-03 22:18 +0000

1import getpass 

2import os 

3import re 

4import shutil 

5from contextlib import contextmanager 

6from datetime import datetime 

7from functools import partial 

8from importlib import import_module 

9from subprocess import PIPE, run 

10from tempfile import TemporaryDirectory 

11 

12import click 

13from jinja2 import Environment, PackageLoader 

14 

15from lektor_ng.utils import locate_executable, slugify 

16 

17pwd = import_module("pwd") if os.name != "nt" else None 

18 

19 

20_var_re = re.compile(r"@([^@]+)@") 

21 

22 

23class Generator: 

24 def __init__(self, base): 

25 self.question = 0 

26 self.jinja_env = Environment( 

27 loader=PackageLoader("lektor_ng", os.path.join("quickstart-templates", base)), 

28 line_statement_prefix="%%", 

29 line_comment_prefix="##", 

30 variable_start_string="${", 

31 variable_end_string="}", 

32 block_start_string="<%", 

33 block_end_string="%>", 

34 comment_start_string="/**", 

35 comment_end_string="**/", 

36 ) 

37 self.options = {} 

38 # term width in [1, 78] 

39 self.term_width = min(max(shutil.get_terminal_size()[0], 1), 78) 

40 self.e = click.secho 

41 self.w = partial(click.wrap_text, width=self.term_width) 

42 

43 @staticmethod 

44 def abort(message): 

45 click.echo(f"Error: {message}", err=True) 

46 raise click.Abort() 

47 

48 def prompt(self, text, default=None, info=None): 

49 self.question += 1 

50 self.e("") 

51 self.e(f"Step {self.question}:", fg="yellow") 

52 if info is not None: 

53 self.e(click.wrap_text(info, self.term_width, "| ", "| ")) 

54 text = "> " + click.style(text, fg="green") 

55 

56 if default is True or default is False: 

57 return click.confirm(text, default=default) 

58 return click.prompt(text, default=default, show_default=True) 

59 

60 def title(self, title): 

61 self.e(title, fg="cyan") 

62 self.e("=" * len(title), fg="cyan") 

63 self.e("") 

64 

65 def warn(self, text): 

66 self.e(self.w(text), fg="magenta") 

67 

68 def text(self, text): 

69 self.e(self.w(text)) 

70 

71 def confirm(self, prompt): 

72 self.e("") 

73 click.confirm(prompt, default=True, abort=True, prompt_suffix=" ") 

74 

75 @contextmanager 

76 def make_target_directory(self, path): 

77 here = os.path.abspath(os.getcwd()) 

78 path = os.path.abspath(path) 

79 if here != path: 

80 try: 

81 os.makedirs(path) 

82 except OSError as e: 

83 self.abort(f"Could not create target folder: {e}") 

84 

85 if os.path.isdir(path): 

86 try: 

87 if len(os.listdir(path)) != 0: 

88 raise OSError("Directory not empty") 

89 except OSError as e: 

90 self.abort(f"Bad target folder: {e}") 

91 

92 with TemporaryDirectory() as scratch: 

93 yield scratch 

94 

95 # Use shutil.move here in case we move across a file system 

96 # boundary. 

97 for filename in os.listdir(scratch): 

98 shutil.move(os.path.join(scratch, filename), os.path.join(path, filename)) 

99 

100 @staticmethod 

101 def expand_filename(base, ctx, template_filename): 

102 def _repl(match): 

103 return ctx[match.group(1)] 

104 

105 return os.path.join(base, _var_re.sub(_repl, template_filename))[:-3] 

106 

107 def run(self, ctx, path): 

108 with self.make_target_directory(path) as scratch: 

109 for template in self.jinja_env.list_templates(): 

110 if not template.endswith(".in"): 

111 continue 

112 fn = self.expand_filename(scratch, ctx, template) 

113 tmpl = self.jinja_env.get_template(template) 

114 rv = tmpl.render(ctx).strip("\r\n") 

115 if rv: 

116 directory = os.path.dirname(fn) 

117 try: 

118 os.makedirs(directory) 

119 except OSError: 

120 pass 

121 with open(fn, "wb") as f: 

122 f.write((rv + "\n").encode("utf-8")) 

123 

124 

125def get_default_author() -> str: 

126 """Attempt to guess an the name of the current user.""" 

127 if pwd is not None: 

128 try: 

129 pw_gecos = pwd.getpwuid(os.getuid()).pw_gecos 

130 except KeyError: 

131 pass 

132 else: 

133 full_name = pw_gecos.split(",", 1)[0].strip() 

134 if full_name: 

135 return full_name 

136 

137 return getpass.getuser() 

138 

139 

140def get_default_author_email() -> str | None: 

141 """Attempt to guess an email address for the current user. 

142 

143 May return an empty string if not reasonable guess can be made. 

144 """ 

145 git = locate_executable("git") 

146 if git: 

147 proc = run((git, "config", "user.email"), stdout=PIPE, errors="strict", check=False) 

148 if proc.returncode == 0: 

149 return proc.stdout.strip() 

150 

151 email = os.environ.get("EMAIL", "").strip() 

152 if email: 

153 return email 

154 # We could fall back to f"{getpass.getuser()}@{socket.getfqdn()}", 

155 # but it is probably better just to go with no default in that 

156 # case. 

157 return None 

158 

159 

160def project_quickstart(defaults=None): 

161 if not defaults: 

162 defaults = {} 

163 

164 g = Generator("project") 

165 

166 g.title("Lektor Quickstart") 

167 g.text( 

168 "This wizard will generate a new basic project with some sensible " 

169 "defaults for getting started quickly. We just need to go through " 

170 "a few questions so that the project is set up correctly for you." 

171 ) 

172 

173 name = defaults.get("name") 

174 if name is None: 

175 name = g.prompt( 

176 "Project Name", 

177 None, 

178 "A project needs a name. The name is primarily used for the admin " 

179 "UI and some other places to refer to your project to not get " 

180 "confused if multiple projects exist. You can change this at " 

181 "any later point.", 

182 ) 

183 

184 author_name = g.prompt( 

185 "Author Name", 

186 get_default_author(), 

187 "Your name. This is used in a few places in the default template " 

188 "to refer to in the default copyright messages.", 

189 ) 

190 

191 path = defaults.get("path") 

192 if path is None: 

193 default_project_path = os.path.join(os.getcwd(), name) 

194 path = g.prompt( 

195 "Project Path", 

196 default_project_path, 

197 "This is the path where the project will be located. You can " 

198 "move a project around later if you do not like the path. If " 

199 "you provide a relative path it will be relative to the working " 

200 "directory.", 

201 ) 

202 path = os.path.expanduser(path) 

203 

204 with_blog = g.prompt( 

205 "Add Basic Blog", 

206 True, 

207 "Do you want to generate a basic blog module? If you enable this " 

208 "the models for a very basic blog will be generated.", 

209 ) 

210 

211 g.confirm("That's all. Create project?") 

212 

213 g.run( 

214 { 

215 "project_name": name, 

216 "project_slug": slugify(name), 

217 "project_path": path, 

218 "with_blog": with_blog, 

219 "this_year": datetime.utcnow().year, 

220 "today": datetime.utcnow().strftime("%Y-%m-%d"), 

221 "author_name": author_name, 

222 }, 

223 path, 

224 ) 

225 

226 

227def plugin_quickstart(defaults=None, project=None): 

228 if defaults is None: 

229 defaults = {} 

230 

231 g = Generator("plugin") 

232 

233 plugin_name = defaults.get("plugin_name") 

234 if plugin_name is None: 

235 plugin_name = g.prompt( 

236 "Plugin Name", 

237 default=None, 

238 info="This is the human readable name for this plugin", 

239 ) 

240 

241 plugin_id = plugin_name.lower() 

242 plugin_id = plugin_id.removeprefix("lektor") 

243 plugin_id = plugin_id.removesuffix("plugin") 

244 plugin_id = slugify(plugin_id) 

245 

246 path = defaults.get("path") 

247 if path is None: 

248 if project is not None: 

249 default_path = os.path.join(project.tree, "packages", plugin_id) 

250 else: 

251 if len(os.listdir(".")) == 0: 

252 default_path = os.getcwd() 

253 else: 

254 default_path = os.path.join(os.getcwd(), plugin_id) 

255 path = g.prompt( 

256 "Plugin Path", 

257 default_path, 

258 "The place where you want to initialize the plugin", 

259 ) 

260 

261 author_name = g.prompt( 

262 "Author Name", 

263 get_default_author(), 

264 "Your name as it will be embedded in the plugin metadata.", 

265 ) 

266 

267 author_email = g.prompt( 

268 "Author E-Mail", 

269 get_default_author_email(), 

270 "Your e-mail address for the plugin info.", 

271 ) 

272 

273 g.confirm("Create Plugin?") 

274 

275 g.run( 

276 { 

277 "plugin_name": plugin_name, 

278 "plugin_id": plugin_id, 

279 "plugin_class": plugin_id.title().replace("-", "") + "Plugin", 

280 "plugin_module": "lektor_" + plugin_id.replace("-", "_"), 

281 "author_name": author_name, 

282 "author_email": author_email, 

283 }, 

284 path, 

285 ) 

286 

287 

288def theme_quickstart(defaults=None, project=None): 

289 if defaults is None: 

290 defaults = {} 

291 

292 g = Generator("theme") 

293 

294 theme_name = defaults.get("theme_name") 

295 if theme_name is None: 

296 theme_name = g.prompt( 

297 "Theme Name", 

298 default=None, 

299 info="This is the human readable name for this theme", 

300 ) 

301 

302 theme_id = theme_name.lower() 

303 if theme_id != "lektor" and theme_id.startswith("lektor"): 

304 theme_id = theme_id[6:].strip() 

305 if theme_id != "theme" and theme_id.startswith("theme"): 

306 theme_id = theme_id[5:] 

307 if theme_id != "theme" and theme_id.endswith("theme"): 

308 theme_id = theme_id[:-5] 

309 theme_id = slugify(theme_id) 

310 

311 path = defaults.get("path") 

312 if path is None: 

313 if project is not None: 

314 default_path = os.path.join(project.tree, "themes", f"lektor-theme-{theme_id}") 

315 else: 

316 if len(os.listdir(".")) == 0: 

317 default_path = os.getcwd() 

318 else: 

319 default_path = os.path.join(os.getcwd(), theme_id) 

320 path = g.prompt( 

321 "Theme Path", 

322 default_path, 

323 "The place where you want to initialize the theme", 

324 ) 

325 

326 author_name = g.prompt( 

327 "Author Name", 

328 get_default_author(), 

329 "Your name as it will be embedded in the theme metadata.", 

330 ) 

331 

332 author_email = g.prompt( 

333 "Author E-Mail", 

334 get_default_author_email(), 

335 "Your e-mail address for the theme info.", 

336 ) 

337 

338 g.confirm("Create Theme?") 

339 

340 g.run( 

341 { 

342 "theme_name": theme_name, 

343 "theme_id": theme_id, 

344 "author_name": author_name, 

345 "author_email": author_email, 

346 }, 

347 path, 

348 ) 

349 

350 # symlink 

351 theme_dir = os.getcwd() 

352 example_themes = os.path.join(path, "example-site/themes") 

353 os.makedirs(example_themes) 

354 os.chdir(example_themes) 

355 try: 

356 os.symlink( 

357 f"../../../lektor-theme-{theme_id}", 

358 f"lektor-theme-{theme_id}", 

359 ) 

360 except OSError as exc: 

361 # Windows, by default, only allows members of the "Administrators" group 

362 # to create symlinks. For users who are not allowed to create symlinks, 

363 # error Code 1314 - "A required privilege is not held by the client" 

364 # is raised. 

365 if getattr(exc, "winerror", None) != 1314: 

366 raise 

367 g.warn("Could not automatically make a symlink to have your example-siteeasily pick up your theme.") 

368 os.chdir(theme_dir) 

369 

370 # Sample image 

371 os.makedirs(os.path.join(path, "images")) 

372 source_image_path = os.path.join( 

373 os.path.dirname(os.path.realpath(__file__)), 

374 "quickstart-templates/theme/images/homepage.png", 

375 ) 

376 destination_image_path = os.path.join(path, "images/homepage.png") 

377 with open(source_image_path, "rb") as f: 

378 image = f.read() 

379 with open(destination_image_path, "wb") as f: 

380 f.write(image)