Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/invoke/context.py: 25%

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

145 statements  

1import os 

2import re 

3from contextlib import contextmanager 

4from itertools import cycle 

5from os import PathLike 

6from typing import ( 

7 TYPE_CHECKING, 

8 Any, 

9 Generator, 

10 Iterator, 

11 List, 

12 Optional, 

13 Union, 

14) 

15from unittest.mock import Mock 

16 

17from .config import Config, DataProxy 

18from .exceptions import Failure, AuthFailure, ResponseNotAccepted 

19from .runners import Result 

20from .watchers import FailingResponder 

21 

22if TYPE_CHECKING: 

23 from invoke.runners import Runner 

24 

25 

26class Context(DataProxy): 

27 """ 

28 Context-aware API wrapper & state-passing object. 

29 

30 `.Context` objects are created during command-line parsing (or, if desired, 

31 by hand) and used to share parser and configuration state with executed 

32 tasks (see :ref:`why-context`). 

33 

34 Specifically, the class offers wrappers for core API calls (such as `.run`) 

35 which take into account CLI parser flags, configuration files, and/or 

36 changes made at runtime. It also acts as a proxy for its `~.Context.config` 

37 attribute - see that attribute's documentation for details. 

38 

39 Instances of `.Context` may be shared between tasks when executing 

40 sub-tasks - either the same context the caller was given, or an altered 

41 copy thereof (or, theoretically, a brand new one). 

42 

43 .. versionadded:: 1.0 

44 """ 

45 

46 def __init__(self, config: Optional[Config] = None) -> None: 

47 """ 

48 :param config: 

49 `.Config` object to use as the base configuration. 

50 

51 Defaults to an anonymous/default `.Config` instance. 

52 """ 

53 #: The fully merged `.Config` object appropriate for this context. 

54 #: 

55 #: `.Config` settings (see their documentation for details) may be 

56 #: accessed like dictionary keys (``c.config['foo']``) or object 

57 #: attributes (``c.config.foo``). 

58 #: 

59 #: As a convenience shorthand, the `.Context` object proxies to its 

60 #: ``config`` attribute in the same way - e.g. ``c['foo']`` or 

61 #: ``c.foo`` returns the same value as ``c.config['foo']``. 

62 config = config if config is not None else Config() 

63 self._set(_config=config) 

64 #: A list of commands to run (via "&&") before the main argument to any 

65 #: `run` or `sudo` calls. Note that the primary API for manipulating 

66 #: this list is `prefix`; see its docs for details. 

67 command_prefixes: List[str] = list() 

68 self._set(command_prefixes=command_prefixes) 

69 #: A list of directories to 'cd' into before running commands with 

70 #: `run` or `sudo`; intended for management via `cd`, please see its 

71 #: docs for details. 

72 command_cwds: List[str] = list() 

73 self._set(command_cwds=command_cwds) 

74 

75 @property 

76 def config(self) -> Config: 

77 # Allows Context to expose a .config attribute even though DataProxy 

78 # otherwise considers it a config key. 

79 return self._config 

80 

81 @config.setter 

82 def config(self, value: Config) -> None: 

83 # NOTE: mostly used by client libraries needing to tweak a Context's 

84 # config at execution time; i.e. a Context subclass that bears its own 

85 # unique data may want to be stood up when parameterizing/expanding a 

86 # call list at start of a session, with the final config filled in at 

87 # runtime. 

88 self._set(_config=value) 

89 

90 def run(self, command: str, **kwargs: Any) -> Optional[Result]: 

91 """ 

92 Execute a local shell command, honoring config options. 

93 

94 Specifically, this method instantiates a `.Runner` subclass (according 

95 to the ``runner`` config option; default is `.Local`) and calls its 

96 ``.run`` method with ``command`` and ``kwargs``. 

97 

98 See `.Runner.run` for details on ``command`` and the available keyword 

99 arguments. 

100 

101 .. versionadded:: 1.0 

102 """ 

103 runner = self.config.runners.local(self) 

104 return self._run(runner, command, **kwargs) 

105 

106 # NOTE: broken out of run() to allow for runner class injection in 

107 # Fabric/etc, which needs to juggle multiple runner class types (local and 

108 # remote). 

109 def _run( 

110 self, runner: "Runner", command: str, **kwargs: Any 

111 ) -> Optional[Result]: 

112 command = self._prefix_commands(command) 

113 return runner.run(command, **kwargs) 

114 

115 def sudo(self, command: str, **kwargs: Any) -> Optional[Result]: 

116 """ 

117 Execute a shell command via ``sudo`` with password auto-response. 

118 

119 **Basics** 

120 

121 This method is identical to `run` but adds a handful of 

122 convenient behaviors around invoking the ``sudo`` program. It doesn't 

123 do anything users could not do themselves by wrapping `run`, but the 

124 use case is too common to make users reinvent these wheels themselves. 

125 

126 .. note:: 

127 If you intend to respond to sudo's password prompt by hand, just 

128 use ``run("sudo command")`` instead! The autoresponding features in 

129 this method will just get in your way. 

130 

131 Specifically, `sudo`: 

132 

133 * Places a `.FailingResponder` into the ``watchers`` kwarg (see 

134 :doc:`/concepts/watchers`) which: 

135 

136 * searches for the configured ``sudo`` password prompt; 

137 * responds with the configured sudo password (``sudo.password`` 

138 from the :doc:`configuration </concepts/configuration>`); 

139 * can tell when that response causes an authentication failure 

140 (e.g. if the system requires a password and one was not 

141 configured), and raises `.AuthFailure` if so. 

142 

143 * Builds a ``sudo`` command string using the supplied ``command`` 

144 argument, prefixed by various flags (see below); 

145 * Executes that command via a call to `run`, returning the result. 

146 

147 **Flags used** 

148 

149 ``sudo`` flags used under the hood include: 

150 

151 - ``-S`` to allow auto-responding of password via stdin; 

152 - ``-p <prompt>`` to explicitly state the prompt to use, so we can be 

153 sure our auto-responder knows what to look for; 

154 - ``-u <user>`` if ``user`` is not ``None``, to execute the command as 

155 a user other than ``root``; 

156 - When ``-u`` is present, ``-H`` is also added, to ensure the 

157 subprocess has the requested user's ``$HOME`` set properly. 

158 

159 **Configuring behavior** 

160 

161 There are a couple of ways to change how this method behaves: 

162 

163 - Because it wraps `run`, it honors all `run` config parameters and 

164 keyword arguments, in the same way that `run` does. 

165 

166 - Thus, invocations such as ``c.sudo('command', echo=True)`` are 

167 possible, and if a config layer (such as a config file or env 

168 var) specifies that e.g. ``run.warn = True``, that too will take 

169 effect under `sudo`. 

170 

171 - `sudo` has its own set of keyword arguments (see below) and they are 

172 also all controllable via the configuration system, under the 

173 ``sudo.*`` tree. 

174 

175 - Thus you could, for example, pre-set a sudo user in a config 

176 file; such as an ``invoke.json`` containing ``{"sudo": {"user": 

177 "someuser"}}``. 

178 

179 :param str password: Runtime override for ``sudo.password``. 

180 :param str user: Runtime override for ``sudo.user``. 

181 

182 .. versionadded:: 1.0 

183 """ 

184 runner = self.config.runners.local(self) 

185 return self._sudo(runner, command, **kwargs) 

186 

187 # NOTE: this is for runner injection; see NOTE above _run(). 

188 def _sudo( 

189 self, runner: "Runner", command: str, **kwargs: Any 

190 ) -> Optional[Result]: 

191 prompt = self.config.sudo.prompt 

192 password = kwargs.pop("password", self.config.sudo.password) 

193 user = kwargs.pop("user", self.config.sudo.user) 

194 env = kwargs.get("env", {}) 

195 # TODO: allow subclassing for 'get the password' so users who REALLY 

196 # want lazy runtime prompting can have it easily implemented. 

197 # TODO: want to print a "cleaner" echo with just 'sudo <command>'; but 

198 # hard to do as-is, obtaining config data from outside a Runner one 

199 # holds is currently messy (could fix that), if instead we manually 

200 # inspect the config ourselves that duplicates logic. NOTE: once we 

201 # figure that out, there is an existing, would-fail-if-not-skipped test 

202 # for this behavior in test/context.py. 

203 # TODO: once that is done, though: how to handle "full debug" output 

204 # exactly (display of actual, real full sudo command w/ -S and -p), in 

205 # terms of API/config? Impl is easy, just go back to passing echo 

206 # through to 'run'... 

207 user_flags = "" 

208 if user is not None: 

209 user_flags = "-H -u {} ".format(user) 

210 env_flags = "" 

211 if env: 

212 env_flags = "--preserve-env='{}' ".format(",".join(env.keys())) 

213 command = self._prefix_commands(command) 

214 cmd_str = "sudo -S -p '{}' {}{}{}".format( 

215 prompt, env_flags, user_flags, command 

216 ) 

217 watcher = FailingResponder( 

218 pattern=re.escape(prompt), 

219 response="{}\n".format(password), 

220 sentinel="Sorry, try again.\n", 

221 ) 

222 # Ensure we merge any user-specified watchers with our own. 

223 # NOTE: If there are config-driven watchers, we pull those up to the 

224 # kwarg level; that lets us merge cleanly without needing complex 

225 # config-driven "override vs merge" semantics. 

226 # TODO: if/when those semantics are implemented, use them instead. 

227 # NOTE: config value for watchers defaults to an empty list; and we 

228 # want to clone it to avoid actually mutating the config. 

229 watchers = kwargs.pop("watchers", list(self.config.run.watchers)) 

230 watchers.append(watcher) 

231 try: 

232 return runner.run(cmd_str, watchers=watchers, **kwargs) 

233 except Failure as failure: 

234 # Transmute failures driven by our FailingResponder, into auth 

235 # failures - the command never even ran. 

236 # TODO: wants to be a hook here for users that desire "override a 

237 # bad config value for sudo.password" manual input 

238 # NOTE: as noted in #294 comments, we MAY in future want to update 

239 # this so run() is given ability to raise AuthFailure on its own. 

240 # For now that has been judged unnecessary complexity. 

241 if isinstance(failure.reason, ResponseNotAccepted): 

242 # NOTE: not bothering with 'reason' here, it's pointless. 

243 error = AuthFailure(result=failure.result, prompt=prompt) 

244 raise error 

245 # Reraise for any other error so it bubbles up normally. 

246 else: 

247 raise 

248 

249 # TODO: wonder if it makes sense to move this part of things inside Runner, 

250 # which would grow a `prefixes` and `cwd` init kwargs or similar. The less 

251 # that's stuffed into Context, probably the better. 

252 def _prefix_commands(self, command: str) -> str: 

253 """ 

254 Prefixes ``command`` with all prefixes found in ``command_prefixes``. 

255 

256 ``command_prefixes`` is a list of strings which is modified by the 

257 `prefix` context manager. 

258 """ 

259 prefixes = list(self.command_prefixes) 

260 current_directory = self.cwd 

261 if current_directory: 

262 prefixes.insert(0, "cd {}".format(current_directory)) 

263 

264 return " && ".join(prefixes + [command]) 

265 

266 @contextmanager 

267 def prefix(self, command: str) -> Generator[None, None, None]: 

268 """ 

269 Prefix all nested `run`/`sudo` commands with given command plus ``&&``. 

270 

271 Most of the time, you'll want to be using this alongside a shell script 

272 which alters shell state, such as ones which export or alter shell 

273 environment variables. 

274 

275 For example, one of the most common uses of this tool is with the 

276 ``workon`` command from `virtualenvwrapper 

277 <https://virtualenvwrapper.readthedocs.io/en/latest/>`_:: 

278 

279 with c.prefix('workon myvenv'): 

280 c.run('./manage.py migrate') 

281 

282 In the above snippet, the actual shell command run would be this:: 

283 

284 $ workon myvenv && ./manage.py migrate 

285 

286 This context manager is compatible with `cd`, so if your virtualenv 

287 doesn't ``cd`` in its ``postactivate`` script, you could do the 

288 following:: 

289 

290 with c.cd('/path/to/app'): 

291 with c.prefix('workon myvenv'): 

292 c.run('./manage.py migrate') 

293 c.run('./manage.py loaddata fixture') 

294 

295 Which would result in executions like so:: 

296 

297 $ cd /path/to/app && workon myvenv && ./manage.py migrate 

298 $ cd /path/to/app && workon myvenv && ./manage.py loaddata fixture 

299 

300 Finally, as alluded to above, `prefix` may be nested if desired, e.g.:: 

301 

302 with c.prefix('workon myenv'): 

303 c.run('ls') 

304 with c.prefix('source /some/script'): 

305 c.run('touch a_file') 

306 

307 The result:: 

308 

309 $ workon myenv && ls 

310 $ workon myenv && source /some/script && touch a_file 

311 

312 Contrived, but hopefully illustrative. 

313 

314 .. versionadded:: 1.0 

315 """ 

316 self.command_prefixes.append(command) 

317 try: 

318 yield 

319 finally: 

320 self.command_prefixes.pop() 

321 

322 @property 

323 def cwd(self) -> str: 

324 """ 

325 Return the current working directory, accounting for uses of `cd`. 

326 

327 .. versionadded:: 1.0 

328 """ 

329 if not self.command_cwds: 

330 # TODO: should this be None? Feels cleaner, though there may be 

331 # benefits to it being an empty string, such as relying on a no-arg 

332 # `cd` typically being shorthand for "go to user's $HOME". 

333 return "" 

334 

335 # get the index for the subset of paths starting with the last / or ~ 

336 for i, path in reversed(list(enumerate(self.command_cwds))): 

337 if path.startswith("~") or path.startswith("/"): 

338 break 

339 

340 # TODO: see if there's a stronger "escape this path" function somewhere 

341 # we can reuse. e.g., escaping tildes or slashes in filenames. 

342 paths = [path.replace(" ", r"\ ") for path in self.command_cwds[i:]] 

343 return str(os.path.join(*paths)) 

344 

345 @contextmanager 

346 def cd(self, path: Union[PathLike, str]) -> Generator[None, None, None]: 

347 """ 

348 Context manager that keeps directory state when executing commands. 

349 

350 Any calls to `run`, `sudo`, within the wrapped block will implicitly 

351 have a string similar to ``"cd <path> && "`` prefixed in order to give 

352 the sense that there is actually statefulness involved. 

353 

354 Because use of `cd` affects all such invocations, any code making use 

355 of the `cwd` property will also be affected by use of `cd`. 

356 

357 Like the actual 'cd' shell builtin, `cd` may be called with relative 

358 paths (keep in mind that your default starting directory is your user's 

359 ``$HOME``) and may be nested as well. 

360 

361 Below is a "normal" attempt at using the shell 'cd', which doesn't work 

362 since all commands are executed in individual subprocesses -- state is 

363 **not** kept between invocations of `run` or `sudo`:: 

364 

365 c.run('cd /var/www') 

366 c.run('ls') 

367 

368 The above snippet will list the contents of the user's ``$HOME`` 

369 instead of ``/var/www``. With `cd`, however, it will work as expected:: 

370 

371 with c.cd('/var/www'): 

372 c.run('ls') # Turns into "cd /var/www && ls" 

373 

374 Finally, a demonstration (see inline comments) of nesting:: 

375 

376 with c.cd('/var/www'): 

377 c.run('ls') # cd /var/www && ls 

378 with c.cd('website1'): 

379 c.run('ls') # cd /var/www/website1 && ls 

380 

381 .. note:: 

382 Space characters will be escaped automatically to make dealing with 

383 such directory names easier. 

384 

385 .. versionadded:: 1.0 

386 .. versionchanged:: 1.5 

387 Explicitly cast the ``path`` argument (the only argument) to a 

388 string; this allows any object defining ``__str__`` to be handed in 

389 (such as the various ``Path`` objects out there), and not just 

390 string literals. 

391 """ 

392 path = str(path) 

393 self.command_cwds.append(path) 

394 try: 

395 yield 

396 finally: 

397 self.command_cwds.pop() 

398 

399 

400class MockContext(Context): 

401 """ 

402 A `.Context` whose methods' return values can be predetermined. 

403 

404 Primarily useful for testing Invoke-using codebases. 

405 

406 .. note:: 

407 This class wraps its ``run``, etc methods in `unittest.mock.Mock` 

408 objects. This allows you to easily assert that the methods (still 

409 returning the values you prepare them with) were actually called. 

410 

411 .. note:: 

412 Methods not given `Results <.Result>` to yield will raise 

413 ``NotImplementedError`` if called (since the alternative is to call the 

414 real underlying method - typically undesirable when mocking.) 

415 

416 .. versionadded:: 1.0 

417 .. versionchanged:: 1.5 

418 Added ``Mock`` wrapping of ``run`` and ``sudo``. 

419 """ 

420 

421 def __init__(self, config: Optional[Config] = None, **kwargs: Any) -> None: 

422 """ 

423 Create a ``Context``-like object whose methods yield `.Result` objects. 

424 

425 :param config: 

426 A Configuration object to use. Identical in behavior to `.Context`. 

427 

428 :param run: 

429 A data structure indicating what `.Result` objects to return from 

430 calls to the instantiated object's `~.Context.run` method (instead 

431 of actually executing the requested shell command). 

432 

433 Specifically, this kwarg accepts: 

434 

435 - A single `.Result` object. 

436 - A boolean; if True, yields a `.Result` whose ``exited`` is ``0``, 

437 and if False, ``1``. 

438 - An iterable of the above values, which will be returned on each 

439 subsequent call to ``.run`` (the first item on the first call, 

440 the second on the second call, etc). 

441 - A dict mapping command strings or compiled regexen to the above 

442 values (including an iterable), allowing specific 

443 call-and-response semantics instead of assuming a call order. 

444 

445 :param sudo: 

446 Identical to ``run``, but whose values are yielded from calls to 

447 `~.Context.sudo`. 

448 

449 :param bool repeat: 

450 A flag determining whether results yielded by this class' methods 

451 repeat or are consumed. 

452 

453 For example, when a single result is indicated, it will normally 

454 only be returned once, causing ``NotImplementedError`` afterwards. 

455 But when ``repeat=True`` is given, that result is returned on 

456 every call, forever. 

457 

458 Similarly, iterable results are normally exhausted once, but when 

459 this setting is enabled, they are wrapped in `itertools.cycle`. 

460 

461 Default: ``True``. 

462 

463 :raises: 

464 ``TypeError``, if the values given to ``run`` or other kwargs 

465 aren't of the expected types. 

466 

467 .. versionchanged:: 1.5 

468 Added support for boolean and string result values. 

469 .. versionchanged:: 1.5 

470 Added support for regex dict keys. 

471 .. versionchanged:: 1.5 

472 Added the ``repeat`` keyword argument. 

473 .. versionchanged:: 2.0 

474 Changed ``repeat`` default value from ``False`` to ``True``. 

475 """ 

476 # Set up like any other Context would, with the config 

477 super().__init__(config) 

478 # Pull out behavioral kwargs 

479 self._set("__repeat", kwargs.pop("repeat", True)) 

480 # The rest must be things like run/sudo - mock Context method info 

481 for method, results in kwargs.items(): 

482 # For each possible value type, normalize to iterable of Result 

483 # objects (possibly repeating). 

484 singletons = (Result, bool, str) 

485 if isinstance(results, dict): 

486 for key, value in results.items(): 

487 results[key] = self._normalize(value) 

488 elif isinstance(results, singletons) or hasattr( 

489 results, "__iter__" 

490 ): 

491 results = self._normalize(results) 

492 # Unknown input value: cry 

493 else: 

494 err = "Not sure how to yield results from a {!r}" 

495 raise TypeError(err.format(type(results))) 

496 # Save results for use by the method 

497 self._set("__{}".format(method), results) 

498 # Wrap the method in a Mock 

499 self._set(method, Mock(wraps=getattr(self, method))) 

500 

501 def _normalize(self, value: Any) -> Iterator[Any]: 

502 # First turn everything into an iterable 

503 if not hasattr(value, "__iter__") or isinstance(value, str): 

504 value = [value] 

505 # Then turn everything within into a Result 

506 results = [] 

507 for obj in value: 

508 if isinstance(obj, bool): 

509 obj = Result(exited=0 if obj else 1) 

510 elif isinstance(obj, str): 

511 obj = Result(obj) 

512 results.append(obj) 

513 # Finally, turn that iterable into an iteratOR, depending on repeat 

514 return cycle(results) if getattr(self, "__repeat") else iter(results) 

515 

516 # TODO: _maybe_ make this more metaprogrammy/flexible (using __call__ etc)? 

517 # Pretty worried it'd cause more hard-to-debug issues than it's presently 

518 # worth. Maybe in situations where Context grows a _lot_ of methods (e.g. 

519 # in Fabric 2; though Fabric could do its own sub-subclass in that case...) 

520 

521 def _yield_result(self, attname: str, command: str) -> Result: 

522 try: 

523 obj = getattr(self, attname) 

524 # Dicts need to try direct lookup or regex matching 

525 if isinstance(obj, dict): 

526 try: 

527 obj = obj[command] 

528 except KeyError: 

529 # TODO: could optimize by skipping this if not any regex 

530 # objects in keys()? 

531 for key, value in obj.items(): 

532 if hasattr(key, "match") and key.match(command): 

533 obj = value 

534 break 

535 else: 

536 # Nope, nothing did match. 

537 raise KeyError 

538 # Here, the value was either never a dict or has been extracted 

539 # from one, so we can assume it's an iterable of Result objects due 

540 # to work done by __init__. 

541 result: Result = next(obj) 

542 # Populate Result's command string with what matched unless 

543 # explicitly given 

544 if not result.command: 

545 result.command = command 

546 return result 

547 except (AttributeError, IndexError, KeyError, StopIteration): 

548 # raise_from(NotImplementedError(command), None) 

549 raise NotImplementedError(command) 

550 

551 def run(self, command: str, *args: Any, **kwargs: Any) -> Result: 

552 # TODO: perform more convenience stuff associating args/kwargs with the 

553 # result? E.g. filling in .command, etc? Possibly useful for debugging 

554 # if one hits unexpected-order problems with what they passed in to 

555 # __init__. 

556 return self._yield_result("__run", command) 

557 

558 def sudo(self, command: str, *args: Any, **kwargs: Any) -> Result: 

559 # TODO: this completely nukes the top-level behavior of sudo(), which 

560 # could be good or bad, depending. Most of the time I think it's good. 

561 # No need to supply dummy password config, etc. 

562 # TODO: see the TODO from run() re: injecting arg/kwarg values 

563 return self._yield_result("__sudo", command) 

564 

565 def set_result_for( 

566 self, attname: str, command: str, result: Result 

567 ) -> None: 

568 """ 

569 Modify the stored mock results for given ``attname`` (e.g. ``run``). 

570 

571 This is similar to how one instantiates `MockContext` with a ``run`` or 

572 ``sudo`` dict kwarg. For example, this:: 

573 

574 mc = MockContext(run={'mycommand': Result("mystdout")}) 

575 assert mc.run('mycommand').stdout == "mystdout" 

576 

577 is functionally equivalent to this:: 

578 

579 mc = MockContext() 

580 mc.set_result_for('run', 'mycommand', Result("mystdout")) 

581 assert mc.run('mycommand').stdout == "mystdout" 

582 

583 `set_result_for` is mostly useful for modifying an already-instantiated 

584 `MockContext`, such as one created by test setup or helper methods. 

585 

586 .. versionadded:: 1.0 

587 """ 

588 attname = "__{}".format(attname) 

589 heck = TypeError( 

590 "Can't update results for non-dict or nonexistent mock results!" 

591 ) 

592 # Get value & complain if it's not a dict. 

593 # TODO: should we allow this to set non-dict values too? Seems vaguely 

594 # pointless, at that point, just make a new MockContext eh? 

595 try: 

596 value = getattr(self, attname) 

597 except AttributeError: 

598 raise heck 

599 if not isinstance(value, dict): 

600 raise heck 

601 # OK, we're good to modify, so do so. 

602 value[command] = self._normalize(result)