Coverage for structured_tutorials / cli.py: 100%

61 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-04 13:17 +0200

1# Copyright (c) 2025 Mathias Ertl 

2# Licensed under the MIT License. See LICENSE file for details. 

3 

4"""Main CLI entrypoint.""" 

5 

6import argparse 

7import sys 

8from collections.abc import Sequence 

9from importlib import import_module 

10from pathlib import Path 

11 

12import yaml 

13 

14from structured_tutorials import __version__ 

15from structured_tutorials.errors import InvalidAlternativesSelectedError, RunTutorialException 

16from structured_tutorials.models import TutorialModel 

17from structured_tutorials.models.tutorial import RunnerConfig 

18from structured_tutorials.output import error, setup_logging 

19from structured_tutorials.runners.base import RunnerBase 

20 

21 

22def get_runner(config: RunnerConfig) -> type[RunnerBase]: 

23 """Get runner class.""" 

24 mod_path, cls_name = config.path.rsplit(".", 1) 

25 mod = import_module(mod_path) 

26 cls = getattr(mod, cls_name) 

27 assert issubclass(cls, RunnerBase) 

28 return cls # type: ignore[no-any-return] 

29 

30 

31def main(argv: Sequence[str] | None = None) -> int: 

32 """Main entry function for the command-line.""" 

33 parser = argparse.ArgumentParser() 

34 parser.add_argument("path", type=Path) 

35 parser.add_argument("--version", action="version", version=__version__) 

36 parser.add_argument("-a", "--alternative", dest="alternatives", action="append", default=[]) 

37 parser.add_argument("--no-colors", action="store_true", default=False) 

38 parser.add_argument( 

39 "-n", 

40 "--non-interactive", 

41 dest="interactive", 

42 action="store_false", 

43 default=True, 

44 help="Never prompt for any user input.", 

45 ) 

46 parser.add_argument( 

47 "--log-level", 

48 choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 

49 default="INFO", 

50 help="Override root log level", 

51 ) 

52 parser.add_argument( 

53 "--hide-commands", 

54 dest="show_commands", 

55 action="store_false", 

56 default=True, 

57 help="Do not show commands that are run by the tutorial.", 

58 ) 

59 parser.add_argument( 

60 "--hide-command-output", 

61 dest="show_command_output", 

62 action="store_false", 

63 default=True, 

64 help="Do not show the output of commands that are run on the terminal.", 

65 ) 

66 parser.add_argument( 

67 "-D", "--define", action="append", default=[], nargs=2, help="Define custom variables in context." 

68 ) 

69 args = parser.parse_args(argv) 

70 

71 setup_logging(level=args.log_level, no_colors=args.no_colors, show_commands=args.show_commands) 

72 context = {k: v for k, v in args.define} 

73 

74 try: 

75 tutorial = TutorialModel.from_file(args.path) 

76 except yaml.YAMLError as exc: # an invalid YAML file 

77 error(f"{args.path}: Invalid YAML file:") 

78 print(exc, file=sys.stderr) 

79 return 1 

80 except ValueError as ex: # thrown by Pydantic model loading 

81 error(f"{args.path}: File is not a valid Tutorial:") 

82 print(ex, file=sys.stderr) 

83 return 1 

84 

85 try: 

86 runner_cls = get_runner(tutorial.configuration.run.runner) 

87 runner = runner_cls( 

88 path=args.path, 

89 tutorial=tutorial, 

90 alternatives=tuple(args.alternatives), 

91 show_command_output=args.show_command_output, 

92 interactive=args.interactive, 

93 context=context, 

94 ) 

95 except Exception as ex: 

96 error(str(ex)) 

97 return 1 

98 

99 try: 

100 runner.validate_alternatives() 

101 except InvalidAlternativesSelectedError as ex: 

102 error(str(ex)) 

103 return 1 

104 

105 try: 

106 runner.prepare_tutorial() 

107 runner.run() 

108 except RunTutorialException as ex: 

109 error(str(ex)) 

110 return 1 # ignored, already handled by cleanup 

111 finally: 

112 runner.cleanup_tutorial() 

113 return 0