Coverage for /usr/local/lib/python3.12/site-packages/prefect/cli/_types.py: 71%

68 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-12-05 10:48 +0000

1""" 

2Custom Prefect CLI types 

3""" 

4 

5import asyncio 1a

6import functools 1a

7import sys 1a

8from datetime import datetime 1a

9from typing import TYPE_CHECKING, Any, Callable, List, Optional 1a

10 

11import typer 1a

12from rich.console import Console 1a

13from rich.theme import Theme 1a

14 

15from prefect._internal.compatibility.deprecated import generate_deprecation_message 1a

16from prefect.cli._utilities import with_cli_exception_handling 1a

17from prefect.settings import get_current_settings 1a

18from prefect.utilities.asyncutils import is_async_fn 1a

19 

20if TYPE_CHECKING: 20 ↛ 21line 20 didn't jump to line 21 because the condition on line 20 was never true1a

21 from prefect.settings.legacy import Setting 

22 

23 

24def SettingsOption(setting: "Setting", *args: Any, **kwargs: Any) -> Any: 1a

25 """Custom `typer.Option` factory to load the default value from settings""" 

26 

27 return typer.Option( 1a

28 # The default is dynamically retrieved 

29 setting.value, 

30 *args, 

31 # Typer shows "(dynamic)" by default. We'd like to actually show the value 

32 # that would be used if the parameter is not specified and a reference if the 

33 # source is from the environment or profile, but typer does not support this 

34 # yet. See https://github.com/tiangolo/typer/issues/354 

35 show_default=f"from {setting.name}", 

36 **kwargs, 

37 ) 

38 

39 

40def SettingsArgument(setting: "Setting", *args: Any, **kwargs: Any) -> Any: 1a

41 """Custom `typer.Argument` factory to load the default value from settings""" 

42 

43 # See comments in `SettingsOption` 

44 return typer.Argument( 

45 setting.value, 

46 *args, 

47 show_default=f"from {setting.name}", 

48 **kwargs, 

49 ) 

50 

51 

52def with_deprecated_message( 1a

53 warning: str, 

54) -> Callable[[Callable[..., Any]], Callable[..., Any]]: 

55 def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: 

56 @functools.wraps(fn) 

57 def wrapper(*args: Any, **kwargs: Any) -> Any: 

58 print("WARNING:", warning, file=sys.stderr, flush=True) 

59 return fn(*args, **kwargs) 

60 

61 return wrapper 

62 

63 return decorator 

64 

65 

66class PrefectTyper(typer.Typer): 1a

67 """ 

68 Wraps commands created by `Typer` to support async functions and handle errors. 

69 """ 

70 

71 console: Console 1a

72 

73 def __init__( 1a

74 self, 

75 *args: Any, 

76 deprecated: bool = False, 

77 deprecated_start_date: Optional[datetime] = None, 

78 deprecated_help: str = "", 

79 deprecated_name: str = "", 

80 **kwargs: Any, 

81 ): 

82 super().__init__(*args, **kwargs) 1a

83 

84 self.deprecated = deprecated 1a

85 if self.deprecated: 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true1a

86 if not deprecated_name: 

87 raise ValueError("Provide the name of the deprecated command group.") 

88 self.deprecated_message: str = generate_deprecation_message( 

89 name=f"The {deprecated_name!r} command group", 

90 start_date=deprecated_start_date, 

91 help=deprecated_help, 

92 ) 

93 

94 self.console = Console( 1a

95 highlight=False, 

96 theme=Theme({"prompt.choices": "bold blue"}), 

97 color_system="auto" if get_current_settings().cli.colors else None, 

98 ) 

99 

100 def add_typer( 1a

101 self, 

102 typer_instance: "PrefectTyper", 

103 *args: Any, 

104 no_args_is_help: bool = True, 

105 aliases: Optional[list[str]] = None, 

106 **kwargs: Any, 

107 ) -> None: 

108 """ 

109 This will cause help to be default command for all sub apps unless specifically stated otherwise, opposite of before. 

110 """ 

111 if aliases: 1a

112 for alias in aliases: 1a

113 super().add_typer( 1a

114 typer_instance, 

115 *args, 

116 name=alias, 

117 no_args_is_help=no_args_is_help, 

118 hidden=True, 

119 **kwargs, 

120 ) 

121 

122 return super().add_typer( 1a

123 typer_instance, *args, no_args_is_help=no_args_is_help, **kwargs 

124 ) 

125 

126 def command( 1a

127 self, 

128 name: Optional[str] = None, 

129 *args: Any, 

130 aliases: Optional[List[str]] = None, 

131 deprecated: bool = False, 

132 deprecated_start_date: Optional[datetime] = None, 

133 deprecated_help: str = "", 

134 deprecated_name: str = "", 

135 **kwargs: Any, 

136 ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: 

137 """ 

138 Create a new command. If aliases are provided, the same command function 

139 will be registered with multiple names. 

140 

141 Provide `deprecated=True` to mark the command as deprecated. If `deprecated=True`, 

142 `deprecated_name` and `deprecated_start_date` must be provided. 

143 """ 

144 

145 def wrapper(original_fn: Callable[..., Any]) -> Callable[..., Any]: 1a

146 # click doesn't support async functions, so we wrap them in 

147 # asyncio.run(). This has the advantage of keeping the function in 

148 # the main thread, which means signal handling works for e.g. the 

149 # server and workers. However, it means that async CLI commands can 

150 # not directly call other async CLI commands (because asyncio.run() 

151 # can not be called nested). In that (rare) circumstance, refactor 

152 # the CLI command so its business logic can be invoked separately 

153 # from its entrypoint. 

154 if is_async_fn(original_fn): 1a

155 async_fn = original_fn 1a

156 

157 @functools.wraps(original_fn) 1a

158 def sync_fn(*args: Any, **kwargs: Any) -> Any: 1a

159 return asyncio.run(async_fn(*args, **kwargs)) 

160 

161 setattr(sync_fn, "aio", async_fn) 1a

162 wrapped_fn = sync_fn 1a

163 else: 

164 wrapped_fn = original_fn 1a

165 

166 wrapped_fn = with_cli_exception_handling(wrapped_fn) 1a

167 if deprecated: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true1a

168 if not deprecated_name or not deprecated_start_date: 

169 raise ValueError( 

170 "Provide the name of the deprecated command and a deprecation start date." 

171 ) 

172 command_deprecated_message = generate_deprecation_message( 

173 name=f"The {deprecated_name!r} command", 

174 start_date=deprecated_start_date, 

175 help=deprecated_help, 

176 ) 

177 wrapped_fn = with_deprecated_message(command_deprecated_message)( 

178 wrapped_fn 

179 ) 

180 elif self.deprecated: 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true1a

181 wrapped_fn = with_deprecated_message(self.deprecated_message)( 

182 wrapped_fn 

183 ) 

184 

185 # register fn with its original name 

186 command_decorator = super(PrefectTyper, self).command( 1a

187 name=name, *args, **kwargs 

188 ) 

189 original_command = command_decorator(wrapped_fn) 1a

190 

191 # register fn for each alias, e.g. @marvin_app.command(aliases=["r"]) 

192 if aliases: 1a

193 for alias in aliases: 1a

194 super(PrefectTyper, self).command( 1a

195 name=alias, 

196 *args, 

197 **{k: v for k, v in kwargs.items() if k != "aliases"}, 

198 )(wrapped_fn) 

199 

200 return original_command 1a

201 

202 return wrapper 1a

203 

204 def setup_console(self, soft_wrap: bool, prompt: bool) -> None: 1a

205 self.console = Console( 1a

206 highlight=False, 

207 color_system="auto" if get_current_settings().cli.colors else None, 

208 theme=Theme({"prompt.choices": "bold blue"}), 

209 soft_wrap=not soft_wrap, 

210 force_interactive=prompt, 

211 )