Changeset 434

Show
Ignore:
Timestamp:
05/24/07 04:02:27 (2 years ago)
Author:
athomas
Message:

cly: Removed the function inspection code. I will finish this off later when I
have time to do it right. Huge restructuring prior to release:

  • Parsing logic into parser.
  • Grammar construction into builder, including all objects previously in types.
  • Exceptions into exceptions.
  • parser, builder and interactive are all imported by the top-level cly.
Files:

Legend:

Unmodified
Added
Removed
Modified
Copied
Moved
  • cly/trunk/cly/builder.py

    r430 r434  
    77# 
    88 
    9 """CLY is a Python module for simplifying the creation of Python interactive 
    10 shells. Kind of like the builtin `cmd` module on steroids. 
    11  
    12 It has the following features: 
    13  
    14   - Tab completion of all commands. 
    15  
    16   - Contextual help. 
    17  
    18   - Extensible grammar - you can define your own commands with full dynamic 
    19     completion, contextual help, and so on. 
    20  
    21   - Simple. Grammars are constructed from objects using a convenient 
    22     ''function-like'' syntax. 
    23  
    24   - Flexible command grouping and ordering. 
    25  
    26   - Grammar parser, including completion and help enumeration, can be used 
    27     independently of the readline-based shell. This allows CLY's parser to 
    28     be used in other environments (think "web-based shell" ;)) 
    29  
    30   - Lots of other cool stuff. 
    31 """ 
     9 
     10"""Classes for constructing CLY grammars.""" 
     11 
    3212 
    3313import re 
     14import os 
    3415import posixpath 
    35 import string 
    36  
    37 __author__ = 'Alec Thomas <alec@swapoff.org>' 
    38 __version__ = '0.9' 
    39  
    40 __all__ = """ 
    41 Error InvalidNodePath ParseError UnexpectedEOL InvalidToken ValidationError 
    42  
    43 Node Alias Group Action Variable Grammar 
    44  
    45 Help LazyHelp HelpParser Context Parser 
    46  
    47 quickstart 
    48 """.split() 
    49  
    50  
    51 class Error(Exception): 
    52     """The base of all CLY exceptions.""" 
    53  
    54 class InvalidHelp(Error): 
    55     """Thrown when the help provided to a Node is of an invalid type.""" 
    56  
    57 class InvalidNodePath(Error): 
    58     """Thrown when an attempt is made to reference an invalid node path. This 
    59     can occur when an Alias target is invalid.""" 
    60  
    61 class InvalidAnonymousNode(Error): 
    62     """When Node is used as a callable to add child nodes, positional arguments 
    63     are treated as anonymous child nodes. This exception is thrown if an object 
    64     that is not a Node is passed.""" 
    65  
    66 class ParseError(Error): 
    67     """Report a parse error. Output is formatted using string templates, 
    68     where variables are passed as arguments to the constructor. 
    69  
    70     >>> print ParseError(Context(None, 'foo bar'), "remaining=$remaining, time=$time", time=123) 
    71     remaining=foo bar, time=123 
    72     """ 
    73     message = 'parse error' 
    74  
    75     def __init__(self, context, message=None, **kwargs): 
    76         Error.__init__(self) 
    77         self.context = context 
    78         self.template_args = kwargs 
    79         if message is not None: 
    80             self.message = message 
    81  
    82     def __str__(self): 
    83         template = string.Template(self.message) 
    84         return template.safe_substitute(remaining=self.context.remaining, 
    85                                         **self.template_args) 
    86  
    87 class UnexpectedEOL(ParseError): 
    88     """Raised when all input is consumed and no terminal (``Action``) node is 
    89     reached.""" 
    90     message = 'more input required' 
    91  
    92 class InvalidToken(ParseError): 
    93     """Raised when a token is reached that is invalid at the current grammar 
    94     branch.""" 
    95     message = "invalid token '$remaining'" 
    96  
    97 class ValidationError(ParseError): 
    98     """Raised when a variable fails to parse. In practise this is rare, as the 
    99     regex for a variable is usually sufficient to rule it out of selection 
    100     before parsing occurs.""" 
    101     message = "validation of '$token' failed; $exception" 
     16 
     17 
     18__all__ = ['Node', 'Alias', 'Group', 'Action', 'Variable', 'Grammar', 'Help', 
     19           'LazyHelp', 'Word', 'String', 'URI', 'LDAPDN', 'Integer', 'Float', 
     20           'IP', 'Hostname', 'Host', 'EMail', 'File'] 
     21__docformat__ = 'restructuredtext en' 
     22 
     23 
     24from cly.exceptions import * 
    10225 
    10326 
     
    314237    def children(self, context, follow=False): 
    315238        """Iterate over child nodes, optionally follow()ing branches. 
     239        >>> from cly.parser import Context 
    316240        >>> tree = Node('One')(two=Node('Two', three=Node('Three'), 
    317241        ...                             four=Node('Four')), five=Alias('../two/*')) 
     
    454378 
    455379 
    456 def annotate(*args, **kwargs): 
    457     def apply_annotation(function): 
    458         function.cly_args = args 
    459         function.cly_kwargs = kwargs 
    460         return function 
    461     return apply_annotation 
    462  
    463  
    464380class Group(Node): 
    465381    """Group all children together at this location. 
    466382 
    467383    >>> import sys 
     384    >>> from cly.parser import HelpParser, Context 
    468385    >>> group = Group(1) 
    469386    >>> top = Node('Top', group(name='top', one=Node('One'), two=Node('Two')), three=Node('Three')) 
     
    505422        or ``?``) all matching nodes are aliased. 
    506423 
     424    >>> from cly.parser import Parser, Context 
    507425    >>> parser = Parser(Grammar(one=Node('One'), two=Node('Two', 
    508426    ...                 three=Node('Three')), four=Alias('../one'), five=Node('Five', six=Alias('../../*')))) 
     
    540458    def selected(self, context, match): 
    541459        """This node was selected by the parser.""" 
    542         raise Exception('Alias nodes should never be selected') 
     460        raise Error('Alias nodes should never be selected') 
    543461 
    544462    def follow(self, context): 
     
    573491    the ``Parser`` will be passed as the first argument. 
    574492 
     493    >>> from cly.parser import Parser, Context 
    575494    >>> def write_text(): 
    576495    ...     print "some text" 
     
    664583        Raises ValidationError if the variable raises InvalidMatch. 
    665584 
     585        >>> from cly.parser import Context 
    666586        >>> c = Context(None, 'foo bar') 
    667587        >>> v = Variable('Test', name='var') 
     
    763683 
    764684 
    765  
    766 class HelpParser(object): 
    767     """Extract the help for children of the specified Node. 
    768  
    769     Help is extracted from the Node's children, following branches, and 
    770     returned ordered by group, order and finally help key and string. 
    771     """ 
    772     def __init__(self, context, node): 
    773         self.help = [] 
    774         self.node = node 
    775  
    776         def add_help(node): 
    777             node_help = sorted(node.help(context)) 
    778             for help in node_help: 
    779                 self.help.append((node.group, node.order, help[0], help[1])) 
    780  
    781         for child in node.children(context, follow=True): 
    782             if child.visible(context): 
    783                 add_help(child) 
    784  
    785         self.help.sort() 
    786  
    787     def __iter__(self): 
    788         """Iterate over each (key, help) help pair. 
    789  
    790         >>> context = Context(None, None) 
    791         >>> help = HelpParser(context, Grammar(one=Node('One'), 
    792         ...     two=Node(Help.pair('<two>', 'Two'), group=2))) 
    793         >>> list(help) 
    794         [(0, 'one', 'One'), (2, '<two>', 'Two')] 
    795         """ 
    796  
    797         for help in self.help: 
    798             yield (help[0],) + help[2:] 
    799  
    800     def format(self, out): 
    801         """Format help into a human readable form. 
    802  
    803         >>> import sys 
    804         >>> context = Context(None, None) 
    805         >>> help = HelpParser(context, Grammar(one=Node('One'), 
    806         ...     two=Node(Help.pair('<two>', 'Two'), group=2))) 
    807         >>> help.format(sys.stdout) 
    808           one   One 
    809         <BLANKLINE> 
    810           <two> Two 
    811         """ 
    812         import cly.console as console 
    813  
    814         if not self.help: 
    815             return 
    816         last_group = None 
    817         max_len = max([len(h[2]) for h in self.help]) 
    818         if out.isatty(): 
    819             write = console.colour_cwrite 
     685class Word(Variable): 
     686    """Matches a Pythonesque variable name. 
     687 
     688    >>> from cly.parser import Parser 
     689    >>> parser = Parser(Grammar(foo=Word('Foo'))) 
     690    >>> parser.parse('a123').vars['foo'] 
     691    'a123' 
     692    >>> parser.parse('123').remaining 
     693    '123' 
     694    """ 
     695    pattern = r'(?i)[A-Z_]\w+' 
     696 
     697 
     698class String(Variable): 
     699    """Matches either a bare word or a quoted string. 
     700 
     701    >>> from cly.parser import Parser 
     702    >>> parser = Parser(Grammar(foo=String('Foo'))) 
     703    >>> parser.parse('"foo bar"').vars['foo'] 
     704    'foo bar' 
     705    >>> parser.parse('foo_bar').vars['foo'] 
     706    'foo_bar' 
     707    """ 
     708    pattern = r"""(\w+)|"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'""" 
     709 
     710    def parse(self, context, match): 
     711        return match.group(match.lastindex).decode('string_escape') 
     712 
     713 
     714 
     715class URI(Variable): 
     716    """Matches a URI. Result is a string. 
     717 
     718    >>> from cly.parser import Parser 
     719    >>> parser = Parser(Grammar(foo=URI('Foo'))) 
     720    >>> parser.parse('http://www.example.com/test/;test?a=10&b=10#fragment').vars['foo'] 
     721    'http://www.example.com/test/;test?a=10&b=10#fragment' 
     722    """ 
     723    pattern = r"""(([a-zA-Z][0-9a-zA-Z+\\-\\.]*:)?/{0,2}[0-9a-zA-Z;/?:@&=+$\\.\\-_!~*'()%]+)(#[0-9a-zA-Z;/?:@&=+$\\.\\-_!~*'()%]+)?""" 
     724 
     725    def __init__(self, doc, scheme='', allow_fragments=1, *argl, **argd): 
     726        Variable.__init__(self, doc, *argl, **argd) 
     727        self.scheme = scheme 
     728        self.allow_fragments = allow_fragments 
     729 
     730    #def parse(self, context, match): 
     731        #import urlparse 
     732        #return urlparse.urlparse(match.string[match.start():match.end()], self.scheme, self.allow_fragments) 
     733 
     734 
     735class LDAPDN(Variable): 
     736    """Matches an LDAP DN. 
     737 
     738    >>> from cly.parser import Parser 
     739    >>> parser = Parser(Grammar(foo=LDAPDN('Foo'))) 
     740    >>> parser.parse('cn=Manager,dc=example,dc=com').vars['foo'] 
     741    'cn=Manager,dc=example,dc=com' 
     742    """ 
     743    pattern = r'(\w+=\w+)(?:,(\w+=\w+))*' 
     744 
     745 
     746class Integer(Variable): 
     747    """Matches an integer. 
     748 
     749    >>> from cly.parser import Parser 
     750    >>> parser = Parser(Grammar(foo=Integer('Foo'))) 
     751    >>> parser.parse('12345').vars['foo'] 
     752    12345 
     753    >>> parser.parse('123.45').remaining 
     754    '123.45' 
     755    """ 
     756    pattern = r'\d+' 
     757 
     758    def parse(self, context, match): 
     759        return int(match.group()) 
     760 
     761 
     762class Float(Variable): 
     763    """Matches a floating point number. 
     764 
     765    >>> from cly.parser import Parser 
     766    >>> parser = Parser(Grammar(foo=Float('Foo'))) 
     767    >>> parser.parse('12345.34').vars['foo'] 
     768    12345.34 
     769    >>> parser.parse('123.45e10').vars['foo'] 
     770    1234500000000.0 
     771    """ 
     772    pattern = r'[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?' 
     773 
     774    def parse(self, context, match): 
     775        return float(match.group()) 
     776 
     777 
     778class IP(Variable): 
     779    """Match an IP address, parsing it as a tuple of four integers. 
     780 
     781    >>> from cly.parser import Parser 
     782    >>> parser = Parser(Grammar(foo=IP('Foo'))) 
     783    >>> parser.parse('123.34.67.89').vars['foo'] 
     784    (123, 34, 67, 89) 
     785    >>> parser.parse('123.34.67.256').remaining 
     786    '123.34.67.256' 
     787    """ 
     788    pattern = r'(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)' 
     789 
     790    def parse(self, context, match): 
     791        return tuple(map(int, match.groups())) 
     792 
     793 
     794class Hostname(Variable): 
     795    """Match a hostname and parse it as a tuple of components. 
     796 
     797    Note: This will match hostnames consisting only of numbers, including but 
     798    not limited to IP addresses. 
     799 
     800    >>> from cly.parser import Parser 
     801    >>> parser = Parser(Grammar(foo=Hostname('Foo'))) 
     802    >>> parser.parse('www.example.com').vars['foo'] 
     803    ('www', 'example', 'com') 
     804    """ 
     805    pattern = r'(?i)([A-Z0-9][A-Z0-9_-]*)(?:\.([A-Z0-9][A-Z0-9_-]*))*' 
     806 
     807    def parse(self, context, match): 
     808        return tuple(match.group().split('.')) 
     809 
     810 
     811class Host(Variable): 
     812    """Match either an IP address or a hostname and return a tuple. 
     813 
     814    If an IP address is matched, the elements of the tuple will be integers. 
     815 
     816    >>> from cly.parser import Parser 
     817    >>> parser = Parser(Grammar(foo=Host('Foo'))) 
     818    >>> parser.parse('www.example.com').vars['foo'] 
     819    ('www', 'example', 'com') 
     820    >>> parser.parse('123.34.67.89').vars['foo'] 
     821    (123, 34, 67, 89) 
     822    """ 
     823 
     824    pattern = r'(?i)(%s)|(%s)' % (IP.pattern, Hostname.pattern) 
     825 
     826    def parse(self, context, match): 
     827        components = match.string[match.start():match.end()].split('.') 
     828        if match.lastindex == 1: 
     829            return tuple(map(int, components)) 
     830        return tuple(components) 
     831 
     832 
     833class EMail(Variable): 
     834    """Match an E-Mail address. 
     835 
     836    >>> from cly.parser import Parser 
     837    >>> parser = Parser(Grammar(foo=EMail('Foo'))) 
     838    >>> parser.parse('foo@bar.com').vars['foo'] 
     839    'foo@bar.com' 
     840    """ 
     841    pattern = r'(?i)[A-Z0-9._%-]+@[A-Z0-9.-]+\.[A-Z]{2,4}' 
     842 
     843 
     844class File(Variable): 
     845    """Match and provide completion candidates for local files. 
     846 
     847    >>> from cly.parser import Parser 
     848    >>> parser = Parser(Grammar(foo=File('Foo', allow_directories=True))) 
     849    >>> parser.parse('.').vars['foo'] 
     850    '.' 
     851    """ 
     852    pattern = r'\S+' 
     853    includes = ['*'] 
     854    excludes = [] 
     855    allow_dotfiles = False 
     856    allow_directories = False 
     857 
     858    def match(self, context): 
     859        match = Variable.match(self, context) 
     860        if match and self.match_file(match.group(), self.allow_directories): 
     861            return match 
     862 
     863    def match_file(self, file, match_directories=True): 
     864        from fnmatch import fnmatch 
     865        file = os.path.expanduser(file) 
     866        if match_directories and os.path.isdir(file): 
     867            return True 
     868        if not self.allow_dotfiles and os.path.basename(file).startswith('.'): 
     869            return False 
     870        for exclude in self.excludes: 
     871            if fnmatch(file, exclude): 
     872                return False 
     873        for include in self.includes: 
     874            if fnmatch(file, include): 
     875                return True 
     876        return False 
     877 
     878    def candidates(self, context, text): 
     879        """Return list of valid file candidates.""" 
     880 
     881        if text.startswith('~'): 
     882            if '/' in text: 
     883                short_home = text[:text.index('/')] 
     884            else: 
     885                short_home = text 
     886            expanded_home = os.path.expanduser(short_home) 
     887            text = os.path.expanduser(text) 
    820888        else: 
    821             write = console.mono_cwrite 
    822         for group, order, command, help in self.help: 
    823             if last_group is not None and last_group != group: 
    824                 out.write('\n') 
    825             last_group = group 
    826             write(out, '  ^B%-*s^B %s\n' % (max_len, command, help)) 
    827  
    828  
    829 class Context(object): 
    830     """Represents the parsing context of a single command. 
    831  
    832     The context contains all the information the parser needs to maintain 
    833     state while parsing the input command. 
    834     """ 
    835     def __init__(self, parser, command, user_context=None): 
    836         self.parser = parser 
    837         self.command = command 
    838         self.cursor = 0 
    839         self.user_context = user_context 
    840         self.vars = {} 
    841         self._traversed = {} 
    842         self.trail = [] 
    843  
    844     def _get_remaining_input(self): 
    845         """Return the current remaining unparsed text in the command.""" 
    846         return self.command[self.cursor:] 
    847     remaining = property(_get_remaining_input) 
    848  
    849     def _get_parsed(self): 
    850         """Return command text that has been successfully parsed.""" 
    851         return self.command[:self.cursor] 
    852     parsed = property(_get_parsed) 
    853  
    854     def _last_node(self): 
    855         """Return the last node parsed.""" 
    856         if self.trail[-1][1] is None or self.trail[-1][1].group(): 
    857             return self.trail[-1][0] 
    858         else: 
    859             return self.trail[-2][0] 
    860     last_node = property(_last_node) 
    861  
    862     def execute(self): 
    863         """Execute the current (terminal) node. If there is still input 
    864         remaining an exception will be thrown.""" 
    865         if self.remaining.strip(): 
    866             raise InvalidToken(self) 
    867         node = self.trail[-1][0] 
    868         return node.terminal(self) 
    869  
    870     def advance(self, distance): 
    871         """Advance cursor.""" 
    872         self.cursor += distance 
    873  
    874     def candidates(self, text=None): 
    875         """Return potential candidates from children of last successfully 
    876         parsed node. 
    877  
    878         If text is not provided, the remaining unparsed text in the current 
    879         command will be used.""" 
    880         if text is None: 
    881             text = self.remaining 
    882         for child in self.last_node.children(self, follow=True): 
    883             for candidate in child.candidates(self, text): 
    884                 yield candidate 
    885  
    886     def help(self): 
    887         """Return a HelpParser object describing the last successfully parsed 
    888         node.""" 
    889         return HelpParser(self, self.last_node) 
    890  
    891     def selected(self, node): 
    892         """The given node has been selected and will be followed.""" 
    893         path = node.path() 
    894         self._traversed.setdefault(path, 0) 
    895         self._traversed[path] += 1 
    896  
    897     def traversed(self, node): 
    898         """How many times has node been traversed in this context?""" 
    899         return self._traversed.get(node.path(), 0) 
    900  
    901     def __repr__(self): 
    902         return "<Context command:'%s' remaining:'%s'>" % (self.command, self.remaining) 
    903  
    904  
    905 class Parser(object): 
    906     """Parse and execute CLY grammars.""" 
    907     def __init__(self, grammar, with_context=False): 
    908         self.grammar = grammar 
    909         self.with_context = with_context 
    910  
    911     def _set_grammar(self, grammar): 
    912         assert isinstance(grammar, Grammar) 
    913         self._grammar = grammar 
    914         for node in self: 
    915             node.parser = self 
    916  
    917     def _get_grammar(self): 
    918         """The grammar associated with this parser.""" 
    919         return self._grammar 
    920  
    921     grammar = property(_get_grammar, _set_grammar) 
    922  
    923     def __iter__(self): 
    924         """Walk every node in the grammar. 
    925  
    926         >>> parser = Parser(Grammar(one=Node('One'), two=Node('Two', three=Node('Three')))) 
    927         >>> list(parser) 
    928         [<Grammar:/>, <Node:/two>, <Node:/two/three>, <Node:/one>] 
    929         """ 
    930         for node in self.grammar.walk(): 
    931             yield node 
    932  
    933     def parse(self, command, user_context=None): 
    934         """Parse command using the current grammar. 
    935  
    936         This will return a Context object that can be used to inspect the state 
    937         of the parser. 
    938  
    939         If a user_context is provided it will be passed on to any ``Action`` 
    940         node callbacks that have set ``with_context=True``. 
    941  
    942         >>> parser = Parser(Grammar(one=Node('One'), two=Node('Two', three=Node('Three', 
    943         ...                 action=Action('Do stuff', lambda: "foo bar"))))) 
    944         >>> context = parser.parse('two three') 
    945         >>> context 
    946         <Context command:'two three' remaining:''> 
    947         >>> context.execute() 
    948         'foo bar' 
    949         >>> parser.parse('two four') 
    950         <Context command:'two four' remaining:'four'> 
    951         """ 
    952         context = Context(self, command, user_context) 
    953  
    954         def parse(node, match): 
    955             context.trail.append((node, match)) 
    956             if match is not None: 
    957                 node.advance(context) 
    958             node.selected(context, match) 
    959  
    960             for subnode in node.next(context): 
    961                 if subnode.valid(context): 
    962                     submatch = subnode.match(context) 
    963                     if submatch is not None: 
    964                         return parse(subnode, submatch) 
     889            short_home = None 
     890 
     891        text = os.path.expanduser(text) 
     892        dir = os.path.dirname(text) or os.path.curdir 
     893        file = os.path.basename(text) 
     894        cwd = os.path.curdir + os.path.sep 
     895 
     896        def clean(file): 
     897            if file.startswith(cwd): 
     898                return file[len(cwd):] 
     899            if short_home and file.startswith(expanded_home): 
     900                return short_home + file[len(expanded_home):] 
     901            return file 
     902 
     903        def get_candidates(dir, file): 
     904            return [f for f in os.listdir(dir) if f.startswith(file) 
     905                    and self.match_file(os.path.join(dir, f))] 
     906 
     907        candidates = get_candidates(dir, file) 
     908        if len(candidates) == 1: 
     909            if os.path.isdir(os.path.join(dir, candidates[0])): 
     910                dir = os.path.join(dir, candidates[0] + '/') 
     911                return [dir] 
     912                file = '' 
     913                candidates = get_candidates(dir, file) 
    965914            else: 
    966                 return 
    967             raise InvalidToken(context) 
    968  
    969         parse(self.grammar, None) 
    970         return context 
    971  
    972     def execute(self, command, user_context=None): 
    973         """Parse and execute the given command. 
    974  
    975         >>> parser = Parser(Grammar(one=Node('One'), two=Node('Two', three=Node('Three', 
    976         ...                 action=Action('Do stuff', lambda: "foo bar"))))) 
    977         >>> parser.execute('two three') 
    978         'foo bar' 
    979         """ 
    980         return self.parse(command, user_context).execute() 
    981  
    982     def find(self, path): 
    983         """Find a node by its absolute path. 
    984  
    985         >>> parser = Parser(Grammar(one=Node('One'), two=Node('Two', three=Node('Three')))) 
    986         >>> parser.find('/two/three') 
    987         <Node:/two/three> 
    988         """ 
    989         return self.grammar.find(path) 
    990  
    991  
    992 def quickstart(grammar_or_callables, *interact_args, **interact_kwargs): 
    993     """Start an interactive session from a grammar, or from inspecting a set of 
    994     callables.""" 
    995     from cly.extra import quickstart 
    996     quickstart(grammar_or_callables, *interact_args, **interact_kwargs) 
     915                return [clean(os.path.join(dir, candidates[0] + ' '))] 
     916        return [clean(os.path.join(dir, f)) 
     917                for f in candidates if self.allow_dotfiles or f[0] != '.'] 
    997918 
    998919 
  • cly/trunk/cly/console.py

    r433 r434  
    4343import os 
    4444import codecs 
     45 
     46 
     47__docformat__ = 'restructuredtext en' 
    4548 
    4649 
  • cly/trunk/cly/extra.py

    r430 r434  
    99"""Useful functions for use in conjunction with CLY.""" 
    1010 
    11 from cly import Parser, Grammar, Node, Action, Variable, Alias 
    12 from cly.interactive import Interact 
    1311 
    14 __all__ = ['quickstart', 'integrate', 'static_candidates'] 
    15  
    16  
    17 def _integrate_function(node, components, doc): 
    18     if not components: 
    19         return node 
    20     key = components.pop(0) 
    21     try: 
    22         next = node[key] 
    23     except KeyError: 
    24         next = Node(doc) 
    25         node[key] = next 
    26     return _integrate_function(next, components, doc) 
    27  
    28  
    29 def _integrate_positional_args(node, args): 
    30     if not args: 
    31         return node 
    32     arg = args.pop(0) 
    33     node[arg] = Variable(arg.title(), pattern=r'[^\s]+') 
    34     return _integrate_positional_args(node[arg], args) 
    35  
    36  
    37 def _integrate_keywords_args(node, kwargs): 
    38     if len(kwargs) > 1: 
    39         for arg in kwargs: 
    40             node[arg] = Node(arg.title()) 
    41             node[arg][arg] = Variable(arg.title())(Alias('../../../*')) 
    42     else: 
    43         node[arg] = Variable(arg.title())(Alias('../../*')) 
    44  
    45  
    46 def integrate(root, functions): 
    47     """Use heuristics to integrate functions into a node. 
    48  
    49     Each underscore separated part of the function name is converted into a 
    50     Node, each argument without a default is converted into a required 
    51     Variable, and each argument with a default is converted into an optional 
    52     Variable. 
    53  
    54     >>> from cly import Grammar 
    55     >>> def foo_bar(one, two, three="Baz", four="Waz"): 
    56     ...   print one, two, three, four 
    57     >>> grammar = Grammar() 
    58     >>> integrate(grammar, [foo_bar]) 
    59     >>> list(grammar.walk())    # doctest: +NORMALIZE_WHITESPACE 
    60     [<Grammar:/>, <Node:/foo>, <Node:/foo/bar>, <Variable:/foo/bar/one>, 
    61     <Variable:/foo/bar/one/two>, <Action:/foo/bar/one/two/action>, 
    62     <Node:/foo/bar/one/two/four>, <Variable:/foo/bar/one/two/four/four>, 
    63     <Alias:/foo/bar/one/two/four/four/__anonymous_0 for /foo/bar/one/two/*>, 
    64     <Node:/foo/bar/one/two/three>, <Variable:/foo/bar/one/two/three/three>, 
    65     <Alias:/foo/bar/one/two/three/three/__anonymous_0 for /foo/bar/one/two/*>] 
    66     """ 
    67     import inspect 
    68  
    69     for function in functions: 
    70         try: 
    71             name = function.func_name 
    72         except AttributeError: 
    73             name = function.__class__.__name__ 
    74  
    75         args, varargs, varkw, defaults = inspect.getargspec(function) 
    76         doc = inspect.getdoc(function) or '' 
    77  
    78         node = _integrate_function(root, name.split('_'), doc) 
    79  
    80         if defaults: 
    81             positional_args = args[:-len(defaults)] 
    82         else: 
    83             positional_args = args[:] 
    84         node = _integrate_positional_args(node, positional_args) 
    85  
    86         node(action=Action(doc, function)) 
    87  
    88         if defaults: 
    89             keyword_args = args[-len(defaults):] 
    90             _integrate_keywords_args(node, keyword_args) 
    91  
    92  
    93 def hint(*args, **kwargs): 
    94     """Annotate a function with CLY function integration hints. 
    95  
    96     XXX Not functional yet. XXX""" 
    97     def apply_hint(function): 
    98         function.cly_args = args 
    99         function.cly_kwargs = kwargs 
    100         return function 
    101     return apply_hint 
    102  
    103  
    104 def quickstart(grammar_or_callables, *interact_args, **interact_kwargs): 
    105     """Start an interactive session from a grammar, or from inspecting a set of 
    106     callables.""" 
    107     if isinstance(grammar_or_callables, (Grammar, Parser)): 
    108         interact = Interact(grammar_or_callables, *interact_args, 
    109                             **interact_kwargs) 
    110     else: 
    111         grammar = Grammar() 
    112         integrate(grammar, grammar_or_callables) 
    113         interact = Interact(grammar, *interact_args, **interact_kwargs) 
    114     interact.interact_loop() 
     12__all__ = ['static_candidates'] 
     13__docformat__ = 'restructuredtext en' 
    11514 
    11615 
     
    12019    Returns a callable that can be used directly with ``Node.candidates=``. 
    12120 
    122     >>> from cly import Parser, Grammar, Node 
     21    >>> from cly.parser import Parser 
     22    >>> from cly.builder import Grammar, Node 
    12323    >>> static_candidates('foo', 'bar')(None, 'f') 
    12424    ['foo '] 
  • cly/trunk/cly/__init__.py

    r430 r434  
    77# 
    88 
    9 """CLY is a Python module for simplifying the creation of Python interactive 
    10 shells. Kind of like the builtin `cmd` module on steroids. 
     9"""CLY is a Python module for simplifying the creation of interactive shells. 
     10Kind of like the builtin ``cmd`` module on steroids. 
    1111 
    1212It has the following features: 
     
    2020 
    2121  - Simple. Grammars are constructed from objects using a convenient 
    22     ''function-like'' syntax. 
     22    ''function-call'' syntax. 
    2323 
    2424  - Flexible command grouping and ordering. 
     
    3131""" 
    3232 
    33 import re 
    34 import posixpath 
    35 import string 
    3633 
     34__docformat__ = 'restructuredtext en' 
    3735__author__ = 'Alec Thomas <alec@swapoff.org>' 
    38 __version__ = '0.9' 
    39  
    40 __all__ = """ 
    41 Error InvalidNodePath ParseError UnexpectedEOL InvalidToken ValidationError 
    42  
    43 Node Alias Group Action Variable Grammar 
    44  
    45 Help LazyHelp HelpParser Context Parser 
    46  
    47 quickstart 
    48 """.split() 
     36try: 
     37    __version__ = __import__('pkg_resources').get_distribution('cly').version 
     38except ImportError: 
     39    pass 
    4940 
    5041 
    51 class Error(Exception): 
    52     """The base of all CLY exceptions.""" 
    53  
    54 class InvalidHelp(Error): 
    55     """Thrown when the help provided to a Node is of an invalid type.""" 
    56  
    57 class InvalidNodePath(Error): 
    58     """Thrown when an attempt is made to reference an invalid node path. This 
    59     can occur when an Alias target is invalid.""" 
    60  
    61 class InvalidAnonymousNode(Error): 
    62     """When Node is used as a callable to add child nodes, positional arguments 
    63     are treated as anonymous child nodes. This exception is thrown if an object 
    64     that is not a Node is passed.""" 
    65  
    66 class ParseError(Error): 
    67     """Report a parse error. Output is formatted using string templates, 
    68     where variables are passed as arguments to the constructor. 
    69  
    70     >>> print ParseError(Context(None, 'foo bar'), "remaining=$remaining, time=$time", time=123) 
    71     remaining=foo bar, time=123 
    72     """ 
    73     message = 'parse error' 
    74  
    75     def __init__(self, context, message=None, **kwargs): 
    76         Error.__init__(self) 
    77         self.context = context 
    78         self.template_args = kwargs 
    79         if message is not None: 
    80             self.message = message 
    81  
    82     def __str__(self): 
    83         template = string.Template(self.message) 
    84         return template.safe_substitute(remaining=self.context.remaining, 
    85                                         **self.template_args) 
    86  
    87 class UnexpectedEOL(ParseError): 
    88     """Raised when all input is consumed and no terminal (``Action``) node is 
    89     reached.""" 
    90     message = 'more input required' 
    91  
    92 class InvalidToken(ParseError): 
    93     """Raised when a token is reached that is invalid at the current grammar 
    94     branch.""" 
    95     message = "invalid token '$remaining'" 
    96  
    97 class ValidationError(ParseError): 
    98     """Raised when a variable fails to parse. In practise this is rare, as the 
    99     regex for a variable is usually sufficient to rule it out of selection 
    100     before parsing occurs.""" 
    101     message = "validation of '$token' failed; $exception" 
    102  
    103  
    104 class Node(object): 
    105     """The base class for all grammar nodes. 
    106  
    107     Constructor arguments are: 
    108  
    109     ``help``: string or callable returning a list of (key, help) tuples 
    110         A help string or a callable returning an iterable of (key, help) 
    111         pairs. There is a useful class called Help which can be used for 
    112         this purpose. 
    113  
    114         Help strings are required. 
    115  
    116         >>> Node() 
    117         Traceback (most recent call last): 
    118         ... 
    119         TypeError: __init__() takes at least 2 arguments (1 given) 
    120         >>> Node("Something") 
    121         <Node:/> 
    122  
    123     ``name=None``: string 
    124         The name of the node. If ommitted the key used by the parent Node 
    125         is used. The node name also defines the node path: 
    126  
    127         >>> Node('Something', name='something') 
    128         <Node:/something> 
    129  
    130     The following constructor arguments are also class variables, and as 
    131     such can be overridden at the class level by subclasses of Node. Useful If 
    132     you find yourself using a particular pattern repeatedly. 
    133  
    134     ``pattern=None``: regular expression string 
    135         The regular expression used to match user input. If not provided, 
    136         the node name is used: 
    137  
    138         >>> a = Node('Something', name='something') 
    139         >>> a.pattern == a.name 
    140         True 
    141  
    142     ``separator=r'\s+|\s*$'``: regular expression string 
    143         A regular expression used to match the text separating this node 
    144         and the next. 
    145  
    146     ``group=0``: integer 
    147         Nodes can be grouped together to provide visual cues. Groups are 
    148         ordered ascending numerically. 
    149  
    150     ``order=0``: integer 
    151         Within a group, nodes are normally ordered alphabetically. This can 
    152         be overridden by setting this to a value other than 0. 
    153  
    154     ``match_candidates=False``: boolean 
    155         The candidates() method returns a list of words that match at the 
    156         current token which are used for completion, but can also be used 
    157         to constrain the allowed matches if match_candidates=True.  Useful 
    158         for situations where you have a general regex pattern (eg. a 
    159         pattern matching files) but a known set of matches at this point 
    160         (eg.  files in the current directory). 
    161  
    162     ``traversals=1``: integer 
    163         The number of times this node can match in any parse context. Alias 
    164         nodes allow for multiple traversal. 
    165  
    166         If ``traversals=0`` the node will match an infinite number of times. 
    167     """ 
    168     pattern = None 
    169     separator = r'\s+|\s*$' 
    170     order = 0 
    171     group = 0 
    172     match_candidates = False 
    173     traversals = 1 
    174  
    175     def __init__(self, help, *args, **kwargs): 
    176         self._children = {} 
    177         if isinstance(help, basestring): 
    178             self.help = LazyHelp(self, help) 
    179         elif callable(help): 
    180             self.help = help 
    181         else: 
    182             raise InvalidHelp('help must be a callable or a string') 
    183         if 'pattern' in kwargs: 
    184             self.pattern = kwargs.pop('pattern') 
    185         if 'separator' in kwargs: 
    186             self.separator = kwargs.pop('separator') 
    187         if self.pattern is not None: 
    188             self._pattern = re.compile(self.pattern) 
    189         self._separator = re.compile(self.separator) 
    190         if self.pattern is not None and self.separator is not None: 
    191             self._full_match = re.compile('(?:%s)(?:%s)' % 
    192                                           (self.pattern, self.separator)) 
    193         self.name = kwargs.pop('name', None) 
    194         self.parent = None 
    195         self.__anonymous_children = 0 
    196         self(*args, **kwargs) 
    197  
    198     def _set_name(self, name): 
    199         """Set the name of this node. If the Node does not have an existing 
    200         matching pattern associated with it, a pattern will be created using 
    201         the name.""" 
    202         self._name = name 
    203         if isinstance(name, basestring) and self.pattern is None: 
    204             self.pattern = name 
    205             self._pattern = re.compile(name) 
    206         if self.pattern is not None and self.separator is not None: 
    207             self._full_match = re.compile('(?:%s)(?:%s)' % 
    208                                           (self.pattern, self.separator)) 
    209     name = property(lambda self: self._name, _set_name) 
    210  
    211     def __call__(self, *anonymous, **options): 
    212         """Update or add options and child nodes. 
    213  
    214         Positional arguments are treated as anonymous child nodes, while 
    215         keyword arguments can either be named child nodes or attribute updates 
    216         for this node. See __init__ for more information on attributes. 
    217  
    218         >>> top = Node('Top', name='top') 
    219         >>> top(subnode=Node('Subnode')) 
    220         <Node:/top> 
    221         >>> top.find('subnode') 
    222         <Node:/top/subnode> 
    223         """ 
    224         for node in anonymous: 
    225             if not isinstance(node, Node): 
    226                 raise InvalidAnonymousNode('Anonymous node is not a Node object') 
    227             # TODO Convert help to name instead of __anonymous_<n> 
    228             node.name = '__anonymous_%i' % self.__anonymous_children 
    229             node.parent = self 
    230             self._children[node.name] = node 
    231             self.__anonymous_children += 1 
    232  
    233         for k, v in options.iteritems(): 
    234             if isinstance(v, Node): 
    235                 if k.endswith('_'): 
    236                     k = k[:-1] 
    237                 v.name = k 
    238                 v.parent = self 
    239                 self._children[k] = v 
    240             else: 
    241                 setattr(self, k, v) 
    242         return self 
    243  
    244     def __iter__(self): 
    245         """Iterate over child nodes, ignoring context. 
    246  
    247         >>> tree = Node('One')(two=Node('Two'), three=Node('Three')) 
    248         >>> list(tree) 
    249         [<Node:/three>, <Node:/two>] 
    250         """ 
    251         children = sorted(self._children.values(), 
    252                           key=lambda i: (i.group, i.order, i.name)) 
    253         for child in children: 
    254             yield child 
    255  
    256     def __setitem__(self, key, child): 
    257         """Emulate dictionary set. 
    258  
    259         >>> node = Node('One') 
    260         >>> node['two'] = Node('Two') 
    261         >>> list(node.walk()) 
    262         [<Node:/>, <Node:/two>] 
    263         """ 
    264         self(**{key: child}) 
    265  
    266     def __getitem__(self, key): 
    267         """Emulate dictionary get. 
    268  
    269         >>> node = Node('One')(two=Node('Two')) 
    270         >>> node['two'] 
    271         <Node:/two> 
    272         """ 
    273         return self._children[key] 
    274  
    275     def __delitem__(self, key): 
    276         """Emulate dictionary delete. 
    277  
    278         >>> node = Node('One')(two=Node('Two'), three=Node('Three')) 
    279         >>> list(node.walk()) 
    280         [<Node:/>, <Node:/three>, <Node:/two>] 
    281         >>> del node['two'] 
    282         >>> list(node.walk()) 
    283         [<Node:/>, <Node:/three>] 
    284         """ 
    285         child = self._children.pop(key) 
    286         child.parent = None 
    287  
    288     def __contains__(self, key): 
    289         """Emulate dictionary key existence test. 
    290  
    291         >>> node = Node('One')(two=Node('Two'), three=Node('Three')) 
    292         >>> 'two' in node 
    293         True 
    294         """ 
    295         return key in self._children 
    296  
    297     def walk(self): 
    298         """Perform a recursive walk of the grammar tree. 
    299  
    300         >>> tree = Node('One')(two=Node('Two', three=Node('Three'), 
    301         ...                             four=Node('Four'))) 
    302         >>> list(tree.walk()) 
    303         [<Node:/>, <Node:/two>, <Node:/two/four>, <Node:/two/three>] 
    304         """ 
    305         def walk(root): 
    306             yield root 
    307             for node in root._children.itervalues(): 
    308                 for subnode in walk(node): 
    309                     yield subnode 
    310  
    311         for node in walk(self): 
    312             yield node 
    313  
    314     def children(self, context, follow=False): 
    315         """Iterate over child nodes, optionally follow()ing branches. 
    316         >>> tree = Node('One')(two=Node('Two', three=Node('Three'), 
    317         ...                             four=Node('Four')), five=Alias('../two/*')) 
    318         >>> context = Context(None, None) 
    319         >>> list(tree.children(context)) 
    320         [<Alias:/five for /two/*>, <Node:/two>] 
    321         >>> list(tree.children(context, follow=True)) 
    322         [<Node:/two/four>, <Node:/two/three>, <Node:/two>] 
    323 &n