Coverage for src/lektor_ng/cli_utils.py: 94%
86 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-09 00:28 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-09 00:28 +0000
1# pylint: disable=import-outside-toplevel
2from __future__ import annotations
4import json
5import os
6from pathlib import Path
7from typing import Any
9import click
11from lektor_ng.i18n import get_default_lang, is_valid_language
12from lektor_ng.project import Project
15def echo_json(data):
16 click.echo(json.dumps(data, indent=2).rstrip())
19def pruneflag(cli):
20 return click.option(
21 "--prune/--no-prune",
22 default=True,
23 help="Whether outdated artifacts are pruned. The default is to prune.",
24 )(cli)
27def extraflag(cli):
28 return click.option(
29 "-f",
30 "--extra-flag",
31 "extra_flags",
32 multiple=True,
33 help="Defines an arbitrary flag. These can be used by plugins "
34 "to customize the build and deploy process. More information can be "
35 "found in the documentation of affected plugins.",
36 )(cli)
39class AliasedGroup(click.Group):
40 # pylint: disable=inconsistent-return-statements
41 def get_command(self, ctx, cmd_name):
42 rv = click.Group.get_command(self, ctx, cmd_name)
43 if rv is not None:
44 return rv
45 matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)]
46 if not matches:
47 return None
48 if len(matches) == 1:
49 return click.Group.get_command(self, ctx, matches[0])
50 ctx.fail(f"Too many matches: {', '.join(sorted(matches))}")
53class Context:
54 def __init__(self):
55 self._project_path = os.environ.get("LEKTOR_PROJECT") or None
56 self._project = None
57 self._env = None
58 self._ui_lang = None
60 def _get_ui_lang(self):
61 rv = self._ui_lang
62 if rv is None:
63 rv = self._ui_lang = get_default_lang()
64 return rv
66 def _set_ui_lang(self, value):
67 self._ui_lang = value
69 ui_lang = property(_get_ui_lang, _set_ui_lang)
70 del _get_ui_lang, _set_ui_lang
72 def set_project_path(self, value):
73 self._project_path = value
74 self._project = None
76 def get_project(self, silent=False):
77 if self._project is not None:
78 return self._project
79 if self._project_path is not None:
80 rv = Project.from_path(self._project_path)
81 else:
82 rv = Project.discover()
83 if rv is None:
84 if silent:
85 return None
86 if self._project_path is None:
87 raise click.UsageError(
88 "Could not automatically discover a "
89 "project in the working or ancestor directories. "
90 "The --project global parameter or the "
91 "LEKTOR_PROJECT environment variable may be used "
92 "to explicitly specify the path to the project."
93 )
94 raise click.UsageError(f'Could not find project "{self._project_path}"')
95 self._project = rv
96 return rv
98 def get_default_output_path(self):
99 rv = os.environ.get("LEKTOR_OUTPUT_PATH")
100 if rv is not None:
101 return rv
102 return self.get_project().get_output_path()
104 def get_env(self, extra_flags=None):
105 if self._env is not None:
106 return self._env
107 from lektor_ng.environment import Environment
109 env = Environment(self.get_project(), load_plugins=False, extra_flags=extra_flags)
110 self._env = env
111 return env
113 def load_plugins(self, reinstall=False, extra_flags=None):
114 from .packages import load_packages
116 load_packages(self.get_env(extra_flags=extra_flags), reinstall=reinstall)
118 if not reinstall:
119 from .pluginsystem import initialize_plugins
121 initialize_plugins(self.get_env())
124pass_context = click.make_pass_decorator(Context, ensure=True)
127def validate_language(ctx, param, value):
128 if value is not None and not is_valid_language(value):
129 raise click.BadParameter(f'Unsupported language "{value}".')
130 return value
133class ResolvedPath(click.Path):
134 """A click paramter type for a resolved path.
136 We could just use ``click.Path(resolve_path=True)`` except that that
137 fails sometimes under Windows running python <= 3.9.
139 See https://github.com/pallets/click/issues/2466
140 """
142 def __init__(self, writable=False, file_okay=True):
143 super().__init__(resolve_path=True, allow_dash=False, writable=writable, file_okay=file_okay)
145 def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> Any:
146 abspath = Path(value).absolute()
147 # fsdecode to ensure that the return value is a str.
148 # (with click<8.0.3 Path.convert will return Path if passed a Path)
149 return os.fsdecode(super().convert(abspath, param, ctx))