Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/face/middleware.py: 21%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

62 statements  

1"""Face Middleware 

2=============== 

3 

4When using Face's Command framework, Face takes over handling dispatch 

5of commands and subcommands. A particular command line string is 

6routed to the configured function, in much the same way that popular 

7web frameworks route requests based on path. 

8 

9In more advanced programs, this basic control flow can be enhanced by 

10adding middleware. Middlewares comprise a stack of functions, each 

11which calls the next, until finally calling the appropriate 

12command-handling function. Middlewares are added to the command, with 

13the outermost middleware being added first. Remember: first added, 

14first called. 

15 

16Middlewares are a great way to handle general setup and logic which is 

17common across many subcommands, such as verbosity, logging, and 

18formatting. Middlewares can also be used to perform additional 

19argument validation, and terminate programs early. 

20 

21The interface of middlewares retains the same injection ability of 

22Command handler functions. Flags and builtins are automatically 

23provided. In addition to having its arguments checked against those 

24available injectables, a middleware _must_ take a ``next_`` parameter 

25as its first argument. Then, much like a decorator, that ``next_`` 

26function must be invoked to continue to program execution:: 

27 

28 

29 import time 

30 

31 from face import face_middleware, echo 

32 

33 @face_middleware 

34 def timing_middleware(next_): 

35 start_time = time.time() 

36 ret = next_() 

37 echo('command executed in:', time.time() - start_time, 'seconds') 

38 return ret 

39 

40As always, code speaks volumes. It's worth noting that ``next_()`` is 

41a normal function. If you return without calling it, your command's 

42handler function will not be called, nor will any other downstream 

43middleware. Another corollary is that this makes it easy to use 

44``try``/``except`` to build error handling. 

45 

46While already practical, there are two significant ways it can be 

47enhanced. The first would be to provide downstream handlers access to 

48the ``start_time`` value. The second would be to make the echo 

49functionality optional. 

50 

51Providing values from middleware 

52-------------------------------- 

53 

54As mentioned above, the first version of our timing middleware works, 

55but what if one or more of our handler functions needs to perform a 

56calculation based on ``start_time``? 

57 

58Common code is easily folded away by middleware, and we can do so here 

59by making the start_time available as an injectable:: 

60 

61 import time 

62 

63 from face import face_middleware, echo 

64 

65 @face_middleware(provides=['start_time']) 

66 def timing_middleware(next_): 

67 start_time = time.time() 

68 ret = next_(start_time=start_time) 

69 echo('command executed in:', time.time() - start_time, 'seconds') 

70 return ret 

71 

72``start_time`` is added to the list of provides in the middleware 

73decoration, and ``next_()`` is simply invoked with a ``start_time`` 

74keyword argument. Any command handler function that takes a 

75``start_time`` keyword argument will automatically pick up the value. 

76 

77That's all well and fine, but what if we don't always want to know the 

78duration of the command? Whose responsibility is it to expose that 

79optional behavior? Lucky for us, middlewares can take care of themselves. 

80 

81Adding flags to middleware 

82-------------------------- 

83 

84Right now our middleware changes command output every time it is 

85run. While that's pretty handy behavior, the command line is all about 

86options. 

87 

88We can make our middleware even more reusable by adding self-contained 

89optional behavior, via a flag:: 

90 

91 import time 

92 

93 from face import face_middleware, Flag, echo 

94 

95 @face_middleware(provides=['start_time'], flags=[Flag('--echo-time', parse_as=True)]) 

96 def timing_middleware(next_, echo_time): 

97 start_time = time.time() 

98 ret = next_(start_time=start_time) 

99 if echo_time: 

100 echo('command executed in:', time.time() - start_time, 'seconds') 

101 return ret 

102 

103Now, every :class:`Command` that adds this middleware will 

104automatically get a flag, ``--echo-time``. Just like other flags, its 

105value will be injected into commands that need it. 

106 

107.. note:: **Weak Dependencies** - Middlewares that set defaults for 

108 keyword arguments are said to have a "weak" dependency on 

109 the associated injectable. If the command handler function, 

110 or another downstream middleware, do not accept the 

111 argument, the flag will not be parsed, or shown in generated 

112 help and error messages. This differs from the command 

113 handler function itself, which will accept arguments even 

114 when the function signature sets a default. 

115 

116Wrapping up 

117----------- 

118 

119I'd like to say that we were only scratching the surface of 

120middlewares, but really there's not much more to them. They are an 

121advanced feature of face, and a very powerful organizing tool for your 

122code, but like many powerful tools, they are simple. You can use them 

123in a wide variety of ways. Other useful middleware ideas: 

124 

125 * Verbosity middleware - provides a ``verbose`` flag for downstream 

126 commands which can write additional output. 

127 * Logging middleware - sets up and provides an associated logger 

128 object for downstream commands. 

129 * Pipe middleware - Many CLIs are made for streaming. There are some 

130 semantics a middleware can help with, like breaking pipes. 

131 * KeyboardInterrupt middleware - Ctrl-C is a common way to exit 

132 programs, but Python generally spits out an ugly stack trace, even 

133 where a keyboard interrupt may have been valid. 

134 * Authentication middleware - provides an AuthenticatedUser object 

135 after checking environment variables and prompting for a username 

136 and password. 

137 * Debugging middleware - Because face middlewares are functions in a 

138 normal Python stack, it's easy to wrap downstream calls in a 

139 ``try``/``except``, and add a flag (or environment variable) that 

140 enables a ``pdb.post_mortem()`` to drop you into a debug console. 

141 

142The possibilities never end. If you build a middleware of particularly 

143broad usefulness, consider contributing it back to the core! 

144 

145""" 

146 

147 

148from face.parser import Flag 

149from face.sinter import make_chain, get_arg_names, get_fb, get_callable_labels 

150from face.sinter import inject # transitive import for external use 

151from typing import Callable, List, Optional, Union 

152 

153INNER_NAME = 'next_' 

154 

155_BUILTIN_PROVIDES = [INNER_NAME, 'args_', 'cmd_', 'subcmds_', 

156 'flags_', 'posargs_', 'post_posargs_', 

157 'command_', 'subcommand_'] 

158 

159 

160def is_middleware(target): 

161 """Mostly for internal use, this function returns True if *target* is 

162 a valid face middleware. 

163 

164 Middlewares can be functions wrapped with the 

165 :func:`face_middleware` decorator, or instances of a user-created 

166 type, as long as it's a callable following face's signature 

167 convention and has the ``is_face_middleware`` attribute set to 

168 True. 

169 """ 

170 if callable(target) and getattr(target, 'is_face_middleware', None): 

171 return True 

172 return False 

173 

174 

175def face_middleware(func: Optional[Callable] = None, 

176 *, 

177 provides: Union[List[str], str] = [], 

178 flags: List[Flag] = [], 

179 optional: bool = False) -> Callable: 

180 """A decorator to mark a function as face middleware, which wraps 

181 execution of a subcommand handler function. This decorator can be 

182 called with or without arguments: 

183 

184 Args: 

185 provides: An optional list of names, declaring which 

186 values be provided by this middleware at execution time. 

187 flags: An optional list of Flag instances, which will be 

188 automatically added to any Command which adds this middleware. 

189 optional: Whether this middleware should be skipped if its  

190 provides are not required by the command. 

191 

192 The first argument of the decorated function must be named 

193 "next_". This argument is a function, representing the next 

194 function in the execution chain, the last of which is the 

195 command's handler function. 

196 

197 Returns: 

198 A decorator function that marks the decorated function as middleware. 

199 """ 

200 if isinstance(provides, str): 

201 provides = [provides] 

202 flags = list(flags) 

203 if flags: 

204 for flag in flags: 

205 if not isinstance(flag, Flag): 

206 raise TypeError(f'expected Flag object, not: {flag!r}') 

207 

208 def decorate_face_middleware(func): 

209 check_middleware(func, provides=provides) 

210 func.is_face_middleware = True 

211 func._face_flags = list(flags) 

212 func._face_provides = list(provides) 

213 func._face_optional = optional 

214 return func 

215 

216 if func and callable(func): 

217 return decorate_face_middleware(func) 

218 

219 return decorate_face_middleware 

220 

221 

222def get_middleware_chain(middlewares, innermost, preprovided): 

223 """Perform basic validation of innermost function, wrap it in 

224 middlewares, and raise a :exc:`NameError` on any unresolved 

225 arguments. 

226 

227 Args: 

228 middlewares (list): A list of middleware functions, prechecked 

229 by :func:`check_middleware`. 

230 innermost (callable): A function to be called after all the 

231 middlewares. 

232 preprovided (list): A list of built-in or otherwise preprovided 

233 injectables. 

234 

235 Returns: 

236 A single function representing the whole middleware chain. 

237 

238 This function is called automatically by :meth:`Command.prepare()` 

239 (and thus, :meth:`Command.run()`), and is more or less for 

240 internal use. 

241 """ 

242 _inner_exc_msg = "argument %r reserved for middleware use only (%r)" 

243 if INNER_NAME in get_arg_names(innermost): 

244 raise NameError(_inner_exc_msg % (INNER_NAME, innermost)) 

245 

246 mw_builtins = set(preprovided) - {INNER_NAME} 

247 mw_provides = [list(mw._face_provides) for mw in middlewares] 

248 

249 mw_chain, mw_chain_args, mw_unres = make_chain(middlewares, mw_provides, innermost, mw_builtins, INNER_NAME) 

250 

251 if mw_unres: 

252 msg = f"unresolved middleware or handler arguments: {sorted(mw_unres)!r}" 

253 avail_unres = mw_unres & (mw_builtins | set(sum(mw_provides, []))) 

254 if avail_unres: 

255 msg += (' (%r provided but not resolvable, check middleware order.)' 

256 % sorted(avail_unres)) 

257 raise NameError(msg) 

258 return mw_chain 

259 

260 

261def check_middleware(func, provides=None): 

262 """Check that a middleware callable adheres to function signature 

263 requirements. Called automatically by 

264 :class:`Command.add_middleware()` and elsewhere, this function 

265 raises :exc:`TypeError` if any issues are found. 

266 """ 

267 if not callable(func): 

268 raise TypeError(f'expected middleware {func!r} to be a function') 

269 fb = get_fb(func) 

270 # TODO: this currently gives __main__abc instead of __main__.abc 

271 func_label = ''.join(get_callable_labels(func)) 

272 arg_names = fb.args 

273 if not arg_names: 

274 raise TypeError('middleware function %r must take at least one' 

275 ' argument "%s" as its first parameter' 

276 % (func_label, INNER_NAME)) 

277 if arg_names[0] != INNER_NAME: 

278 raise TypeError('middleware function %r must take argument' 

279 ' "%s" as the first parameter, not "%s"' 

280 % (func_label, INNER_NAME, arg_names[0])) 

281 if fb.varargs: 

282 raise TypeError('middleware function %r may only take explicitly' 

283 ' named arguments, not "*%s"' % (func_label, fb.varargs)) 

284 if fb.varkw: 

285 raise TypeError('middleware function %r may only take explicitly' 

286 ' named arguments, not "**%s"' % (func_label, fb.varkw)) 

287 

288 provides = provides if provides is not None else func._face_provides 

289 conflict_args = list(set(_BUILTIN_PROVIDES) & set(provides)) 

290 if conflict_args: 

291 raise TypeError('middleware function %r provides conflict with' 

292 ' reserved face builtins: %r' % (func_label, conflict_args)) 

293 

294 return