├── sample ├── __init__.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── apps.py └── models.py ├── django_classy_doc ├── templatetags │ ├── __init__.py │ └── classy_doc.py ├── management │ └── commands │ │ ├── __init__.py │ │ └── classify.py ├── formatters │ ├── __init__.py │ └── markdown.py ├── apps.py ├── app_settings.py ├── urls.py ├── templates │ └── django_classy_doc │ │ ├── show_checkboxes.html │ │ ├── index.html │ │ ├── base.html │ │ └── klass.html ├── __init__.py ├── views.py └── utils.py ├── .gitignore ├── MANIFEST.in ├── mkdocstrings_handlers └── classydoc │ ├── __init__.py │ ├── templates │ └── material │ │ ├── style.css │ │ └── class.html.jinja │ └── handler.py ├── asgi.py ├── wsgi.py ├── .gitlab-ci.yml ├── manage.py ├── urls.py ├── LICENSE.md ├── setup.py ├── settings.py └── README.md /sample/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sample/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_classy_doc/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_classy_doc/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | django_classy_doc.egg-info 3 | output/ 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include django_classy_doc * 2 | recursive-exclude * *.pyc __pycache__ .DS_Store 3 | include README.md 4 | -------------------------------------------------------------------------------- /django_classy_doc/formatters/__init__.py: -------------------------------------------------------------------------------- 1 | """Formatters for django_classy_doc output.""" 2 | 3 | from .markdown import MarkdownFormatter 4 | 5 | __all__ = ['MarkdownFormatter'] 6 | -------------------------------------------------------------------------------- /sample/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SampleConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'sample' 7 | -------------------------------------------------------------------------------- /django_classy_doc/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjangoClassyDocConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'django_classy_doc' 7 | -------------------------------------------------------------------------------- /django_classy_doc/app_settings.py: -------------------------------------------------------------------------------- 1 | CLASSY_DOC_BASES = [''] 2 | CLASSY_DOC_NON_INSTALLED_APPS = [] 3 | CLASSY_DOC_ALSO_INCLUDE = [] 4 | CLASSY_DOC_MODULE_TYPES = [ 5 | 'models', 6 | 'views', 7 | ] 8 | CLASSY_DOC_ALSO_EXCLUDE = [] 9 | CLASSY_DOC_KNOWN_APPS = { 10 | 'Django': ['django'] 11 | } 12 | -------------------------------------------------------------------------------- /django_classy_doc/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import ClassyView, ClassyIndexView 4 | 5 | 6 | urlpatterns = [ 7 | path('.html', ClassyView.as_view()), 8 | path('classify.html', ClassyIndexView.as_view()), 9 | path('', ClassyIndexView.as_view()), 10 | ] 11 | -------------------------------------------------------------------------------- /mkdocstrings_handlers/classydoc/__init__.py: -------------------------------------------------------------------------------- 1 | """ClassyDoc handler for mkdocstrings. 2 | 3 | This handler allows mkdocstrings to render documentation for Django classes 4 | using django_classy_doc's extraction and formatting capabilities. 5 | """ 6 | 7 | from mkdocstrings_handlers.classydoc.handler import ClassyDocHandler, get_handler 8 | 9 | __all__ = ["ClassyDocHandler", "get_handler"] 10 | -------------------------------------------------------------------------------- /django_classy_doc/templates/django_classy_doc/show_checkboxes.html: -------------------------------------------------------------------------------- 1 | {% for app in known_apps.keys %} 2 |
3 | 4 | 7 |
8 | {% endfor %} 9 | 10 | -------------------------------------------------------------------------------- /asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for django_classy_doc project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_classy_doc.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for django_classy_doc project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_classy_doc.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | deploy: 2 | script: 3 | - cd /home/levit/django-classy-doc; git stash; git pull; git stash apply 4 | - cd /home/levit/django-classy-doc; source venv/bin/activate; pip install -e . 5 | - cd /home/levit/django-classy-doc; source venv/bin/activate; rm -rf output/*; ./manage.py classify 6 | variables: 7 | GIT_STRATEGY: none 8 | environment: 9 | name: production 10 | url: https://cddb.levit.be 11 | tags: 12 | - maggie 13 | only: 14 | - main 15 | 16 | -------------------------------------------------------------------------------- /django_classy_doc/__init__.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings as user_settings 2 | from . import app_settings as default_settings 3 | 4 | 5 | class AppSettings: 6 | def __getattr__(self, name): 7 | if hasattr(user_settings, name): 8 | return getattr(user_settings, name) 9 | 10 | if hasattr(default_settings, name): 11 | return getattr(default_settings, name) 12 | 13 | raise AttributeError(f"Settings object has no attribute '{name}'") 14 | 15 | 16 | settings = AppSettings() 17 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for django_classy_doc project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/5.0/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | from django.contrib import admin 18 | from django.urls import path, include 19 | 20 | urlpatterns = [ 21 | path('admin/', admin.site.urls), 22 | path('accounts/', include('django.contrib.auth.urls')), 23 | path('__doc__/', include('django_classy_doc.urls')), 24 | ] 25 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2024] [LevIT SCS] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /django_classy_doc/views.py: -------------------------------------------------------------------------------- 1 | from django.http import Http404 2 | from django.views.generic import TemplateView 3 | 4 | from .utils import build_context, build_list_of_documentables, get_index_context 5 | from . import settings as app_settings 6 | 7 | 8 | class ClassyView(TemplateView): 9 | template_name = 'django_classy_doc/klass.html' 10 | 11 | def get_context_data(self, **kwargs): 12 | context = super().get_context_data(**kwargs) 13 | klass = self.kwargs['klass'] 14 | 15 | try: 16 | context['klass'] = build_context(klass, exit=False) 17 | except ImportError: 18 | raise Http404(f'Unable to import {klass}') 19 | if context['klass'] is False: 20 | raise Http404(f'Undocuemented class {klass}') 21 | context['known_apps'] = app_settings.CLASSY_DOC_KNOWN_APPS 22 | 23 | return context 24 | 25 | 26 | class ClassyIndexView(TemplateView): 27 | template_name = 'django_classy_doc/index.html' 28 | 29 | def get_context_data(self, **kwargs): 30 | context = super().get_context_data(**kwargs) 31 | apps, _ = build_list_of_documentables() 32 | context.update(get_index_context(apps)) 33 | return context 34 | -------------------------------------------------------------------------------- /django_classy_doc/templatetags/classy_doc.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.utils.text import capfirst 3 | 4 | from django_classy_doc import settings as app_settings 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.filter 10 | def class_from(value, arg): 11 | from_map = app_settings.CLASSY_DOC_KNOWN_APPS 12 | 13 | try: 14 | return any([value['defining_class'].__module__.startswith(f'{mod}.') for mod in from_map.get(arg, [])]) 15 | except AttributeError: 16 | # it's a tuple not a string 17 | return any([value['defining_class'][0].startswith(f'{mod}') for mod in from_map.get(arg, [])]) 18 | except Exception: 19 | return False 20 | 21 | 22 | @register.filter 23 | def display_if(value): 24 | from_map = app_settings.CLASSY_DOC_KNOWN_APPS 25 | 26 | try: 27 | module_name = value['defining_class'].__module__ 28 | except AttributeError: 29 | # it's a tuple not a string 30 | module_name = value['defining_class'][0] 31 | 32 | for name, mods in from_map.items(): 33 | if any([module_name.startswith(f'{mod}.') or module_name == mod for mod in mods]): 34 | return f'show{capfirst(name)}' 35 | 36 | return 'true' 37 | 38 | 39 | @register.simple_tag 40 | def init_show_vars(): 41 | from_map = app_settings.CLASSY_DOC_KNOWN_APPS 42 | 43 | return ', '.join([f'show{capfirst(app)}: false' for app in from_map.keys()]) 44 | 45 | 46 | @register.filter 47 | def module(value): 48 | return value['defining_class'].__module__ 49 | 50 | 51 | @register.filter 52 | def class_name(value): 53 | return value['defining_class'].__name__ 54 | 55 | 56 | @register.filter 57 | def items(value, key): 58 | return value[key].items() 59 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_namespace_packages 3 | 4 | with open(os.path.join(os.path.dirname(__file__), 'README.md')) as readme: 5 | README = readme.read() 6 | 7 | # allow setup.py to be run from any path 8 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 9 | 10 | setup( 11 | name='django_classy_doc', 12 | version='0.1.0', 13 | packages=find_namespace_packages(include=[ 14 | 'django_classy_doc', 15 | 'django_classy_doc.*', 16 | 'mkdocstrings_handlers.*', 17 | ]), 18 | include_package_data=True, 19 | license='MIT License', # example license 20 | description='Django package to generate ccbv.co.uk-style documentation for your own code', 21 | long_description=README, 22 | long_description_content_type="text/markdown", 23 | url='https://github.com/nanuxbe/django-classy-doc', 24 | author='LevIT SCS', 25 | author_email='info@levit.be', 26 | classifiers=[ 27 | 'Environment :: Web Environment', 28 | 'Framework :: Django', 29 | 'Framework :: Django :: 5.0', 30 | 'Framework :: Django :: 4.2', 31 | 'Framework :: Django :: 3.2', 32 | 'Intended Audience :: Developers', 33 | 'License :: OSI Approved :: MIT License', # example license 34 | 'Operating System :: OS Independent', 35 | 'Programming Language :: Python', 36 | # Replace these appropriately if you are stuck on Python 2. 37 | 'Programming Language :: Python :: 3', 38 | 'Programming Language :: Python :: 3.8', 39 | 'Programming Language :: Python :: 3.9', 40 | 'Programming Language :: Python :: 3.10', 41 | 'Programming Language :: Python :: 3.11', 42 | 'Topic :: Internet :: WWW/HTTP', 43 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 44 | ], 45 | install_requires=[ 46 | 'Django>=3.2', 47 | ], 48 | extras_require={ 49 | 'mkdocs': [ 50 | 'mkdocstrings>=0.20', 51 | 'mkdocs>=1.5', 52 | ], 53 | }, 54 | entry_points={ 55 | 'mkdocstrings.handlers': [ 56 | 'classydoc = mkdocstrings_handlers.classydoc:get_handler', 57 | ], 58 | }, 59 | package_data={ 60 | 'mkdocstrings_handlers.classydoc': [ 61 | 'templates/material/*.jinja', 62 | 'templates/material/*.css', 63 | ], 64 | }, 65 | ) 66 | 67 | -------------------------------------------------------------------------------- /django_classy_doc/templates/django_classy_doc/index.html: -------------------------------------------------------------------------------- 1 | {% extends "./base.html" %} 2 | {% load classy_doc %} 3 | 4 | {% block content %} 5 |
6 |

Django Classy Docs

7 | 15 |
16 | 17 | {% for app, modules in apps.items %} 18 |
19 |

{{app}}

20 |
21 | {% for module, klasses in modules.items %} 22 |
23 |

{{module}}

24 |
    25 | {% for klass in klasses %} 26 |
  • {{klass.0}}
  • 27 | {% endfor %} 28 |
29 |
30 | {% endfor %} 31 |
32 |
33 | {% endfor %} 34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /django_classy_doc/templates/django_classy_doc/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ klass.name }} 6 | 7 | 8 | 11 | 14 | {% if push_state_url %} 15 | 16 | 19 | {% endif %} 20 | 21 | 22 | 23 | 32 | 33 | 34 |
35 |
40 | 41 |
42 |
43 | 44 |
45 |
46 | {% block content %} 47 | {% endblock %} 48 |
49 |
50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /mkdocstrings_handlers/classydoc/templates/material/style.css: -------------------------------------------------------------------------------- 1 | /* ClassyDoc handler styles for mkdocstrings */ 2 | 3 | .doc-classydoc { 4 | margin-bottom: 2em; 5 | } 6 | 7 | .doc-classydoc .doc-module-path { 8 | margin-bottom: 1em; 9 | font-size: 0.9em; 10 | } 11 | 12 | .doc-classydoc .doc-module-path code { 13 | background: var(--md-code-bg-color, #f5f5f5); 14 | padding: 0.2em 0.4em; 15 | border-radius: 3px; 16 | } 17 | 18 | /* Method Resolution Order */ 19 | .doc-classydoc .doc-mro { 20 | margin: 1.5em 0; 21 | } 22 | 23 | .doc-classydoc .mro-list { 24 | margin: 0.5em 0; 25 | padding-left: 1.5em; 26 | } 27 | 28 | .doc-classydoc .mro-list li { 29 | margin: 0.25em 0; 30 | } 31 | 32 | /* Tables */ 33 | .doc-classydoc table { 34 | width: 100%; 35 | border-collapse: collapse; 36 | margin: 1em 0; 37 | } 38 | 39 | .doc-classydoc th, 40 | .doc-classydoc td { 41 | text-align: left; 42 | padding: 0.5em; 43 | border: 1px solid var(--md-default-fg-color--lightest, #ddd); 44 | } 45 | 46 | .doc-classydoc th { 47 | background: var(--md-default-fg-color--lightest, #f5f5f5); 48 | font-weight: 600; 49 | } 50 | 51 | .doc-classydoc td code { 52 | background: transparent; 53 | padding: 0; 54 | } 55 | 56 | /* Methods */ 57 | .doc-classydoc .doc-method { 58 | margin: 1.5em 0; 59 | padding: 1em; 60 | border: 1px solid var(--md-default-fg-color--lightest, #e0e0e0); 61 | border-radius: 4px; 62 | } 63 | 64 | .doc-classydoc .doc-method-heading { 65 | margin: 0 0 0.5em 0; 66 | font-size: 1.1em; 67 | } 68 | 69 | .doc-classydoc .doc-method-heading code { 70 | font-weight: 600; 71 | } 72 | 73 | .doc-classydoc .doc-label { 74 | display: inline-block; 75 | padding: 0.1em 0.4em; 76 | font-size: 0.75em; 77 | font-weight: normal; 78 | border-radius: 3px; 79 | background: var(--md-accent-fg-color, #448aff); 80 | color: white; 81 | margin-left: 0.5em; 82 | vertical-align: middle; 83 | } 84 | 85 | .doc-classydoc .doc-defined-in { 86 | font-size: 0.85em; 87 | color: var(--md-default-fg-color--light, #666); 88 | margin: 0.5em 0; 89 | } 90 | 91 | /* Source code details */ 92 | .doc-classydoc .doc-source { 93 | margin-top: 1em; 94 | border: 1px solid var(--md-default-fg-color--lightest, #e0e0e0); 95 | border-radius: 4px; 96 | } 97 | 98 | .doc-classydoc .doc-source summary { 99 | padding: 0.5em 1em; 100 | cursor: pointer; 101 | background: var(--md-default-fg-color--lightest, #f5f5f5); 102 | font-size: 0.85em; 103 | } 104 | 105 | .doc-classydoc .doc-source summary:hover { 106 | background: var(--md-default-fg-color--lighter, #e8e8e8); 107 | } 108 | 109 | .doc-classydoc .doc-source pre { 110 | margin: 0; 111 | padding: 1em; 112 | overflow-x: auto; 113 | } 114 | 115 | .doc-classydoc .doc-source code { 116 | font-size: 0.85em; 117 | } 118 | 119 | /* Docstring sections */ 120 | .doc-classydoc .doc-docstring, 121 | .doc-classydoc .doc-method-docstring { 122 | margin: 0.5em 0; 123 | } 124 | 125 | .doc-classydoc .doc-docstring p:first-child, 126 | .doc-classydoc .doc-method-docstring p:first-child { 127 | margin-top: 0; 128 | } 129 | 130 | .doc-classydoc .doc-docstring p:last-child, 131 | .doc-classydoc .doc-method-docstring p:last-child { 132 | margin-bottom: 0; 133 | } 134 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for django_classy_doc project. 3 | 4 | Generated by 'django-admin startproject' using Django 5.0.4. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/5.0/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'django-insecure--39!-$oatryx$mgtg7#*9fqlmbq4#u7yfn+b#avy(nmrli&5g2' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'sample', 35 | 36 | 'django.contrib.admin', 37 | 'django.contrib.auth', 38 | 'django.contrib.contenttypes', 39 | 'django.contrib.sessions', 40 | 'django.contrib.messages', 41 | 'django.contrib.staticfiles', 42 | 43 | 'django_classy_doc', 44 | ] 45 | 46 | MIDDLEWARE = [ 47 | 'django.middleware.security.SecurityMiddleware', 48 | 'django.contrib.sessions.middleware.SessionMiddleware', 49 | 'django.middleware.common.CommonMiddleware', 50 | 'django.middleware.csrf.CsrfViewMiddleware', 51 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 52 | 'django.contrib.messages.middleware.MessageMiddleware', 53 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 54 | ] 55 | 56 | ROOT_URLCONF = 'urls' 57 | 58 | TEMPLATES = [ 59 | { 60 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 61 | 'DIRS': [], 62 | 'APP_DIRS': True, 63 | 'OPTIONS': { 64 | 'context_processors': [ 65 | 'django.template.context_processors.debug', 66 | 'django.template.context_processors.request', 67 | 'django.contrib.auth.context_processors.auth', 68 | 'django.contrib.messages.context_processors.messages', 69 | ], 70 | }, 71 | }, 72 | ] 73 | 74 | WSGI_APPLICATION = 'wsgi.application' 75 | 76 | 77 | # Database 78 | # https://docs.djangoproject.com/en/5.0/ref/settings/#databases 79 | 80 | DATABASES = { 81 | 'default': { 82 | 'ENGINE': 'django.db.backends.sqlite3', 83 | 'NAME': BASE_DIR / 'db.sqlite3', 84 | } 85 | } 86 | 87 | 88 | # Password validation 89 | # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators 90 | 91 | AUTH_PASSWORD_VALIDATORS = [ 92 | { 93 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 94 | }, 95 | { 96 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 97 | }, 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 100 | }, 101 | { 102 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 103 | }, 104 | ] 105 | 106 | 107 | # Internationalization 108 | # https://docs.djangoproject.com/en/5.0/topics/i18n/ 109 | 110 | LANGUAGE_CODE = 'en-ie' 111 | 112 | TIME_ZONE = 'UTC' 113 | 114 | USE_I18N = True 115 | 116 | USE_TZ = True 117 | 118 | 119 | # Static files (CSS, JavaScript, Images) 120 | # https://docs.djangoproject.com/en/5.0/howto/static-files/ 121 | 122 | STATIC_URL = 'static/' 123 | 124 | # Default primary key field type 125 | # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field 126 | 127 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 128 | -------------------------------------------------------------------------------- /sample/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.validators import MaxValueValidator, MinValueValidator 3 | from django.db import models 4 | from django import forms 5 | 6 | 7 | class Category(models.Model): 8 | name = models.CharField(max_length=255) 9 | 10 | class Meta: 11 | ordering = ('name', ) 12 | 13 | def __str__(self): 14 | return self.name 15 | 16 | 17 | class Product(models.Model): 18 | name = models.CharField(max_length=255) 19 | category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='products') 20 | price = models.DecimalField(max_digits=9, decimal_places=2) 21 | 22 | class Meta: 23 | ordering = ('name', ) 24 | 25 | def __str__(self): 26 | return self.name 27 | 28 | 29 | class Project(models.Model): 30 | 31 | STATES = ( 32 | ('draft', 'draft'), 33 | ('running', 'running'), 34 | ('finished', 'finished'), 35 | ('cancelled', 'cancelled'), 36 | ('archived', 'archived'), 37 | ) 38 | 39 | name = models.CharField(max_length=255) 40 | description = models.TextField(blank=True) 41 | state = models.CharField(max_length=30, choices=STATES, default='draft', editable=False) 42 | owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='+') 43 | 44 | def __str__(self): 45 | return self.name 46 | 47 | 48 | class Todo(models.Model): 49 | description = models.CharField(max_length=255) 50 | 51 | def __str__(self): 52 | return self.description 53 | 54 | 55 | class Feeling(models.Model): 56 | name = models.CharField(max_length=30) 57 | 58 | def __str__(self): 59 | return self.name 60 | 61 | 62 | class Care(models.Model): 63 | habits = models.CharField(max_length=30) 64 | 65 | def __str__(self): 66 | return self.habits 67 | 68 | 69 | class Enough(models.Model): 70 | do = models.CharField(max_length=30) 71 | 72 | def __str__(self): 73 | return self.do 74 | 75 | 76 | class Moodtracker(models.Model): 77 | MOOD = ( 78 | ("VG", "Very Good"), 79 | ("G", "Good"), 80 | ("N", "Neutral"), 81 | ("B", "Bad"), 82 | ("VB", "Very Bad"), 83 | ) 84 | 85 | date = models.DateField(auto_now_add=True) 86 | mood_am = models.CharField(max_length=30, choices=MOOD, default='Neutral') 87 | mood_pm = models.CharField(max_length=30, choices=MOOD, default="Neutral") 88 | feelings = models.ManyToManyField(Feeling, blank=True) 89 | cares = models.ManyToManyField(Care, blank=True) 90 | stress = models.PositiveIntegerField(default=0, 91 | validators=[ 92 | MaxValueValidator(5), 93 | MinValueValidator(0) 94 | ]) 95 | energy = models.PositiveIntegerField(default=0, 96 | validators=[ 97 | MaxValueValidator(10), 98 | MinValueValidator(0) 99 | ]) 100 | what_worked = models.TextField(blank=True) 101 | what_didnt_work = models.TextField(blank=True) 102 | did_enough = models.ManyToManyField(Enough, blank=True) 103 | notes = models.TextField(blank=True) 104 | 105 | def __str__(self): 106 | return str(self.date) 107 | 108 | 109 | class CategoryForm(forms.ModelForm): 110 | 111 | name = forms.CharField() 112 | 113 | class Meta: 114 | model = Category 115 | fields = '__all__' 116 | 117 | 118 | class BaseProductForm(forms.ModelForm): 119 | 120 | name = forms.CharField() 121 | 122 | class Meta: 123 | model = Product 124 | fields = ['name'] 125 | 126 | 127 | class ProductForm(BaseProductForm): 128 | class Meta: 129 | model = Product 130 | fields = '__all__' 131 | -------------------------------------------------------------------------------- /sample/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.4 on 2024-05-02 10:48 2 | 3 | import django.core.validators 4 | import django.db.models.deletion 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Care', 20 | fields=[ 21 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('habits', models.CharField(max_length=30)), 23 | ], 24 | ), 25 | migrations.CreateModel( 26 | name='Category', 27 | fields=[ 28 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 29 | ('name', models.CharField(max_length=255)), 30 | ], 31 | options={ 32 | 'ordering': ('name',), 33 | }, 34 | ), 35 | migrations.CreateModel( 36 | name='Enough', 37 | fields=[ 38 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 39 | ('do', models.CharField(max_length=30)), 40 | ], 41 | ), 42 | migrations.CreateModel( 43 | name='Feeling', 44 | fields=[ 45 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 46 | ('name', models.CharField(max_length=30)), 47 | ], 48 | ), 49 | migrations.CreateModel( 50 | name='Todo', 51 | fields=[ 52 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 53 | ('description', models.CharField(max_length=255)), 54 | ], 55 | ), 56 | migrations.CreateModel( 57 | name='Moodtracker', 58 | fields=[ 59 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 60 | ('date', models.DateField(auto_now_add=True)), 61 | ('mood_am', models.CharField(choices=[('VG', 'Very Good'), ('G', 'Good'), ('N', 'Neutral'), ('B', 'Bad'), ('VB', 'Very Bad')], default='Neutral', max_length=30)), 62 | ('mood_pm', models.CharField(choices=[('VG', 'Very Good'), ('G', 'Good'), ('N', 'Neutral'), ('B', 'Bad'), ('VB', 'Very Bad')], default='Neutral', max_length=30)), 63 | ('stress', models.PositiveIntegerField(default=0, validators=[django.core.validators.MaxValueValidator(5), django.core.validators.MinValueValidator(0)])), 64 | ('energy', models.PositiveIntegerField(default=0, validators=[django.core.validators.MaxValueValidator(10), django.core.validators.MinValueValidator(0)])), 65 | ('what_worked', models.TextField(blank=True)), 66 | ('what_didnt_work', models.TextField(blank=True)), 67 | ('notes', models.TextField(blank=True)), 68 | ('cares', models.ManyToManyField(blank=True, to='sample.care')), 69 | ('did_enough', models.ManyToManyField(blank=True, to='sample.enough')), 70 | ('feelings', models.ManyToManyField(blank=True, to='sample.feeling')), 71 | ], 72 | ), 73 | migrations.CreateModel( 74 | name='Product', 75 | fields=[ 76 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 77 | ('name', models.CharField(max_length=255)), 78 | ('price', models.DecimalField(decimal_places=2, max_digits=9)), 79 | ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to='sample.category')), 80 | ], 81 | options={ 82 | 'ordering': ('name',), 83 | }, 84 | ), 85 | migrations.CreateModel( 86 | name='Project', 87 | fields=[ 88 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 89 | ('name', models.CharField(max_length=255)), 90 | ('description', models.TextField(blank=True)), 91 | ('state', models.CharField(choices=[('draft', 'draft'), ('running', 'running'), ('finished', 'finished'), ('cancelled', 'cancelled'), ('archived', 'archived')], default='draft', editable=False, max_length=30)), 92 | ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), 93 | ], 94 | ), 95 | ] 96 | -------------------------------------------------------------------------------- /django_classy_doc/management/commands/classify.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import os 3 | 4 | from django.conf import settings 5 | from django.core.management.base import BaseCommand 6 | from django.template.loader import render_to_string 7 | 8 | from ...utils import build_context, build_list_of_documentables, get_index_context 9 | from ... import settings as app_settings 10 | from ...formatters.markdown import MarkdownFormatter, format_index 11 | 12 | 13 | def serve(port, output): 14 | from http import server 15 | import socketserver 16 | import webbrowser 17 | 18 | Handler = server.SimpleHTTPRequestHandler 19 | port = int(port) 20 | found_free_port = False 21 | while not found_free_port: 22 | try: 23 | httpd = socketserver.TCPServer(('', port), Handler) 24 | found_free_port = True 25 | except OSError: 26 | port += 1 27 | print('Serving on port: {0}'.format(port)) 28 | webbrowser.open_new_tab(f'http://localhost:{port}/{output}/classify.html') 29 | httpd.serve_forever() 30 | 31 | 32 | def gen_index(apps, output): 33 | index = render_to_string('django_classy_doc/index.html', get_index_context(apps)) 34 | with open(output, 'w') as f: 35 | f.write(index) 36 | 37 | 38 | def output_path(output, filename='classify.html'): 39 | path = os.path.join(settings.BASE_DIR, output) 40 | if not os.path.exists(path): 41 | os.makedirs(path) 42 | return os.path.join(path, filename) 43 | 44 | 45 | class Command(BaseCommand): 46 | 47 | def add_arguments(self, parser): 48 | parser.add_argument('klass', metavar='KLASS', nargs='*') 49 | parser.add_argument('--output', '-o', action='store', dest='output', 50 | default='output', help='Relative path for output files to be saved') 51 | parser.add_argument('-p', '--port', action='store', dest='port', type=int, default=8000) 52 | parser.add_argument('-s', '--serve', action='store_true', dest='serve') 53 | parser.add_argument('--clean', action='store_true', dest='clean', 54 | help='Clear html files from output directory before generating new files') 55 | parser.add_argument('--format', '-f', action='store', dest='format', 56 | default='html', choices=['html', 'markdown'], 57 | help='Output format: html or markdown (default: html)') 58 | parser.add_argument('--title', action='store', dest='title', 59 | default='API Reference', 60 | help='Title for the index page (markdown format only)') 61 | parser.add_argument('--no-index', action='store_true', dest='no_index', 62 | help='Skip generating index file') 63 | 64 | def handle(self, *args, **options): 65 | output_format = options['format'] 66 | file_extension = '.md' if output_format == 'markdown' else '.html' 67 | 68 | if options['clean']: 69 | output_dir = os.path.join(settings.BASE_DIR, options['output']) 70 | if os.path.exists(output_dir): 71 | for filename in os.listdir(output_dir): 72 | if not filename.endswith(file_extension): 73 | continue 74 | file_path = os.path.join(output_dir, filename) 75 | if os.path.isfile(file_path) or os.path.islink(file_path): 76 | os.unlink(file_path) 77 | 78 | klasses = options['klass'] 79 | apps = collections.defaultdict(lambda: collections.defaultdict(list)) 80 | 81 | if len(klasses) == 0: 82 | apps, klasses = build_list_of_documentables(apps) 83 | 84 | # Collect all structures for markdown index 85 | all_structures = [] 86 | 87 | for klass in klasses: 88 | structure = build_context(klass) 89 | if structure is False: 90 | continue 91 | 92 | if output_format == 'markdown': 93 | # Use markdown formatter 94 | formatter = MarkdownFormatter(structure, app_settings.CLASSY_DOC_KNOWN_APPS) 95 | output_content = formatter.format() 96 | all_structures.append(structure) 97 | 98 | if len(klasses) == 1: 99 | filename = 'index.md' 100 | else: 101 | name = structure["name"] 102 | # Use kebab-case if configured 103 | if getattr(app_settings, 'CLASSY_DOC_KEBAB_CASE_FILENAMES', False): 104 | import re 105 | # Handle acronyms properly (e.g., CSSAsset -> css-asset, not c-s-s-asset) 106 | name = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1-\2', name) 107 | name = re.sub(r'([a-z\d])([A-Z])', r'\1-\2', name) 108 | name = name.lower() 109 | filename = f'{name}.md' 110 | else: 111 | # Use HTML template 112 | output_content = render_to_string('django_classy_doc/klass.html', { 113 | 'klass': structure, 114 | 'known_apps': app_settings.CLASSY_DOC_KNOWN_APPS, 115 | }) 116 | 117 | filename = 'classify.html' 118 | if len(klasses) > 1: 119 | filename = f'{klass}.html' 120 | 121 | with open(output_path(options['output'], filename), 'w') as f: 122 | f.write(output_content) 123 | 124 | # Generate index 125 | if len(klasses) > 1 and not options['no_index']: 126 | if output_format == 'markdown': 127 | # Generate markdown index 128 | index_content = format_index(all_structures, title=options['title']) 129 | index_filename = 'index.md' 130 | else: 131 | # Generate HTML index 132 | index_content = render_to_string('django_classy_doc/index.html', get_index_context(apps)) 133 | index_filename = 'index.html' 134 | 135 | with open(output_path(options['output'], index_filename), 'w') as f: 136 | f.write(index_content) 137 | 138 | if options['serve']: 139 | if output_format == 'markdown': 140 | self.stdout.write(self.style.WARNING( 141 | 'Serve option is not supported for markdown format. ' 142 | 'Use mkdocs serve instead.' 143 | )) 144 | else: 145 | serve(options['port'], options['output']) 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Classy DOC 2 | 3 | *django-classy-doc* brings [Classy Class-Based Views](https://ccbv.co.uk)-style docs to your own code 4 | 5 | ## Installation 6 | 7 | ### From PyPI 8 | 9 | ```bash 10 | pip install django-classy-doc 11 | ``` 12 | 13 | ### From the repo 14 | 15 | ```bash 16 | pip install -e https://gitlab.levitnet.be/levit/django-classy-doc.git 17 | ``` 18 | 19 | ## Getting started 20 | 21 | First add `'django_classy_doc',` to your `INSTALLED_APPS` in your `settings.py` file. 22 | 23 | To generate the documentation statically, run 24 | 25 | ```bash 26 | ./manage.py classify 27 | ``` 28 | 29 | This will create documentation for your project and save the output in `./output` 30 | 31 | For more usage information run 32 | 33 | ```bash 34 | ./manage.py classify --help 35 | ``` 36 | 37 | If instead (or alongside) of generating the documentation statically, 38 | you can also have Django render the documentation by adding the following line 39 | to your `urlpatterns` in `urls.py` 40 | 41 | ```python 42 | urlpatterns = [ 43 | ... 44 | path('__doc__/', include('django_classy_doc.urls')), 45 | ] 46 | ``` 47 | 48 | ## Configuration 49 | 50 | Set these in your `settings.py` file. 51 | 52 | *django-classy-doc* has several configuration options, the most important are `CLASSY_DOC_BASES`, `CLASSY_DOC_MODULE_TYPES` and `CLASSY_DOC_KNOWN_APPS`. 53 | 54 | ### `CLASSY_DOC_BASES` 55 | 56 | This is the list of strings of the base modules you want to document, if you leave it unset, *django-classy-doc* will document every application from your `INSTALLED_APPS` 57 | 58 | *django-classy-docs* will string-match everything from your `INSTALLED_APPS` that **starts with** any of the mentioned strings 59 | 60 | ex: 61 | ```python 62 | CLASSY_DOC_BASES = ['catalog', 'custom_auth', 'account'] 63 | ``` 64 | 65 | ### `CLASSY_DOC_MODULE_TYPES` 66 | 67 | These are the modules type *django-classy-doc* will try to import from every application that matches `CLASSY_DOC_BASES`. It defaults to `['models', 'views']`. 68 | 69 | So, assuming your project looks like this: 70 | ``` 71 | + mod1 72 | | + apps.py 73 | | + admin.py 74 | | + models.py 75 | | + views.py 76 | + mod2 77 | | + apps.py 78 | | + admin.py 79 | | + models.py 80 | + mod3 81 | | + apps.py 82 | | + views.py 83 | ``` 84 | 85 | The following modules will be documented: `mod1.models`, `mod1.views`, `mod2.models`, `mod3.views` 86 | 87 | ### `CLASSY_DOC_KNOWN_APPS` 88 | 89 | A dictionary of lists that represents the "known apps" that you want to hide by default. This means that properties and methods present in your classes (that extend these bases classes) that are only defined in these base classes, will be hidden at first. 90 | All sections of the generated documentation will have a checkbox for each of these known apps that will let you show/hide thes properties and methods. 91 | 92 | If left unset, it will default to `{'django': ['django']}` 93 | 94 | ex: 95 | ```python 96 | CLASSY_KNOWN_APPS = { 97 | 'django': ['django'], 98 | 'DRF': ['rest_framework', 'django_filters'], 99 | 'wagtail': ['wagtail', 'treebeard', 'modelcluster'], 100 | } 101 | ``` 102 | 103 | ## Other configuration 104 | 105 | ### `CLASSY_DOC_ALSO_INCLUDE` 106 | 107 | A list of modules (that would otherwise not be matched) that *django-classy-doc* should also try to document. This defaults to an empty list. 108 | 109 | ### `CLASSY_DOC_ALSO_EXCLUDE` 110 | 111 | A list of modules (that would otherwise be matched) that *django-classy-doc* **should not** try to document. This defaults to an empty list. 112 | 113 | 114 | ### `CLASSY_DOC_NON_INSTALLED_APPS` 115 | 116 | A list of modules, not present in `INSTALLED_APPS` to include in the search for modules. This is mostly useful if you want to document DJango itself. 117 | 118 | # Recipes 119 | 120 | ## CCBV 121 | 122 | In order to replicate [CCBV](https://ccbv.co.uk), these are the settings you should set: 123 | 124 | ```python 125 | CLASSY_DOC_BASES = ['django.views.generic'] 126 | CLASSY_DOC_NON_INSTALLED_APPS = ['django.views.generic'] 127 | CLASSY_DOC_MODULE_TYPES = [ 128 | 'base', 129 | 'dates', 130 | 'detail', 131 | 'edit', 132 | 'list', 133 | ] 134 | CLASSY_DOC_KNOWN_APPS = {} 135 | ``` 136 | 137 | If you'd like to include `django.contrib.views` in your documentation, 138 | you'll first have to include them in your `urls.py`: 139 | 140 | ```python 141 | urlpatterns = [ 142 | ... 143 | path('accounts/', include('django.contrib.auth.urls')), 144 | ... 145 | ] 146 | ``` 147 | 148 | Once this is done, you can then use the following settings: 149 | 150 | ```python 151 | CLASSY_DOC_BASES = ['django.views.generic', 'django.contrib.auth'] 152 | CLASSY_DOC_NON_INSTALLED_APPS = ['django.views.generic'] 153 | CLASSY_DOC_MODULE_TYPES = [ 154 | 'base', 155 | 'dates', 156 | 'detail', 157 | 'edit', 158 | 'list', 159 | 'views', 160 | ] 161 | CLASSY_DOC_KNOWN_APPS = {} 162 | ``` 163 | 164 | 165 | ## CDRF 166 | 167 | In order to replicate [CDRF](https://cdrf.co), these are the settings you should set: 168 | 169 | ```python 170 | CLASSY_DOC_BASES = ['rest_framework'] 171 | CLASSY_DOC_MODULE_TYPES = ['generics', 'mixins', 'pagination', 'serializers', 'views', 'viewsets'] 172 | CLASSY_DOC_KNOWN_APPS = {} 173 | ``` 174 | 175 | ## CDDB 176 | 177 | In order to replicate [CDDB](https://cddb.levit.be), these are the settings you should set: 178 | 179 | ```python 180 | CLASSY_DOC_BASES = ['django.db', 'django.db.models'] 181 | CLASSY_DOC_NON_INSTALLED_APPS = ['django.db.models', 'django.db'] 182 | CLASSY_DOC_MODULE_TYPES = [ 183 | 'base', 184 | 'fields', 185 | 'enums', 186 | 'expressions', 187 | 'constraints', 188 | 'indexes', 189 | 'lookups', 190 | 'aggregates', 191 | 'constants', 192 | 'deletion', 193 | 'functions', 194 | 'manager', 195 | 'query_utils', 196 | 'sql', 197 | 'options', 198 | 'query', 199 | 'signals', 200 | 'utils', 201 | 'transaction', 202 | ] 203 | CLASSY_DOC_KNOWN_APPS = {} 204 | ``` 205 | 206 | 207 | ## CDF 208 | 209 | In order to replicate [CDF](https://cdf.9vo.lt), these are the settings you should set: 210 | 211 | ```python 212 | CLASSY_DOC_BASES = ['django.forms'] 213 | CLASSY_DOC_NON_INSTALLED_APPS = ['django.forms'] 214 | CLASSY_DOC_MODULE_TYPES = [ 215 | 'boundfield', 216 | 'fields', 217 | 'forms', 218 | 'formsets', 219 | 'models', 220 | 'renderers', 221 | 'widgets', 222 | ] 223 | CLASSY_DOC_KNOWN_APPS = {} 224 | ``` 225 | 226 | # MkDocs Integration 227 | 228 | ## mkdocstrings Handler 229 | 230 | *django-classy-doc* provides a custom handler for [mkdocstrings](https://mkdocstrings.github.io/) that allows you to embed class documentation directly in your MkDocs-based documentation. 231 | 232 | ### Installation 233 | 234 | Install with the mkdocs extra: 235 | 236 | ```bash 237 | pip install django-classy-doc[mkdocs] 238 | ``` 239 | 240 | ### Configuration 241 | 242 | In your `mkdocs.yml`, configure the handler: 243 | 244 | ```yaml 245 | plugins: 246 | - mkdocstrings: 247 | handlers: 248 | classydoc: 249 | # Handler options (all optional) 250 | options: 251 | show_source: true 252 | show_mro: true 253 | show_attributes: true 254 | show_methods: true 255 | show_fields: true 256 | heading_level: 2 257 | ``` 258 | 259 | Make sure to set your `DJANGO_SETTINGS_MODULE` environment variable so the handler can access your Django configuration: 260 | 261 | ```bash 262 | export DJANGO_SETTINGS_MODULE=myproject.settings 263 | ``` 264 | 265 | ### Usage 266 | 267 | In your markdown files, use the `::: classydoc` directive to include class documentation: 268 | 269 | ```markdown 270 | # My Model Documentation 271 | 272 | ::: myapp.models.MyModel 273 | handler: classydoc 274 | options: 275 | show_source: true 276 | show_mro: true 277 | ``` 278 | 279 | The handler supports these options: 280 | 281 | | Option | Default | Description | 282 | |--------|---------|-------------| 283 | | `show_source` | `true` | Display source code for methods | 284 | | `show_mro` | `true` | Display Method Resolution Order | 285 | | `show_attributes` | `true` | Display class attributes | 286 | | `show_methods` | `true` | Display methods with signatures | 287 | | `show_fields` | `true` | Display Django model fields | 288 | | `heading_level` | `2` | Starting heading level for sections | 289 | 290 | ## Markdown Formatter 291 | 292 | For programmatic use, *django-classy-doc* provides a `MarkdownFormatter` class that generates mkdocs-compatible markdown from classified class data. 293 | 294 | ### Usage 295 | 296 | ```python 297 | from django_classy_doc.utils import build 298 | from django_classy_doc.formatters.markdown import MarkdownFormatter 299 | 300 | # Get class data 301 | klass_data = build('myapp.models.MyModel') 302 | 303 | # Format as markdown 304 | formatter = MarkdownFormatter(klass_data) 305 | markdown_content = formatter.format() 306 | ``` 307 | 308 | The formatter supports Google-style docstrings and will parse sections like Args, Returns, Examples, and Notes into properly formatted markdown. 309 | 310 | -------------------------------------------------------------------------------- /mkdocstrings_handlers/classydoc/handler.py: -------------------------------------------------------------------------------- 1 | """ClassyDoc handler for mkdocstrings. 2 | 3 | This handler uses django_classy_doc's extraction logic to collect class 4 | documentation and renders it using Jinja templates with collapsible source code. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import os 10 | from pathlib import Path 11 | from typing import Any, ClassVar, Mapping 12 | 13 | from mkdocstrings import BaseHandler, CollectionError 14 | 15 | 16 | class ClassyDocHandler(BaseHandler): 17 | """Handler for Django class documentation using django_classy_doc. 18 | 19 | This handler allows mkdocstrings to render documentation for Django classes 20 | with features like collapsible source code, method resolution order display, 21 | and proper docstring parsing. 22 | """ 23 | 24 | name: ClassVar[str] = "classydoc" 25 | domain: ClassVar[str] = "py" 26 | fallback_theme: ClassVar[str] = "material" 27 | enable_inventory: ClassVar[bool] = False 28 | 29 | def __init__(self, **kwargs: Any) -> None: 30 | """Initialize the handler. 31 | 32 | This ensures Django is configured before the handler is used. 33 | """ 34 | super().__init__(**kwargs) 35 | self._ensure_django_configured() 36 | 37 | # Add custom filters 38 | self.env.filters["basename"] = lambda path: os.path.basename(path) if path else "" 39 | self.env.filters["parse_docstring"] = self._parse_docstring 40 | self.env.filters["format_value"] = self._format_value 41 | 42 | def _parse_docstring(self, docstring: str) -> dict: 43 | """Parse a Google-style docstring into structured sections.""" 44 | from django_classy_doc.formatters.markdown import GoogleDocstringParser 45 | return GoogleDocstringParser(docstring).parse() 46 | 47 | def _format_value(self, value: Any, max_length: int = 60) -> str: 48 | """Format an attribute value for display.""" 49 | import html 50 | import re 51 | 52 | if value is None: 53 | return "None" 54 | 55 | value_str = str(value) 56 | 57 | # Decode HTML entities 58 | value_str = html.unescape(value_str) 59 | 60 | # Clean up class representations: → foo.Bar 61 | class_match = re.match(r"^$", value_str) 62 | if class_match: 63 | value_str = class_match.group(1) 64 | 65 | # Clean up function repr: → name() 66 | value_str = re.sub(r']+)[^>]*>', r'\1()', value_str) 67 | 68 | # Clean up bound method repr 69 | value_str = re.sub(r']+)>', r'\1()', value_str) 70 | 71 | # Truncate long values 72 | if len(value_str) > max_length: 73 | value_str = value_str[:max_length - 3] + '...' 74 | 75 | return value_str 76 | 77 | def _ensure_django_configured(self) -> None: 78 | """Ensure Django is configured for use by the handler. 79 | 80 | If DJANGO_SETTINGS_MODULE is set, use those settings. 81 | Otherwise, try to configure Django with minimal settings. 82 | """ 83 | import sys 84 | import django 85 | from django.conf import settings 86 | 87 | if settings.configured: 88 | return 89 | 90 | # Add current working directory to path for settings module discovery 91 | cwd = os.getcwd() 92 | if cwd not in sys.path: 93 | sys.path.insert(0, cwd) 94 | 95 | # Check if we should use existing settings 96 | if os.environ.get("DJANGO_SETTINGS_MODULE"): 97 | django.setup() 98 | return 99 | 100 | # Minimal Django configuration - user must set DJANGO_SETTINGS_MODULE 101 | # for proper CLASSY_DOC settings 102 | settings.configure( 103 | DEBUG=True, 104 | INSTALLED_APPS=[ 105 | "django.contrib.contenttypes", 106 | "django.contrib.auth", 107 | "django_classy_doc", 108 | ], 109 | DATABASES={}, 110 | USE_TZ=True, 111 | ) 112 | django.setup() 113 | 114 | def get_templates_dir(self, handler: str | None = None) -> Path: 115 | """Return the path to the handler's templates directory. 116 | 117 | Args: 118 | handler: The name of the handler (unused, always returns classydoc templates). 119 | 120 | Returns: 121 | The path to the templates directory. 122 | """ 123 | return Path(__file__).parent / "templates" 124 | 125 | def collect(self, identifier: str, options: Mapping[str, Any]) -> dict[str, Any]: 126 | """Collect documentation for a class identifier. 127 | 128 | Args: 129 | identifier: The fully qualified class name (e.g., 'djadmin.layout.Fieldset'). 130 | options: Configuration options for collection. 131 | 132 | Returns: 133 | A dictionary containing the class documentation data. 134 | 135 | Raises: 136 | CollectionError: If the class cannot be found or documented. 137 | """ 138 | from django_classy_doc.utils import build 139 | 140 | try: 141 | # Use build() directly instead of build_context() to bypass settings checks 142 | structure = build(identifier) 143 | except ImportError as e: 144 | raise CollectionError(f"Could not import: {identifier}") from e 145 | except Exception as e: 146 | raise CollectionError(f"Could not document: {identifier}: {e}") from e 147 | 148 | if structure is False: 149 | raise CollectionError(f"Could not document: {identifier}") 150 | 151 | # Post-process attributes like build_context does 152 | from collections import OrderedDict 153 | for name, lst in structure['attributes'].items(): 154 | for i, definition in enumerate(lst): 155 | a = definition['defining_class'] 156 | structure['attributes'][name][i]['defining_class'] = (a.__module__, a.__name__) 157 | 158 | if isinstance(definition['object'], list): 159 | try: 160 | s = '[{0}]'.format(', '.join([c.__name__ for c in definition['object']])) 161 | except AttributeError: 162 | pass 163 | else: 164 | structure['attributes'][name][i]['default'] = s 165 | 166 | sorted_attributes = sorted(structure['attributes'].items(), key=lambda t: t[0]) 167 | structure['attributes'] = OrderedDict(sorted_attributes) 168 | 169 | sorted_methods = sorted(structure['methods'].items(), key=lambda t: t[0]) 170 | structure['methods'] = OrderedDict(sorted_methods) 171 | 172 | # Add options to the structure for use in templates 173 | structure["_options"] = dict(options) 174 | 175 | return structure 176 | 177 | def render( 178 | self, 179 | data: dict[str, Any], 180 | options: Mapping[str, Any], 181 | *, 182 | locale: str | None = None, 183 | ) -> str: 184 | """Render the collected data using Jinja templates. 185 | 186 | Args: 187 | data: The collected class documentation data. 188 | options: Configuration options for rendering. 189 | locale: Locale for translations (not used). 190 | 191 | Returns: 192 | Rendered HTML string. 193 | """ 194 | # Merge default options with provided options 195 | merged_options = { 196 | "show_source": True, 197 | "show_mro": True, 198 | "show_attributes": True, 199 | "show_methods": True, 200 | "show_fields": True, 201 | "heading_level": 2, 202 | } 203 | merged_options.update(options) 204 | 205 | template = self.env.get_template("class.html.jinja") 206 | return template.render( 207 | class_data=data, 208 | config=merged_options, 209 | heading_level=merged_options["heading_level"], 210 | ) 211 | 212 | 213 | def get_handler( 214 | *, 215 | theme: str, 216 | custom_templates: str | None = None, 217 | mdx: list | None = None, 218 | mdx_config: dict | None = None, 219 | handler_config: dict | None = None, 220 | tool_config: Any = None, 221 | **kwargs: Any, 222 | ) -> ClassyDocHandler: 223 | """Create and return a ClassyDocHandler instance. 224 | 225 | This function is the entry point for mkdocstrings to obtain a handler instance. 226 | 227 | Args: 228 | theme: The MkDocs theme name. 229 | custom_templates: Path to custom templates directory. 230 | mdx: Markdown extensions configuration. 231 | mdx_config: Markdown extensions configuration options. 232 | handler_config: Handler-specific configuration. 233 | tool_config: MkDocs tool configuration. 234 | **kwargs: Additional keyword arguments. 235 | 236 | Returns: 237 | A configured ClassyDocHandler instance. 238 | """ 239 | return ClassyDocHandler( 240 | theme=theme, 241 | custom_templates=custom_templates, 242 | mdx=mdx or [], 243 | mdx_config=mdx_config or {}, 244 | ) 245 | -------------------------------------------------------------------------------- /mkdocstrings_handlers/classydoc/templates/material/class.html.jinja: -------------------------------------------------------------------------------- 1 | {#- Template for Django classes using ClassyDoc. 2 | 3 | This template renders a Django class with collapsible source code, 4 | method resolution order, attributes, methods, and fields. 5 | 6 | Context: 7 | class_data (dict): The class documentation data from build_context(). 8 | config (dict): The configuration options. 9 | heading_level (int): The HTML heading level to use. 10 | -#} 11 | 12 |
13 | {% set class_name = class_data.name %} 14 | {% set module = class_data.module %} 15 | {% set html_id = module ~ "." ~ class_name %} 16 | 17 | {# Main heading #} 18 | {% filter heading( 19 | heading_level, 20 | role="class", 21 | id=html_id, 22 | class="doc doc-heading", 23 | toc_label=class_name, 24 | ) %} 25 | 26 | {{ class_name }} 27 | {% endfilter %} 28 | 29 |
30 | {# Module path #} 31 |

32 | {{ module }}.{{ class_name }} 33 |

34 | 35 | {# Docstring #} 36 | {% if class_data.docstring %} 37 | {% set parsed_doc = class_data.docstring | parse_docstring %} 38 |
39 | {# Summary #} 40 | {% if parsed_doc.summary %} 41 |

{{ parsed_doc.summary }}

42 | {% endif %} 43 | 44 | {# Description #} 45 | {% if parsed_doc.description %} 46 | {{ parsed_doc.description | convert_markdown(heading_level + 1, html_id) }} 47 | {% endif %} 48 | 49 | {# Attributes from docstring #} 50 | {% if parsed_doc.attributes %} 51 | Class Attributes 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | {% for attr in parsed_doc.attributes %} 62 | 63 | 64 | 65 | 66 | 67 | {% endfor %} 68 | 69 |
AttributeTypeDescription
{{ attr.name }}{% if attr.type %}{{ attr.type }}{% else %}-{% endif %}{{ attr.description }}
70 | {% endif %} 71 | 72 | {# Examples #} 73 | {% if parsed_doc.examples or parsed_doc.example %} 74 | {% set examples = parsed_doc.examples or parsed_doc.example %} 75 | Examples 76 | {{ examples | convert_markdown(heading_level + 2, html_id ~ "-examples") }} 77 | {% endif %} 78 | 79 | {# Notes #} 80 | {% if parsed_doc.notes or parsed_doc.note %} 81 | {% set notes = parsed_doc.notes or parsed_doc.note %} 82 | Notes 83 | {{ notes | convert_markdown(heading_level + 2, html_id ~ "-notes") }} 84 | {% endif %} 85 |
86 | {% endif %} 87 | 88 | {# Method Resolution Order #} 89 | {% if config.show_mro and class_data.ancestors %} 90 |
91 | Method Resolution Order 92 |
    93 | {% for ancestor in class_data.ancestors %} 94 | {% if ancestor[1] != 'object' %} 95 |
  1. {{ ancestor[0] }}.{{ ancestor[1] }}
  2. 96 | {% endif %} 97 | {% endfor %} 98 |
99 |
100 | {% endif %} 101 | 102 | {# Attributes #} 103 | {% if config.show_attributes and class_data.attributes %} 104 |
105 | Attributes 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | {% for attr_name, declarations in class_data.attributes.items() | sort %} 116 | {% if declarations %} 117 | {% set first_decl = declarations[0] if declarations is iterable else declarations %} 118 | {% set value = first_decl.default if first_decl.default else first_decl.object | default('') %} 119 | {% set defining_class = first_decl.defining_class %} 120 | 121 | 122 | 123 | 130 | 131 | {% endif %} 132 | {% endfor %} 133 | 134 |
AttributeValueDefined in
{{ attr_name }}{{ value | format_value }} 124 | {% if defining_class is iterable and defining_class is not string %} 125 | {{ defining_class[0] }}.{{ defining_class[1] }} 126 | {% else %} 127 | {{ defining_class }} 128 | {% endif %} 129 |
135 |
136 | {% endif %} 137 | 138 | {# Methods #} 139 | {% if config.show_methods and class_data.methods %} 140 |
141 | Methods 142 | {% for method_name, declarations in class_data.methods.items() | sort %} 143 | {% if declarations %} 144 | {% set decl_list = declarations if declarations is iterable else [declarations] %} 145 | {% set first_decl = decl_list[0] %} 146 | {% set arguments = first_decl.arguments if first_decl.arguments else '' %} 147 | {% set method_type = first_decl.type | default('method') %} 148 | {% set method_id = html_id ~ "." ~ method_name %} 149 | 150 |
151 | 152 | {{ method_name }}{% if arguments and 'property' not in method_type %}{{ arguments }}{% endif %} 153 | {% if method_type == 'class method' %} 154 | @classmethod 155 | {% elif method_type == 'static method' %} 156 | @staticmethod 157 | {% elif 'property' in method_type %} 158 | @property 159 | {% endif %} 160 | 161 | 162 | {# Defining class #} 163 | {% set defining_class = first_decl.defining_class %} 164 | {% if defining_class %} 165 |

166 | Defined in: 167 | {% if defining_class is iterable and defining_class is not string %} 168 | {{ defining_class[0] }}.{{ defining_class[1] }} 169 | {% else %} 170 | {{ defining_class }} 171 | {% endif %} 172 |

173 | {% endif %} 174 | 175 | {# Method docstring #} 176 | {% if first_decl.docstring %} 177 |
178 | {{ first_decl.docstring | convert_markdown(heading_level + 3, method_id) }} 179 |
180 | {% endif %} 181 | 182 | {# Source code - collapsible #} 183 | {% if config.show_source %} 184 | {% for decl in decl_list %} 185 | {% if decl.code %} 186 |
187 | 188 | Source code 189 | {% if decl.file %} 190 | in {{ decl.file | basename }} 191 | {% if decl.lines and decl.lines.start %} 192 | line {{ decl.lines.start }} 193 | {% endif %} 194 | {% endif %} 195 | 196 |
{{ decl.code | e }}
197 |
198 | {% endif %} 199 | {% endfor %} 200 | {% endif %} 201 |
202 | {% endif %} 203 | {% endfor %} 204 |
205 | {% endif %} 206 | 207 | {# Fields (for Django models) #} 208 | {% if config.show_fields and class_data.fields %} 209 |
210 | Fields 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | {% for field_name, declarations in class_data.fields.items() | sort %} 221 | {% if declarations %} 222 | {% set first_decl = declarations[0] if declarations is iterable else declarations %} 223 | 224 | 225 | 226 | 233 | 234 | {% endif %} 235 | {% endfor %} 236 | 237 |
FieldTypeRelated To
{{ field_name }}{{ first_decl.field_type | default('-') }} 227 | {% if first_decl.related %} 228 | {{ first_decl.related[0] }}.{{ first_decl.related[1] }} 229 | {% else %} 230 | - 231 | {% endif %} 232 |
238 |
239 | {% endif %} 240 |
241 |
242 | -------------------------------------------------------------------------------- /django_classy_doc/templates/django_classy_doc/klass.html: -------------------------------------------------------------------------------- 1 | {% extends "./base.html" %} 2 | {% load classy_doc %} 3 | 4 | {% block content %} 5 |
6 |

class {{klass.name}}

7 | 35 |
36 | 37 | from {{klass.module}} import {{klass.name}} 38 |
{{klass.docstring}}
39 | 40 |
41 |

Ancestors (MRO)

42 |
    43 | {% for ancestor in klass.ancestors %} 44 |
  1. {{ ancestor.0 }}.{{ancestor.1}}
  2. 45 | {% endfor %} 46 |
47 |
48 | 49 | {% if klass.Meta %} 50 |
51 |
52 |

Meta

53 |
54 | 55 | 56 | 57 | 58 | 59 | {% for name, value in klass.Meta.items %} 60 | 61 | 62 | 63 | 64 | {% endfor %} 65 | 66 |
AttributeValue
{{name}}{{value}}
67 |
68 | {% endif %} 69 | 70 | {% if klass.fields %} 71 |
72 |
73 |

Fields

74 |
75 | {% include './show_checkboxes.html' with section='fields' %} 76 |
77 |
78 | 79 | 80 | 81 | 82 | 83 | {% for name, declarations in klass|items:'fields' %} 84 | {% with value=declarations|last %} 85 | {% for declaration in declarations reversed %} 86 | 100 | {% endfor %} 101 | {% endwith %} 102 | {% endfor %} 103 | 104 |
AttributeTypeDefined in
105 |
106 | {% endif %} 107 | 108 | {% if klass.attributes %} 109 |
110 |
111 |

Attributes

112 |
113 | {% include './show_checkboxes.html' with section='attributes' %} 114 |
115 |
116 | 117 | 118 | 119 | 120 | 121 | {% for name, attributes in klass|items:'attributes' %} 122 | {% with value=attributes|last %} 123 | {% for attribute in attributes reversed %} 124 | 133 | {% endfor %} 134 | {% endwith %} 135 | {% endfor %} 136 | 137 |
AttributeValueDefined in
138 |
139 | {% endif %} 140 | 141 | {% if klass.methods %} 142 |
143 |
144 |

Methods

145 |
146 | {% include './show_checkboxes.html' with section='methods' %} 147 |
148 |
149 | {% for name, declarations in klass|items:'methods' %} 150 | {% with value=declarations|last %} 151 |
156 |
157 |
{% if value.type|slice:"-8" == "property" %}@property
158 |   def {{name}}(self){% elif value.arguments %}def {{ name }}{{ value.arguments }}{% else %}def {{name}}(self){% endif %}
159 |
160 | {{ value|module }}.{{ value|class_name }} 161 |
162 |
163 |
164 | {% for declaration in declarations reversed %} 165 | {% if declarations|length > 1 %} 166 |
167 |
168 | {{ declaration|module }}.{{ declaration|class_name }} 169 |
170 |
171 | {% endif %} 172 | 173 | {% if declaration.docstring %} 174 |
{{ declaration.docstring|escape }}
175 | {% endif %} 176 | 177 | {% if declaration.lines.total > 0 %} 178 |
{{ declaration.code }}
179 | {% endif %} 180 | 181 | {% if declarations|length > 1 %} 182 |
183 |
184 | {% endif %} 185 | {% endfor %} 186 |
187 |
188 | {% endwith %} 189 | {% endfor %} 190 |
191 | {% endif %} 192 | 193 | {% if klass.everything %} 194 |

Others

195 | {% for name, declarations in klass|items:'everything' %} 196 | {% for declaration in declarations %} 197 |
198 |

{{ name }}{{ declaration }}

199 |
200 | {% endfor %} 201 | {% endfor %} 202 | {% endif %} 203 | {% endblock %} 204 | -------------------------------------------------------------------------------- /django_classy_doc/utils.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | from collections import OrderedDict, defaultdict 3 | from copy import copy 4 | import inspect 5 | import pydoc 6 | import sys 7 | 8 | from django.conf import settings 9 | from django.db.models import Model 10 | from django.forms.models import ModelForm 11 | from django.forms.forms import BaseForm 12 | from django.utils.module_loading import import_string 13 | from django.utils.html import escape 14 | 15 | from . import settings as app_settings 16 | 17 | 18 | class DefaultOrderedDict(OrderedDict): 19 | 20 | def __init__(self, default_factory, *args, **kwargs): 21 | super().__init__(*args, **kwargs) 22 | self.default_factory = default_factory 23 | 24 | def __missing__(self, key): 25 | value = self.default_factory() 26 | self[key] = value 27 | return value 28 | 29 | 30 | def get_attrs(obj): 31 | all_attrs = filter(lambda data: pydoc.visiblename(data[0], obj=obj), 32 | pydoc.classify_class_attrs(obj)) 33 | return filter(lambda data: data[2] == obj, all_attrs) 34 | 35 | 36 | def tf_attributes(attr): 37 | return { 38 | 'name': attr[0], 39 | 'type': attr[1], 40 | 'object': escape(getattr(attr[2], attr[0])), 41 | 'defining_class': attr[2] 42 | } 43 | 44 | 45 | def tf_methods(attr): 46 | arguments = None 47 | lines = [] 48 | start_line = 0 49 | source = None 50 | 51 | try: 52 | func = getattr(attr[2], attr[0]) 53 | docstring = pydoc.getdoc(attr[3]) 54 | 55 | # Get the method signature using inspect.signature() 56 | try: 57 | sig = inspect.signature(func) 58 | arguments = str(sig) 59 | except (TypeError, ValueError): 60 | # Fallback for built-in functions or other edge cases 61 | pass 62 | 63 | # Get source line details 64 | try: 65 | lines, start_line = inspect.getsourcelines(func) 66 | source = inspect.getsourcefile(func) 67 | except TypeError: 68 | pass 69 | except OSError: 70 | pass 71 | 72 | except AttributeError as e: 73 | docstring = f'{e}' 74 | 75 | return { 76 | 'name': attr[0], 77 | 'type': attr[1], 78 | 'docstring': docstring, 79 | 'defining_class': attr[2], 80 | 'arguments': arguments, 81 | 'code': ''.join(lines), 82 | 'lines': {'start': start_line, 'total': len(lines)}, 83 | 'file': source 84 | } 85 | 86 | 87 | def tf_everything(attr): 88 | return { 89 | 'name': attr[0], 90 | 'type': attr[1], 91 | 'rest': attr 92 | } 93 | 94 | 95 | def tf_fields(attr): 96 | field_class = attr[3].__class__ 97 | related = None 98 | 99 | if field_class.__name__.endswith('Descriptor'): 100 | try: 101 | field_class = attr[3].field.__class__ 102 | related_model = attr[3].field.remote_field.model 103 | related = (related_model.__module__, related_model.__name__) 104 | except Exception: 105 | pass 106 | elif field_class.__name__.endswith('DeferredAttribute'): 107 | try: 108 | # Related field 109 | field_class = attr[3].field.remote_field.field.__class__ 110 | except AttributeError: 111 | # Not a relationship 112 | field_class = attr[3].field.__class__ 113 | 114 | return { 115 | 'name': attr[0], 116 | 'type': attr[1], 117 | 'defining_class': (attr[2].__module__, attr[2].__name__), 118 | 'field_type': field_class.__name__, 119 | 'related': related, 120 | } 121 | 122 | 123 | def classify(klass, obj, name=None, mod=None, *ignored): 124 | if not inspect.isclass(obj): 125 | raise Exception 126 | 127 | mro = list(reversed(inspect.getmro(obj))) 128 | 129 | klass.update({ 130 | 'name': obj.__name__, 131 | 'module': obj.__module__, 132 | 'docstring': pydoc.getdoc(obj), 133 | 'ancestors': [(k.__module__, k.__name__) for k in mro], 134 | 'parents': inspect.getclasstree([obj])[-1][0][1] 135 | }) 136 | 137 | for cls in mro: 138 | if cls is builtins.object: 139 | continue 140 | 141 | for attribute in get_attrs(cls): 142 | 143 | if attribute[0] == 'Meta': 144 | continue 145 | 146 | if attribute[1] == 'data': 147 | target = 'attributes' 148 | elif ( 149 | attribute[1] in ['method', 'class method', 'static method'] or attribute[1].endswith('property') 150 | ) and getattr(attribute[3], '__class__', type).__name__ != "DeferredAttribute": 151 | target = 'methods' 152 | elif attribute[1] == 'data descriptor' \ 153 | or getattr(attribute[3], '__class__', type).__name__ == "DeferredAttribute": 154 | if attribute[3].__class__.__name__ == 'ReverseOneToOneDescriptor' and attribute[2].__name__ == 'Page': 155 | continue 156 | target = 'fields' 157 | else: 158 | target = 'everything' 159 | 160 | tf = globals()[f'tf_{target}'] 161 | tf_ed = tf(attribute) 162 | name = tf_ed.pop('name') 163 | klass[target][name].append(tf_ed) 164 | 165 | if issubclass(cls, BaseForm) and hasattr(cls, 'declared_fields'): 166 | for field, field_type in cls.declared_fields.items(): 167 | klass['fields'][field].append({ 168 | 'name': field, 169 | 'field_type': field_type.__class__.__name__, 170 | 'defining_class': (cls.__module__, cls.__name__) 171 | }) 172 | 173 | if issubclass(obj, Model): 174 | klass['Meta'] = obj._meta.original_attrs 175 | elif issubclass(obj, ModelForm) and hasattr(obj, 'Meta'): 176 | klass['Meta'] = { 177 | attr: str(getattr(obj.Meta, attr)) 178 | for attr in dir(obj.Meta) 179 | if not attr.startswith('__') 180 | } 181 | if issubclass(cls, BaseForm) and hasattr(cls, 'base_fields'): 182 | for field, field_type in cls.base_fields.items(): 183 | if field in klass['fields']: 184 | continue 185 | klass['fields'][field].append({ 186 | 'name': field, 187 | 'field_type': field_type.__class__.__name__, 188 | 'defining_class': ('Auto', '') 189 | }) 190 | 191 | return klass 192 | 193 | 194 | def build(thing): 195 | """Build a dictionary mapping of a class.""" 196 | sys.path.insert(0, '') 197 | 198 | klass = { 199 | 'attributes': DefaultOrderedDict(list), 200 | 'methods': DefaultOrderedDict(list), 201 | 'fields': DefaultOrderedDict(list), 202 | 'properties': [], 203 | 'ancestors': [], 204 | 'parents': [], 205 | 'everything': DefaultOrderedDict(list), 206 | } 207 | 208 | obj, name = pydoc.resolve(thing, forceload=0) 209 | 210 | if not any( 211 | [obj.__module__.startswith(base) for base in app_settings.CLASSY_DOC_BASES] 212 | ) and f'{obj.__module__}.{obj.__name__}' not in app_settings.CLASSY_DOC_ALSO_INCLUDE: 213 | return False 214 | 215 | return classify(klass, obj, name) 216 | 217 | 218 | def build_context(klass, exit=True): 219 | is_documented = False 220 | if klass in app_settings.CLASSY_DOC_ALSO_INCLUDE: 221 | is_documented = True 222 | elif klass not in app_settings.CLASSY_DOC_ALSO_EXCLUDE: 223 | if not any([ 224 | klass.startswith(app) 225 | for app in settings.INSTALLED_APPS + app_settings.CLASSY_DOC_NON_INSTALLED_APPS 226 | ]): 227 | return False 228 | if not any([klass.startswith(base) for base in app_settings.CLASSY_DOC_BASES]): 229 | return False 230 | if not any([f'.{mod_name}.' in klass for mod_name in app_settings.CLASSY_DOC_MODULE_TYPES]): 231 | return False 232 | is_documented = True 233 | if not is_documented: 234 | return False 235 | 236 | try: 237 | structure = build(klass) 238 | if structure is False: 239 | return False 240 | 241 | except ImportError as e: 242 | sys.stderr.write(f'Could not import: {klass}\n') 243 | if exit: 244 | sys.exit(1) 245 | raise e 246 | 247 | for name, lst in structure['attributes'].items(): 248 | for i, definition in enumerate(lst): 249 | a = definition['defining_class'] 250 | structure['attributes'][name][i]['defining_class'] = (a.__module__, a.__name__) 251 | 252 | if isinstance(definition['object'], list): 253 | try: 254 | s = '[{0}]'.format(', '.join([c.__name__ for c in definition['object']])) 255 | except AttributeError: 256 | pass 257 | else: 258 | structure['attributes'][name][i]['default'] = s 259 | continue 260 | 261 | sorted_attributes = sorted(structure['attributes'].items(), key=lambda t: t[0]) 262 | structure['attributes'] = OrderedDict(sorted_attributes) 263 | 264 | sorted_methods = sorted(structure['methods'].items(), key=lambda t: t[0]) 265 | structure['methods'] = OrderedDict(sorted_methods) 266 | 267 | return structure 268 | 269 | 270 | def build_list_of_documentables(apps=None): 271 | if apps is None: 272 | apps = defaultdict(lambda: defaultdict(list)) 273 | klasses = copy(app_settings.CLASSY_DOC_ALSO_INCLUDE) 274 | 275 | for app in list(settings.INSTALLED_APPS) + list(app_settings.CLASSY_DOC_NON_INSTALLED_APPS): 276 | if not any([app.startswith(base) for base in app_settings.CLASSY_DOC_BASES]): 277 | continue 278 | 279 | for mod_name in app_settings.CLASSY_DOC_MODULE_TYPES: 280 | mod_string = f'{app}.{mod_name}' 281 | print(f'Trying {mod_string}') 282 | found = False 283 | for mods in app_settings.CLASSY_DOC_KNOWN_APPS.values(): 284 | if any([f'{mod_string}.'.startswith(f'{mod}.') for mod in mods]): 285 | found = True 286 | break 287 | if found: 288 | continue 289 | 290 | try: 291 | module = import_string(mod_string) 292 | for name, obj in inspect.getmembers(module): 293 | if not inspect.isclass(obj) or not obj.__module__.startswith(f'{app}.{mod_name}'): 294 | continue 295 | 296 | full_name = f'{app}.{mod_name}.{name}' 297 | 298 | if full_name in app_settings.CLASSY_DOC_ALSO_EXCLUDE: 299 | continue 300 | 301 | klasses.append(full_name) 302 | apps[app][mod_name].append((name, full_name)) 303 | except ImportError as e: 304 | print(f'Unable to import {app}.{mod_name}', e) 305 | 306 | return apps, klasses 307 | 308 | 309 | def get_index_context(apps): 310 | return { 311 | 'apps': { 312 | app: { 313 | mod: klasses 314 | for mod, klasses in modules.items() 315 | } 316 | for app, modules in apps.items() 317 | }, 318 | } 319 | -------------------------------------------------------------------------------- /django_classy_doc/formatters/markdown.py: -------------------------------------------------------------------------------- 1 | """Markdown formatter for django_classy_doc. 2 | 3 | Generates mkdocs-compatible markdown documentation from classified class data. 4 | Supports Google-style docstring parsing. 5 | """ 6 | 7 | import html 8 | import re 9 | from collections import OrderedDict 10 | 11 | 12 | class GoogleDocstringParser: 13 | """Parser for Google-style docstrings. 14 | 15 | Parses sections like Args, Returns, Examples, Raises, Attributes, Notes. 16 | """ 17 | 18 | SECTION_NAMES = [ 19 | 'Args', 20 | 'Arguments', 21 | 'Attributes', 22 | 'Example', 23 | 'Examples', 24 | 'Keyword Args', 25 | 'Keyword Arguments', 26 | 'Note', 27 | 'Notes', 28 | 'Other Parameters', 29 | 'Parameters', 30 | 'Raises', 31 | 'Returns', 32 | 'Return', 33 | 'See Also', 34 | 'Todo', 35 | 'Warning', 36 | 'Warnings', 37 | 'Yields', 38 | ] 39 | 40 | def __init__(self, docstring): 41 | """Initialize the parser. 42 | 43 | Args: 44 | docstring: The raw docstring to parse 45 | """ 46 | self.docstring = docstring or '' 47 | self._parsed = None 48 | 49 | def parse(self): 50 | """Parse the docstring into sections. 51 | 52 | Returns: 53 | Dict with keys: summary, description, and section names in lowercase 54 | """ 55 | if self._parsed is not None: 56 | return self._parsed 57 | 58 | result = { 59 | 'summary': '', 60 | 'description': '', 61 | } 62 | 63 | if not self.docstring: 64 | self._parsed = result 65 | return result 66 | 67 | lines = self.docstring.split('\n') 68 | 69 | # Find summary (first paragraph) 70 | summary_lines = [] 71 | idx = 0 72 | for idx, line in enumerate(lines): 73 | stripped = line.strip() 74 | if stripped: 75 | summary_lines.append(stripped) 76 | else: 77 | break 78 | else: 79 | # Loop completed without break - move past last line 80 | idx += 1 81 | 82 | result['summary'] = ' '.join(summary_lines) 83 | 84 | # Skip blank lines after summary 85 | while idx < len(lines) and not lines[idx].strip(): 86 | idx += 1 87 | 88 | # Build section pattern 89 | section_pattern = re.compile( 90 | r'^(' + '|'.join(re.escape(s) for s in self.SECTION_NAMES) + r'):\s*$' 91 | ) 92 | 93 | # Find all section starts 94 | current_section = 'description' 95 | section_content = [] 96 | 97 | for i in range(idx, len(lines)): 98 | line = lines[i] 99 | match = section_pattern.match(line.strip()) 100 | 101 | if match: 102 | # Save previous section 103 | if section_content: 104 | key = current_section.lower().replace(' ', '_') 105 | result[key] = self._process_section(current_section, section_content) 106 | section_content = [] 107 | 108 | current_section = match.group(1) 109 | else: 110 | section_content.append(line) 111 | 112 | # Save last section 113 | if section_content: 114 | key = current_section.lower().replace(' ', '_') 115 | result[key] = self._process_section(current_section, section_content) 116 | 117 | self._parsed = result 118 | return result 119 | 120 | def _process_section(self, section_name, lines): 121 | """Process a section's content based on section type. 122 | 123 | Args: 124 | section_name: Name of the section 125 | lines: List of content lines 126 | 127 | Returns: 128 | Processed content (string or list of dicts for Args/Attributes) 129 | """ 130 | # Remove common leading whitespace 131 | if lines: 132 | # Find minimum indentation (ignoring empty lines) 133 | min_indent = float('inf') 134 | for line in lines: 135 | if line.strip(): 136 | leading = len(line) - len(line.lstrip()) 137 | min_indent = min(min_indent, leading) 138 | 139 | if min_indent == float('inf'): 140 | min_indent = 0 141 | 142 | lines = [line[min_indent:] if len(line) > min_indent else line.lstrip() for line in lines] 143 | 144 | # For Args/Attributes/Parameters, parse into structured format 145 | if section_name.lower() in ['args', 'arguments', 'parameters', 'attributes', 'keyword args', 'keyword arguments']: 146 | return self._parse_params(lines) 147 | 148 | # For Returns/Raises/Yields, keep as text 149 | return '\n'.join(lines).strip() 150 | 151 | def _parse_params(self, lines): 152 | """Parse parameter/attribute section into list of dicts. 153 | 154 | Args: 155 | lines: Content lines of the section 156 | 157 | Returns: 158 | List of dicts with keys: name, type, description 159 | """ 160 | params = [] 161 | current_param = None 162 | desc_lines = [] 163 | 164 | # Pattern: name (type): description OR name: description 165 | param_pattern = re.compile(r'^(\w+)(?:\s*\(([^)]+)\))?:\s*(.*)$') 166 | 167 | for line in lines: 168 | stripped = line.strip() 169 | 170 | if not stripped: 171 | if current_param and desc_lines: 172 | desc_lines.append('') 173 | continue 174 | 175 | match = param_pattern.match(stripped) 176 | 177 | if match and not line.startswith(' ' * 4): # New param (not continuation) 178 | # Save previous param 179 | if current_param: 180 | current_param['description'] = ' '.join(desc_lines).strip() 181 | params.append(current_param) 182 | desc_lines = [] 183 | 184 | current_param = { 185 | 'name': match.group(1), 186 | 'type': match.group(2) or '', 187 | 'description': '', 188 | } 189 | if match.group(3): 190 | desc_lines = [match.group(3)] 191 | elif current_param: 192 | # Continuation of description 193 | desc_lines.append(stripped) 194 | 195 | # Save last param 196 | if current_param: 197 | current_param['description'] = ' '.join(desc_lines).strip() 198 | params.append(current_param) 199 | 200 | return params 201 | 202 | 203 | class MarkdownFormatter: 204 | """Format classified class data as mkdocs-compatible markdown.""" 205 | 206 | def __init__(self, klass_data, known_apps=None): 207 | """Initialize the formatter. 208 | 209 | Args: 210 | klass_data: Dict from classify() function 211 | known_apps: Dict mapping app names to module patterns (for filtering) 212 | """ 213 | self.klass_data = klass_data 214 | self.known_apps = known_apps or {} 215 | 216 | def format(self): 217 | """Generate markdown content for the class. 218 | 219 | Returns: 220 | Markdown string 221 | """ 222 | lines = [] 223 | 224 | # Title 225 | class_name = self.klass_data.get('name', 'Unknown') 226 | module = self.klass_data.get('module', '') 227 | lines.append(f'# {class_name}') 228 | lines.append('') 229 | 230 | if module: 231 | lines.append(f'`{module}.{class_name}`') 232 | lines.append('') 233 | 234 | # Parse and render docstring 235 | docstring = self.klass_data.get('docstring', '') 236 | parsed_doc = GoogleDocstringParser(docstring).parse() 237 | 238 | # Summary 239 | if parsed_doc.get('summary'): 240 | lines.append(parsed_doc['summary']) 241 | lines.append('') 242 | 243 | # Description 244 | if parsed_doc.get('description'): 245 | lines.append(parsed_doc['description']) 246 | lines.append('') 247 | 248 | # Class Attributes from docstring 249 | if parsed_doc.get('attributes'): 250 | lines.extend(self._format_docstring_attributes(parsed_doc['attributes'])) 251 | 252 | # Examples from docstring 253 | if parsed_doc.get('examples') or parsed_doc.get('example'): 254 | examples = parsed_doc.get('examples') or parsed_doc.get('example') 255 | lines.extend(self._format_examples(examples)) 256 | 257 | # Notes from docstring 258 | if parsed_doc.get('notes') or parsed_doc.get('note'): 259 | notes = parsed_doc.get('notes') or parsed_doc.get('note') 260 | lines.extend(self._format_notes(notes)) 261 | 262 | # Method Resolution Order 263 | if self.klass_data.get('ancestors'): 264 | lines.extend(self._format_mro()) 265 | 266 | # Attributes (code-level) 267 | if self.klass_data.get('attributes'): 268 | lines.extend(self._format_attributes()) 269 | 270 | # Methods 271 | if self.klass_data.get('methods'): 272 | lines.extend(self._format_methods()) 273 | 274 | # Fields (for Django models) 275 | if self.klass_data.get('fields'): 276 | lines.extend(self._format_fields()) 277 | 278 | return '\n'.join(lines) 279 | 280 | def _format_docstring_attributes(self, attributes): 281 | """Format attributes from docstring as a table. 282 | 283 | Args: 284 | attributes: List of attribute dicts from docstring parser 285 | 286 | Returns: 287 | List of markdown lines 288 | """ 289 | lines = [ 290 | '## Class Attributes', 291 | '', 292 | '| Attribute | Type | Description |', 293 | '|-----------|------|-------------|', 294 | ] 295 | 296 | for attr in attributes: 297 | name = f"`{attr['name']}`" 298 | type_str = f"`{attr['type']}`" if attr.get('type') else '-' 299 | desc = attr.get('description', '').replace('|', '\\|').replace('\n', ' ') 300 | lines.append(f'| {name} | {type_str} | {desc} |') 301 | 302 | lines.append('') 303 | return lines 304 | 305 | def _format_examples(self, examples): 306 | """Format examples section. 307 | 308 | Args: 309 | examples: String containing examples 310 | 311 | Returns: 312 | List of markdown lines 313 | """ 314 | lines = [ 315 | '## Examples', 316 | '', 317 | ] 318 | 319 | # Process example text - look for code blocks indicated by :: 320 | example_lines = examples.split('\n') 321 | in_code_block = False 322 | code_lines = [] 323 | 324 | for line in example_lines: 325 | stripped = line.strip() 326 | 327 | if stripped.endswith('::'): 328 | # Start of code block 329 | if in_code_block and code_lines: 330 | lines.append('```python') 331 | lines.extend(code_lines) 332 | lines.append('```') 333 | lines.append('') 334 | code_lines = [] 335 | 336 | # Add the description without :: 337 | desc = stripped[:-2].strip() 338 | if desc: 339 | lines.append(f'**{desc}**') 340 | lines.append('') 341 | in_code_block = True 342 | 343 | elif in_code_block: 344 | if stripped and not line.startswith(' ' * 4) and not line.startswith('\t'): 345 | # End of code block 346 | if code_lines: 347 | lines.append('```python') 348 | lines.extend(code_lines) 349 | lines.append('```') 350 | lines.append('') 351 | code_lines = [] 352 | in_code_block = False 353 | lines.append(line) 354 | else: 355 | # Inside code block 356 | # Remove leading 4 spaces 357 | if line.startswith(' '): 358 | code_lines.append(line[4:]) 359 | elif line.startswith('\t'): 360 | code_lines.append(line[1:]) 361 | elif not stripped: 362 | code_lines.append('') 363 | else: 364 | code_lines.append(line) 365 | else: 366 | lines.append(line) 367 | 368 | # Close any remaining code block 369 | if code_lines: 370 | lines.append('```python') 371 | lines.extend(code_lines) 372 | lines.append('```') 373 | lines.append('') 374 | 375 | return lines 376 | 377 | def _format_notes(self, notes): 378 | """Format notes section. 379 | 380 | Args: 381 | notes: String containing notes 382 | 383 | Returns: 384 | List of markdown lines 385 | """ 386 | lines = [ 387 | '## Notes', 388 | '', 389 | ] 390 | 391 | for line in notes.split('\n'): 392 | stripped = line.strip() 393 | if stripped.startswith('- '): 394 | lines.append(stripped) 395 | elif stripped: 396 | lines.append(stripped) 397 | else: 398 | lines.append('') 399 | 400 | lines.append('') 401 | return lines 402 | 403 | def _format_mro(self): 404 | """Format Method Resolution Order. 405 | 406 | Returns: 407 | List of markdown lines 408 | """ 409 | lines = [ 410 | '## Method Resolution Order', 411 | '', 412 | ] 413 | 414 | ancestors = self.klass_data.get('ancestors', []) 415 | for i, (module, name) in enumerate(ancestors, 1): 416 | if name == 'object': 417 | continue 418 | lines.append(f'{i}. `{module}.{name}`') 419 | 420 | lines.append('') 421 | return lines 422 | 423 | def _format_attributes(self): 424 | """Format code-level attributes as a table. 425 | 426 | Returns: 427 | List of markdown lines 428 | """ 429 | lines = [ 430 | '## Attributes', 431 | '', 432 | '| Attribute | Value | Defined in |', 433 | '|-----------|-------|------------|', 434 | ] 435 | 436 | for attr_name, declarations in sorted(self.klass_data['attributes'].items()): 437 | if not declarations: 438 | continue 439 | 440 | first_decl = declarations[0] if isinstance(declarations, list) else declarations 441 | defining_class = self._get_defining_class_str(first_decl) 442 | 443 | value = first_decl.get('object', '') if isinstance(first_decl, dict) else '' 444 | if first_decl.get('default'): 445 | value = first_decl['default'] 446 | 447 | value_str = self._format_attribute_value(value) 448 | lines.append(f'| `{attr_name}` | `{value_str}` | {defining_class} |') 449 | 450 | lines.append('') 451 | return lines 452 | 453 | def _format_methods(self): 454 | """Format methods with signatures and source code. 455 | 456 | Returns: 457 | List of markdown lines 458 | """ 459 | lines = [ 460 | '## Methods', 461 | '', 462 | ] 463 | 464 | for method_name, declarations in sorted(self.klass_data['methods'].items()): 465 | if not declarations: 466 | continue 467 | 468 | decl_list = declarations if isinstance(declarations, list) else [declarations] 469 | first_decl = decl_list[0] 470 | 471 | # Get method signature 472 | arguments = first_decl.get('arguments') if isinstance(first_decl, dict) else None 473 | if not arguments: 474 | arguments = '(self)' 475 | 476 | # Get method type 477 | method_type = first_decl.get('type', 'method') 478 | type_indicator = '' 479 | if method_type == 'class method': 480 | type_indicator = ' `@classmethod`' 481 | elif method_type == 'static method': 482 | type_indicator = ' `@staticmethod`' 483 | elif 'property' in method_type: 484 | type_indicator = ' `@property`' 485 | 486 | defining_class = self._get_defining_class_str(first_decl) 487 | 488 | lines.append(f'### `{method_name}{arguments}`{type_indicator}') 489 | lines.append('') 490 | 491 | if defining_class: 492 | lines.append(f'**Defined in:** {defining_class}') 493 | lines.append('') 494 | 495 | # Get and parse docstring 496 | docstring = first_decl.get('docstring', '') if isinstance(first_decl, dict) else '' 497 | if docstring: 498 | parsed = GoogleDocstringParser(docstring).parse() 499 | 500 | # Summary 501 | if parsed.get('summary'): 502 | lines.append(parsed['summary']) 503 | lines.append('') 504 | 505 | # Description 506 | if parsed.get('description'): 507 | lines.append(parsed['description']) 508 | lines.append('') 509 | 510 | # Args 511 | if parsed.get('args') or parsed.get('arguments') or parsed.get('parameters'): 512 | params = parsed.get('args') or parsed.get('arguments') or parsed.get('parameters') 513 | lines.append('**Arguments:**') 514 | lines.append('') 515 | for param in params: 516 | type_str = f' ({param["type"]})' if param.get('type') else '' 517 | lines.append(f'- **{param["name"]}**{type_str}: {param.get("description", "")}') 518 | lines.append('') 519 | 520 | # Returns 521 | if parsed.get('returns') or parsed.get('return'): 522 | returns = parsed.get('returns') or parsed.get('return') 523 | lines.append(f'**Returns:** {returns}') 524 | lines.append('') 525 | 526 | # Raises 527 | if parsed.get('raises'): 528 | lines.append('**Raises:**') 529 | lines.append('') 530 | lines.append(parsed['raises']) 531 | lines.append('') 532 | 533 | # Show source code (with docstring stripped) 534 | for decl in decl_list: 535 | if not isinstance(decl, dict): 536 | continue 537 | 538 | code = decl.get('code', '') 539 | if code: 540 | # Strip docstring from source code 541 | code = self._strip_docstring_from_code(code) 542 | 543 | if len(decl_list) > 1: 544 | decl_class = self._get_defining_class_str(decl) 545 | lines.append(f'**Source from {decl_class}:**') 546 | lines.append('') 547 | 548 | lines.append('```python') 549 | lines.append(code.strip()) 550 | lines.append('```') 551 | lines.append('') 552 | 553 | return lines 554 | 555 | def _strip_docstring_from_code(self, code): 556 | """Strip the docstring from method source code. 557 | 558 | Args: 559 | code: Source code string 560 | 561 | Returns: 562 | Code with docstring removed 563 | """ 564 | # Pattern to match docstring at start of function body 565 | # Matches: def ...: followed by optional whitespace and triple-quoted string 566 | pattern = r'^([ \t]*def[^:]+:\s*\n)([ \t]*)(["\'])\3\3(.*?)\3\3\3' 567 | 568 | match = re.search(pattern, code, re.DOTALL) 569 | if match: 570 | # Remove the docstring, keeping the def line 571 | def_line = match.group(1) 572 | # Find where the docstring ends 573 | docstring_end = match.end() 574 | rest_of_code = code[docstring_end:] 575 | 576 | # If only whitespace remains after stripping, add pass 577 | if not rest_of_code.strip(): 578 | indent = match.group(2) 579 | return def_line + indent + 'pass' 580 | 581 | return def_line + rest_of_code.lstrip('\n') 582 | 583 | return code 584 | 585 | def _format_fields(self): 586 | """Format Django model fields. 587 | 588 | Returns: 589 | List of markdown lines 590 | """ 591 | lines = [ 592 | '## Fields', 593 | '', 594 | '| Field | Type | Related To |', 595 | '|-------|------|------------|', 596 | ] 597 | 598 | for field_name, declarations in sorted(self.klass_data['fields'].items()): 599 | if not declarations: 600 | continue 601 | 602 | first_decl = declarations[0] if isinstance(declarations, list) else declarations 603 | 604 | field_type = first_decl.get('field_type', '-') 605 | related = first_decl.get('related') 606 | if related: 607 | related_str = f'`{related[0]}.{related[1]}`' 608 | else: 609 | related_str = '-' 610 | 611 | lines.append(f'| `{field_name}` | `{field_type}` | {related_str} |') 612 | 613 | lines.append('') 614 | return lines 615 | 616 | def _get_defining_class_str(self, declaration): 617 | """Extract defining class string from a declaration dict. 618 | 619 | Args: 620 | declaration: Dict with 'defining_class' key 621 | 622 | Returns: 623 | String like 'module.ClassName' 624 | """ 625 | if not isinstance(declaration, dict): 626 | return '' 627 | 628 | defining_class = declaration.get('defining_class') 629 | if not defining_class: 630 | return '' 631 | 632 | if isinstance(defining_class, tuple): 633 | return f'{defining_class[0]}.{defining_class[1]}' 634 | elif hasattr(defining_class, '__module__') and hasattr(defining_class, '__name__'): 635 | return f'{defining_class.__module__}.{defining_class.__name__}' 636 | else: 637 | return str(defining_class) 638 | 639 | def _format_attribute_value(self, value): 640 | """Format attribute value for markdown display. 641 | 642 | Args: 643 | value: The attribute value 644 | 645 | Returns: 646 | Formatted string suitable for markdown table 647 | """ 648 | if value is None: 649 | return 'None' 650 | 651 | value_str = str(value) 652 | 653 | # Decode HTML entities 654 | value_str = html.unescape(value_str) 655 | 656 | # Clean up class representations: → foo.Bar 657 | class_match = re.match(r"^$", value_str) 658 | if class_match: 659 | value_str = class_match.group(1) 660 | 661 | # Clean up function repr: → name() 662 | value_str = re.sub(r']+)[^>]*>', r'\1()', value_str) 663 | 664 | # Clean up bound method repr 665 | value_str = re.sub(r']+)>', r'\1()', value_str) 666 | 667 | # Truncate long values 668 | if len(value_str) > 50: 669 | value_str = value_str[:47] + '...' 670 | 671 | # Escape pipe characters and remove newlines for table compatibility 672 | value_str = value_str.replace('|', '\\|').replace('\n', ' ') 673 | 674 | return value_str 675 | 676 | 677 | def format_index(klasses, title='API Reference'): 678 | """Generate index markdown for multiple classes. 679 | 680 | Args: 681 | klasses: List of klass_data dicts 682 | title: Title for the index page 683 | 684 | Returns: 685 | Markdown string 686 | """ 687 | lines = [ 688 | f'# {title}', 689 | '', 690 | 'Auto-generated API documentation.', 691 | '', 692 | '| Class | Module | Description |', 693 | '|-------|--------|-------------|', 694 | ] 695 | 696 | for klass in klasses: 697 | name = klass.get('name', 'Unknown') 698 | module = klass.get('module', '') 699 | docstring = klass.get('docstring', '') 700 | 701 | # Get first line of docstring as description 702 | desc = docstring.split('\n')[0] if docstring else '' 703 | if len(desc) > 60: 704 | desc = desc[:57] + '...' 705 | desc = desc.replace('|', '\\|') 706 | 707 | # Create link 708 | filename = f'{name}.md' 709 | link = f'[{name}]({filename})' 710 | 711 | lines.append(f'| {link} | `{module}` | {desc} |') 712 | 713 | lines.append('') 714 | return '\n'.join(lines) 715 | --------------------------------------------------------------------------------