# Encoding: UTF-8 u"""Automatic documentation generation for pokédex tables This adds a "dex-table" directive to Sphinx, which works like "autoclass", but documents Pokédex mapped classes. """ # XXX: This assumes all the tables are in pokedex.db.tables import functools import textwrap from docutils import nodes from docutils.statemachine import ViewList from sphinx.util.compat import Directive, make_admonition from sphinx.locale import _ from sphinx.domains.python import PyClasslike from sphinx.util.docfields import Field, GroupedField, TypedField from sphinx.ext.autodoc import ClassLevelDocumenter from sqlalchemy import types from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm import configure_mappers from sqlalchemy.ext.associationproxy import AssociationProxy from pokedex.db.markdown import MoveEffectPropertyMap, MoveEffectProperty from pokedex.db import tables, markdown # Make sure all the backrefs are in place configure_mappers() column_to_cls = {} for cls in tables.mapped_classes: for column in cls.__table__.c: column_to_cls[column] = cls class dextabledoc(nodes.Admonition, nodes.Element): pass def visit_todo_node(self, node): self.visit_admonition(node) def depart_todo_node(self, node): self.depart_admonition(node) def column_type_str(column): """Extract the type name from a SQLA column """ type_ = column.type # We're checking the specific type here: no issubclass if type(type_) in (types.Integer, types.SmallInteger): return 'int' if type(type_) == types.Boolean: return 'bool' if type(type_) == types.Unicode: return u'unicode – %s' % column.info['format'] if type(type_) == types.Enum: return 'enum: [%s]' % ', '.join(type_.enums) if type(type_) == markdown.MarkdownColumn: return 'markdown' raise ValueError(repr(type_)) common_columns = 'id identifier name'.split() def column_header(c, class_name=None, transl_name=None, show_type=True, relation=None, relation_name=None): """Return the column header for the given column""" result = [] if relation_name: name = relation_name else: name = c.name if class_name: result.append(u'%s.\ **%s**' % (class_name, name)) else: result.append(u'**%s**' % c.name) if c.foreign_keys: for fk in c.foreign_keys: if fk.column in column_to_cls: foreign_cls = column_to_cls[fk.column] if relation_name and relation_name + '_id' == c.name: result.append(u'(%s →' % c.name) elif relation_name: result.append(u'(**%s** →' % c.name) else: result.append(u'(→') result.append(u':class:`~pokedex.db.tables.%s`.%s)' % ( foreign_cls.__name__, fk.column.name )) break elif show_type: result.append(u'(*%s*)' % column_type_str(c)) if transl_name: result.append(u'via *%s*' % transl_name) return ' '.join(result) def with_header(header=None): """Decorator that adds a section header if there's a any output The decorated function should yield output lines; if there are any the header gets added. """ def wrap(func): @functools.wraps(func) def wrapped(cls, remaining_attrs): result = list(func(cls, remaining_attrs)) if result: # Sphinx/ReST doesn't allow "-----" just anywhere :( yield u'' yield u'.. raw:: html' yield u'' yield u'
' yield u'' if header: yield header + u':' yield u'' for row in result: yield row return wrapped return wrap ### Section generation functions def generate_table_header(cls, remaining_attrs): first_line, sep, next_lines = unicode(cls.__doc__).partition(u'\n') yield first_line for line in textwrap.dedent(next_lines).split('\n'): yield line yield '' yield u'Table name: *%s*' % cls.__tablename__ try: yield u'(single: *%s*)' % cls.__singlename__ except AttributeError: pass yield u'' def generate_common(cls, remaining_attrs): common_col_headers = [] for c in cls.__table__.c: if c.name in common_columns: common_col_headers.append(column_header(c, show_type=False)) remaining_attrs.remove(c.name) for translation_class in cls.translation_classes: for c in translation_class.__table__.c: if c.name in common_columns: common_col_headers.append(column_header(c, None, translation_class.__table__.name, show_type=False)) remaining_attrs.remove(c.name) if common_col_headers: if len(common_col_headers) > 1: common_col_headers[-1] = 'and ' + common_col_headers[-1] if len(common_col_headers) > 2: separator = u', ' else: separator = u' ' yield u'Has' yield separator.join(common_col_headers) + '.' yield u'' @with_header(u'Columns') def generate_columns(cls, remaining_attrs): name = cls.__name__ for c in [c for c in cls.__table__.c if c.name not in common_columns]: remaining_attrs.remove(c.name) relation_name = c.name[:-3] if c.name.endswith('_id') and relation_name in remaining_attrs: relation = getattr(cls, relation_name) yield column_header(c, name, relation=relation, relation_name=relation_name) remaining_attrs.remove(relation_name) else: yield column_header(c, name) + ':' yield u'' yield u' ' + unicode(c.info['description']) yield u'' @with_header(u'Internationalized strings') def generate_strings(cls, remaining_attrs): for translation_class in cls.translation_classes: for c in translation_class.__table__.c: if 'format' in c.info: remaining_attrs.discard(c.name) remaining_attrs.discard(c.name + '_map') if c.name in common_columns: continue yield column_header(c, cls.__name__, translation_class.__table__.name) yield u'' yield u' ' + unicode(c.info['description']) yield u'' @with_header(u'Relationships') def generate_relationships(cls, remaining_attrs): order = cls.relationship_info.get('_order', []) def sort_key((key, value)): try: return 0, order.index(key) except ValueError: return 1, key infos = sorted(cls.relationship_info.items(), key=sort_key) for rel_name, info in infos: if rel_name in remaining_attrs: info = cls.relationship_info.get(rel_name) if info['type'] in ('relationship', 'backref'): yield u'%s.\ **%s**' % (cls.__name__, rel_name) class_name = u':class:`~pokedex.db.tables.%s`' % info['argument'].__name__ if info.get('uselist', True): class_name = u'[%s]' % class_name yield u'(→ %s)' % class_name if 'description' in info: yield u'' yield u' ' + unicode(info['description']) ''' if info.get('secondary') is not None: yield u'' yield ' Association table: ``%s``' % info['secondary'] if 'primaryjoin' in info: yield u'') yield ' Join condition: ``%s``' % info['primaryjoin'] if 'secondaryjoin' in info: yield ' , ``%s``' % info['secondaryjoin'] ''' if 'order_by' in info: yield u'' try: order = iter(info['order_by']) except TypeError: order = [info['order_by']] yield u' ' yield ' Ordered by: ' + u', '.join( u'``%s``' % o for o in order) elif info['type'] == 'association_proxy': yield u'%s.\ **%s**:' % (cls.__name__, rel_name) yield '``{info[attr]}`` of ``self.{info[target_collection]}``'.format( info=info) if 'description' in info: yield u'' yield u' ' + unicode(info['description']) else: continue yield u'' remaining_attrs.remove(rel_name) @with_header(u'Undocumented') def generate_undocumented(cls, remaining_attrs): for c in sorted([c for c in remaining_attrs if isinstance(getattr(cls, c), (InstrumentedAttribute, AssociationProxy, MoveEffectPropertyMap, MoveEffectProperty))]): yield u'' yield u'%s.\ **%s**' % (cls.__name__, c) remaining_attrs.remove(c) @with_header(None) def generate_other(cls, remaining_attrs): for c in sorted(remaining_attrs): yield u'' member = getattr(cls, c) if callable(member): yield '.. automethod:: %s.%s' % (cls.__name__, c) else: yield '.. autoattribute:: %s.%s' % (cls.__name__, c) yield u'' remaining_attrs.clear() class DexTable(PyClasslike): """The actual Sphinx documentation generation whatchamacallit """ doc_field_types = [ TypedField('field', label='Fields', typerolename='obj', typenames=('fieldname', 'type')), ] def get_signature_prefix(self, sig): return '' #return u'mapped class ' def run(self): section = nodes.section() super_result = super(DexTable, self).run() title_text = self.names[0][0] section += nodes.title(text=title_text) section += super_result section['ids'] = ['dex-table-%s' % title_text.lower()] return [section] def before_content(self): name = self.names[0][0] for cls in tables.mapped_classes: if name == cls.__name__: break else: raise ValueError('Table %s not found' % name) table = cls.__table__ remaining_attrs = set(x for x in dir(cls) if not x.startswith('_')) remaining_attrs.difference_update(['metadata', 'translation_classes', 'add_relationships', 'relationship_info', 'summary_column']) for transl_class in cls.translation_classes: remaining_attrs.difference_update([ transl_class.relation_name, transl_class.relation_name + '_table', transl_class.relation_name + '_local', ]) generated_content = [] # Just a list of lines! generated_content.extend(generate_table_header(cls, remaining_attrs)) generated_content.extend(generate_common(cls, remaining_attrs)) generated_content.extend(generate_columns(cls, remaining_attrs)) generated_content.extend(generate_strings(cls, remaining_attrs)) generated_content.extend(generate_relationships(cls, remaining_attrs)) generated_content.extend(generate_undocumented(cls, remaining_attrs)) generated_content.extend(generate_other(cls, remaining_attrs)) generated_content.append(u'') self.content = ViewList(generated_content + list(self.content)) return super(DexTable, self).before_content() def get_index_text(self, modname, name_cls): return '%s (mapped class)' % name_cls[0] def setup(app): app.add_directive('dex-table', DexTable) # XXX: Specify that this depends on pokedex.db.tables ...?