Coverage for src/lektor_ng/cli.py: 62%

266 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-03 16:04 +0000

1# pylint: disable=import-outside-toplevel 

2import os 

3import sys 

4import warnings 

5from itertools import chain 

6 

7import click 

8 

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 

22 

23 

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. 

40 

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) 

50 

51 

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. 

105 

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. 

110 

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. 

115 

116 To enforce a clean build you have to issue a `clean` command first. 

117 

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 

123 

124 if output_path is None: 

125 output_path = ctx.get_default_output_path() 

126 

127 ctx.load_plugins(extra_flags=extra_flags) 

128 

129 env = ctx.get_env() 

130 

131 with CliReporter(env, verbosity=verbosity): 

132 builds = ["first"] 

133 if watch: 

134 from lektor_ng.watcher import watch_project 

135 

136 click.secho("Watching for file system changes", fg="cyan") 

137 builds = chain(builds, watch_project(env, output_path, raise_interrupt=False)) 

138 

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 

155 

156 return sys.exit(0 if success else 1) 

157 

158 

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. 

179 

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 

185 

186 if output_path is None: 

187 output_path = ctx.get_default_output_path() 

188 

189 ctx.load_plugins(extra_flags=extra_flags) 

190 env = ctx.get_env() 

191 

192 reporter = CliReporter(env, verbosity=verbosity) 

193 with reporter: 

194 builder = Builder(env.new_pad(), output_path) 

195 builder.prune(all=True) 

196 

197 

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. 

235 

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. 

241 

242 For more information see the deployment chapter in the documentation. 

243 """ 

244 from lektor_ng.publisher import PublishError, publish 

245 

246 if output_path is None: 

247 output_path = ctx.get_default_output_path() 

248 

249 ctx.load_plugins(extra_flags=extra_flags) 

250 env = ctx.get_env() 

251 config = env.load_config() 

252 

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") 

261 

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 

275 

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!") 

286 

287 

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. 

318 

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 

325 

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 ) 

342 

343 

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 

385 

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']}") 

396 

397 

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 

408 

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}") 

414 

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") 

423 

424 if project is None: 

425 fail("no file indicated a project") 

426 

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) 

432 

433 if not project_files: 

434 fail("no files resolve in project") 

435 

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}") 

452 

453 

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 """ 

459 

460 

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. 

468 

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 

475 

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") 

482 

483 

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 

493 

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']})") 

503 

504 

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()) 

525 

526 if as_json: 

527 echo_json({"plugins": [x.to_json() for x in plugins]}) 

528 return 

529 

530 if verbosity == 0: 

531 for plugin in plugins: 

532 click.echo(f"{plugin.id} (version {plugin.version})") 

533 return 

534 

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}") 

545 

546 

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 

556 

557 wipe_package_cache(ctx.get_env()) 

558 click.echo("All done!") 

559 

560 

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) 

571 

572 

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 

584 

585 project_quickstart(options) 

586 

587 

588cli.add_command(devcli, "dev") 

589 

590 

591def main(as_module=False): 

592 args = sys.argv[1:] 

593 name = None 

594 

595 if as_module: 

596 name = "python -m lektor_ng" 

597 sys.argv = ["-m", "lektor_ng"] + args 

598 

599 cli.main(args=args, prog_name=name) 

600 

601 

602if __name__ == "__main__": 

603 main(as_module=True)