1"""This modules define the actual display implementations used in Certbot""" 2import logging 3import sys 4from typing import Any 5from typing import Iterable 6from typing import List 7from typing import Optional 8from typing import TextIO 9from typing import Tuple 10from typing import TypeVar 11from typing import Union 12 13import zope.component 14import zope.interface 15 16from certbot import errors 17from certbot import interfaces 18from certbot._internal import constants 19from certbot._internal.display import completer 20from certbot._internal.display import util 21from certbot.compat import os 22 23logger = logging.getLogger(__name__) 24 25# Display exit codes 26OK = "ok" 27"""Display exit code indicating user acceptance.""" 28 29CANCEL = "cancel" 30"""Display exit code for a user canceling the display.""" 31 32# Display constants 33SIDE_FRAME = ("- " * 39) + "-" 34"""Display boundary (alternates spaces, so when copy-pasted, markdown doesn't interpret 35it as a heading)""" 36 37# This class holds the global state of the display service. Using this class 38# eliminates potential gotchas that exist if self.display was just a global 39# variable. In particular, in functions `_DISPLAY = <value>` would create a 40# local variable unless the programmer remembered to use the `global` keyword. 41# Adding a level of indirection causes the lookup of the global _DisplayService 42# object to happen first avoiding this potential bug. 43class _DisplayService: 44 def __init__(self) -> None: 45 self.display: Optional[Union[FileDisplay, NoninteractiveDisplay]] = None 46 47 48_SERVICE = _DisplayService() 49 50T = TypeVar("T") 51 52 53# This use of IDisplay can be removed when this class is no longer accessible 54# through the public API in certbot.display.util. 55@zope.interface.implementer(interfaces.IDisplay) 56class FileDisplay: 57 """File-based display.""" 58 # see https://github.com/certbot/certbot/issues/3915 59 60 def __init__(self, outfile: TextIO, force_interactive: bool) -> None: 61 super().__init__() 62 self.outfile = outfile 63 self.force_interactive = force_interactive 64 self.skipped_interaction = False 65 66 def notification(self, message: str, pause: bool = True, wrap: bool = True, 67 force_interactive: bool = False, decorate: bool = True) -> None: 68 """Displays a notification and waits for user acceptance. 69 70 :param str message: Message to display 71 :param bool pause: Whether or not the program should pause for the 72 user's confirmation 73 :param bool wrap: Whether or not the application should wrap text 74 :param bool force_interactive: True if it's safe to prompt the user 75 because it won't cause any workflow regressions 76 :param bool decorate: Whether to surround the message with a 77 decorated frame 78 79 """ 80 if wrap: 81 message = util.wrap_lines(message) 82 83 logger.debug("Notifying user: %s", message) 84 85 self.outfile.write( 86 (("{line}{frame}{line}" if decorate else "") + 87 "{msg}{line}" + 88 ("{frame}{line}" if decorate else "")) 89 .format(line=os.linesep, frame=SIDE_FRAME, msg=message) 90 ) 91 self.outfile.flush() 92 93 if pause: 94 if self._can_interact(force_interactive): 95 util.input_with_timeout("Press Enter to Continue") 96 else: 97 logger.debug("Not pausing for user confirmation") 98 99 def menu(self, message: str, choices: Union[List[Tuple[str, str]], List[str]], 100 ok_label: Optional[str] = None, cancel_label: Optional[str] = None, # pylint: disable=unused-argument 101 help_label: Optional[str] = None, default: Optional[int] = None, # pylint: disable=unused-argument 102 cli_flag: Optional[str] = None, force_interactive: bool = False, 103 **unused_kwargs: Any) -> Tuple[str, int]: 104 """Display a menu. 105 106 .. todo:: This doesn't enable the help label/button (I wasn't sold on 107 any interface I came up with for this). It would be a nice feature 108 109 :param str message: title of menu 110 :param choices: Menu lines, len must be > 0 111 :type choices: list of tuples (tag, item) or 112 list of descriptions (tags will be enumerated) 113 :param default: default value to return (if one exists) 114 :param str cli_flag: option used to set this value with the CLI 115 :param bool force_interactive: True if it's safe to prompt the user 116 because it won't cause any workflow regressions 117 118 :returns: tuple of (`code`, `index`) where 119 `code` - str display exit code 120 `index` - int index of the user's selection 121 122 :rtype: tuple 123 124 """ 125 return_default = self._return_default(message, default, cli_flag, force_interactive) 126 if return_default is not None: 127 return OK, return_default 128 129 self._print_menu(message, choices) 130 131 code, selection = self._get_valid_int_ans(len(choices)) 132 133 return code, selection - 1 134 135 def input(self, message: str, default: Optional[str] = None, cli_flag: Optional[str] = None, 136 force_interactive: bool = False, **unused_kwargs: Any) -> Tuple[str, str]: 137 """Accept input from the user. 138 139 :param str message: message to display to the user 140 :param default: default value to return (if one exists) 141 :param str cli_flag: option used to set this value with the CLI 142 :param bool force_interactive: True if it's safe to prompt the user 143 because it won't cause any workflow regressions 144 145 :returns: tuple of (`code`, `input`) where 146 `code` - str display exit code 147 `input` - str of the user's input 148 :rtype: tuple 149 150 """ 151 return_default = self._return_default(message, default, cli_flag, force_interactive) 152 if return_default is not None: 153 return OK, return_default 154 155 # Trailing space must be added outside of util.wrap_lines to 156 # be preserved 157 message = util.wrap_lines("%s (Enter 'c' to cancel):" % message) + " " 158 ans = util.input_with_timeout(message) 159 160 if ans in ("c", "C"): 161 return CANCEL, "-1" 162 return OK, ans 163 164 def yesno(self, message: str, yes_label: str = "Yes", no_label: str = "No", 165 default: Optional[bool] = None, cli_flag: Optional[str] = None, 166 force_interactive: bool = False, **unused_kwargs: Any) -> bool: 167 """Query the user with a yes/no question. 168 169 Yes and No label must begin with different letters, and must contain at 170 least one letter each. 171 172 :param str message: question for the user 173 :param str yes_label: Label of the "Yes" parameter 174 :param str no_label: Label of the "No" parameter 175 :param default: default value to return (if one exists) 176 :param str cli_flag: option used to set this value with the CLI 177 :param bool force_interactive: True if it's safe to prompt the user 178 because it won't cause any workflow regressions 179 180 :returns: True for "Yes", False for "No" 181 :rtype: bool 182 183 """ 184 return_default = self._return_default(message, default, cli_flag, force_interactive) 185 if return_default is not None: 186 return return_default 187 188 message = util.wrap_lines(message) 189 190 self.outfile.write("{0}{frame}{msg}{0}{frame}".format( 191 os.linesep, frame=SIDE_FRAME + os.linesep, msg=message)) 192 self.outfile.flush() 193 194 while True: 195 ans = util.input_with_timeout("{yes}/{no}: ".format( 196 yes=util.parens_around_char(yes_label), 197 no=util.parens_around_char(no_label))) 198 199 # Couldn't get pylint indentation right with elif 200 # elif doesn't matter in this situation 201 if (ans.startswith(yes_label[0].lower()) or 202 ans.startswith(yes_label[0].upper())): 203 return True 204 if (ans.startswith(no_label[0].lower()) or 205 ans.startswith(no_label[0].upper())): 206 return False 207 208 def checklist(self, message: str, tags: List[str], default: Optional[List[str]] = None, 209 cli_flag: Optional[str] = None, force_interactive: bool = False, 210 **unused_kwargs: Any) -> Tuple[str, List[str]]: 211 """Display a checklist. 212 213 :param str message: Message to display to user 214 :param list tags: `str` tags to select, len(tags) > 0 215 :param default: default value to return (if one exists) 216 :param str cli_flag: option used to set this value with the CLI 217 :param bool force_interactive: True if it's safe to prompt the user 218 because it won't cause any workflow regressions 219 220 :returns: tuple of (`code`, `tags`) where 221 `code` - str display exit code 222 `tags` - list of selected tags 223 :rtype: tuple 224 225 """ 226 return_default = self._return_default(message, default, cli_flag, force_interactive) 227 if return_default is not None: 228 return OK, return_default 229 230 while True: 231 self._print_menu(message, tags) 232 233 code, ans = self.input("Select the appropriate numbers separated " 234 "by commas and/or spaces, or leave input " 235 "blank to select all options shown", 236 force_interactive=True) 237 238 if code == OK: 239 if not ans.strip(): 240 ans = " ".join(str(x) for x in range(1, len(tags)+1)) 241 indices = util.separate_list_input(ans) 242 selected_tags = self._scrub_checklist_input(indices, tags) 243 if selected_tags: 244 return code, selected_tags 245 self.outfile.write( 246 "** Error - Invalid selection **%s" % os.linesep) 247 self.outfile.flush() 248 else: 249 return code, [] 250 251 def _return_default(self, prompt: str, default: Optional[T], 252 cli_flag: Optional[str], force_interactive: bool) -> Optional[T]: 253 """Should we return the default instead of prompting the user? 254 255 :param str prompt: prompt for the user 256 :param T default: default answer to prompt 257 :param str cli_flag: command line option for setting an answer 258 to this question 259 :param bool force_interactive: if interactivity is forced 260 261 :returns: The default value if we should return it else `None` 262 :rtype: T or `None` 263 264 """ 265 # assert_valid_call(prompt, default, cli_flag, force_interactive) 266 if self._can_interact(force_interactive): 267 return None 268 if default is None: 269 msg = "Unable to get an answer for the question:\n{0}".format(prompt) 270 if cli_flag: 271 msg += ( 272 "\nYou can provide an answer on the " 273 "command line with the {0} flag.".format(cli_flag)) 274 raise errors.Error(msg) 275 logger.debug( 276 "Falling back to default %s for the prompt:\n%s", 277 default, prompt) 278 return default 279 280 def _can_interact(self, force_interactive: bool) -> bool: 281 """Can we safely interact with the user? 282 283 :param bool force_interactive: if interactivity is forced 284 285 :returns: True if the display can interact with the user 286 :rtype: bool 287 288 """ 289 if (self.force_interactive or force_interactive or 290 sys.stdin.isatty() and self.outfile.isatty()): 291 return True 292 if not self.skipped_interaction: 293 logger.warning( 294 "Skipped user interaction because Certbot doesn't appear to " 295 "be running in a terminal. You should probably include " 296 "--non-interactive or %s on the command line.", 297 constants.FORCE_INTERACTIVE_FLAG) 298 self.skipped_interaction = True 299 return False 300 301 def directory_select(self, message: str, default: Optional[str] = None, 302 cli_flag: Optional[str] = None, force_interactive: bool = False, 303 **unused_kwargs: Any) -> Tuple[str, str]: 304 """Display a directory selection screen. 305 306 :param str message: prompt to give the user 307 :param default: default value to return (if one exists) 308 :param str cli_flag: option used to set this value with the CLI 309 :param bool force_interactive: True if it's safe to prompt the user 310 because it won't cause any workflow regressions 311 312 :returns: tuple of the form (`code`, `string`) where 313 `code` - display exit code 314 `string` - input entered by the user 315 316 """ 317 with completer.Completer(): 318 return self.input(message, default, cli_flag, force_interactive) 319 320 def _scrub_checklist_input(self, indices: Iterable[Union[str, int]], 321 tags: List[str]) -> List[str]: 322 """Validate input and transform indices to appropriate tags. 323 324 :param list indices: input 325 :param list tags: Original tags of the checklist 326 327 :returns: valid tags the user selected 328 :rtype: :class:`list` of :class:`str` 329 330 """ 331 # They should all be of type int 332 try: 333 indices_int = [int(index) for index in indices] 334 except ValueError: 335 return [] 336 337 # Remove duplicates 338 indices_int = list(set(indices_int)) 339 340 # Check all input is within range 341 for index in indices_int: 342 if index < 1 or index > len(tags): 343 return [] 344 # Transform indices_int to appropriate tags 345 return [tags[index - 1] for index in indices_int] 346 347 def _print_menu(self, message: str, 348 choices: Union[List[Tuple[str, str]], List[str]]) -> None: 349 """Print a menu on the screen. 350 351 :param str message: title of menu 352 :param choices: Menu lines 353 :type choices: list of tuples (tag, item) or 354 list of descriptions (tags will be enumerated) 355 356 """ 357 # Can take either tuples or single items in choices list 358 if choices and isinstance(choices[0], tuple): 359 choices = ["%s - %s" % (c[0], c[1]) for c in choices] 360 361 # Write out the message to the user 362 self.outfile.write( 363 "{new}{msg}{new}".format(new=os.linesep, msg=message)) 364 self.outfile.write(SIDE_FRAME + os.linesep) 365 366 # Write out the menu choices 367 for i, desc in enumerate(choices, 1): 368 msg = "{num}: {desc}".format(num=i, desc=desc) 369 self.outfile.write(util.wrap_lines(msg)) 370 371 # Keep this outside of the textwrap 372 self.outfile.write(os.linesep) 373 374 self.outfile.write(SIDE_FRAME + os.linesep) 375 self.outfile.flush() 376 377 def _get_valid_int_ans(self, max_: int) -> Tuple[str, int]: 378 """Get a numerical selection. 379 380 :param int max: The maximum entry (len of choices), must be positive 381 382 :returns: tuple of the form (`code`, `selection`) where 383 `code` - str display exit code ('ok' or cancel') 384 `selection` - int user's selection 385 :rtype: tuple 386 387 """ 388 selection = -1 389 if max_ > 1: 390 input_msg = ("Select the appropriate number " 391 "[1-{max_}] then [enter] (press 'c' to " 392 "cancel): ".format(max_=max_)) 393 else: 394 input_msg = ("Press 1 [enter] to confirm the selection " 395 "(press 'c' to cancel): ") 396 while selection < 1: 397 ans = util.input_with_timeout(input_msg) 398 if ans.startswith("c") or ans.startswith("C"): 399 return CANCEL, -1 400 try: 401 selection = int(ans) 402 if selection < 1 or selection > max_: 403 selection = -1 404 raise ValueError 405 406 except ValueError: 407 self.outfile.write( 408 "{0}** Invalid input **{0}".format(os.linesep)) 409 self.outfile.flush() 410 411 return OK, selection 412 413 414# This use of IDisplay can be removed when this class is no longer accessible 415# through the public API in certbot.display.util. 416@zope.interface.implementer(interfaces.IDisplay) 417class NoninteractiveDisplay: 418 """A display utility implementation that never asks for interactive user input""" 419 420 def __init__(self, outfile: TextIO, *unused_args: Any, **unused_kwargs: Any) -> None: 421 super().__init__() 422 self.outfile = outfile 423 424 def _interaction_fail(self, message: str, cli_flag: Optional[str], 425 extra: str = "") -> errors.MissingCommandlineFlag: 426 """Return error to raise in case of an attempt to interact in noninteractive mode""" 427 msg = "Missing command line flag or config entry for this setting:\n" 428 msg += message 429 if extra: 430 msg += "\n" + extra 431 if cli_flag: 432 msg += "\n\n(You can set this with the {0} flag)".format(cli_flag) 433 return errors.MissingCommandlineFlag(msg) 434 435 def notification(self, message: str, pause: bool = False, wrap: bool = True, # pylint: disable=unused-argument 436 decorate: bool = True, **unused_kwargs: Any) -> None: 437 """Displays a notification without waiting for user acceptance. 438 439 :param str message: Message to display to stdout 440 :param bool pause: The NoninteractiveDisplay waits for no keyboard 441 :param bool wrap: Whether or not the application should wrap text 442 :param bool decorate: Whether to apply a decorated frame to the message 443 444 """ 445 if wrap: 446 message = util.wrap_lines(message) 447 448 logger.debug("Notifying user: %s", message) 449 450 self.outfile.write( 451 (("{line}{frame}{line}" if decorate else "") + 452 "{msg}{line}" + 453 ("{frame}{line}" if decorate else "")) 454 .format(line=os.linesep, frame=SIDE_FRAME, msg=message) 455 ) 456 self.outfile.flush() 457 458 def menu(self, message: str, choices: Union[List[Tuple[str, str]], List[str]], 459 ok_label: Optional[str] = None, cancel_label: Optional[str] = None, 460 help_label: Optional[str] = None, default: Optional[int] = None, 461 cli_flag: Optional[str] = None, **unused_kwargs: Any) -> Tuple[str, int]: 462 # pylint: disable=unused-argument 463 """Avoid displaying a menu. 464 465 :param str message: title of menu 466 :param choices: Menu lines, len must be > 0 467 :type choices: list of tuples (tag, item) or 468 list of descriptions (tags will be enumerated) 469 :param int default: the default choice 470 :param dict kwargs: absorbs various irrelevant labelling arguments 471 472 :returns: tuple of (`code`, `index`) where 473 `code` - str display exit code 474 `index` - int index of the user's selection 475 :rtype: tuple 476 :raises errors.MissingCommandlineFlag: if there was no default 477 478 """ 479 if default is None: 480 raise self._interaction_fail(message, cli_flag, "Choices: " + repr(choices)) 481 482 return OK, default 483 484 def input(self, message: str, default: Optional[str] = None, cli_flag: Optional[str] = None, 485 **unused_kwargs: Any) -> Tuple[str, str]: 486 """Accept input from the user. 487 488 :param str message: message to display to the user 489 490 :returns: tuple of (`code`, `input`) where 491 `code` - str display exit code 492 `input` - str of the user's input 493 :rtype: tuple 494 :raises errors.MissingCommandlineFlag: if there was no default 495 496 """ 497 if default is None: 498 raise self._interaction_fail(message, cli_flag) 499 return OK, default 500 501 def yesno(self, message: str, yes_label: Optional[str] = None, no_label: Optional[str] = None, # pylint: disable=unused-argument 502 default: Optional[bool] = None, cli_flag: Optional[str] = None, 503 **unused_kwargs: Any) -> bool: 504 """Decide Yes or No, without asking anybody 505 506 :param str message: question for the user 507 :param dict kwargs: absorbs yes_label, no_label 508 509 :raises errors.MissingCommandlineFlag: if there was no default 510 :returns: True for "Yes", False for "No" 511 :rtype: bool 512 513 """ 514 if default is None: 515 raise self._interaction_fail(message, cli_flag) 516 return default 517 518 def checklist(self, message: str, tags: Iterable[str], default: Optional[List[str]] = None, 519 cli_flag: Optional[str] = None, **unused_kwargs: Any) -> Tuple[str, List[str]]: 520 """Display a checklist. 521 522 :param str message: Message to display to user 523 :param list tags: `str` tags to select, len(tags) > 0 524 :param dict kwargs: absorbs default_status arg 525 526 :returns: tuple of (`code`, `tags`) where 527 `code` - str display exit code 528 `tags` - list of selected tags 529 :rtype: tuple 530 531 """ 532 if default is None: 533 raise self._interaction_fail(message, cli_flag, "? ".join(tags) + "?") 534 return OK, default 535 536 def directory_select(self, message: str, default: Optional[str] = None, 537 cli_flag: Optional[str] = None, **unused_kwargs: Any) -> Tuple[str, str]: 538 """Simulate prompting the user for a directory. 539 540 This function returns default if it is not ``None``, otherwise, 541 an exception is raised explaining the problem. If cli_flag is 542 not ``None``, the error message will include the flag that can 543 be used to set this value with the CLI. 544 545 :param str message: prompt to give the user 546 :param default: default value to return (if one exists) 547 :param str cli_flag: option used to set this value with the CLI 548 549 :returns: tuple of the form (`code`, `string`) where 550 `code` - int display exit code 551 `string` - input entered by the user 552 553 """ 554 return self.input(message, default, cli_flag) 555 556 557def get_display() -> Union[FileDisplay, NoninteractiveDisplay]: 558 """Get the display utility. 559 560 :return: the display utility 561 :rtype: Union[FileDisplay, NoninteractiveDisplay] 562 :raise: ValueError if the display utility is not configured yet. 563 564 """ 565 if not _SERVICE.display: 566 raise ValueError("This function was called too early in Certbot's execution " 567 "as the display utility hasn't been configured yet.") 568 return _SERVICE.display 569 570 571def set_display(display: Union[FileDisplay, NoninteractiveDisplay]) -> None: 572 """Set the display service. 573 574 :param Union[FileDisplay, NoninteractiveDisplay] display: the display service 575 576 """ 577 # This call is done only for retro-compatibility purposes. 578 # TODO: Remove this call once zope dependencies are removed from Certbot. 579 zope.component.provideUtility(display, interfaces.IDisplay) 580 581 _SERVICE.display = display 582