diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..9797093 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +# Each line is a file pattern followed by one or more owners. + +# These owners will be the default owners for everything in the repo. +# Unless a later match takes precedence, they will be requested for review when someone opens a pull request. +* @timobrembeck diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000..111a009 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,43 @@ +--- +name: "Bug report \U0001F41B" +about: "Create a report to help us improve" +labels: "bug" + +--- + +### Describe the Bug + + + + +### Minimal Example to Reproduce + + + +### Expected Behavior + + + +### Actual Behavior + + + +### Additional Information + + +
+ Traceback + + ``` + ``` + +
+ + +### System Information + + +OS version: +Python version: +Sphinx version: +sphinxcontrib_django version: diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 0000000..77e2e2a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,23 @@ +--- +name: "Feature request \U0001F4A1" +about: "Suggest an idea for this project" +labels: "feature request" + +--- + +### Motivation + + + + +### Proposed Solution + + + +### Alternatives + + + +### Additional Context + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4fa69a1 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +### Short description + + + +### Proposed changes + + +- +- + + +### Resolved issues + + +Fixes: # diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..be006de --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +# Keep GitHub Actions up to date with GitHub's Dependabot... +# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + groups: + github-actions: + patterns: + - "*" # Group all Actions updates into a single larger pull request + schedule: + interval: weekly diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index adde2f5..fa09f64 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -18,8 +18,8 @@ jobs: needs: has runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 - name: Install dependencies run: pip install build twine - name: Build a binary wheel and a source tarball diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 52876b4..6d538f9 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -4,23 +4,11 @@ jobs: black: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 - uses: psf/black@stable - isort: + ruff: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - uses: jamescurtin/isort-action@master - with: - configuration: --check-only - flake8: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - name: Install dependencies - run: pip install flake8 flake8-pyproject - - name: Run flake8 - run: flake8 . + - uses: actions/checkout@v4 + - uses: chartboost/ruff-action@v1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fc4f1c2..0a96194 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,14 +5,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] - django-version: ["django~=3.2", "django~=4.0", "django~=4.1"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + django-version: ["django~=3.2", "django~=4.2", "django~=5.0"] optional-dependencies: ["optional-deps", "no-optional-deps"] exclude: - - python-version: "3.7" - django-version: "django~=4.0" - - python-version: "3.7" - django-version: "django~=4.1" + - python-version: "3.8" + django-version: "django~=5.0" + - python-version: "3.9" + django-version: "django~=5.0" env: OS: ubuntu-latest PYTHON: ${{ matrix.python-version }} @@ -34,6 +34,8 @@ jobs: run: coverage run - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: env_vars: OS,PYTHON,DJANGO name: codecov-umbrella diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 92ef5e3..5721d5f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,13 +3,8 @@ repos: rev: 23.1.0 hooks: - id: black - - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.0.291 hooks: - - id: isort - - repo: https://github.com/pycqa/flake8 - rev: 6.0.0 - hooks: - - id: flake8 - additional_dependencies: - - flake8-pyproject + - id: ruff + args: [--fix, --exit-non-zero-on-fix] diff --git a/.readthedocs.yaml b/.readthedocs.yaml index cf5f062..6e9fc9e 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -5,6 +5,12 @@ # Required version: 2 +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.11" + # Build documentation in the docs/ directory with Sphinx sphinx: configuration: docs/conf.py diff --git a/AUTHORS b/AUTHORS index 53a9043..b8b0d86 100644 --- a/AUTHORS +++ b/AUTHORS @@ -4,4 +4,4 @@ Original authors: Maintainer since 2020: -* Timo Ludwig (@timoludwig) +* Timo Brembeck (@timobrembeck) diff --git a/CHANGES.rst b/CHANGES.rst index b8db4b3..b5613d1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,10 +1,44 @@ Changelog ========= +Unreleased +---------- + +* Add support for Python 3.12 + + +Version 2.5 (2023-09-26) +------------------------ + +* Drop support for sphinx < 3.4.0 +* [ `#45 `_ ] Fix rendering of inheritance diagrams +* Drop support for Python 3.7 + + +Version 2.4 (2023-07-02) +------------------------ + +* [ `#39 `_ ] Fix table names of abstract models (`@insspb `__) +* [ `#41 `_ ] Fix rendering of iterable choices (`@insspb `__) + + +Version 2.3 (2023-04-12) +------------------------ + +* Add support for Django 4.2 +* Drop support for Django 4.0 + + +Version 2.2 (2023-03-01) +------------------------ + +* [ `#35 `_ ] Fix interference with other ``autodoc-skip-member`` signal handlers + + Version 2.1 (2023-03-01) ------------------------ -* [ `#32 `_ ] Fix rendering of nested directives in model parameter documentation +* [ `#32 `_ ] Fix rendering of nested directives in model parameter documentation Version 2.0 (2023-01-02) diff --git a/README.rst b/README.rst index 488937c..b7b200a 100644 --- a/README.rst +++ b/README.rst @@ -1,25 +1,25 @@ -.. image:: https://github.com/edoburu/sphinxcontrib-django/workflows/Tests/badge.svg +.. image:: https://github.com/sphinx-doc/sphinxcontrib-django/workflows/Tests/badge.svg :alt: GitHub Workflow Status - :target: https://github.com/edoburu/sphinxcontrib-django/actions?query=workflow%3ATests + :target: https://github.com/sphinx-doc/sphinxcontrib-django/actions?query=workflow%3ATests .. image:: https://img.shields.io/pypi/v/sphinxcontrib-django.svg :alt: PyPi :target: https://pypi.org/project/sphinxcontrib-django/ -.. image:: https://codecov.io/gh/edoburu/sphinxcontrib-django/branch/main/graph/badge.svg +.. image:: https://codecov.io/gh/sphinx-doc/sphinxcontrib-django/branch/main/graph/badge.svg :alt: Code coverage - :target: https://codecov.io/gh/edoburu/sphinxcontrib-django + :target: https://codecov.io/gh/sphinx-doc/sphinxcontrib-django .. image:: https://img.shields.io/badge/code%20style-black-000000.svg :alt: Black Code Style :target: https://github.com/psf/black -.. image:: https://img.shields.io/github/license/edoburu/sphinxcontrib-django +.. image:: https://img.shields.io/github/license/sphinx-doc/sphinxcontrib-django :alt: GitHub license - :target: https://github.com/edoburu/sphinxcontrib-django/blob/main/LICENSE + :target: https://github.com/sphinx-doc/sphinxcontrib-django/blob/main/LICENSE .. image:: https://readthedocs.org/projects/sphinxcontrib-django/badge/?version=latest :alt: Documentation Status :target: https://sphinxcontrib-django.readthedocs.io/en/latest/?badge=latest | -.. image:: https://raw.githubusercontent.com/edoburu/sphinxcontrib-django/main/docs/images/django-sphinx-logo-blue.png +.. image:: https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-django/main/docs/images/django-sphinx-logo-blue.png :width: 500 :alt: logo :target: https://pypi.org/project/sphinxcontrib-django/ @@ -80,8 +80,16 @@ Optionally, you can include the table names of your models in their docstrings w .. code-block:: python # Include the database table names of Django models - django_show_db_tables = True + django_show_db_tables = True # Boolean, default: False + # Add abstract database tables names (only takes effect if django_show_db_tables is True) + django_show_db_tables_abstract = True # Boolean, default: False +Optionally, you can extend amount of displayed choices in model fields with them: + +.. code-block:: python + + # Integer amount of model field choices to show, default 10 + django_choices_to_show = 10 Advanced Usage -------------- diff --git a/docs/conf.py b/docs/conf.py index 807df08..57e350f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -9,8 +9,8 @@ # -- Project information ----------------------------------------------------- project = "sphinxcontrib-django" -copyright = "2021" -author = "Timo Ludwig" +copyright = "2023" +author = "Timo Brembeck" # The full version, including alpha/beta/rc tags release = __version__ @@ -31,6 +31,9 @@ # Warn about all references where the target cannot be found nitpicky = True +# A list of (type, target) tuples that should be ignored when :attr:`nitpicky` is ``True`` +nitpick_ignore = [("py:class", "sphinx.ext.autodoc.Options")] + # Add any paths that contain templates here, relative to this directory. templates_path = ["templates"] @@ -45,7 +48,7 @@ html_theme = "sphinx_rtd_theme" # The logos shown in the menu bar html_logo = "images/django-sphinx-logo-white.png" -# The facivon of the html doc files +# The favicon of the html doc files html_favicon = "images/favicon.svg" # Do not include links to the documentation source (.rst files) in build html_show_sourcelink = False diff --git a/pyproject.toml b/pyproject.toml index 972d776..73bcbd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,44 +5,47 @@ [project] authors = [ { name = "Diederik van der Boor", email = "opensource@edoburu.nl" }, - { name = "Timo Ludwig", email = "ti.ludwig@web.de" } + { name = "Timo Brembeck", email = "opensource@timo.brembeck.email" }, ] classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django :: 3.2", - "Framework :: Django :: 4.0", - "Framework :: Django :: 4.1", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", "Framework :: Django", "Framework :: Sphinx :: Extension", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", ] - dependencies = ["Django>=2.2", "Sphinx>=0.5", "pprintpp"] + dependencies = ["Django>=3.2", "Sphinx>=3.4.0", "pprintpp"] description = "Improve the Sphinx autodoc for Django classes." dynamic = ["version"] keywords = ["django", "docstrings", "extension", "sphinx"] license = { text = "Apache2 2.0 License" } + maintainers = [ + { name = "Timo Brembeck", email = "opensource@timo.brembeck.email" }, + ] name = "sphinxcontrib-django" readme = "README.rst" - requires-python = ">=3.7" + requires-python = ">=3.8" [project.urls] - "Bug Tracker" = "https://github.com/edoburu/sphinxcontrib-django/issues" + "Bug Tracker" = "https://github.com/sphinx-doc/sphinxcontrib-django/issues" "Documentation" = "https://sphinxcontrib-django.readthedocs.io/" - "Release Notes" = "https://github.com/edoburu/sphinxcontrib-django/blob/main/CHANGES.rst" - "Source Code" = "https://github.com/edoburu/sphinxcontrib-django" + "Release Notes" = "https://github.com/sphinx-doc/sphinxcontrib-django/blob/main/CHANGES.rst" + "Source Code" = "https://github.com/sphinx-doc/sphinxcontrib-django" [project.optional-dependencies] dev = ["pre-commit"] @@ -68,24 +71,28 @@ command_line = "-m pytest" source = ["sphinxcontrib_django"] +[tool.coverage.report] + exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + ] + [tool.pytest.ini_options] addopts = "-ra -vv --color=yes" minversion = "6.0" testpaths = ["tests"] -[tool.flake8] - ignore = [ - "D1", # Missing docstrings - "E203", # whitespace before ':' in slice (incompatible with black) - "E731", # Allow lambdas - "F405", # name undefined due to star imports - "W503", # line break before binary operator (incompatible with black) - ] - max-line-length = 99 - -[tool.isort] - known_first_party = "sphinxcontrib_django" - # Approach Black compatibility (just run black after isort) - include_trailing_comma = true - line_length = 88 - multi_line_output = 3 +[tool.ruff] + select = [ + "F", + "E", + "W", + "I", + # "D", + "RET", + "SIM", + ] + ignore = [ + "D1", # Missing docstrings + ] + line-length = 99 diff --git a/sphinxcontrib_django/__init__.py b/sphinxcontrib_django/__init__.py index 62ad31b..192121e 100644 --- a/sphinxcontrib_django/__init__.py +++ b/sphinxcontrib_django/__init__.py @@ -1,12 +1,20 @@ """ This is a sphinx extension which improves the documentation of Django apps. """ -__version__ = "2.1" + +from __future__ import annotations + +__version__ = "2.5" + +from typing import TYPE_CHECKING from . import docstrings, roles +if TYPE_CHECKING: + import sphinx + -def setup(app): +def setup(app: sphinx.application.Sphinx) -> dict: """ Allow this module to be used as sphinx extension. @@ -14,7 +22,6 @@ def setup(app): :mod:`~sphinxcontrib_django.roles` which can also be imported separately. :param app: The Sphinx application object - :type app: ~sphinx.application.Sphinx """ docstrings.setup(app) roles.setup(app) diff --git a/sphinxcontrib_django/docstrings/__init__.py b/sphinxcontrib_django/docstrings/__init__.py index ff172de..c561b0e 100644 --- a/sphinxcontrib_django/docstrings/__init__.py +++ b/sphinxcontrib_django/docstrings/__init__.py @@ -14,8 +14,12 @@ * Fix the intersphinx mappings to the Django documentation (see :mod:`~sphinxcontrib_django.docstrings.patches`) """ + +from __future__ import annotations + import importlib import os +from typing import TYPE_CHECKING import django from sphinx.errors import ConfigError @@ -23,12 +27,15 @@ from .. import __version__ from .attributes import improve_attribute_docstring from .classes import improve_class_docstring -from .config import EXCLUDE_MEMBERS, INCLUDE_MEMBERS +from .config import CHOICES_LIMIT, EXCLUDE_MEMBERS, INCLUDE_MEMBERS from .data import improve_data_docstring from .methods import improve_method_docstring +if TYPE_CHECKING: + import sphinx + -def setup(app): +def setup(app: sphinx.application.Sphinx) -> dict: """ Allow this package to be used as Sphinx extension. @@ -43,7 +50,6 @@ def setup(app): :event:`config-inited` event. :param app: The Sphinx application object - :type app: ~sphinx.application.Sphinx """ from .patches import patch_django_for_autodoc @@ -59,8 +65,13 @@ def setup(app): "django_settings", os.environ.get("DJANGO_SETTINGS_MODULE"), True ) + # Django models tables names configuration. # Set default of django_show_db_tables to False app.add_config_value("django_show_db_tables", False, True) + # Set default of django_show_db_tables_abstract to False + app.add_config_value("django_show_db_tables_abstract", False, True) + # Integer amount of model field choices to show + app.add_config_value("django_choices_to_show", CHOICES_LIMIT, True) # Setup Django after config is initialized app.connect("config-inited", setup_django) @@ -81,7 +92,7 @@ def setup(app): } -def setup_django(app, config): +def setup_django(app: sphinx.application.Sphinx, config: sphinx.config.Config) -> None: """ This function calls :func:`django.setup` so it doesn't have to be done in the app's ``conf.py``. @@ -89,10 +100,8 @@ def setup_django(app, config): Called on the :event:`config-inited` event. :param app: The Sphinx application object - :type app: ~sphinx.application.Sphinx :param config: The Sphinx configuration - :type config: ~sphinx.config.Config :raises ~sphinx.errors.ConfigError: If setting ``django_settings`` is not set correctly """ @@ -107,7 +116,7 @@ def setup_django(app, config): raise ConfigError( "The module you specified in the configuration 'django_settings' in your" " conf.py cannot be imported. Make sure the module path is correct and the" - " source directoy is added to sys.path." + " source directory is added to sys.path." ) from e os.environ["DJANGO_SETTINGS_MODULE"] = config.django_settings django.setup() @@ -116,7 +125,14 @@ def setup_django(app, config): app.emit("django-configured") -def autodoc_skip(app, what, name, obj, skip, options): +def autodoc_skip( + app: sphinx.application.Sphinx, + what: str, + name: str, + obj: object, + options: sphinx.ext.autodoc.Options, + lines: list[str], +) -> bool | None: """ Hook to tell autodoc to include or exclude certain fields (see :event:`autodoc-skip-member`). @@ -124,19 +140,10 @@ def autodoc_skip(app, what, name, obj, skip, options): so only the ``name`` can be used for referencing. :param app: The Sphinx application object - :type app: ~sphinx.application.Sphinx - :param what: The parent type, ``class`` or ``module`` - :type what: str - :param name: The name of the child method/attribute. - :type name: str - :param obj: The child value (e.g. a method, dict, or module reference) - :type obj: object - :param options: The current autodoc settings. - :type options: dict """ if name in EXCLUDE_MEMBERS: return True @@ -144,41 +151,37 @@ def autodoc_skip(app, what, name, obj, skip, options): if name in INCLUDE_MEMBERS: return False - return skip + return None -def improve_docstring(app, what, name, obj, options, lines): +def improve_docstring( + app: sphinx.application.Sphinx, + what: str, + name: str, + obj: object, + options: sphinx.ext.autodoc.Options, + lines: list[str], +) -> list[str]: """ Hook to improve the autodoc docstrings for Django models (see :event:`autodoc-process-docstring`). :param what: The type of the object which the docstring belongs to (one of ``module``, ``class``, ``exception``, ``function``, ``method`` and ``attribute``) - :type what: str - :param name: The fully qualified name of the object - :type name: str - :param obj: The documented object - :type obj: object - :param options: The options given to the directive: an object with attributes ``inherited_members``, ``undoc_members``, ``show_inheritance`` and ``noindex`` that are ``True`` if the flag option of same name was given to the auto directive - :type options: object - :param lines: A list of strings – the lines of the processed docstring – that the event handler can modify in place to change what Sphinx puts into the output. - :type lines: list [ str ] - :return: The modified list of lines - :rtype: list [ str ] """ if what == "class": improve_class_docstring(app, obj, lines) elif what == "attribute": - improve_attribute_docstring(obj, name, lines) + improve_attribute_docstring(app, obj, name, lines) elif what == "method": improve_method_docstring(name, lines) elif what == "data": diff --git a/sphinxcontrib_django/docstrings/attributes.py b/sphinxcontrib_django/docstrings/attributes.py index 1fb4104..86c36bd 100644 --- a/sphinxcontrib_django/docstrings/attributes.py +++ b/sphinxcontrib_django/docstrings/attributes.py @@ -1,6 +1,9 @@ """ This module contains all functions which are used to improve the documentation of attributes. """ + +from __future__ import annotations + from django.db import models from django.db.models.fields import related_descriptors from django.db.models.fields.files import FileDescriptor @@ -9,7 +12,6 @@ from django.utils.module_loading import import_string from sphinx.util.docstrings import prepare_docstring -from .config import CHOICES_LIMIT from .field_utils import get_field_type, get_field_verbose_name FIELD_DESCRIPTORS = (FileDescriptor, related_descriptors.ForwardManyToOneDescriptor) @@ -23,12 +25,15 @@ PhoneNumberDescriptor = None -def improve_attribute_docstring(attribute, name, lines): +def improve_attribute_docstring(app, attribute, name, lines): """ Improve the documentation of various model fields. This improves the navigation between related objects. + :param app: The Sphinx application object + :type app: ~sphinx.application.Sphinx + :param attribute: The instance of the object to document :type attribute: object @@ -56,21 +61,21 @@ def improve_attribute_docstring(attribute, name, lines): f"Internal field, use :class:`~{cls_path}.{field.name}` instead." ) else: - lines.extend(get_field_details(field)) + lines.extend(get_field_details(app, field)) elif isinstance(attribute, FIELD_DESCRIPTORS): # Display a reasonable output for forward descriptors (foreign key and one to one fields). - lines.extend(get_field_details(attribute.field)) + lines.extend(get_field_details(app, attribute.field)) elif isinstance(attribute, related_descriptors.ManyToManyDescriptor): # Check this case first since ManyToManyDescriptor inherits from ReverseManyToOneDescriptor # This descriptor is used for both forward and reverse relationships if attribute.reverse: - lines.extend(get_field_details(attribute.rel)) + lines.extend(get_field_details(app, attribute.rel)) else: - lines.extend(get_field_details(attribute.field)) + lines.extend(get_field_details(app, attribute.field)) elif isinstance(attribute, related_descriptors.ReverseManyToOneDescriptor): - lines.extend(get_field_details(attribute.rel)) + lines.extend(get_field_details(app, attribute.rel)) elif isinstance(attribute, related_descriptors.ReverseOneToOneDescriptor): - lines.extend(get_field_details(attribute.related)) + lines.extend(get_field_details(app, attribute.related)) elif isinstance(attribute, (models.Manager, ManagerDescriptor)): # Somehow the 'objects' manager doesn't pass through the docstrings. module, model_name, field_name = name.rsplit(".", 2) @@ -92,17 +97,22 @@ def improve_attribute_docstring(attribute, name, lines): lines.extend(docstring_lines[:-1]) -def get_field_details(field): +def get_field_details(app, field): """ This function returns the detail docstring of a model field. It includes the field type and the verbose name of the field. + :param app: The Sphinx application object + :type app: ~sphinx.application.Sphinx + :param field: The field :type field: ~django.db.models.Field :return: The field details as list of strings :rtype: list [ str ] """ + choices_limit = app.config.django_choices_to_show + field_details = [ f"Type: {get_field_type(field)}", "", @@ -111,13 +121,16 @@ def get_field_details(field): if hasattr(field, "choices") and field.choices: field_details.extend(["", "Choices:", ""]) field_details.extend( - [f"* ``{key}``" for key, value in field.choices[:CHOICES_LIMIT]] + [ + f"* ``{key}``" if key != "" else "* ``''`` (Empty string)" + for key, value in field.choices[:choices_limit] + ] ) # Check if list has been truncated - if len(field.choices) > CHOICES_LIMIT: + if len(field.choices) > choices_limit: # If only one element has been truncated, just list it as well - if len(field.choices) == CHOICES_LIMIT + 1: + if len(field.choices) == choices_limit + 1: field_details.append(f"* ``{field.choices[-1][0]}``") else: - field_details.append(f"* and {len(field.choices) - CHOICES_LIMIT} more") + field_details.append(f"* and {len(field.choices) - choices_limit} more") return field_details diff --git a/sphinxcontrib_django/docstrings/classes.py b/sphinxcontrib_django/docstrings/classes.py index 7489244..0dc5003 100644 --- a/sphinxcontrib_django/docstrings/classes.py +++ b/sphinxcontrib_django/docstrings/classes.py @@ -2,25 +2,30 @@ This module contains all functions which are used to improve the documentation of classes. """ +from __future__ import annotations + +from typing import TYPE_CHECKING + from django import forms from django.db import models from sphinx.pycode import ModuleAnalyzer from .field_utils import get_field_type, get_field_verbose_name +if TYPE_CHECKING: + import django + import sphinx + -def improve_class_docstring(app, cls, lines): +def improve_class_docstring( + app: sphinx.application.Sphinx, cls: type, lines: list[str] +) -> None: """ Improve the documentation of a class if it's a Django model or form :param app: The Sphinx application object - :type app: ~sphinx.application.Sphinx - :param cls: The instance of the class to document - :type cls: object - :param lines: The docstring lines - :type lines: list [ str ] """ if issubclass(cls, models.Model): improve_model_docstring(app, cls, lines) @@ -28,26 +33,22 @@ def improve_class_docstring(app, cls, lines): improve_form_docstring(cls, lines) -def improve_model_docstring(app, model, lines): +def improve_model_docstring( + app: sphinx.application.Sphinx, model: django.db.models.Model, lines: list[str] +) -> None: """ Improve the documentation of a Django :class:`~django.db.models.Model` subclass. This adds all model fields as parameters to the ``__init__()`` method. :param app: The Sphinx application object - :type app: ~sphinx.application.Sphinx - :param model: The instance of the model to document - :type model: ~django.db.models.Model - :param lines: The docstring lines - :type lines: list [ str ] """ # Add database table name if app.config.django_show_db_tables: - lines.insert(0, "") - lines.insert(0, f"**Database table:** ``{model._meta.db_table}``") + add_db_table_name(app, model, lines) # Get predefined params to exclude them from the automatically inserted params param_offset = len(":param ") @@ -113,21 +114,38 @@ def improve_model_docstring(app, model, lines): and "sphinx.ext.graphviz" in app.extensions and not any("inheritance-diagram::" in line for line in lines) ): - lines.append(".. inheritance-diagram::") # pragma: no cover + lines.append("") + lines.append(f".. inheritance-diagram:: {model.__module__}.{model.__name__}") + lines.append("") -def add_model_parameters(fields, lines, field_docs): +def add_db_table_name( + app: sphinx.application.Sphinx, model: django.db.models.Model, lines: list[str] +) -> None: + """ + Format and add table name by extension configuration. + + :param app: The Sphinx application object + :param model: The instance of the model to document + :param lines: The docstring lines + """ + if model._meta.abstract and not app.config.django_show_db_tables_abstract: + return + + table_name = None if model._meta.abstract else model._meta.db_table + lines.insert(0, "") + lines.insert(0, f"**Database table:** ``{table_name}``") + + +def add_model_parameters( + fields: list[django.db.models.Field], lines: list[str], field_docs: dict +) -> None: """ Add the given fields as model parameter with the ``:param:`` directive :param fields: The list of fields - :type fields: list [ ~django.db.models.Field ] - :param lines: The list of current docstring lines - :type lines: list [ str ] - :param field_docs: The attribute docstrings of the model - :type field_docs: dict """ for field in fields: # Add docstrings if they are found @@ -145,16 +163,13 @@ def add_model_parameters(fields, lines, field_docs): lines.append(f":type {field.name}: {get_field_type(field, include_role=False)}") -def improve_form_docstring(form, lines): +def improve_form_docstring(form: django.forms.Form, lines: list[str]) -> None: """ Improve the documentation of a Django :class:`~django.forms.Form` class. This highlights the available fields in the form. :param form: The form object - :type form: ~django.forms.Form - :param lines: The list of existing docstring lines - :type lines: list [ str ] """ lines.append("**Form fields:**") lines.append("") diff --git a/sphinxcontrib_django/docstrings/config.py b/sphinxcontrib_django/docstrings/config.py index 9ebcb5d..1f65e95 100644 --- a/sphinxcontrib_django/docstrings/config.py +++ b/sphinxcontrib_django/docstrings/config.py @@ -2,6 +2,7 @@ This module contains configuration of the members which should in-/excluded in sphinx (see :event:`autodoc-skip-member`) """ + #: Ensure that the __init__ method gets documented (also see :confval:`autoclass_content` setting) INCLUDE_MEMBERS = {"__init__"} @@ -22,5 +23,6 @@ "polymorphic_super_sub_accessors_replaced", } -#: How many choices should be shown for model fields +#: How many choices should be shown for model fields by default, +#: used as default for ``django_choices_to_show`` option CHOICES_LIMIT = 10 diff --git a/sphinxcontrib_django/docstrings/field_utils.py b/sphinxcontrib_django/docstrings/field_utils.py index bb732e2..77eee22 100644 --- a/sphinxcontrib_django/docstrings/field_utils.py +++ b/sphinxcontrib_django/docstrings/field_utils.py @@ -3,24 +3,27 @@ :mod:`~sphinxcontrib_django.docstrings.attributes` and :mod:`~sphinxcontrib_django.docstrings.classes` modules. """ + +from __future__ import annotations + +from typing import TYPE_CHECKING + from django.apps import apps from django.contrib import contenttypes from django.db import models from django.utils.encoding import force_str +if TYPE_CHECKING: + import django -def get_field_type(field, include_role=True): + +def get_field_type(field: django.db.models.Field, include_role: bool = True) -> str: """ Get the type of a field including the correct intersphinx mappings. :param field: The field - :type field: ~django.db.models.Field - :param include_directive: Whether or not the role :any:`py:class` should be included - :type include_directive: bool - :return: The type of the field - :rtype: str """ if isinstance(field, models.fields.related.RelatedField): to = field.remote_field.model @@ -31,23 +34,22 @@ def get_field_type(field, include_role=True): f":class:`~{type(field).__module__}.{type(field).__name__}` to" f" :class:`~{to.__module__}.{to.__name__}`" ) - elif isinstance(field, models.fields.reverse_related.ForeignObjectRel): + if isinstance(field, models.fields.reverse_related.ForeignObjectRel): to = field.remote_field.model return ( "Reverse" f" :class:`~{type(field.remote_field).__module__}.{type(field.remote_field).__name__}`" f" from :class:`~{to.__module__}.{to.__name__}`" ) - else: - if include_role: - # For the docstrings of attributes, the :class: role is required - return f":class:`~{type(field).__module__}.{type(field).__name__}`" - else: - # For the :param: role in class docstrings, the :class: role is not required - return f"~{type(field).__module__}.{type(field).__name__}" + if include_role: + # For the docstrings of attributes, the :class: role is required + return f":class:`~{type(field).__module__}.{type(field).__name__}`" + # For the :param: role in class docstrings, the :class: role is not required + return f"~{type(field).__module__}.{type(field).__name__}" -def get_field_verbose_name(field): + +def get_field_verbose_name(field: django.db.models.Field) -> str: """ Get the verbose name of the field. If the field has a ``help_text``, it is also included. @@ -56,7 +58,6 @@ def get_field_verbose_name(field): For reverse related fields, the originating field is linked. :param field: The field - :type field: ~django.db.models.Field """ help_text = "" # Check whether the field is a reverse related field @@ -138,18 +139,15 @@ def get_field_verbose_name(field): return verbose_name -def get_model_from_string(field, model_string): +def get_model_from_string( + field: django.db.models.Field, model_string: str +) -> type[django.db.models.Model]: """ Get a model class from a string :param field: The field - :type field: ~django.db.models.Field - :param model_string: The string label of the model - :type model_string: str - :return: The class of the model - :rtype: type """ if "." in model_string: model = apps.get_model(model_string) diff --git a/sphinxcontrib_django/docstrings/methods.py b/sphinxcontrib_django/docstrings/methods.py index c5973f2..771ab6c 100644 --- a/sphinxcontrib_django/docstrings/methods.py +++ b/sphinxcontrib_django/docstrings/methods.py @@ -1,6 +1,7 @@ """ This module contains all functions which are used to improve the documentation of methods. """ + import re RE_GET_FOO_DISPLAY = re.compile(r"\.get_(?P[a-zA-Z0-9_]+)_display$") diff --git a/sphinxcontrib_django/docstrings/patches.py b/sphinxcontrib_django/docstrings/patches.py index d3c91c2..2597b80 100644 --- a/sphinxcontrib_django/docstrings/patches.py +++ b/sphinxcontrib_django/docstrings/patches.py @@ -1,6 +1,9 @@ """ This module contains patches for Django to improve its interaction with Sphinx. """ + +import contextlib + from django import apps, forms, test from django.db import models @@ -84,10 +87,8 @@ def patch_django_for_autodoc(): for parent_module_str, django_modules in DJANGO_MODULE_PATHS.items(): for django_module in django_modules: for module_class in map(django_module.__dict__.get, django_module.__all__): - try: + with contextlib.suppress(AttributeError): module_class.__module__ = parent_module_str - except AttributeError: - pass # Fix module path of model manager models.manager.Manager.__module__ = "django.db.models" diff --git a/sphinxcontrib_django/roles.py b/sphinxcontrib_django/roles.py index f80a696..8f66d12 100644 --- a/sphinxcontrib_django/roles.py +++ b/sphinxcontrib_django/roles.py @@ -21,16 +21,23 @@ "sphinxcontrib_django.roles", ] """ + +from __future__ import annotations + import logging +from typing import TYPE_CHECKING from sphinx.errors import ExtensionError from . import __version__ +if TYPE_CHECKING: + import sphinx + logger = logging.getLogger(__name__) -def setup(app): +def setup(app: sphinx.application.Sphinx) -> dict: """ Allow this module to be used as Sphinx extension. @@ -39,7 +46,6 @@ def setup(app): It adds cross-reference types via :meth:`~sphinx.application.Sphinx.add_crossref_type`. :param app: The Sphinx application object - :type app: ~sphinx.application.Sphinx """ # Load sphinx.ext.intersphinx extension app.setup_extension("sphinx.ext.intersphinx") @@ -65,7 +71,9 @@ def setup(app): } -def add_default_intersphinx_mappings(app, config): +def add_default_intersphinx_mappings( + app: sphinx.application.Sphinx, config: sphinx.config.Config +) -> None: """ This function provides a default intersphinx mapping to the documentations of Python, Django and Sphinx if ``intersphinx_mapping`` is not given in ``conf.py``. @@ -73,10 +81,7 @@ def add_default_intersphinx_mappings(app, config): Called on the :event:`config-inited` event. :param app: The Sphinx application object - :type app: ~sphinx.application.Sphinx - :param config: The Sphinx configuration - :type config: ~sphinx.config.Config """ DEFAULT_INTERSPHINX_MAPPING = { "python": ("https://docs.python.org/", None), @@ -87,4 +92,5 @@ def add_default_intersphinx_mappings(app, config): ), } if not config.intersphinx_mapping: - config.intersphinx_mapping = DEFAULT_INTERSPHINX_MAPPING + # TYPING: type hints are missing `.intersphinx_mapping` attribute. + config.intersphinx_mapping = DEFAULT_INTERSPHINX_MAPPING # type: ignore[attr-defined ] diff --git a/tests/conftest.py b/tests/conftest.py index c8cc794..3ab43f0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ https://github.com/sphinx-doc/sphinx/issues/7008), so this setup was created using the given code snippets and the existing test cases for the autodoc extension. """ + from unittest.mock import Mock import pytest @@ -43,8 +44,7 @@ def setup_app_with_different_config(app_params, make_app): def setup_app_with_different_config(**confoverrides): args, kwargs = app_params kwargs["confoverrides"] = confoverrides - _app = make_app(*args, **kwargs) - return _app + return make_app(*args, **kwargs) return setup_app_with_different_config @@ -54,7 +54,7 @@ def do_autodoc(): """ This function simulates the autodoc functionality. - Taken from https://github.com/sphinx-doc/sphinx/blob/d635d94eebbca0ebb1a5402aa07ed58c0464c6d3/tests/test_ext_autodoc.py#L33-L45 # noqa: E501 + Taken from https://github.com/sphinx-doc/sphinx/blob/d635d94eebbca0ebb1a5402aa07ed58c0464c6d3/tests/test_ext_autodoc.py#L33-L45 """ def do_autodoc(app, objtype, name, options=None): diff --git a/tests/roots/test-docstrings/conf.py b/tests/roots/test-docstrings/conf.py index fcd54cd..e4e6cf8 100644 --- a/tests/roots/test-docstrings/conf.py +++ b/tests/roots/test-docstrings/conf.py @@ -5,7 +5,11 @@ sys.path.insert(0, os.path.abspath(".")) project = "sphinx dummy Test" -extensions = ["sphinxcontrib_django"] +extensions = [ + "sphinxcontrib_django", + "sphinx.ext.graphviz", + "sphinx.ext.inheritance_diagram", +] # Configure Django settings module django_settings = "dummy_django_app.settings" diff --git a/tests/roots/test-docstrings/dummy_django_app/forms.py b/tests/roots/test-docstrings/dummy_django_app/forms.py index 0f92c5e..0fbcf50 100644 --- a/tests/roots/test-docstrings/dummy_django_app/forms.py +++ b/tests/roots/test-docstrings/dummy_django_app/forms.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from django import forms from .models import SimpleModel @@ -7,7 +9,7 @@ class SimpleForm(forms.ModelForm): test1 = forms.CharField(label="Test1") test2 = forms.CharField(help_text="Test2") - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: """ This is a custom init method """ diff --git a/tests/roots/test-docstrings/dummy_django_app/models.py b/tests/roots/test-docstrings/dummy_django_app/models.py index bfcf9be..8be4566 100644 --- a/tests/roots/test-docstrings/dummy_django_app/models.py +++ b/tests/roots/test-docstrings/dummy_django_app/models.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models @@ -95,6 +97,9 @@ class ChoiceModel(models.Model): choice_limit_above = models.IntegerField( choices=[(i, i) for i in range(CHOICES_LIMIT + 2)] ) + choice_with_empty = models.CharField( + choices=[("", "Empty"), ("Something", "Not empty")] + ) class TaggedItem(models.Model): @@ -105,7 +110,7 @@ class TaggedItem(models.Model): object_id = models.PositiveIntegerField() content_object = GenericForeignKey("content_type", "object_id") - def __str__(self): + def __str__(self) -> str: return self.tag diff --git a/tests/test_attribute_docstrings.py b/tests/test_attribute_docstrings.py index f292f14..3b582c3 100644 --- a/tests/test_attribute_docstrings.py +++ b/tests/test_attribute_docstrings.py @@ -371,6 +371,64 @@ def test_choice_field_limit_above(app, do_autodoc): ] +@pytest.mark.sphinx( + "html", testroot="docstrings", confoverrides={"django_choices_to_show": 15} +) +def test_choice_field_custom_limit(app, do_autodoc): + actual = do_autodoc( + app, "attribute", "dummy_django_app.models.ChoiceModel.choice_limit_above" + ) + print(actual) + assert list(actual) == [ + "", + ".. py:attribute:: ChoiceModel.choice_limit_above", + " :module: dummy_django_app.models", + "", + " Type: :class:`~django.db.models.IntegerField`", + "", + " Choice limit above", + "", + " Choices:", + "", + " * ``0``", + " * ``1``", + " * ``2``", + " * ``3``", + " * ``4``", + " * ``5``", + " * ``6``", + " * ``7``", + " * ``8``", + " * ``9``", + " * ``10``", + " * ``11``", + "", + ] + + +@pytest.mark.sphinx("html", testroot="docstrings") +def test_choice_field_empty(app, do_autodoc): + actual = do_autodoc( + app, "attribute", "dummy_django_app.models.ChoiceModel.choice_with_empty" + ) + print(actual) + assert list(actual) == [ + "", + ".. py:attribute:: ChoiceModel.choice_with_empty", + " :module: dummy_django_app.models", + "", + " Type: :class:`~django.db.models.CharField`", + "", + " Choice with empty", + "", + " Choices:", + "", + " * ``''`` (Empty string)", + " * ``Something``", + "", + ] + + if PHONENUMBER: @pytest.mark.sphinx("html", testroot="docstrings") diff --git a/tests/test_class_docstrings.py b/tests/test_class_docstrings.py index 3456fe5..960f24e 100644 --- a/tests/test_class_docstrings.py +++ b/tests/test_class_docstrings.py @@ -81,6 +81,8 @@ def test_simple_model(app, do_autodoc): " :class:`~dummy_django_app.models.ChildModelB`" ), "", + " .. inheritance-diagram:: dummy_django_app.models.SimpleModel", + "", ] @@ -168,6 +170,8 @@ def test_database_table(app, do_autodoc): " :class:`~dummy_django_app.models.ChildModelB`" ), "", + " .. inheritance-diagram:: dummy_django_app.models.SimpleModel", + "", ] @@ -199,6 +203,85 @@ def test_abstract_model(app, do_autodoc): " :class:`~dummy_django_app.models.AbstractModel`" ), "", + " .. inheritance-diagram:: dummy_django_app.models.AbstractModel", + "", + ] + + +@pytest.mark.sphinx( + "html", testroot="docstrings", confoverrides={"django_show_db_tables": True} +) +def test_abstract_model_with_tables_names_and_ignore_abstract(app, do_autodoc): + actual = do_autodoc(app, "class", "dummy_django_app.models.AbstractModel") + print(actual) + assert list(actual) == [ + "", + ".. py:class:: AbstractModel(*args, **kwargs)", + " :module: dummy_django_app.models", + "", + "", + " Relationship fields:", + "", + " :param simple_model: Simple model", + ( + " :type simple_model: :class:`~django.db.models.ForeignKey` to" + " :class:`~dummy_django_app.models.SimpleModel`" + ), + " :param user: User", + ( + " :type user: :class:`~django.db.models.ForeignKey` to" + " :class:`~django.contrib.auth.models.User`" + ), + " :param foreignkey_self: Foreignkey self", + ( + " :type foreignkey_self: :class:`~django.db.models.ForeignKey` to" + " :class:`~dummy_django_app.models.AbstractModel`" + ), + "", + " .. inheritance-diagram:: dummy_django_app.models.AbstractModel", + "", + ] + + +@pytest.mark.sphinx( + "html", + testroot="docstrings", + confoverrides={ + "django_show_db_tables": True, + "django_show_db_tables_abstract": True, + }, +) +def test_abstract_model_with_tables_names_and_abstract_show(app, do_autodoc): + actual = do_autodoc(app, "class", "dummy_django_app.models.AbstractModel") + print(actual) + assert list(actual) == [ + "", + ".. py:class:: AbstractModel(*args, **kwargs)", + " :module: dummy_django_app.models", + "", + " **Database table:** ``None``", + "", + "", + " Relationship fields:", + "", + " :param simple_model: Simple model", + ( + " :type simple_model: :class:`~django.db.models.ForeignKey` to" + " :class:`~dummy_django_app.models.SimpleModel`" + ), + " :param user: User", + ( + " :type user: :class:`~django.db.models.ForeignKey` to" + " :class:`~django.contrib.auth.models.User`" + ), + " :param foreignkey_self: Foreignkey self", + ( + " :type foreignkey_self: :class:`~django.db.models.ForeignKey` to" + " :class:`~dummy_django_app.models.AbstractModel`" + ), + "", + " .. inheritance-diagram:: dummy_django_app.models.AbstractModel", + "", ] @@ -227,6 +310,8 @@ def test_file_model(app, do_autodoc): " :class:`~dummy_django_app.models.SimpleModel`" ), "", + " .. inheritance-diagram:: dummy_django_app.models.FileModel", + "", ] @@ -266,6 +351,8 @@ def test_tagged_item(app, do_autodoc): " :class:`~django.contrib.contenttypes.models.ContentType`" ), "", + " .. inheritance-diagram:: dummy_django_app.models.TaggedItem", + "", ] diff --git a/tests/test_django_configured.py b/tests/test_django_configured.py index 01d465d..876448e 100644 --- a/tests/test_django_configured.py +++ b/tests/test_django_configured.py @@ -15,4 +15,6 @@ def test_django_configured(app, do_autodoc): " :param id: Primary key: ID", " :type id: ~django.db.models.AutoField", "", + " .. inheritance-diagram:: dummy_django_app.models.MonkeyPatched", + "", ]