Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/cmd/base.py: 100%

109 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-11-24 19:30 -0600

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# WuttJamaican -- Base package for Wutta Framework 

5# Copyright © 2023 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

9# Wutta Framework is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by the Free 

11# Software Foundation, either version 3 of the License, or (at your option) any 

12# later version. 

13# 

14# Wutta Framework is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

17# more details. 

18# 

19# You should have received a copy of the GNU General Public License along with 

20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24WuttJamaican - command framework 

25""" 

26 

27import argparse 

28import logging 

29import sys 

30import warnings 

31 

32from wuttjamaican import __version__ 

33from wuttjamaican.conf import make_config 

34from wuttjamaican.util import load_entry_points 

35 

36 

37log = logging.getLogger(__name__) 

38 

39 

40class Command: 

41 """ 

42 Primary command for the application. 

43 

44 A primary command will usually have multiple subcommands it can 

45 run. The typical command line interface is like: 

46 

47 .. code-block:: none 

48 

49 <command> [command-options] <subcommand> [subcommand-options] 

50 

51 :class:`Subcommand` will contain most of the logic, in terms of 

52 what actually happens when it runs. Top-level commands are mostly 

53 a stub for sake of logically grouping the subcommands. 

54 

55 :param config: Optional config object to use. 

56 

57 Usually a command is being ran via actual command line, and 

58 there is no config object yet so it must create one. (It does 

59 this within its :meth:`run()` method.) 

60 

61 But if you already have a config object you can specify it here 

62 and it will be used instead. 

63 

64 :param name: Optional value to assign to :attr:`name`. Usually 

65 this is declared within the command class definition, but if 

66 needed it can be provided dynamically. 

67 

68 :param stdout: Optional replacement to use for :attr:`stdout`. 

69 

70 :param stderr: Optional replacement to use for :attr:`stderr`. 

71 

72 :param subcommands: Optional dictionary to use for 

73 :attr:`subcommands`, instead of loading those via entry points. 

74 

75 This base class also serves as the primary ``wutta`` command for 

76 WuttJamaican. Most apps will subclass this and register their own 

77 top-level command, then create subcommands as needed. 

78 

79 For more info see :doc:`/narr/cli/commands`. 

80 

81 .. attribute:: name 

82 

83 Name of the primary command, e.g. ``wutta`` 

84 

85 .. attribute:: description 

86 

87 Description of the app itself or the primary command. 

88 

89 .. attribute:: version 

90 

91 Version string for the app or primary command. 

92 

93 .. attribute:: stdout 

94 

95 Reference to file-like object which should be used for writing 

96 to STDOUT. By default this is just ``sys.stdout``. 

97 

98 .. attribute:: stderr 

99 

100 Reference to file-like object which should be used for writing 

101 to STDERR. By default this is just ``sys.stderr``. 

102 

103 .. attribute:: subcommands 

104 

105 Dictionary of available subcommand classes, keyed by subcommand 

106 name. These are usually loaded from setuptools entry points. 

107 """ 

108 name = 'wutta' 

109 version = __version__ 

110 description = "Wutta Software Framework" 

111 

112 def __init__( 

113 self, 

114 config=None, 

115 name=None, 

116 stdout=None, 

117 stderr=None, 

118 subcommands=None): 

119 

120 self.config = config 

121 self.name = name or self.name 

122 self.stdout = stdout or sys.stdout 

123 self.stderr = stderr or sys.stderr 

124 

125 # nb. default entry point is like 'wutta_poser.subcommands' 

126 safe_name = self.name.replace('-', '_') 

127 self.subcommands = subcommands or load_entry_points(f'{safe_name}.subcommands') 

128 if not self.subcommands: 

129 

130 # nb. legacy entry point is like 'wutta_poser.commands' 

131 self.subcommands = load_entry_points(f'{safe_name}.commands') 

132 if self.subcommands: 

133 msg = (f"entry point group '{safe_name}.commands' uses deprecated name; " 

134 f"please define '{safe_name}.subcommands' instead") 

135 warnings.warn(msg, DeprecationWarning, stacklevel=2) 

136 log.warning(msg) 

137 

138 def __str__(self): 

139 return self.name 

140 

141 def sorted_subcommands(self): 

142 """ 

143 Get the list of subcommand classes, sorted by name. 

144 """ 

145 return [self.subcommands[name] 

146 for name in sorted(self.subcommands)] 

147 

148 def print_help(self): 

149 """ 

150 Print usage help text for the main command. 

151 """ 

152 self.parser.print_help() 

153 

154 def run(self, *args): 

155 """ 

156 Parse command line arguments and execute appropriate 

157 subcommand. 

158 

159 Or, if requested, or args are ambiguous, show help for either 

160 the top-level or subcommand. 

161 

162 Usually of course this method is invoked by way of command 

163 line. But if you need to run it programmatically, you must 

164 specify the full command line args *except* not the top-level 

165 command name. So for example to run the equivalent of this 

166 command line: 

167 

168 .. code-block:: sh 

169 

170 wutta setup --help 

171 

172 You could do this in Python:: 

173 

174 from wuttjamaican.cmd import Command 

175 

176 cmd = Command() 

177 assert cmd.name == 'wutta' 

178 cmd.run('setup', '--help') 

179 """ 

180 # build arg parser 

181 self.parser = self.make_arg_parser() 

182 self.add_args() 

183 

184 # primary parser gets first pass at full args, and stores 

185 # everything not used within args.argv 

186 args = self.parser.parse_args(args) 

187 if not args or not args.argv: 

188 self.print_help() 

189 sys.exit(1) 

190 

191 # then argv should include <subcommand> [subcommand-options] 

192 subcmd = args.argv[0] 

193 if subcmd in self.subcommands: 

194 if '-h' in args.argv or '--help' in args.argv: 

195 subcmd = self.subcommands[subcmd](self) 

196 subcmd.print_help() 

197 sys.exit(0) 

198 else: 

199 self.print_help() 

200 sys.exit(1) 

201 

202 # we should be done needing to print help messages. now it's 

203 # safe to redirect STDOUT/STDERR, if necessary 

204 if args.stdout: 

205 self.stdout = args.stdout 

206 if args.stderr: 

207 self.stderr = args.stderr 

208 

209 # make the config object 

210 if not self.config: 

211 self.config = self.make_config(args) 

212 

213 # invoke subcommand 

214 log.debug("running command line: %s", sys.argv) 

215 subcmd = self.subcommands[subcmd](self) 

216 self.prep_subcommand(subcmd, args) 

217 subcmd._run(*args.argv[1:]) 

218 

219 # nb. must flush these in case they are file objects 

220 self.stdout.flush() 

221 self.stderr.flush() 

222 

223 def make_arg_parser(self): 

224 """ 

225 Must return a new :class:`argparse.ArgumentParser` instance 

226 for use by the main command. 

227 

228 This will use :class:`CommandArgumentParser` by default. 

229 """ 

230 subcommands = "" 

231 for subcmd in self.sorted_subcommands(): 

232 subcommands += f" {subcmd.name:<20s} {subcmd.description}\n" 

233 

234 epilog = f"""\ 

235subcommands: 

236{subcommands} 

237 

238also try: {self.name} <subcommand> -h 

239""" 

240 

241 return CommandArgumentParser( 

242 prog=self.name, 

243 description=self.description, 

244 add_help=False, 

245 usage=f"{self.name} [options] <subcommand> [subcommand-options]", 

246 epilog=epilog, 

247 formatter_class=argparse.RawDescriptionHelpFormatter) 

248 

249 def add_args(self): 

250 """ 

251 Configure args for the main command arg parser. 

252 

253 Anything you setup here will then be available when the 

254 command runs. You can add arguments directly to 

255 ``self.parser``, e.g.:: 

256 

257 self.parser.add_argument('--foo', default='bar', help="Foo value") 

258 

259 See also docs for :meth:`python:argparse.ArgumentParser.add_argument()`. 

260 """ 

261 parser = self.parser 

262 

263 parser.add_argument('-c', '--config', metavar='PATH', 

264 action='append', dest='config_paths', 

265 help="Config path (may be specified more than once)") 

266 

267 parser.add_argument('--plus-config', metavar='PATH', 

268 action='append', dest='plus_config_paths', 

269 help="Extra configs to load in addition to normal config") 

270 

271 parser.add_argument('-P', '--progress', action='store_true', default=False, 

272 help="Report progress when relevant") 

273 

274 parser.add_argument('-V', '--version', action='version', 

275 version=f"%(prog)s {self.version}") 

276 

277 parser.add_argument('--stdout', metavar='PATH', type=argparse.FileType('w'), 

278 help="Optional path to which STDOUT should be written") 

279 parser.add_argument('--stderr', metavar='PATH', type=argparse.FileType('w'), 

280 help="Optional path to which STDERR should be written") 

281 

282 def make_config(self, args): 

283 """ 

284 Make the config object in preparation for running a subcommand. 

285 

286 By default this is a straightforward wrapper around 

287 :func:`wuttjamaican.conf.make_config()`. 

288 

289 :returns: The new config object. 

290 """ 

291 return make_config(args.config_paths, 

292 plus_files=args.plus_config_paths) 

293 

294 def prep_subcommand(self, subcommand, args): 

295 """ 

296 Prepare the subcommand for running, as needed. 

297 """ 

298 

299 

300class CommandArgumentParser(argparse.ArgumentParser): 

301 """ 

302 Custom argument parser for use with :class:`Command`. 

303 

304 This is based on standard :class:`python:argparse.ArgumentParser` 

305 but overrides some of the parsing logic which is specific to the 

306 primary command object, to separate command options from 

307 subcommand options. 

308 

309 This is documented as FYI but you probably should not need to know 

310 about or try to use this yourself. It will be used automatically 

311 by :class:`Command` or a subclass thereof. 

312 """ 

313 

314 def parse_args(self, args=None, namespace=None): 

315 args, argv = self.parse_known_args(args, namespace) 

316 args.argv = argv 

317 return args 

318 

319 

320class Subcommand: 

321 """ 

322 Base class for application subcommands. 

323 

324 Subcommands are where the real action happens. Each must define 

325 the :meth:`run()` method with whatever logic is needed. They can 

326 also define :meth:`add_args()` to expose options. 

327 

328 Subcommands always belong to a top-level command - the association 

329 is made by way of :term:`entry point` registration, and the 

330 constructor for this class. 

331 

332 :param command: Reference to top-level :class:`Command` object. 

333 

334 Note that unlike :class:`Command`, the base ``Subcommand`` does 

335 not correspond to any real subcommand for WuttJamaican. (It's 

336 *only* a base class.) For a real example see 

337 :class:`~wuttjamaican.cmd.make_appdir.MakeAppDir`. 

338 

339 For more info see :doc:`/narr/cli/subcommands`. 

340 

341 .. attribute:: stdout 

342 

343 Reference to file-like object which should be used for writing 

344 to STDOUT. This is inherited from :attr:`Command.stdout`. 

345 

346 .. attribute:: stderr 

347 

348 Reference to file-like object which should be used for writing 

349 to STDERR. This is inherited from :attr:`Command.stderr`. 

350 """ 

351 name = 'UNDEFINED' 

352 description = "TODO: not defined" 

353 

354 def __init__( 

355 self, 

356 command, 

357 ): 

358 self.command = command 

359 self.stdout = self.command.stdout 

360 self.stderr = self.command.stderr 

361 self.config = self.command.config 

362 if self.config: 

363 self.app = self.config.get_app() 

364 

365 # build arg parser 

366 self.parser = self.make_arg_parser() 

367 self.add_args() 

368 

369 def __repr__(self): 

370 return f"Subcommand(name={self.name})" 

371 

372 def make_arg_parser(self): 

373 """ 

374 Must return a new :class:`argparse.ArgumentParser` instance 

375 for use by the subcommand. 

376 """ 

377 return argparse.ArgumentParser( 

378 prog=f'{self.command.name} {self.name}', 

379 description=self.description) 

380 

381 def add_args(self): 

382 """ 

383 Configure additional args for the subcommand arg parser. 

384 

385 Anything you setup here will then be available within 

386 :meth:`run()`. You can add arguments directly to 

387 ``self.parser``, e.g.:: 

388 

389 self.parser.add_argument('--foo', default='bar', help="Foo value") 

390 

391 See also docs for :meth:`python:argparse.ArgumentParser.add_argument()`. 

392 """ 

393 

394 def print_help(self): 

395 """ 

396 Print usage help text for the subcommand. 

397 """ 

398 self.parser.print_help() 

399 

400 def _run(self, *args): 

401 args = self.parser.parse_args(args) 

402 return self.run(args) 

403 

404 def run(self, args): 

405 """ 

406 Run the subcommand logic. Subclass should override this. 

407 

408 :param args: Reference to the 

409 :class:`python:argparse.Namespace` object, as returned by 

410 the subcommand arg parser. 

411 

412 The ``args`` should have values for everything setup in 

413 :meth:`add_args()`. For example if you added the ``--foo`` 

414 arg then here in ``run()`` you can do:: 

415 

416 print("foo value is:", args.foo) 

417 

418 Usually of course this method is invoked by way of command 

419 line. But if you need to run it programmatically, you should 

420 *not* try to invoke this method directly. Instead create the 

421 ``Command`` object and invoke its :meth:`~Command.run()` 

422 method. 

423 

424 For a command line like ``bin/poser hello --foo=baz`` then, 

425 you might do this:: 

426 

427 from poser.commands import PoserCommand 

428 

429 cmd = PoserCommand() 

430 assert cmd.name == 'poser' 

431 cmd.run('hello', '--foo=baz') 

432 """ 

433 self.stdout.write("TODO: command logic not yet implemented\n") 

434 

435 

436def main(*args): 

437 """ 

438 Primary entry point for the ``wutta`` command. 

439 """ 

440 args = list(args) or sys.argv[1:] 

441 

442 cmd = Command() 

443 cmd.run(*args)