From f3dc173240d2a91c831b08dd6820e1d83322f3da Mon Sep 17 00:00:00 2001 From: Zan Anderle Date: Thu, 30 Oct 2014 20:45:01 +0100 Subject: [PATCH] Fixed #24917 -- Made admindocs display model methods that take arguments. --- .../templates/admin_doc/model_detail.html | 25 +++++++++ django/contrib/admindocs/views.py | 39 +++++++++++--- django/utils/inspect.py | 53 ++++++++++++++++++- docs/ref/contrib/admin/admindocs.txt | 13 +++-- docs/releases/1.9.txt | 6 +++ tests/admin_docs/models.py | 11 ++++ tests/admin_docs/tests.py | 33 +++++++++++- tests/utils_tests/test_inspect.py | 35 ++++++++++++ 8 files changed, 201 insertions(+), 14 deletions(-) create mode 100644 tests/utils_tests/test_inspect.py diff --git a/django/contrib/admindocs/templates/admin_doc/model_detail.html b/django/contrib/admindocs/templates/admin_doc/model_detail.html index 59d8abcd8f..6472119f54 100644 --- a/django/contrib/admindocs/templates/admin_doc/model_detail.html +++ b/django/contrib/admindocs/templates/admin_doc/model_detail.html @@ -27,6 +27,7 @@ {{ description }} +

{% trans 'Fields' %}

@@ -48,6 +49,30 @@
+{% if methods %} +

{% trans 'Methods with arguments' %}

+
+ + + + + + + + + +{% for method in methods|dictsort:"name" %} + + + + + +{% endfor %} + +
{% trans 'Method' %}{% trans 'Arguments' %}{% trans 'Description' %}
{{ method.name }}{{ method.arguments }}{{ method.verbose }}
+
+{% endif %} +

‹ {% trans 'Back to Model Documentation' %}

{% endblock %} diff --git a/django/contrib/admindocs/views.py b/django/contrib/admindocs/views.py index 543b3e6b02..996650cefb 100644 --- a/django/contrib/admindocs/views.py +++ b/django/contrib/admindocs/views.py @@ -14,7 +14,10 @@ from django.db import models from django.http import Http404 from django.template.engine import Engine from django.utils.decorators import method_decorator -from django.utils.inspect import func_has_no_args +from django.utils.inspect import ( + func_accepts_kwargs, func_accepts_var_args, func_has_no_args, + get_func_full_args, +) from django.utils.translation import ugettext as _ from django.views.generic import TemplateView @@ -219,7 +222,7 @@ class ModelDetailView(BaseAdminDocsView): fields.append({ 'name': field.name, 'data_type': data_type, - 'verbose': verbose, + 'verbose': verbose or '', 'help_text': field.help_text, }) @@ -242,9 +245,10 @@ class ModelDetailView(BaseAdminDocsView): 'verbose': utils.parse_rst(_("number of %s") % verbose, 'model', _('model:') + opts.model_name), }) + methods = [] # Gather model methods. for func_name, func in model.__dict__.items(): - if inspect.isfunction(func) and func_has_no_args(func): + if inspect.isfunction(func): try: for exclude in MODEL_METHODS_EXCLUDE: if func_name.startswith(exclude): @@ -254,11 +258,29 @@ class ModelDetailView(BaseAdminDocsView): verbose = func.__doc__ if verbose: verbose = utils.parse_rst(utils.trim_docstring(verbose), 'model', _('model:') + opts.model_name) - fields.append({ - 'name': func_name, - 'data_type': get_return_data_type(func_name), - 'verbose': verbose, - }) + # If a method has no arguments, show it as a 'field', otherwise + # as a 'method with arguments'. + if func_has_no_args(func) and not func_accepts_kwargs(func) and not func_accepts_var_args(func): + fields.append({ + 'name': func_name, + 'data_type': get_return_data_type(func_name), + 'verbose': verbose or '', + }) + else: + arguments = get_func_full_args(func) + print_arguments = arguments + # Join arguments with ', ' and in case of default value, + # join it with '='. Use repr() so that strings will be + # correctly displayed. + print_arguments = ', '.join([ + '='.join(list(arg_el[:1]) + [repr(el) for el in arg_el[1:]]) + for arg_el in arguments + ]) + methods.append({ + 'name': func_name, + 'arguments': print_arguments, + 'verbose': verbose or '', + }) # Gather related objects for rel in opts.related_objects: @@ -282,6 +304,7 @@ class ModelDetailView(BaseAdminDocsView): 'summary': title, 'description': body, 'fields': fields, + 'methods': methods, }) return super(ModelDetailView, self).get_context_data(**kwargs) diff --git a/django/utils/inspect.py b/django/utils/inspect.py index 3e3ad0ac23..597b2c095e 100644 --- a/django/utils/inspect.py +++ b/django/utils/inspect.py @@ -43,6 +43,44 @@ def get_func_args(func): ] +def get_func_full_args(func): + """ + Return a list of (argument name, default value) tuples. If the argument + does not have a default value, omit it in the tuple. Arguments such as + *args and **kwargs are also included. + """ + if six.PY2: + argspec = inspect.getargspec(func) + args = argspec.args[1:] # ignore 'self' + defaults = argspec.defaults or [] + # Split args into two lists depending on whether they have default value + no_default = args[:len(args) - len(defaults)] + with_default = args[len(args) - len(defaults):] + # Join the two lists and combine it with default values + args = [(arg,) for arg in no_default] + zip(with_default, defaults) + # Add possible *args and **kwargs and prepend them with '*' or '**' + varargs = [('*' + argspec.varargs,)] if argspec.varargs else [] + kwargs = [('**' + argspec.keywords,)] if argspec.keywords else [] + return args + varargs + kwargs + + sig = inspect.signature(func) + args = [] + for arg_name, param in sig.parameters.items(): + name = arg_name + # Ignore 'self' + if name == 'self': + continue + if param.kind == inspect.Parameter.VAR_POSITIONAL: + name = '*' + name + elif param.kind == inspect.Parameter.VAR_KEYWORD: + name = '**' + name + if param.default != inspect.Parameter.empty: + args.append((name, param.default)) + else: + args.append((name,)) + return args + + def func_accepts_kwargs(func): if six.PY2: # Not all callables are inspectable with getargspec, so we'll @@ -64,10 +102,23 @@ def func_accepts_kwargs(func): ) +def func_accepts_var_args(func): + """ + Return True if function 'func' accepts positional arguments *args. + """ + if six.PY2: + return inspect.getargspec(func)[1] is not None + + return any( + p for p in inspect.signature(func).parameters.values() + if p.kind == p.VAR_POSITIONAL + ) + + def func_has_no_args(func): args = inspect.getargspec(func)[0] if six.PY2 else [ p for p in inspect.signature(func).parameters.values() - if p.kind == p.POSITIONAL_OR_KEYWORD and p.default is p.empty + if p.kind == p.POSITIONAL_OR_KEYWORD ] return len(args) == 1 diff --git a/docs/ref/contrib/admin/admindocs.txt b/docs/ref/contrib/admin/admindocs.txt index 2d76748e02..241e12da36 100644 --- a/docs/ref/contrib/admin/admindocs.txt +++ b/docs/ref/contrib/admin/admindocs.txt @@ -60,10 +60,15 @@ Model reference =============== The **models** section of the ``admindocs`` page describes each model in the -system along with all the fields and methods (without any arguments) available -on it. While model properties don't have any arguments, they are not listed. -Relationships to other models appear as hyperlinks. Descriptions are pulled -from ``help_text`` attributes on fields or from docstrings on model methods. +system along with all the fields and methods available on it. Relationships +to other models appear as hyperlinks. Descriptions are pulled from ``help_text`` +attributes on fields or from docstrings on model methods. + +.. versionchanged:: 1.9 + + The **models** section of the ``admindocs`` now describes methods that take + arguments as well. In previous versions it was restricted to methods + without arguments. A model with useful documentation might look like this:: diff --git a/docs/releases/1.9.txt b/docs/releases/1.9.txt index a0219bd370..04b5a6e781 100644 --- a/docs/releases/1.9.txt +++ b/docs/releases/1.9.txt @@ -163,6 +163,12 @@ Minor features * JavaScript slug generation now supports Romanian characters. +:mod:`django.contrib.admindocs` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +* The model section of the ``admindocs`` now also describes methods that take + arguments, rather than ignoring them. + :mod:`django.contrib.auth` ^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/admin_docs/models.py b/tests/admin_docs/models.py index 9ddcb762e1..a425ae0fcd 100644 --- a/tests/admin_docs/models.py +++ b/tests/admin_docs/models.py @@ -44,6 +44,17 @@ class Person(models.Model): def _get_full_name(self): return "%s %s" % (self.first_name, self.last_name) + def rename_company(self, new_name): + self.company.name = new_name + self.company.save() + return new_name + + def dummy_function(self, baz, rox, *some_args, **some_kwargs): + return some_kwargs + + def suffix_company_name(self, suffix='ltd'): + return self.company.name + suffix + def add_image(self): pass diff --git a/tests/admin_docs/tests.py b/tests/admin_docs/tests.py index 7619e01465..ff860f9929 100644 --- a/tests/admin_docs/tests.py +++ b/tests/admin_docs/tests.py @@ -246,7 +246,7 @@ class TestModelDetailView(TestDataMixin, AdminDocsTestCase): def setUp(self): self.client.login(username='super', password='secret') with captured_stderr() as self.docutils_stderr: - self.response = self.client.get(reverse('django-admindocs-models-detail', args=['admin_docs', 'person'])) + self.response = self.client.get(reverse('django-admindocs-models-detail', args=['admin_docs', 'Person'])) def test_method_excludes(self): """ @@ -261,6 +261,34 @@ class TestModelDetailView(TestDataMixin, AdminDocsTestCase): self.assertNotContains(self.response, "set_status") self.assertNotContains(self.response, "save_changes") + def test_methods_with_arguments(self): + """ + Methods that take arguments should also displayed. + """ + self.assertContains(self.response, "

Methods with arguments

") + self.assertContains(self.response, "rename_company") + self.assertContains(self.response, "dummy_function") + self.assertContains(self.response, "suffix_company_name") + + def test_methods_with_arguments_display_arguments(self): + """ + Methods with arguments should have their arguments displayed. + """ + self.assertContains(self.response, "new_name") + + def test_methods_with_arguments_display_arguments_default_value(self): + """ + Methods with keyword arguments should have their arguments displayed. + """ + self.assertContains(self.response, "suffix='ltd'") + + def test_methods_with_multiple_arguments_display_arguments(self): + """ + Methods with multiple arguments should have all their arguments + displayed, but omitting 'self'. + """ + self.assertContains(self.response, "baz, rox, *some_args, **some_kwargs") + def test_method_data_types(self): """ We should be able to get a basic idea of the type returned @@ -368,6 +396,9 @@ class TestModelDetailView(TestDataMixin, AdminDocsTestCase): self.assertContains(self.response, body, html=True) self.assertContains(self.response, model_body, html=True) + def test_model_detail_title(self): + self.assertContains(self.response, '

admin_docs.Person

', html=True) + @unittest.skipUnless(utils.docutils_is_available, "no docutils installed.") class TestUtils(AdminDocsTestCase): diff --git a/tests/utils_tests/test_inspect.py b/tests/utils_tests/test_inspect.py new file mode 100644 index 0000000000..88a9fb0d74 --- /dev/null +++ b/tests/utils_tests/test_inspect.py @@ -0,0 +1,35 @@ +import unittest + +from django.utils import inspect + + +class Person(object): + def no_arguments(self): + return None + + def one_argument(self, something): + return something + + def just_args(self, *args): + return args + + def all_kinds(self, name, address='home', age=25, *args, **kwargs): + return kwargs + + +class TestInspectMethods(unittest.TestCase): + def test_get_func_full_args_no_arguments(self): + self.assertEqual(inspect.get_func_full_args(Person.no_arguments), []) + + def test_get_func_full_args_one_argument(self): + self.assertEqual(inspect.get_func_full_args(Person.one_argument), [('something',)]) + + def test_get_func_full_args_all_arguments(self): + arguments = [('name',), ('address', 'home'), ('age', 25), ('*args',), ('**kwargs',)] + self.assertEqual(inspect.get_func_full_args(Person.all_kinds), arguments) + + def test_func_accepts_var_args_has_var_args(self): + self.assertEqual(inspect.func_accepts_var_args(Person.just_args), True) + + def test_func_accepts_var_args_no_var_args(self): + self.assertEqual(inspect.func_accepts_var_args(Person.one_argument), False)