Thursday, February 2, 2017

Documentation Decorators: Deprecated, FIXME and TODO

Another long post...

I'm hoping this isn't a pattern for all my posts, but at the same time, I'm trying to make sure that I keep all the stuff I'm writing at least somewhat logically grouped (even if I can't seem to keep it short, sweet, and to the point).

So, having given it some thought since the post before last, I think I'm going to attack what I believe to be the simpler set of choices that I left off with in my last post: Decorators for to-do, fix-me, and deprecated items. I believe these to be the simpler choice because:

  • Each of them feels to me like they should be little more than a list of string or unicode values at most; and
  • The sequence of those values doesn't strike me as being significant, at least not within the context of all to-do items, or all of any of the other types.
Consider, if you will, a function or method that has two or more of either to-do or fix-me items that should be documented. Chances are good that if there are any critical details for either, they will (should?) be documented either in the code as comments, or in some completely external document or system. All that is really relevant from an API documentation perspective is some indication that they exist, and some indication of their scope or effects, either now or as expected in the future. Any single given item, of either type, may have a lot of information associated with it, and it may even be scattered around and about several places in the code that needs the attention, but each individual item is, for all practical purposes, a single to-do or fix-me as far as the documentation itself is concerned.

Is the sequence of those items relevant from the perspective of someone reading the documentation? It might be. But I suspect that most of the time (nearly always in my experience), it won't be. If there is some urgent need for a to-do or fix-me to be resolved, it will be communicated to the developer(s) as needed, but there is no reason to commit within the documentation to any specific resolution sequence.

So, structurally, to-do and fix-me items need not be any more complicated than a couple of lists of strings in the overall metadata-structure. Something like this:

{
    'fixmes':<list<str|unicode>>,
    'todos':<list<str|unicode>>,
}

Deprecated items are even more simple, I think. Functionality that is being deprecated is going to fall into one of two categories that I can recall personally:

  • It's going to be removed because something else does the job better
  • It's going to be removed because it's no longer useful.
The latter of these is not common, in my experience, but I have seen it on occasion, usually when the no-longer-useful functionality serves no good purpose any longer and there's some impetus to keep the codebase it resides in clear of code that isn't in use. In that case, it's as much a development-policy decision as a functional one. In either case, use of the deprecated functionality should be avoided, because there's no guarantee that it will even exist in later versions. From a documentation perspective, it would be useful to provide information on the replacement functionality, if applicable. It might also be useful to provide an anticipated time-frame, whether by actual date, or by some future version indicator, when the deprecated functionality will no longer be available. None of these, though, require anything more than a simple text-value.

Here's what I expect these three decorations to look like in use, added to the Ook class that I've been using for examples for the last few posts:

class Ook( object ):
    """
Test-class."""
    # argument decorators removed for brevity
    @describe.deprecated( 'Use new_Fnord instead.' )
    def Fnord( self, arg1, arg2, *args, **kwargs ):
        """Ook.Fnord (method) original doc-string"""
        return None

    # argument decorators removed for brevity
    def new_Fnord( self, arg1, arg2, *args, **kwargs ):
        """Ook.Fnord (method) original doc-string"""
        return None

    @classmethod
    # argument decorators removed for brevity
    @describe.deprecated( 'Will be removed by version X.YY.ZZ' )
    def Bleep( cls, arg1, arg2=None, *args, **kwargs ):
        """Ook.Bleep (classmethod) original doc-string"""
        return None


    @staticmethod
    # argument decorators removed for brevity
    @describe.todo( 'Clean up output to remove empty members' )
    @describe.todo( 'Change output to class with the same interface' )
    @describe.fixme( 'Magic _parameters value needs to be removed' )
    @describe.fixme( 'Rewrite list-loops to perform the same operations in fewer passes' )
    def Flup( arg1, arg2, *args, **kwargs ):
        """Ook.Flup (staticmethod) original doc-string"""
        return None

In the data-structure, they would look like this:

{
    # argument metadata removed for brevity
    'deprecated':<str|unicode>,
    'fixmes':<list<str|unicode>>,
    'todos':<list<str|unicode>>,
}

...and that, I believe, will make their implementation easy.

Before I dive into those implementations, though it occurred to me that the returns metadata probably also falls neatly into one of these models. This realization stemmed from thinking out the answer to the question what can functions retrurn, really, when it comes right down to it? The answer is pretty much anything, really. None is the default if there is no explicit return defined. Strings and other text-values, numbers, booleans, dictionaries, sequences, and objects can all be returned. The number of permutations allowed is mathematically infinite, I think, even if the reality is much more restricted. Given that, I asked myself if it made any sense to even try to generate a decoration process that could capture all of those possibilities, when writing True if [some condition], False otherwise, or something equally difficult is so simple?

I think not. That, fortunately or not, falls squarely in the realm of expecting or requiring a certain amount of discipline in writing documentation. That same certain amount of discipline is already a requirement for providing any documentation for what a function returns in Python anyway, since there is no indication in the code itself what (if anything) will be returned. It leaves that particular part of the documentation in the same semi-nebulous state that pure doc-string documentation is in, but at least it provides a ready means of identifying what part of the documentation states what the return-value is. That, while it may not be much, is worth something to my thinking.

I'm only going to show the detailed code for one of the two variations of these constructs (one each for a list-of-strings and single-string metadata model). Apart from some name-changes, and, of course, where they are stored in the metadata data-structure, they function identically. The same set of changes need to be made in all four cases, though:

  • The existing api_documentation class needs to be altered to set up default storage;
  • A describe.[whatever] method needs to be built;
  • The __str__ method of the api_documentation class needs to be altered to output the argument-list metadata; and (since I'm documenting these decorators with themselves)
  • The documentation-decorators need to be created for anything new that's being added to the mix.
So, here we go. First, the changes to the api_documentation class:

class api_documentation( object ):
    """
Provides a common collection-point for all API documentation metadata for a 
programmatic element (class, function, method, whatever)."""

    # ...

    #####################################
    # Instance property-getter methods  #
    #####################################

    # ...

    def _GetDeprecated( self ):
        """
Gets the deprecation-information, if any, of the item that the instance has 
been used to document/describe."""
        return self._deprecated

    # ...

    def _GetReturns( self ):
        """
Gets the returns-information, if any, of the item that the instance has 
been used to document/describe."""
        return self._returns

    # ...

    #####################################
    # Instance Properties               #
    #####################################

    # ...

    deprecated = property( _GetDeprecated, None, None, 
        _GetDeprecated.__doc__)
    # ...
    returns = property( _GetReturns, None, None, 
        _GetReturns.__doc__)

    # ...

Then the additions to describe:

class describe( object ):
    """
Nominally-static class (not intended to be instantiated) that provides the 
actual functionality for generating documentation-metadata structures."""

    # ...

    @classmethod
    def deprecated( cls, information ):
        """
Decorates a function or method by attaching documentation-metadata about its 
deprecation-state to it."""
        # Type- and value-check information
        if type( information ) not in ( str, unicode ):
            raise TypeError( '%s.keywordargs expects a non-empty '
                'string or unicode text-value for its "information" argument, '
                'but was passed "%s" (%s)' % ( cls.__name__, information, 
                    type( information ).__name__ ) )
        if not information.strip():
            raise ValueError( '%s.keywordargs expects a non-empty '
                'string or unicode text-value for its "information" argument, '
                'but was passed "%s" (%s)' % ( cls.__name__, information, 
                    type( information ).__name__ ) )
        def _deprecatedDecorator( decoratedItem ):
            """
Performs the actual deprecated-state decoration"""
            # Make sure the decorated item has a _documentation attribute, and 
            # if it doesn't, create and attach one:
            try:
                _documentation = decoratedItem._documentation
            except AttributeError:
                decoratedItem.__dict__[ '_documentation' ] = api_documentation( decoratedItem )
                _documentation = decoratedItem._documentation
            # If we reach this point, then we should set the deprecated value 
            # to the information provided.
            _documentation._deprecated = information
            # Return the decorated item!
            return decoratedItem
        return _deprecatedDecorator

At this point, the documentation for the newly-decorated Ook class members looks like this (bearing in mind that describe.fixme and describe.todo are not implemented yet):

--------------------------------------------------------------------------------
Fnord( self, arg1, arg2, *args, **kwargs ) [function]
Ook.Fnord (method) original doc-string

DEPRECATED: Use new_Fnord instead.

RETURNS: None (at least until the method is implemented)

ARGUMENTS:

self .............. (instance, required): The object-instance that the method will bind to for execution.
arg1 .............. (bool|None, required): Ook.Fnord (method) arg1 description
arg2 .............. (any, required): Ook.Fnord (method) arg2 description
*args ............. (int|long|float): Ook.Fnord (method) arglist description
  - arg1 .......... (float): Ook.Fnord.args[0] description
  - arg2 .......... (int|long): Ook.Fnord.args[1] description
  - arg3 .......... (bool): Ook.Fnord.args[2] description
  - values ........ (str|unicode): Ook.Fnord.args[3] (values) description
**kwargs .......... Ook.Fnord keyword-arguments list description
  - keyword1 ...... (int|long|float, required): Ook.Fnord (method) "keyword1" description
  - keyword2 ...... (None|str|unicode, defaults to None): Ook.Fnord (method) "keyword2" description
  - keyword3 ...... (None|str|unicode): Ook.Fnord (method) "keyword3" description

--------------------------------------------------------------------------------
Bleep( cls, arg1, arg2, *args, **kwargs ) [function]
Ook.Bleep (classmethod) original doc-string

DEPRECATED: Will be removed by version X.YY.ZZ

ARGUMENTS:

cls ............... (class, required): The class that the method will bind to for execution.
arg1 .............. (int|long|float, required): Ook.Bleep (classmethod) arg1 description
arg2 .............. (any, optional, defaults to None): Ook.Bleep (classmethod) arg2 description
*args ............. (any): Ook.Bleep (classmethod) arglist description

--------------------------------------------------------------------------------

The documentation output for Ook.Bleep shows exactly the effects of not exerting that certain amount of discipline mentioned repeatedly earlier: It shows nothing for a return, not even the actual None that would be returned. I'm goiong to let that sit and ferment for a while, though — I have some ideas about how to deal with that situation, but I want to think out the ramifications of them before I commit to any of them.

On, then, to the list-of-strings items. Again, first the changes and additions to api_documentation:

class api_documentation( object ):
    """
Provides a common collection-point for all API documentation metadata for a 
programmatic element (class, function, method, whatever)."""

    # ...

    #####################################
    # Instance property-getter methods  #
    #####################################

    # ...

    def _GetFixMes( self ):
        """
Gets the "FixMe" items, if any, of the item that the instance has 
been used to document/describe."""
        return self._fixmes

    # ...

    #####################################
    # Instance Properties               #
    #####################################

    # ...

    fixmes = property( _GetFixMes, None, None, 
        _GetFixMes.__doc__ )

And the changes and additions to describe:

class describe( object ):
    """
Nominally-static class (not intended to be instantiated) that provides the 
actual functionality for generating documentation-metadata structures."""

    # ...

    #####################################
    # Class Methods                     #
    #####################################

    # ...

    @classmethod
    def fixme( cls, information ):
        """
Decorates a function or method by attaching a "fixme" item to it."""
        # Type- and value-check information
        if type( information ) not in ( str, unicode ):
            raise TypeError( '%s.keywordargs expects a non-empty '
                'string or unicode text-value for its "information" argument, '
                'but was passed "%s" (%s)' % ( cls.__name__, information, 
                    type( information ).__name__ ) )
        if not information.strip():
            raise ValueError( '%s.keywordargs expects a non-empty '
                'string or unicode text-value for its "information" argument, '
                'but was passed "%s" (%s)' % ( cls.__name__, information, 
                    type( information ).__name__ ) )
        def _fixmeDecorator( decoratedItem ):
            """
Performs the actual fixme-item decoration"""
            # Make sure the decorated item has a _documentation attribute, and 
            # if it doesn't, create and attach one:
            try:
                _documentation = decoratedItem._documentation
            except AttributeError:
                decoratedItem.__dict__[ '_documentation' ] = api_documentation( decoratedItem )
                _documentation = decoratedItem._documentation
            # If we reach this point, then we should set the returns value 
            # to the information provided.
            _documentation._fixmes.append( information )
            # Return the decorated item!
            return decoratedItem
        return _fixmeDecorator

    # ...

And the resulting output:

--------------------------------------------------------------------------------
Flup( arg1, arg2, *args, **kwargs ) [function]
Ook.Flup (staticmethod) original doc-string

FIX ME:
  - Rewrite list-loops to perform the same operations in fewer passes
  - Magic _parameters value needs to be removed

ARGUMENTS:
arg1 .............. (int|long|float, required): Ook.Flup (staticmethod) arg1 description
arg2 .............. (any, required): Ook.Flup (staticmethod) arg2 description
*args ............. (any): Ook.Flup (staticmethod) arglist description

TO DO:
  - Change output to class with the same interface
  - Clean up output to remove empty members
--------------------------------------------------------------------------------

All of these implementations were relatively painless, and none required any helper-methods like the ones created for the arguments — ultimately, they were all either concerned with either just setting a single value, or with appending something to an existing one, so there was no need to complicate things with the decorator-helper structure that's been the pattern so far.

As far as function- and method-API documentation is concerned, then, the only remaining item is documenting what errors/exceptions a callable explicitly raises.

When actually documenting exceptions that a callable raises, there are two important pieces of information:

  • What the error is; and
  • Why the error happens.
Documenting what the error is allows other developers to error-trap if/as necessary when using the callable. Knowing that if anything expected goes wrong, it will raise one of some limited number of error-types makes it relatively easy to write code to handle those errors, if it's even deemed necessary. Documenting why each error-type can happen gives insight into what not to do with the functionality. For example, if a method is documented as raising a TypeError if a certain argument is passed a non-text value, then the developer knows not to pass non-text values in that argument. Since it's possible (perhaps even likely) that multiple conditions can raise the same error-type, the internal metadata-structure should probably be built out as a dictionary of lists:

{
    'raises':<dict <Exception>:<list <str|unicode>>>,
}

The keys of that dict-structure are Exception-derived classes, which would allow the decorator-call to use the actual error-classes in their calls:

class Ook( object ):
    """
Test-class."""
    # argument decorators removed for brevity
    @describe.deprecated( 'Use new_Fnord instead.' )
    @describe.raises( NotImplementedError, 'if executed.' )
    def Fnord( self, arg1, arg2, *args, **kwargs ):
        """Ook.Fnord (method) original doc-string"""
        raise NotImplementedError( '%s.Fnord is not yet implemented' % 
            self.__class__.__name )

The implementation of related api_docuumentation is pretty typical of the other items in this post, so I won't reproduce yet another variant of it and waste your time. The decorator implementation is also pretty typical in many ways, but this is the first time that I've used this sort of dictionary structure, so it probably bears showing:

    @classmethod
    def raises( cls, errorType, errorCondition ):
        """
Decorates a function or method by attaching documentation-metadata about its 
deprecation-state to it."""
        # Type- and value-check errorType
        if not issubclass( errorType, Exception ):
            raise TypeError( '%s.raises expects a non-empty '
                'string or unicode text-value for its "errorCondition" argument, '
                'but was passed "%s" (%s)' % ( cls.__name__, errorCondition, 
                    type( errorCondition ).__name__ ) )
        # Type- and value-check errorCondition
        if type( errorCondition ) not in ( str, unicode ):
            raise TypeError( '%s.raises expects a non-empty '
                'string or unicode text-value for its "errorCondition" argument, '
                'but was passed "%s" (%s)' % ( cls.__name__, errorCondition, 
                    type( errorCondition ).__name__ ) )
        if not errorCondition.strip():
            raise ValueError( '%s.raises expects a non-empty '
                'string or unicode text-value for its "information" argument, '
                'but was passed "%s" (%s)' % ( cls.__name__, errorCondition, 
                    type( errorCondition ).__name__ ) )
        def _raisesDecorator( decoratedItem ):
            """
Performs the actual raises-state decoration"""
            # Make sure the decorated item has a _documentation attribute, and 
            # if it doesn't, create and attach one:
            try:
                _documentation = decoratedItem._documentation
            except AttributeError:
                decoratedItem.__dict__[ '_documentation' ] = api_documentation( decoratedItem )
                _documentation = decoratedItem._documentation
            # If we reach this point, then we should set the raises value 
            # to the information provided.
            try:
                _documentation._raises[ errorType ].append( errorCondition )
            except KeyError:
                _documentation._raises[ errorType ] = [ errorCondition ]
            # Return the decorated item!
            return decoratedItem
        return _raisesDecorator

With all of these decorators in place, here is the decorated code and resulting documentation for the Oook.Fnord method:

class Ook( object ):
    """
Test-class."""
    @describe.argument( 'arg1', 'Ook.Fnord (method) arg1 description', bool, None )
    @describe.argument( 'arg2', 'Ook.Fnord (method) arg2 description' )
    @describe.arglist( 'Ook.Fnord (method) arglist description', int, long, float )
    @describe.arglistitem( 0, 'arg1', 'Ook.Fnord.args[0] description', float )
    @describe.arglistitem( 1, 'arg2', 'Ook.Fnord.args[1] description', int, long )
    @describe.arglistitem( 2, 'arg3', 'Ook.Fnord.args[2] description', bool )
    @describe.arglistitem( -1, 'values', 'Ook.Fnord.args[3] (values) description', str, unicode )
    @describe.keywordargs( 'Ook.Fnord keyword-arguments list description' )
    @describe.keyword( 'keyword1', 'Ook.Fnord (method) "keyword1" description', int, long, float, required=True )
    @describe.keyword( 'keyword2', 'Ook.Fnord (method) "keyword2" description',None, str, unicode, default=None )
    @describe.keyword( 'keyword3', 'Ook.Fnord (method) "keyword3" description',None, str, unicode )
    @describe.deprecated( 'Use new_Fnord instead.' )
    @describe.returns( 'None (at least until the method is implemented)' )
    @describe.raises( NotImplementedError, 'if called' )
    @describe.todo( 'Clean up output to remove empty members' )
    @describe.todo( 'Change output to class with the same interface' )
    @describe.fixme( 'Magic _parameters value needs to be removed' )
    @describe.fixme( 'Rewrite list-loops to perform the same operations in fewer passes' )
    def Fnord( self, arg1, arg2, *args, **kwargs ):
        """Ook.Fnord (method) original doc-string"""
        raise NotImplementedError( '%s.Fnord is not yet implemented' % 
            self.__class__.__name__ )
--------------------------------------------------------------------------------
Fnord( self, arg1, arg2, *args, **kwargs ) [function]
Ook.Fnord (method) original doc-string

DEPRECATED: Use new_Fnord instead.

RETURNS: None (at least until the method is implemented)

FIX ME:
  - Rewrite list-loops to perform the same operations in fewer passes
  - Magic _parameters value needs to be removed

ARGUMENTS:

self .............. (instance, required): The object-instance that the method will bind to for execution.
arg1 .............. (bool|None, required): Ook.Fnord (method) arg1 description
arg2 .............. (any, required): Ook.Fnord (method) arg2 description
*args ............. (int|long|float): Ook.Fnord (method) arglist description
  - arg1 .......... (float): Ook.Fnord.args[0] description
  - arg2 .......... (int|long): Ook.Fnord.args[1] description
  - arg3 .......... (bool): Ook.Fnord.args[2] description
  - values ........ (str|unicode): Ook.Fnord.args[3] (values) description
**kwargs .......... Ook.Fnord keyword-arguments list description
  - keyword1 ...... (int|long|float, required): Ook.Fnord (method) "keyword1" description
  - keyword2 ...... (None|str|unicode, defaults to None): Ook.Fnord (method) "keyword2" description
  - keyword3 ...... (None|str|unicode): Ook.Fnord (method) "keyword3" description

RAISES:
 - NotImplementedError
   + if called

TO DO:
  - Change output to class with the same interface
  - Clean up output to remove empty members

There are a few other things that I want to do before I call this complete. First and foremost, is a name-change for api_documentation. That class is not a full API documentation, but merely the collection of documentation-metadata for a single function or method (a callable), so I'm going to refactor-rename it to callable_documentation.

The next thing I'm going to do is work up an HTML-savvy documentation-output mechanism for it. The text-only documentation is important, and I'll come back to it shortly, but for presentation-purposes here, I'd really like to be able to generate something that looks better.

Like, say, this:

[function]
Fnord(self, arg1, arg2, *args, **kwargs)
Ook.Fnord (method) original doc-string
Deprecated: Use new_Fnord instead.
Returns: None (at least until the method is implemented)
Fix Me:
  • Rewrite list-loops to perform the same operations in fewer passes
  • Magic _parameters value needs to be removed
Arguments
self
(instance, required): The object-instance that the method will bind to for execution.
arg1
(bool|None, required): Ook.Fnord (method) arg1 description
arg2
(any, required): Ook.Fnord (method) arg2 description
*args
(int|long|float): Ook.Fnord (method) arglist description
The following values are specified by position:
arg1
(float): Ook.Fnord.args[0] description
arg2
(int|long): Ook.Fnord.args[1] description
arg3
(bool): Ook.Fnord.args[2] description
values
(str|unicode): Ook.Fnord.args[3] (values) description
**kwargs
Ook.Fnord keyword-arguments list description
keyword1
(int|long|float, required): Ook.Fnord (method) "keyword1" description
keyword2
(None|str|unicode, defaults to None): Ook.Fnord (method) "keyword2" description
keyword3
(None|str|unicode): Ook.Fnord (method) "keyword3" description
Exceptions
NotImplementedError
if called
To-Do:
  • Change output to class with the same interface
  • Clean up output to remove empty members
[function]
Bleep(cls, arg1, arg2, *args, **kwargs)
Ook.Bleep (classmethod) original doc-string
Deprecated: Will be removed by version X.YY.ZZ
Arguments
cls
(class, required): The class that the method will bind to for execution.
arg1
(int|long|float, required): Ook.Bleep (classmethod) arg1 description
arg2
(any, optional, defaults to None): Ook.Bleep (classmethod) arg2 description
*args
(any): Ook.Bleep (classmethod) arglist description
[function]
Flup(arg1, arg2, *args, **kwargs)
Ook.Flup (staticmethod) original doc-string
Fix Me:
  • Rewrite list-loops to perform the same operations in fewer passes
  • Magic _parameters value needs to be removed
Arguments
arg1
(int|long|float, required): Ook.Flup (staticmethod) arg1 description
arg2
(any, required): Ook.Flup (staticmethod) arg2 description
*args
(any): Ook.Flup (staticmethod) arglist description
To-Do:
  • Change output to class with the same interface
  • Clean up output to remove empty members

This is a combination of a toHTML method created in callable_documentation to generate the basic documentation markup...

    def toHTML( self ):
    """
Creates and returns an HTML representation of the documentation of the item."""
    moduleName = self.DecoratedItem.__module__
    className = None
    for name, cls in inspect.getmembers( inspect.getmodule( self.DecoratedItem ), 
        inspect.isclass ):
        classMembers = dict( inspect.getmembers( cls ) )
        for classMember in classMembers.values():
            try:
                if classMember.__name__ == self.DecoratedItem.__name__:
                    className = name
            except AttributeError:
                pass
    methodName = self.DecoratedItem.__name__
    itemId = '.'.join( [ item for item in 
        [ moduleName, className, methodName ] if item ] )
    results = '<div id="%s" class="callable documentation">\n' % itemId
    results += """    <div class="heading">
    <div class="api_type">[%s]</div>
    <div class="signature"><span class="api_name">%s</span>(""" % ( 
        type( self.DecoratedItem ).__name__, self.DecoratedItem.__name__ )
    if self.arguments or self.arglist or self.kwargs:
        argItems = []
        if self._argSpecs.args:
            argItems += self._argSpecs.args
        if self._argSpecs.varargs:
            argItems.append( '*%s' % self._argSpecs.varargs )
        if self._argSpecs.keywords:
            argItems.append( '**%s' % self._argSpecs.keywords )
        results += ', '.join( argItems )
    results += """)</div>\n    </div>\n"""
    if self._originalDocstring:
        results += """    <div>%s</div>\n""" % ( 
            self._originalDocstring.strip().replace( '\n', ' ' ).replace( 
            '  ', ' ' ).replace( '  ', ' ' ) )
    if self.deprecated:
        results += """    <div class="deprecated"><strong>Deprecated:<"""
            """/strong> %s</div>\n""" % self.deprecated
    if self.returns:
        results += """    <div class="returns"><strong>Returns:</strong> """
            """%s</div>\n""" % self.returns
    if self.fixmes:
        results += """    <div class="fixme"><strong>Fix Me:</strong>\n        <ul>\n"""
        for fixme in self.fixmes:
            results += '            <li>%s</li>\n' % fixme
        results += """        </ul>\n    </div>\n"""
        results += '\n'
    if self.arguments or self.arglist or self.kwargs:
        results += """    <div><div class="subhead">Arguments</div>\n"""
        results += """        <dl class="arguments">\n"""
        for argName in self._argSpecs.args:
            results += """            <dt>%s</dt>\n""" % ( argName )
            results += """            <dd>"""
            if argName not in ( 'self', 'cls' ):
                results += '('
                if len( self.arguments[ argName ][ 'expects' ] ) > 1:
                    results += ( '|'.join( 
                        [ item.__name__ if hasattr( item, '__name__' ) 
                        else str( item ) 
                        for item in self.arguments[ argName ][ 'expects' ] 
                        ] ) ).replace( 'NoneType', 'None' )
                else:
                    if self.arguments[ argName ][ 'expects' ] != ( object, ):
                        results += self.arguments[ argName ][ 'expects' ][ 0 ].__name__
                    else:
                        results += 'any'
                if not self.arguments[ argName ][ 'hasDefault' ]:
                    results += ', required'
                else:
                    if self.arguments[ argName ][ 'defaultValue' ]:
                        results += ', optional, defaults to "%s" [%s]' % ( 
                            self.arguments[ argName ][ 'defaultValue' ], 
                            type( self.arguments[ argName ][ 'defaultValue' ] 
                            ).__name__ )
                    else:
                        results += ', optional, defaults to %s' % ( 
                            self.arguments[ argName ][ 'defaultValue' ] )
                results += '): '
                results += self.arguments[ argName ][ 'description' ]
            elif argName == 'self':
                results += '(instance, required): The object-instance that the '
                    'method will bind to for execution.'
            elif argName == 'cls':
                results += '(class, required): The class that the method will '
                    'bind to for execution.'
            results += """</dd>\n"""

        if self.arglist:
            results += """            <dt>*%s</dt>\n""" % ( 
                self.arglist[ 'name' ] )
            results += """            <dd>("""
            if len( self.arglist[ 'expects' ] ) > 1:
                results += ( '|'.join( 
                    [ item.__name__ if hasattr( item, '__name__' ) 
                        else str( item ) for item 
                        in self.arglist[ 'expects' ] 
                    ] ) ).replace( 'NoneType', 'None' )
            else:
                if self.arglist[ 'expects' ] != ( object, ):
                    results += self.arglist[ 'expects' ][ 0 ].__name__
                else:
                    results += 'any'
            results += '): ' + self.arglist[ 'description' ] + '</dd>\n'
            if self.arglist[ 'sequence' ]:
                results += """            <dd>The following values are """
                    """specified by position:</dd>\n"""
                results += """            <dd><dl>\n"""
                for argListItem in self.arglist[ 'sequence' ]:
                    results += """                <dt>%s</dt>\n""" % ( 
                        argListItem[ 'name' ] )
                    results += """                <dd>("""
                    if len( argListItem[ 'expects' ] ) > 1:
                        results += ( '|'.join( 
                            [ item.__name__ if hasattr( item, '__name__' ) 
                                else str( item ) for item 
                                in argListItem[ 'expects' ] 
                            ] ) ).replace( 'NoneType', 'None' )
                    else:
                        if argListItem[ 'expects' ] != ( object, ):
                            results += argListItem[ 'expects' ][ 0 ].__name__
                        else:
                            results += 'any'
                    results += '): ' + argListItem[ 'description' ]
                    results += """</dd>\n"""
                if self.arglist.get( 'final' ):
                    argListItem = self.arglist[ 'final' ]
                    results += """                <dt>%s</dt>\n""" % ( 
                        argListItem[ 'name' ] )
                    results += """                <dd>("""
                    if len( argListItem[ 'expects' ] ) > 1:
                        results += ( '|'.join( 
                            [ item.__name__ if hasattr( item, '__name__' ) 
                                else str( item ) for item 
                                in argListItem[ 'expects' ] 
                            ] ) ).replace( 'NoneType', 'None' )
                    else:
                        if argListItem[ 'expects' ] != ( object, ):
                            results += argListItem[ 'expects' ][ 0 ].__name__
                        else:
                            results += 'any'
                    results += '): ' + argListItem[ 'description' ]
                    results += """</dd>\n"""
                results += """            </dl></dd>\n"""
            results += """            </dd>\n"""

        if self.keywordargs:
            results += """            <dt>**%s</dt>\n""" % ( 
                self.keywordargs[ 'name' ] )
            results += """            <dd>%s</dd>\n""" % ( 
                self.keywordargs[ 'description' ] )
            if self.keywordargs[ 'keywords' ]:
                results += """            <dd><dl>\n"""
                for keyword in sorted( self.keywordargs[ 'keywords' ] ):
                    keywordItem = self.keywordargs[ 'keywords' ][ keyword ]
                    results += """                <dt>%s</dt>\n""" % ( 
                        keywordItem[ 'name' ] )
                    results += """                <dd>("""
                    if len( keywordItem[ 'expects' ] ) > 1:
                        results += ( '|'.join( 
                            [ item.__name__ if hasattr( item, '__name__' ) 
                                else str( item ) for item 
                                in keywordItem[ 'expects' ] 
                            ] ) ).replace( 'NoneType', 'None' )
                    else:
                        if keywordItem[ 'expects' ] != ( object, ):
                            results += keywordItem[ 'expects' ][ 0 ].__name__
                        else:
                            results += 'any'
                    if keywordItem[ 'required' ]:
                        results += ', required'
                    if keywordItem[ 'hasDefault' ]:
                        results += ', defaults to %s' % keywordItem[ 'defaultValue' ]
                    results += '): ' + keywordItem[ 'description' ]
                    results += """</dd>\n"""
                results += """            </dl></dd>\n"""

        results += """        </dl>\n"""
        results += """    </div>\n"""
    if self.raises:
        results += """    <div><div class="subhead">Exceptions</div>\n"""
        results += """        <dl class="exceptions">\n"""
        for errorClass in sorted( self.raises, key=lambda c: c.__name__ ):
            results += """            <dt>%s</dt>\n""" % ( errorClass.__name__ )
            for line in self.raises[ errorClass ]:
                results += """            <dd>%s</dd>\n""" % ( line )
        results += """        </dl>\n"""
        results += """    </div>\n"""
    if self.todos:
        results += """    <div class="todo"><strong>To-Do:</strong>\n        <ul>\n"""
        for todo in self.todos:
            results += '            <li>%s</li>\n' % todo
        results += """        </ul>\n    </div>\n"""
    results += '</div>\n'
    return results

...and a relatively basic style-sheet in CSS:

.callable
{}
.documentation
{ font-size: 10pt; margin-top:12pt; font-family: sans-serif; }
.api_type
{ float:right; font-size: 10pt; padding-top:2pt; }
.signature
{ font-family: monospace; margin-bottom:6pt; }
.api_name
{ font-weight:bold; }
.documentation .heading
{ clear:left; font-size: 12pt; margin:12pt 0 6pt 0; padding-top:6px; border-top:1px solid black; border-bottom: 1px solid black; }
.documentation .subhead
{ clear:left; font-weight: bold; font-size: 10pt; margin:6pt 0 3pt 0; }
.documentation dl, .documentation ul
{ margin: 0px; }
.documentation dl dt
{ clear:left; float:left; width:10em; text-align:right; margin-right:0.5em; font-weight:bold; }
.documentation dl dt:after
{ content: ':';}
.documentation dl.arguments dt
{ font-family: monospace; font-size: 9pt; }
.documentation dl dd
{ margin-left: 9.5em; }
.documentation dl dd dl
{ margin-left:-7em; }

Finally, and coming back to the plain-text documentation as promised, I'd like to be able to take the documentation-string that's generated by the callable_documentation class-instances, and stuff that into the __doc__ of the decorated functions/methods. There's some basic formatting that will need to be done as well, to keep the resulting __doc__ within an 80-character width, to provide intelligent dot-leaders and hanging indentation on the text, and maybe a few other light-weight formatting items. The main, or at least visible part, though is one more decorator-method on describe:

    @classmethod
    def AttachDocumentation( cls ):
        """
Decorates a function or method by attaching a "TODO" item to it."""
        def _AttachDocumentationDecorator( decoratedItem ):
            """
Performs the actual __doc__ attachment decoration"""
            # Get the documentation metadata
            _documentation = decoratedItem._documentation
            # Create the formatted docstring
            newDocLines = []
            line = decoratedItem.__name__
            if _documentation.arguments or _documentation.arglist or \
                _documentation.keywords:
                # It's a function or method, so generate a series of arguments, etc.
                line += '( '
                argItems = []
                argSpecs = inspect.getargspec( decoratedItem )
                if argSpecs.args:
                    argItems = argSpecs.args
                if argSpecs.varargs:
                    argItems.append( '*%s' % argSpecs.varargs )
                if argSpecs.keywords:
                    argItems.append( '**%s' % argSpecs.keywords )
                line += ', '.join( argItems )
                line += ' )'
                line = line.replace( '(  )', '()' )
            newDocLines.append( FormatLine( line, 4 ) )
            if _documentation.deprecated:
                newDocLines.append( '' )
                newDocLines.append( FormatLine( 'DEPRECATED: %s' % 
                    _documentation.deprecated, 4 ) )
            else:
                newDocLines.append( '' )
            newDocLines.append( FormatLine( _documentation._originalDocstring, 4 ) )
            if _documentation.returns:
                newDocLines.append( '' )
                newDocLines.append( FormatLine( 'RETURNS: %s' % 
                    _documentation.returns, 4 ) )
            if _documentation.fixmes:
                newDocLines.append( '' )
                newDocLines.append( FormatLine( 'FIX ME:' ) )
                for fixme in _documentation.fixmes:
                    newDocLines.append( FormatLine( '  - %s\n' % fixme, 4 ) )
            if _documentation.arguments or _documentation.arglist or \
                _documentation.keywordargs:
                newDocLines.append( '' )
                newDocLines.append( FormatLine( 'ARGUMENTS:' ) )
                # Determine the dot-leader length for all argument items in the 
                #docstring
                argNames = _documentation.arguments.keys()
                if _documentation.arglist[ 'sequence' ]: 
                    argNames += [ ' - %s' % item[ 'name' ] for item in 
                        _documentation.arglist[ 'sequence' ] ]
                    if _documentation.arglist[ 'final' ]:
                        argNames.append( ' - %s' % _documentation.arglist[ 'final' 
                            ][ 'name' ] )
                if _documentation.keywordargs.get( 'keywords' ): 
                    argNames += [ ' - %s' % _documentation.keywordargs[ 'keywords' 
                        ][ item ][ 'name' ] 
                        for item in _documentation.keywordargs[ 'keywords' ] ]
                dotLeadLen = max( [ len( item ) for item in argNames ] ) + 3
                hang = dotLeadLen + 6
                if _documentation.arguments:
#                     print argSpecs
                    for argName in [ name for name in argSpecs.args 
                        if name[ 0 ] != '*' ]:
                        if argName not in ( 'self', 'cls' ):
                            arg = _documentation.arguments[ argName ]
                            line = ( '%s ' % ( arg[ 'name' ] ) ).ljust( 
                                hang - 1, '.' ) + ' '
                            line += '('
                            if len( arg[ 'expects' ] ) > 1:
                                line += ( '|'.join( [ item.__name__ 
                                    if hasattr( item, '__name__' ) 
                                    else str( item ) for item in arg[ 
                                        'expects' ] ] ) ).replace( 
                                            'NoneType', 'None' )
                            else:
                                if arg[ 'expects' ] != ( object, ):
                                    line += arg[ 'expects' ][ 0 ].__name__
                                else:
                                    line += 'any'
                            if not arg[ 'hasDefault' ]:
                                line += ', required'
                            else:
                                if arg[ 'defaultValue' ]:
                                    line += ', optional, defaults to '
                                    '"%s" [%s]' % ( arg[ 'defaultValue' ], 
                                        type( arg[ 'defaultValue' ] ).__name__ )
                                else:
                                    line += ', optional, defaults to %s' % ( 
                                        arg[ 'defaultValue' ] )
                            line += '): '
                            line += arg[ 'description' ]
                        elif argName == 'self':
                            line = ( 'self %s (instance, required): The object-'
                                'instance that the method will bind to at '
                                'execution.' % ( '.'*dotLeadLen ) )
                        elif argName == 'cls':
                            line = ( 'self %s (class, required): The class '
                                'that the method will bind to at '
                                'execution.' % ( '.'*dotLeadLen ) )
                        else:
                            raise RuntimeError( 'oops, hahaha!')
                        newDocLines.append( FormatLine( line, hang ) )
                if _documentation.arglist:
                    line = ( '*%s ' % ( _documentation.arglist[ 'name' ] ) 
                        ).ljust( hang - 1, '.' ) + ' %s' % ( 
                            _documentation.arglist[ 'description' ] )
                    newDocLines.append( FormatLine( line, hang ) )
                    arglist = _documentation.arglist
                    if arglist[ 'sequence' ]:
                        for arg in arglist[ 'sequence' ]:
                            line = ( ' - %s ' % ( arg[ 'name' ] ) ).ljust( 
                                hang - 1, '.' ) + ' '
                            line += '('
                            if len( arg[ 'expects' ] ) > 1:
                                line += ( '|'.join( [ item.__name__ 
                                    if hasattr( item, '__name__' ) 
                                    else str( item ) for item in arg[ 
                                        'expects' ] ] ) ).replace( 
                                            'NoneType', 'None' )
                            else:
                                if arg[ 'expects' ] != ( object, ):
                                    line += arg[ 'expects' ][ 0 ].__name__
                                else:
                                    line += 'any'
                            line += '): '
                            line += arg[ 'description' ]
                            newDocLines.append( FormatLine( line, hang ) )
                    if arglist.get( 'final' ):
                        arg = arglist[ 'final' ]
                        line = ( ' - %s ' % ( arg[ 'name' ] ) ).ljust( 
                            hang - 1, '.' ) + ' '
                        line += '('
                        if len( arg[ 'expects' ] ) > 1:
                            line += ( '|'.join( [ item.__name__ 
                                if hasattr( item, '__name__' ) 
                                else str( item ) for item in arg[ 'expects' ] 
                                ] ) ).replace( 'NoneType', 'None' )
                        else:
                            if arg[ 'expects' ] != ( object, ):
                                line += arg[ 'expects' ][ 0 ].__name__
                            else:
                                line += 'any'
                        line += '): '
                        line += arg[ 'description' ]
                        newDocLines.append( FormatLine( line, hang ) )
                if _documentation.keywordargs:
                    line = ( '*%s ' % ( _documentation.keywordargs[ 'name' ] 
                        ) ).ljust( hang - 1, '.' ) + ' %s' % ( 
                            _documentation.keywordargs[ 'description' ] )
                    newDocLines.append( FormatLine( line, hang ) )
                    if _documentation.keywordargs[ 'keywords' ]:
                        for keywordItem in sorted( _documentation.keywordargs[ 
                            'keywords' ] ):
                            arg = _documentation.keywordargs[ 'keywords' ][ 
                                keywordItem ]
                            line = ( ' - %s ' % ( arg[ 'name' ] ) ).ljust( 
                                hang - 1, '.' ) + ' '
                            line += '('
                            if len( arg[ 'expects' ] ) > 1:
                                line += ( '|'.join( [ item.__name__ 
                                    if hasattr( item, '__name__' ) 
                                    else str( item ) for item in arg[ 
                                        'expects' ] ] ) ).replace( 
                                            'NoneType', 'None' )
                            else:
                                if arg[ 'expects' ] != ( object, ):
                                    line += arg[ 'expects' ][ 0 ].__name__
                                else:
                                    line += 'any'
                            if not arg[ 'hasDefault' ]:
                                line += ', required'
                            else:
                                if arg[ 'defaultValue' ]:
                                    line += ( ', optional, defaults to "%s" '
                                        '[%s]' % ( arg[ 'defaultValue' ], 
                                            type( arg[ 'defaultValue' ] 
                                                ).__name__ ) )
                                else:
                                    line += ( ', optional, defaults to %s' % ( 
                                        arg[ 'defaultValue' ] ) )
                            line += '): '
                            line += arg[ 'description' ]
                            newDocLines.append( FormatLine( line, hang ) )
            if _documentation.raises:
                newDocLines.append( FormatLine( '' ) )
                newDocLines.append( FormatLine( 'RAISES:' ) )
                for errorClass in sorted( _documentation.raises, 
                    key=lambda err: err.__name__ ):
                    newDocLines.append( FormatLine( ' - %s' % 
                        errorClass.__name__ ) )
                    for line in _documentation.raises[ errorClass ]:
                        newDocLines.append( FormatLine( '   + %s' % line, 
                            5 ) )
            if _documentation.todos:
                newDocLines.append( FormatLine( '' ) )
                newDocLines.append( FormatLine( 'TO-DO:' ) )
                for line in _documentation.todos:
                    newDocLines.append( FormatLine( ' - %s' % line ) )
            # Try to replace the current __doc__ with the new doc-string
            try:
                decoratedItem.__doc__ = ( '\n'.join( newDocLines ) ).strip()
            except:
                pass
            # Return the decorated item!
            return decoratedItem
        return _AttachDocumentationDecorator

As more documentation-decoration efforts are undertaken, I'm expecting that I'll have to come back to this method to add type-based detection to it. The reason behind that is that different documented items will have different metadata structures associated with them. Classes and properties, for example, will not have arguments of any kind. Ideally, though, I'd like to be able to apply this same decorator to any documentation-decorated item and at least not have it raise errors. Whether that will be realized is to be determined (though I already know that it won't matter for classes unless something's changed since the last time I checked).

Ultimately, all the AttachDocumentation method is doing is gathering the documentation-metadata, formatting it, and trying to attach it to the original decorated item in the existing __doc__ property. In generating the final format, it's making an attempt to stick to official (if, maybe outdated) Python conventions of an 80-character line-width, and providing some basic hanging-indentation structure. That's what the global FormatLine function's purpose is:

#####################################
# Defined functions.                #
#####################################

def FormatLine( line, hang=0, width=80 ):
    """
Formats the provided line into one-to-many lines constrained to the width (in 
spaces), with a hanging indent (also in spaces), returning those lines."""
    # First, make sure that the incoming line is just that: ONE line
    if '\n' in line or '\r' in line:
        line = line.replace( '\n', ' ' ).replace( '\r', ' ' )
        # Reduce extraneous spaces
        while '  ' in line:
            line = line.replace( '  ', ' ' )
    # set up second- and subsequent-line indent
    if hang:
        newLineStart =' ' * hang
    else:
        newLineStart = ''
    results = ''
    currentLine = ''
    tokens = line.split( ' ' )
    for token in tokens:
        if len( currentLine ) + len( token ) + 1 <= width:
            currentLine += token + ' '
        else:
            results += currentLine
            currentLine = '\n%s' % newLineStart + token + ' '
    results += currentLine
    return results.rstrip()

__all__.append( 'FormatLine' )

Printing the __doc__ of Ook.Fnord, Ook.Bleep and Ook.Flup with AttachDocumentation called on each yields:

Fnord( self, arg1, arg2, *args, **kwargs )

DEPRECATED: Use new_Fnord instead.
Ook.Fnord (method) original doc-string

RETURNS: None (at least until the method is implemented)

FIX ME:
 - Rewrite list-loops to perform the same operations in fewer passes
 - Magic _parameters value needs to be removed

ARGUMENTS:
self .............. (instance, required): The object-instance that the method 
                    will bind to at execution.
arg1 .............. (bool|None, required): Ook.Fnord (method) arg1 description
arg2 .............. (any, required): Ook.Fnord (method) arg2 description
*args ............. Ook.Fnord (method) arglist description
 - argitem1 ....... (float): Ook.Fnord.args[0] description
 - argitem2 ....... (int|long): Ook.Fnord.args[1] description
 - argitem3 ....... (bool): Ook.Fnord.args[2] description
 - values ......... (str|unicode): Ook.Fnord.args[3] (values) description
*kwargs ........... Ook.Fnord keyword-arguments list description
 - keyword1 ....... (int|long|float, required): Ook.Fnord (method) "keyword1" 
                    description
 - keyword2 ....... (None|str|unicode, optional, defaults to None): Ook.Fnord 
                    (method) "keyword2" description
 - keyword3 ....... (None|str|unicode, required): Ook.Fnord (method) "keyword3" 
                    description

RAISES:
 - NotImplementedError
   + if called

TO-DO:
 - Change output to class with the same interface
 - Clean up output to remove empty members
--------------------------------------------------------------------------------
Bleep( cls, arg1, arg2, *args, **kwargs )

DEPRECATED: Will be removed by version X.YY.ZZ
Ook.Bleep (classmethod) original doc-string

ARGUMENTS:
self ....... (class, required): The class that the method will bind to at 
             execution.
arg1 ....... (int|long|float, required): Ook.Bleep (classmethod) arg1 
             description
arg2 ....... (any, optional, defaults to None): Ook.Bleep (classmethod) arg2 
             description
*args ...... Ook.Bleep (classmethod) arglist description
--------------------------------------------------------------------------------
Flup( arg1, arg2, *args, **kwargs )

Ook.Flup (staticmethod) original doc-string

FIX ME:
 - Rewrite list-loops to perform the same operations in fewer passes
 - Magic _parameters value needs to be removed

ARGUMENTS:
arg1 ....... (int|long|float, required): Ook.Flup (staticmethod) arg1 
             description
arg2 ....... (any, required): Ook.Flup (staticmethod) arg2 description
*args ...... Ook.Flup (staticmethod) arglist description

TO-DO:
 - Change output to class with the same interface
 - Clean up output to remove empty members

With that in place, the API-documentation decorators, at least for functions and methods, is complete for now. As I start working on actual project code, I'm expecting that I'll want to come back and revisit it to deal with things like configuration-file tie-ins, and maybe other items that I'm not anticipating just yet. For now, though, it's complete.

With so many decorators in play, even just on the throw-away Ook class, it feels like it might be time to re-visit the earlier concern about performance impact. To test that, I captured the time it took to define/compile Ook, decorated as above, and the time it took to define/compile another class, Eek, that was identical except for the decoration. The bad news it that the decorated class took upwards of ten times longer to complete its definition/compilation. The good news is that even with all the decoration in place, that ten times longer is still topping out at about 0.0007 seconds, and has gotten as short as 0.0004 seconds with some frequency when run directly.

Next up will be documenting class property-members, and classes themselves, using the same sort of decoration process. See you then!

No comments:

Post a Comment