Coverage for src/lektor_ng/cli.py: 62%
266 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-08 00:39 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-08 00:39 +0000
1# pylint: disable=import-outside-toplevel
2import os
3import sys
4import warnings
5from itertools import chain
7import click
9from lektor_ng.cli_utils import (
10 AliasedGroup,
11 ResolvedPath,
12 echo_json,
13 extraflag,
14 pass_context,
15 pruneflag,
16 validate_language,
17)
18from lektor_ng.devcli import cli as devcli
19from lektor_ng.project import Project
20from lektor_ng.utils import secure_url
21from lektor_ng.version import get_version
24@click.group(cls=AliasedGroup)
25@click.option(
26 "--project",
27 type=click.Path(exists=True),
28 help="The path to the lektor project to work with.",
29)
30@click.option(
31 "--language",
32 default=None,
33 callback=validate_language,
34 help="The UI language to use (overrides autodetection).",
35)
36@click.version_option(prog_name="Lektor", version=get_version())
37@pass_context
38def cli(ctx, project=None, language=None):
39 """The lektor management application.
41 This command can invoke lektor locally and serve up the website. It's
42 intended for local development of websites.
43 """
44 if not sys.warnoptions:
45 warnings.simplefilter("default")
46 if language is not None:
47 ctx.ui_lang = language
48 if project is not None:
49 ctx.set_project_path(project)
52@cli.command("build")
53@click.option(
54 "-O",
55 "--output-path",
56 type=ResolvedPath(writable=True, file_okay=False),
57 default=None,
58 help="The output path.",
59)
60@click.option(
61 "--watch",
62 is_flag=True,
63 help="If this is enabled the build "
64 "process goes into an automatic loop where it watches the "
65 "file system for changes and rebuilds.",
66)
67@pruneflag
68@click.option(
69 "-v",
70 "--verbose",
71 "verbosity",
72 count=True,
73 help="Increases the verbosity of the logging.",
74)
75@click.option(
76 "--source-info-only",
77 is_flag=True,
78 help="Instead of building only updates the source infos. The "
79 "source info is used by the web admin panel to quickly find "
80 "information about the source files (for instance jump to "
81 "files).",
82)
83@click.option(
84 "--buildstate-path",
85 type=click.Path(writable=True, file_okay=False),
86 default=None,
87 help="Path to a directory that Lektor will use for coordinating "
88 "the state of the build. Defaults to a directory named "
89 "`.lektor` inside the output path.",
90)
91@extraflag
92@pass_context
93def build_cmd(
94 ctx,
95 *,
96 output_path,
97 watch,
98 prune,
99 verbosity,
100 source_info_only,
101 buildstate_path,
102 extra_flags,
103):
104 """Builds the entire project into the final artifacts.
106 The default behavior is to build the project into the default build
107 output path which can be discovered with the `project-info` command
108 but an alternative output folder can be provided with the `--output-path`
109 option.
111 The default behavior is to perform a build followed by a pruning step
112 which removes no longer referenced artifacts from the output folder.
113 Lektor will only build the files that require rebuilding if the output
114 folder is reused.
116 To enforce a clean build you have to issue a `clean` command first.
118 If the build fails the exit code will be `1` otherwise `0`. This can be
119 used by external scripts to only deploy on successful build for instance.
120 """
121 from lektor_ng.builder import Builder
122 from lektor_ng.reporter import CliReporter
124 if output_path is None:
125 output_path = ctx.get_default_output_path()
127 ctx.load_plugins(extra_flags=extra_flags)
129 env = ctx.get_env()
131 with CliReporter(env, verbosity=verbosity):
132 builds = ["first"]
133 if watch:
134 from lektor_ng.watcher import watch_project
136 click.secho("Watching for file system changes", fg="cyan")
137 builds = chain(builds, watch_project(env, output_path, raise_interrupt=False))
139 success = False
140 for _ in builds:
141 builder = Builder(
142 env.new_pad(),
143 output_path,
144 buildstate_path=buildstate_path,
145 extra_flags=extra_flags,
146 )
147 if source_info_only:
148 builder.update_all_source_infos()
149 success = True
150 else:
151 failures = builder.build_all()
152 if prune:
153 builder.prune()
154 success = failures == 0
156 return sys.exit(0 if success else 1)
159@cli.command("clean")
160@click.option(
161 "-O",
162 "--output-path",
163 type=ResolvedPath(writable=True, file_okay=False),
164 default=None,
165 help="The output path.",
166)
167@click.option(
168 "-v",
169 "--verbose",
170 "verbosity",
171 count=True,
172 help="Increases the verbosity of the logging.",
173)
174@click.confirmation_option(help="Confirms the cleaning.")
175@extraflag
176@pass_context
177def clean_cmd(ctx, *, output_path, verbosity, extra_flags):
178 """Cleans the entire build folder.
180 If not build folder is provided, the default build folder of the project
181 in the Lektor cache is used.
182 """
183 from lektor_ng.builder import Builder
184 from lektor_ng.reporter import CliReporter
186 if output_path is None:
187 output_path = ctx.get_default_output_path()
189 ctx.load_plugins(extra_flags=extra_flags)
190 env = ctx.get_env()
192 reporter = CliReporter(env, verbosity=verbosity)
193 with reporter:
194 builder = Builder(env.new_pad(), output_path)
195 builder.prune(all=True)
198@cli.command("deploy", short_help="Deploy the website.")
199@click.argument("server", required=False)
200@click.option(
201 "-O",
202 "--output-path",
203 type=ResolvedPath(writable=True, file_okay=False),
204 default=None,
205 help="The output path.",
206)
207@click.option(
208 "--username",
209 envvar="LEKTOR_DEPLOY_USERNAME",
210 help="An optional username to override the URL.",
211)
212@click.option(
213 "--password",
214 envvar="LEKTOR_DEPLOY_PASSWORD",
215 help="An optional password to override the URL or the default prompt.",
216)
217@click.option(
218 "--key-file",
219 envvar="LEKTOR_DEPLOY_KEY_FILE",
220 help="The path to a key file that should be used for the authentication of the deployment.",
221)
222@click.option(
223 "--key",
224 envvar="LEKTOR_DEPLOY_KEY",
225 help="The contents of a key file directly a string that should be used for authentication of the deployment.",
226)
227@extraflag
228@pass_context
229def deploy_cmd(ctx, *, server, output_path, extra_flags, **credentials):
230 """This command deploys the entire contents of the build folder
231 (`--output-path`) onto a configured remote server. The name of the
232 server must fit the name from a target in the project configuration.
233 If no server is supplied then the default server from the config is
234 used.
236 The deployment credentials are typically contained in the project config
237 file but it's also possible to supply them here explicitly. In this
238 case the `--username` and `--password` parameters (as well as the
239 `LEKTOR_DEPLOY_USERNAME` and `LEKTOR_DEPLOY_PASSWORD` environment
240 variables) can override what's in the URL.
242 For more information see the deployment chapter in the documentation.
243 """
244 from lektor_ng.publisher import PublishError, publish
246 if output_path is None:
247 output_path = ctx.get_default_output_path()
249 ctx.load_plugins(extra_flags=extra_flags)
250 env = ctx.get_env()
251 config = env.load_config()
253 if server is None:
254 server_info = config.get_default_server()
255 if server_info is None:
256 raise click.BadParameter("No default server configured.", param_hint="server")
257 else:
258 server_info = config.get_server(server)
259 if server_info is None:
260 raise click.BadParameter(f"Server {server!r} does not exist.", param_hint="server")
262 try:
263 event_iter = publish(
264 env,
265 server_info.target,
266 output_path,
267 credentials=credentials,
268 server_info=server_info,
269 extra_flags=extra_flags,
270 )
271 except PublishError as exc:
272 server_desc = "Default server" if server is None else f"Server {server!r}"
273 message = f"{server_desc} configuration error: {exc}"
274 raise click.UsageError(message) from exc
276 click.echo(f"Deploying to {server_info.name}")
277 click.echo(f" Build cache: {output_path}")
278 click.echo(f" Target: {secure_url(server_info.target)}")
279 try:
280 for line in event_iter:
281 click.echo(f" {click.style(line, fg='cyan')}")
282 except PublishError as e:
283 click.secho(f"Error: {e}", fg="red")
284 else:
285 click.echo("Done!")
288@cli.command("server", short_help="Launch a local server.")
289@click.option(
290 "-h",
291 "--host",
292 default="127.0.0.1",
293 help="The network interface to bind to. The default is the "
294 "loopback device, but by setting it to 0.0.0.0 it becomes "
295 "available on all network interfaces.",
296)
297@click.option("-p", "--port", default=5000, help="The port to bind to.", show_default=True)
298@click.option(
299 "-O",
300 "--output-path",
301 type=ResolvedPath(writable=True, file_okay=False),
302 default=None,
303 help="The dev server will build into the same folder as the build command by default.",
304)
305@pruneflag
306@click.option(
307 "-v",
308 "--verbose",
309 "verbosity",
310 count=True,
311 help="Increases the verbosity of the logging.",
312)
313@extraflag
314@click.option("--browse", is_flag=True)
315@pass_context
316def server_cmd(ctx, *, host, port, output_path, prune, verbosity, extra_flags, browse):
317 """The server command will launch a local server for development.
319 Lektor's development server will automatically build all files into
320 pages similar to how the build command with the `--watch` switch
321 works, but also at the same time serve up the website on a local
322 HTTP server.
323 """
324 from lektor_ng.devserver import run_server
326 if output_path is None:
327 output_path = ctx.get_default_output_path()
328 ctx.load_plugins(extra_flags=extra_flags)
329 click.echo(f" * Project path: {ctx.get_project().project_path}")
330 click.echo(f" * Output path: {output_path}")
331 run_server(
332 (host, port),
333 env=ctx.get_env(),
334 output_path=output_path,
335 prune=prune,
336 verbosity=verbosity,
337 ui_lang=ctx.ui_lang,
338 extra_flags=extra_flags,
339 lektor_dev=os.environ.get("LEKTOR_DEV") == "1",
340 browse=browse,
341 )
344@cli.command("project-info", short_help="Shows the info about a project.")
345@click.option("as_json", "--json", is_flag=True, help="Prints out the data as json.")
346@click.option(
347 "--name",
348 is_flag=True,
349 help="Print the project name",
350)
351@click.option(
352 "--project-file",
353 is_flag=True,
354 help="Print the path to the project file.",
355)
356@click.option(
357 "--tree",
358 is_flag=True,
359 help="Print the path to the tree.",
360)
361@click.option(
362 "default_output_path",
363 "--output-path",
364 is_flag=True,
365 help="Print the path to the default output path.",
366)
367@click.option(
368 "package_cache_path",
369 "--package-cache",
370 is_flag=True,
371 help="Print the path to the package cache.",
372)
373@pass_context
374def project_info_cmd(ctx, *, as_json, **opts):
375 """Prints out information about the project. This is particular
376 useful for script usage or for discovering information about a
377 Lektor project that is not immediately obvious (like the paths
378 to the default output folder).
379 """
380 project = ctx.get_project()
381 json_data = project.to_json()
382 if as_json:
383 echo_json(json_data)
384 return
386 ops = [k for k, v in opts.items() if v]
387 if ops:
388 for op in ops:
389 click.echo(json_data.get(op, ""))
390 else:
391 click.echo(f"Name: {json_data['name']}")
392 click.echo(f"File: {json_data['project_file']}")
393 click.echo(f"Tree: {json_data['tree']}")
394 click.echo(f"Output: {json_data['default_output_path']}")
395 click.echo(f"Package Cache: {json_data['package_cache_path']}")
398@cli.command("content-file-info", short_help="Provides information for a set of lektor files.")
399@click.option("as_json", "--json", is_flag=True, help="Prints out the data as json.")
400@click.argument("files", nargs=-1, type=click.Path(dir_okay=False))
401@pass_context
402def content_file_info_cmd(ctx, files, *, as_json):
403 """Given a list of files this returns the information for those files
404 in the context of a project. If the files are from different projects
405 an error is generated.
406 """
407 project = None
409 def fail(msg):
410 if as_json:
411 echo_json({"success": False, "error": msg})
412 sys.exit(1)
413 raise click.UsageError(f"Could not find content file info: {msg}")
415 for filename in files:
416 this_project = Project.discover(filename)
417 if this_project is None:
418 fail("no project found")
419 if project is None:
420 project = this_project
421 elif project.project_path != this_project.project_path:
422 fail("multiple projects")
424 if project is None:
425 fail("no file indicated a project")
427 project_files = []
428 for filename in files:
429 content_path = project.content_path_from_filename(filename)
430 if content_path is not None:
431 project_files.append(content_path)
433 if not project_files:
434 fail("no files resolve in project")
436 if as_json:
437 echo_json(
438 {
439 "success": True,
440 "project": project.to_json(),
441 "paths": project_files,
442 }
443 )
444 else:
445 click.echo("Project:")
446 click.echo(f" Name: {project.name}")
447 click.echo(f" File: {project.project_file}")
448 click.echo(f" Tree: {project.tree}")
449 click.echo("Paths:")
450 for project_file in project_files:
451 click.echo(f" - {project_file}")
454@cli.group("plugins", short_help="Manages plugins.")
455def plugins_cmd():
456 """This command group provides various helpers to manages plugins
457 in a Lektor project.
458 """
461@plugins_cmd.command("add", short_help="Adds a new plugin to the project.")
462@click.argument("name")
463@pass_context
464def plugins_add_cmd(ctx, name):
465 """This command can add a new plugin to the project. If just given
466 the name of the plugin the latest version of that plugin is added to
467 the project.
469 The argument is either the name of the plugin or the name of the plugin
470 suffixed with `@version` with the version. For instance to install
471 the version 0.1 of the plugin demo you would do `demo@0.1`.
472 """
473 project = ctx.get_project()
474 from .packages import add_package_to_project
476 try:
477 info = add_package_to_project(project, name)
478 except RuntimeError as e:
479 click.echo(f"Error: {e}", err=True)
480 else:
481 click.echo(f"Package {info['name']} ({info['version']}) was added to the project")
484@plugins_cmd.command("remove", short_help="Removes a plugin from the project.")
485@click.argument("name")
486@pass_context
487def plugins_remove_cmd(ctx, name):
488 """This command can remove a plugin to the project again. The name
489 of the plugin is the only argument to the function.
490 """
491 project = ctx.get_project()
492 from .packages import remove_package_from_project
494 try:
495 old_info = remove_package_from_project(project, name)
496 except RuntimeError as e:
497 click.echo(f"Error: {e}", err=True)
498 else:
499 if old_info is None:
500 click.echo("Package was not registered with the project. Nothing was removed.")
501 else:
502 click.echo(f"Removed package {old_info['name']} ({old_info['version']})")
505@plugins_cmd.command("list", short_help="List all plugins.")
506@click.option("as_json", "--json", is_flag=True, help="Prints out the data as json.")
507@click.option(
508 "-v",
509 "--verbose",
510 "verbosity",
511 count=True,
512 help="Increases the verbosity of the output.",
513)
514@pass_context
515def plugins_list_cmd(ctx, *, as_json, verbosity):
516 """This returns a list of all currently actively installed plugins
517 in the project. By default it only prints out the plugin IDs and
518 version numbers but the entire information can be returned by
519 increasing verbosity with `-v`. Additionally JSON output can be
520 requested with `--json`.
521 """
522 ctx.load_plugins()
523 env = ctx.get_env()
524 plugins = sorted(env.plugins.values(), key=lambda x: x.id.lower())
526 if as_json:
527 echo_json({"plugins": [x.to_json() for x in plugins]})
528 return
530 if verbosity == 0:
531 for plugin in plugins:
532 click.echo(f"{plugin.id} (version {plugin.version})")
533 return
535 for idx, plugin in enumerate(plugins):
536 if idx:
537 click.echo()
538 click.echo(f"{plugin.name} ({plugin.id})")
539 for line in plugin.description.splitlines():
540 click.echo(f" {line}")
541 if plugin.path is not None:
542 click.echo(f" path: {plugin.path}")
543 click.echo(f" version: {plugin.version}")
544 click.echo(f" import-name: {plugin.import_name}")
547@plugins_cmd.command("flush-cache", short_help="Flushes the plugin installation cache.")
548@pass_context
549def plugins_flush_cache_cmd(ctx):
550 """This uninstalls all plugins in the cache. On next usage the plugins
551 will be reinstalled automatically. This is mostly just useful during
552 plugin development when the cache got corrupted.
553 """
554 click.echo("Flushing plugin cache ...")
555 from .packages import wipe_package_cache
557 wipe_package_cache(ctx.get_env())
558 click.echo("All done!")
561@plugins_cmd.command("reinstall", short_help="Reinstall all plugins.")
562@pass_context
563def plugins_reinstall_cmd(ctx):
564 """Forces a re-installation of all plugins. This will download the
565 requested versions of the plugins and install them into the plugin
566 cache folder. Alternatively one can just use `flush-cache` to
567 flush the cache and on next build Lektor will automatically download
568 the plugins again.
569 """
570 ctx.load_plugins(reinstall=True)
573@cli.command("quickstart", short_help="Starts a new empty project.")
574@click.option("--name", help="The name of the project.")
575@click.option(
576 "--path",
577 type=click.Path(file_okay=False, dir_okay=False, writable=True),
578 help="Output directory",
579)
580@pass_context
581def quickstart_cmd(ctx, **options):
582 """Starts a new empty project with a minimum boilerplate."""
583 from lektor_ng.quickstart import project_quickstart
585 project_quickstart(options)
588cli.add_command(devcli, "dev")
591def main(as_module=False):
592 args = sys.argv[1:]
593 name = None
595 if as_module:
596 name = "python -m lektor_ng"
597 sys.argv = ["-m", "lektor_ng"] + args
599 cli.main(args=args, prog_name=name)
602if __name__ == "__main__":
603 main(as_module=True)