# copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved. # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr # # This file is part of logilab-common. # # logilab-common is free software: you can redistribute it and/or modify it under # the terms of the GNU Lesser General Public License as published by the Free # Software Foundation, either version 2.1 of the License, or (at your option) any # later version. # # logilab-common is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more # details. # # You should have received a copy of the GNU Lesser General Public License along # with logilab-common. If not, see . """Add an abstraction level to transparently import optik classes from optparse (python >= 2.3) or the optik package. It also defines three new types for optik/optparse command line parser : * regexp argument of this type will be converted using re.compile * csv argument of this type will be converted using split(',') * yn argument of this type will be true if 'y' or 'yes', false if 'n' or 'no' * named argument of this type are in the form = or : * password argument of this type wont be converted but this is used by other tools such as interactive prompt for configuration to double check value and use an invisible field * multiple_choice same as default "choice" type but multiple choices allowed * file argument of this type wont be converted but checked that the given file exists * color argument of this type wont be converted but checked its either a named color or a color specified using hexadecimal notation (preceded by a #) * time argument of this type will be converted to a float value in seconds according to time units (ms, s, min, h, d) * bytes argument of this type will be converted to a float value in bytes according to byte units (b, kb, mb, gb, tb) """ from __future__ import print_function __docformat__ = "restructuredtext en" import re import sys import time from copy import copy from os.path import exists from logilab.common import attrdict from typing import Any, Union, List, Optional, Tuple, Dict from _io import StringIO # python >= 2.3 from optparse import ( # noqa OptionParser as BaseParser, Option as BaseOption, OptionGroup, OptionContainer, OptionValueError, OptionError, Values, HelpFormatter, SUPPRESS_HELP, NO_DEFAULT, ) try: from mx import DateTime HAS_MX_DATETIME = True except ImportError: HAS_MX_DATETIME = False from logilab.common.textutils import splitstrip, TIME_UNITS, BYTE_UNITS, apply_units def check_regexp(option, opt, value): """check a regexp value by trying to compile it return the compiled regexp """ if hasattr(value, "pattern"): return value try: return re.compile(value) except ValueError: raise OptionValueError("option %s: invalid regexp value: %r" % (opt, value)) def check_csv( option: Optional["Option"], opt: str, value: Union[List[str], Tuple[str, ...], str] ) -> Union[List[str], Tuple[str, ...]]: """check a csv value by trying to split it return the list of separated values """ if isinstance(value, (list, tuple)): return value try: return splitstrip(value) except ValueError: raise OptionValueError("option %s: invalid csv value: %r" % (opt, value)) def check_yn(option: Optional["Option"], opt: str, value: Union[bool, str]) -> bool: """check a yn value return true for yes and false for no """ if isinstance(value, int): return bool(value) if value in ("y", "yes"): return True if value in ("n", "no"): return False msg = "option %s: invalid yn value %r, should be in (y, yes, n, no)" raise OptionValueError(msg % (opt, value)) def check_named( option: Optional[Any], opt: str, value: Union[Dict[str, str], str] ) -> Dict[str, str]: """check a named value return a dictionary containing (name, value) associations """ if isinstance(value, dict): return value values: List[Tuple[str, str]] = [] for value in check_csv(option, opt, value): # mypy: Argument 1 to "append" of "list" has incompatible type "List[str]"; # mypy: expected "Tuple[str, str]" # we know that the split will give a 2 items list if value.find("=") != -1: values.append(value.split("=", 1)) # type: ignore elif value.find(":") != -1: values.append(value.split(":", 1)) # type: ignore if values: return dict(values) msg = "option %s: invalid named value %r, should be = or \ :" raise OptionValueError(msg % (opt, value)) def check_password(option, opt, value): """check a password value (can't be empty)""" # no actual checking, monkey patch if you want more return value def check_file(option, opt, value): """check a file value return the filepath """ if exists(value): return value msg = "option %s: file %r does not exist" raise OptionValueError(msg % (opt, value)) # XXX use python datetime def check_date(option, opt, value): """check a file value return the filepath """ try: return DateTime.strptime(value, "%Y/%m/%d") except DateTime.Error: raise OptionValueError("expected format of %s is yyyy/mm/dd" % opt) def check_color(option, opt, value): """check a color value and returns it /!\\ does *not* check color labels (like 'red', 'green'), only checks hexadecimal forms """ # Case (1) : color label, we trust the end-user if re.match("[a-z0-9 ]+$", value, re.I): return value # Case (2) : only accepts hexadecimal forms if re.match("#[a-f0-9]{6}", value, re.I): return value # Else : not a color label neither a valid hexadecimal form => error msg = "option %s: invalid color : %r, should be either hexadecimal \ value or predefined color" raise OptionValueError(msg % (opt, value)) def check_time(option, opt, value): if isinstance(value, (int, float)): return value return apply_units(value, TIME_UNITS) def check_bytes(option: Optional["Option"], opt: str, value: Any) -> int: if hasattr(value, "__int__"): return value # mypy: Incompatible return value type (got "Union[float, int]", expected "int") # we force "int" using "final=int" return apply_units(value, BYTE_UNITS, final=int) # type: ignore class Option(BaseOption): """override optik.Option to add some new option types""" TYPES = BaseOption.TYPES + ( "regexp", "csv", "yn", "named", "password", "multiple_choice", "file", "color", "time", "bytes", ) ATTRS = BaseOption.ATTRS + ["hide", "level"] TYPE_CHECKER = copy(BaseOption.TYPE_CHECKER) TYPE_CHECKER["regexp"] = check_regexp TYPE_CHECKER["csv"] = check_csv TYPE_CHECKER["yn"] = check_yn TYPE_CHECKER["named"] = check_named TYPE_CHECKER["multiple_choice"] = check_csv TYPE_CHECKER["file"] = check_file TYPE_CHECKER["color"] = check_color TYPE_CHECKER["password"] = check_password TYPE_CHECKER["time"] = check_time TYPE_CHECKER["bytes"] = check_bytes if HAS_MX_DATETIME: TYPES += ("date",) TYPE_CHECKER["date"] = check_date def __init__(self, *opts: str, **attrs: Any) -> None: BaseOption.__init__(self, *opts, **attrs) # mypy: "Option" has no attribute "hide" # we test that in the if if hasattr(self, "hide") and self.hide: # type: ignore self.help = SUPPRESS_HELP def _check_choice(self) -> None: """FIXME: need to override this due to optik misdesign""" if self.type in ("choice", "multiple_choice"): # mypy: "Option" has no attribute "choices" # we know that option of this type has this attribute if self.choices is None: # type: ignore raise OptionError("must supply a list of choices for type 'choice'", self) elif not isinstance(self.choices, (tuple, list)): # type: ignore raise OptionError( "choices must be a list of strings ('%s' supplied)" % str(type(self.choices)).split("'")[1], # type: ignore self, ) elif self.choices is not None: # type: ignore raise OptionError("must not supply choices for type %r" % self.type, self) # mypy: Unsupported target for indexed assignment # black magic? BaseOption.CHECK_METHODS[2] = _check_choice # type: ignore def process(self, opt: str, value: str, values: Values, parser: BaseParser) -> int: # First, convert the value(s) to the right type. Howl if any # value(s) are bogus. value = self.convert_value(opt, value) if self.type == "named": assert self.dest is not None existant = getattr(values, self.dest) if existant: existant.update(value) value = existant # And then take whatever action is expected of us. # This is a separate method to make life easier for # subclasses to add new actions. # mypy: Argument 2 to "take_action" of "Option" has incompatible type "Optional[str]"; # mypy: expected "str" # is it ok? return self.take_action(self.action, self.dest, opt, value, values, parser) # type: ignore class OptionParser(BaseParser): """override optik.OptionParser to use our Option class""" def __init__(self, option_class: type = Option, *args: Any, **kwargs: Any) -> None: # mypy: Argument "option_class" to "__init__" of "OptionParser" has incompatible type # mypy: "type"; expected "Option" # mypy is doing really weird things with *args/**kwargs and looks buggy BaseParser.__init__(self, option_class=option_class, *args, **kwargs) # type: ignore def format_option_help(self, formatter: Optional[HelpFormatter] = None) -> str: if formatter is None: formatter = self.formatter outputlevel = getattr(formatter, "output_level", 0) formatter.store_option_strings(self) result = [] result.append(formatter.format_heading("Options")) formatter.indent() if self.option_list: result.append(OptionContainer.format_option_help(self, formatter)) result.append("\n") for group in self.option_groups: # mypy: "OptionParser" has no attribute "level" # but it has one no? if group.level <= outputlevel and ( # type: ignore group.description or level_options(group, outputlevel) ): result.append(group.format_help(formatter)) result.append("\n") formatter.dedent() # Drop the last "\n", or the header if no options or option groups: return "".join(result[:-1]) # mypy error: error: "Type[OptionGroup]" has no attribute "level" # monkeypatching OptionGroup.level = 0 # type: ignore def level_options(group: BaseParser, outputlevel: int) -> List[BaseOption]: # mypy: "Option" has no attribute "help" # but it does return [ option for option in group.option_list if (getattr(option, "level", 0) or 0) <= outputlevel and option.help is not SUPPRESS_HELP # type: ignore # noqa ] def format_option_help(self, formatter): result = [] outputlevel = getattr(formatter, "output_level", 0) or 0 for option in level_options(self, outputlevel): result.append(formatter.format_option(option)) return "".join(result) # mypy error: Cannot assign to a method # but we still do it because magic OptionContainer.format_option_help = format_option_help # type: ignore class ManHelpFormatter(HelpFormatter): """Format help using man pages ROFF format""" def __init__( self, indent_increment: int = 0, max_help_position: int = 24, width: int = 79, short_first: int = 0, ) -> None: HelpFormatter.__init__(self, indent_increment, max_help_position, width, short_first) def format_heading(self, heading: str) -> str: return ".SH %s\n" % heading.upper() def format_description(self, description): return description def format_option(self, option: BaseParser) -> str: try: # mypy: "Option" has no attribute "option_strings" # we handle if it doesn't optstring = option.option_strings # type: ignore except AttributeError: optstring = self.format_option_strings(option) # mypy: "OptionParser" has no attribute "help" # it does if option.help: # type: ignore # mypy: Argument 1 to "expand_default" of "HelpFormatter" has incompatible type # mypy: "OptionParser"; expected "Option" # it still works? help_text = self.expand_default(option) # type: ignore help = " ".join([line.strip() for line in help_text.splitlines()]) else: help = "" return """.IP "%s" %s """ % ( optstring, help, ) def format_head(self, optparser: OptionParser, pkginfo: attrdict, section: int = 1) -> str: long_desc = "" pgm = optparser.get_prog_name() short_desc = self.format_short_description(pgm, pkginfo.description) if hasattr(pkginfo, "long_desc"): long_desc = self.format_long_description(pgm, pkginfo.long_desc) return "%s\n%s\n%s\n%s" % ( self.format_title(pgm, section), short_desc, self.format_synopsis(pgm), long_desc, ) def format_title(self, pgm: str, section: int) -> str: date = "-".join([str(num) for num in time.localtime()[:3]]) return '.TH %s %s "%s" %s' % (pgm, section, date, pgm) def format_short_description(self, pgm: str, short_desc: str) -> str: return r""".SH NAME .B %s \- %s """ % ( pgm, short_desc.strip(), ) def format_synopsis(self, pgm: str) -> str: return ( """.SH SYNOPSIS .B %s [ .I OPTIONS ] [ .I ] """ % pgm ) def format_long_description(self, pgm, long_desc): long_desc = "\n".join([line.lstrip() for line in long_desc.splitlines()]) long_desc = long_desc.replace("\n.\n", "\n\n") if long_desc.lower().startswith(pgm): long_desc = long_desc[len(pgm) :] return """.SH DESCRIPTION .B %s %s """ % ( pgm, long_desc.strip(), ) def format_tail(self, pkginfo: attrdict) -> str: tail = """.SH SEE ALSO /usr/share/doc/pythonX.Y-%s/ .SH BUGS Please report bugs on the project\'s mailing list: %s .SH AUTHOR %s <%s> """ % ( getattr(pkginfo, "debian_name", pkginfo.modname), pkginfo.mailinglist, pkginfo.author, pkginfo.author_email, ) if hasattr(pkginfo, "copyright"): tail += ( """ .SH COPYRIGHT %s """ % pkginfo.copyright ) return tail def generate_manpage( optparser: OptionParser, pkginfo: attrdict, section: int = 1, stream: StringIO = sys.stdout, level: int = 0, ) -> None: """generate a man page from an optik parser""" formatter = ManHelpFormatter() # mypy: "ManHelpFormatter" has no attribute "output_level" # dynamic attribute? formatter.output_level = level # type: ignore formatter.parser = optparser print(formatter.format_head(optparser, pkginfo, section), file=stream) print(optparser.format_option_help(formatter), file=stream) print(formatter.format_tail(pkginfo), file=stream) __all__ = ("OptionParser", "Option", "OptionGroup", "OptionValueError", "Values")