Coverage for src/lektor_ng/quickstart.py: 80%
197 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-05 15:10 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-05 15:10 +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
13import click
14from jinja2 import Environment, PackageLoader
16from lektor_ng.utils import locate_executable, slugify
18pwd = import_module("pwd") if os.name != "nt" else None
21_var_re = re.compile(r"@([^@]+)@")
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)
44 @staticmethod
45 def abort(message):
46 click.echo(f"Error: {message}", err=True)
47 raise click.Abort()
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")
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)
61 def title(self, title):
62 self.e(title, fg="cyan")
63 self.e("=" * len(title), fg="cyan")
64 self.e("")
66 def warn(self, text):
67 self.e(self.w(text), fg="magenta")
69 def text(self, text):
70 self.e(self.w(text))
72 def confirm(self, prompt):
73 self.e("")
74 click.confirm(prompt, default=True, abort=True, prompt_suffix=" ")
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}")
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}")
93 with TemporaryDirectory() as scratch:
94 yield scratch
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))
101 @staticmethod
102 def expand_filename(base, ctx, template_filename):
103 def _repl(match):
104 return ctx[match.group(1)]
106 return os.path.join(base, _var_re.sub(_repl, template_filename))[:-3]
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"))
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
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
141 return getpass.getuser()
144def get_default_author_email() -> str | None:
145 """Attempt to guess an email address for the current user.
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()
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
164def project_quickstart(defaults=None):
165 if not defaults:
166 defaults = {}
168 g = Generator("project")
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 )
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 )
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 )
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)
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 )
215 g.confirm("That's all. Create project?")
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 )
231def plugin_quickstart(defaults=None, project=None):
232 if defaults is None:
233 defaults = {}
235 g = Generator("plugin")
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 )
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)
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 )
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 )
271 author_email = g.prompt(
272 "Author E-Mail",
273 get_default_author_email(),
274 "Your e-mail address for the plugin info.",
275 )
277 g.confirm("Create Plugin?")
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 )
292def theme_quickstart(defaults=None, project=None):
293 if defaults is None:
294 defaults = {}
296 g = Generator("theme")
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 )
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)
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 )
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 )
336 author_email = g.prompt(
337 "Author E-Mail",
338 get_default_author_email(),
339 "Your e-mail address for the theme info.",
340 )
342 g.confirm("Create Theme?")
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 )
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)
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)