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

197 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-05 15:26 +0000

1import getpass 

2import os 

3import re 

4import shutil 

5import sys 

6from contextlib import contextmanager 

7from datetime import datetime 

8from functools import partial 

9from importlib import import_module 

10from subprocess import PIPE, run 

11from tempfile import TemporaryDirectory 

12 

13import click 

14from jinja2 import Environment, PackageLoader 

15 

16from lektor_ng.utils import locate_executable, slugify 

17 

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

19 

20 

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

22 

23 

24class Generator: 

25 def __init__(self, base): 

26 self.question = 0 

27 self.jinja_env = Environment( 

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

29 line_statement_prefix="%%", 

30 line_comment_prefix="##", 

31 variable_start_string="${", 

32 variable_end_string="}", 

33 block_start_string="<%", 

34 block_end_string="%>", 

35 comment_start_string="/**", 

36 comment_end_string="**/", 

37 ) 

38 self.options = {} 

39 # term width in [1, 78] 

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

41 self.e = click.secho 

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

43 

44 @staticmethod 

45 def abort(message): 

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

47 raise click.Abort() 

48 

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

50 self.question += 1 

51 self.e("") 

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

53 if info is not None: 

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

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

56 

57 if default is True or default is False: 

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

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

60 

61 def title(self, title): 

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

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

64 self.e("") 

65 

66 def warn(self, text): 

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

68 

69 def text(self, text): 

70 self.e(self.w(text)) 

71 

72 def confirm(self, prompt): 

73 self.e("") 

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

75 

76 @contextmanager 

77 def make_target_directory(self, path): 

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

79 path = os.path.abspath(path) 

80 if here != path: 

81 try: 

82 os.makedirs(path) 

83 except OSError as e: 

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

85 

86 if os.path.isdir(path): 

87 try: 

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

89 raise OSError("Directory not empty") 

90 except OSError as e: 

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

92 

93 with TemporaryDirectory() as scratch: 

94 yield scratch 

95 

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

97 # boundary. 

98 for filename in os.listdir(scratch): 

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

100 

101 @staticmethod 

102 def expand_filename(base, ctx, template_filename): 

103 def _repl(match): 

104 return ctx[match.group(1)] 

105 

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

107 

108 def run(self, ctx, path): 

109 with self.make_target_directory(path) as scratch: 

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

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

112 continue 

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

114 tmpl = self.jinja_env.get_template(template) 

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

116 if rv: 

117 directory = os.path.dirname(fn) 

118 try: 

119 os.makedirs(directory) 

120 except OSError: 

121 pass 

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

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

124 

125 

126def get_default_author() -> str: 

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

128 if sys.platform == "win32": 

129 return getpass.getuser() 

130 import pwd 

131 

132 try: 

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

134 except KeyError: 

135 pass 

136 else: 

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

138 if full_name: 

139 return full_name 

140 

141 return getpass.getuser() 

142 

143 

144def get_default_author_email() -> str | None: 

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

146 

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

148 """ 

149 git = locate_executable("git") 

150 if git: 

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

152 if proc.returncode == 0: 

153 return proc.stdout.strip() 

154 

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

156 if email: 

157 return email 

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

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

160 # case. 

161 return None 

162 

163 

164def project_quickstart(defaults=None): 

165 if not defaults: 

166 defaults = {} 

167 

168 g = Generator("project") 

169 

170 g.title("Lektor Quickstart") 

171 g.text( 

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

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

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

175 ) 

176 

177 name = defaults.get("name") 

178 if name is None: 

179 name = g.prompt( 

180 "Project Name", 

181 None, 

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

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

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

185 "any later point.", 

186 ) 

187 

188 author_name = g.prompt( 

189 "Author Name", 

190 get_default_author(), 

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

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

193 ) 

194 

195 path = defaults.get("path") 

196 if path is None: 

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

198 path = g.prompt( 

199 "Project Path", 

200 default_project_path, 

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

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

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

204 "directory.", 

205 ) 

206 path = os.path.expanduser(path) 

207 

208 with_blog = g.prompt( 

209 "Add Basic Blog", 

210 True, 

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

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

213 ) 

214 

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

216 

217 g.run( 

218 { 

219 "project_name": name, 

220 "project_slug": slugify(name), 

221 "project_path": path, 

222 "with_blog": with_blog, 

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

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

225 "author_name": author_name, 

226 }, 

227 path, 

228 ) 

229 

230 

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

232 if defaults is None: 

233 defaults = {} 

234 

235 g = Generator("plugin") 

236 

237 plugin_name = defaults.get("plugin_name") 

238 if plugin_name is None: 

239 plugin_name = g.prompt( 

240 "Plugin Name", 

241 default=None, 

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

243 ) 

244 

245 plugin_id = plugin_name.lower() 

246 plugin_id = plugin_id.removeprefix("lektor") 

247 plugin_id = plugin_id.removesuffix("plugin") 

248 plugin_id = slugify(plugin_id) 

249 

250 path = defaults.get("path") 

251 if path is None: 

252 if project is not None: 

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

254 else: 

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

256 default_path = os.getcwd() 

257 else: 

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

259 path = g.prompt( 

260 "Plugin Path", 

261 default_path, 

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

263 ) 

264 

265 author_name = g.prompt( 

266 "Author Name", 

267 get_default_author(), 

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

269 ) 

270 

271 author_email = g.prompt( 

272 "Author E-Mail", 

273 get_default_author_email(), 

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

275 ) 

276 

277 g.confirm("Create Plugin?") 

278 

279 g.run( 

280 { 

281 "plugin_name": plugin_name, 

282 "plugin_id": plugin_id, 

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

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

285 "author_name": author_name, 

286 "author_email": author_email, 

287 }, 

288 path, 

289 ) 

290 

291 

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

293 if defaults is None: 

294 defaults = {} 

295 

296 g = Generator("theme") 

297 

298 theme_name = defaults.get("theme_name") 

299 if theme_name is None: 

300 theme_name = g.prompt( 

301 "Theme Name", 

302 default=None, 

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

304 ) 

305 

306 theme_id = theme_name.lower() 

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

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

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

310 theme_id = theme_id[5:] 

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

312 theme_id = theme_id[:-5] 

313 theme_id = slugify(theme_id) 

314 

315 path = defaults.get("path") 

316 if path is None: 

317 if project is not None: 

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

319 else: 

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

321 default_path = os.getcwd() 

322 else: 

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

324 path = g.prompt( 

325 "Theme Path", 

326 default_path, 

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

328 ) 

329 

330 author_name = g.prompt( 

331 "Author Name", 

332 get_default_author(), 

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

334 ) 

335 

336 author_email = g.prompt( 

337 "Author E-Mail", 

338 get_default_author_email(), 

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

340 ) 

341 

342 g.confirm("Create Theme?") 

343 

344 g.run( 

345 { 

346 "theme_name": theme_name, 

347 "theme_id": theme_id, 

348 "author_name": author_name, 

349 "author_email": author_email, 

350 }, 

351 path, 

352 ) 

353 

354 # symlink 

355 theme_dir = os.getcwd() 

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

357 os.makedirs(example_themes) 

358 os.chdir(example_themes) 

359 try: 

360 os.symlink( 

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

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

363 ) 

364 except OSError as exc: 

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

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

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

368 # is raised. 

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

370 raise 

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

372 os.chdir(theme_dir) 

373 

374 # Sample image 

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

376 source_image_path = os.path.join( 

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

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

379 ) 

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

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

382 image = f.read() 

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

384 f.write(image)