Coverage for src/lektor_ng/quickstart.py: 80%
194 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-03 21:08 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-03 21:08 +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
12import click
13from jinja2 import Environment, PackageLoader
15from lektor_ng.utils import locate_executable, slugify
17pwd = import_module("pwd") if os.name != "nt" else None
20_var_re = re.compile(r"@([^@]+)@")
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)
43 @staticmethod
44 def abort(message):
45 click.echo(f"Error: {message}", err=True)
46 raise click.Abort()
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")
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)
60 def title(self, title):
61 self.e(title, fg="cyan")
62 self.e("=" * len(title), fg="cyan")
63 self.e("")
65 def warn(self, text):
66 self.e(self.w(text), fg="magenta")
68 def text(self, text):
69 self.e(self.w(text))
71 def confirm(self, prompt):
72 self.e("")
73 click.confirm(prompt, default=True, abort=True, prompt_suffix=" ")
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}")
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}")
92 with TemporaryDirectory() as scratch:
93 yield scratch
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))
100 @staticmethod
101 def expand_filename(base, ctx, template_filename):
102 def _repl(match):
103 return ctx[match.group(1)]
105 return os.path.join(base, _var_re.sub(_repl, template_filename))[:-3]
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"))
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
137 return getpass.getuser()
140def get_default_author_email() -> str | None:
141 """Attempt to guess an email address for the current user.
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()
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
160def project_quickstart(defaults=None):
161 if not defaults:
162 defaults = {}
164 g = Generator("project")
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 )
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 )
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 )
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)
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 )
211 g.confirm("That's all. Create project?")
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 )
227def plugin_quickstart(defaults=None, project=None):
228 if defaults is None:
229 defaults = {}
231 g = Generator("plugin")
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 )
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)
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 )
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 )
267 author_email = g.prompt(
268 "Author E-Mail",
269 get_default_author_email(),
270 "Your e-mail address for the plugin info.",
271 )
273 g.confirm("Create Plugin?")
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 )
288def theme_quickstart(defaults=None, project=None):
289 if defaults is None:
290 defaults = {}
292 g = Generator("theme")
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 )
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)
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 )
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 )
332 author_email = g.prompt(
333 "Author E-Mail",
334 get_default_author_email(),
335 "Your e-mail address for the theme info.",
336 )
338 g.confirm("Create Theme?")
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 )
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)
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)