Skip to content

Commit 6416a05

Browse files
authored
feat: Add support for Django models (#101)
Issue #39: #39 PR #101: #101
1 parent c4b58a6 commit 6416a05

File tree

4 files changed

+119
-4
lines changed

4 files changed

+119
-4
lines changed

Diff for: pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ toml = "^0.10.2"
5959
wemake-python-styleguide = "^0.14.1"
6060
pydantic = "^1.8.1"
6161
marshmallow = "^3.11.1"
62+
Django = "^3.2"
6263

6364
[tool.poetry.scripts]
6465
pytkdocs = "pytkdocs.cli:main"

Diff for: src/pytkdocs/loader.py

+84-4
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
import warnings
1313
from functools import lru_cache
1414
from itertools import chain
15+
from operator import attrgetter
1516
from pathlib import Path
16-
from typing import Any, Dict, List, Optional, Set, Union
17+
from typing import Any, Dict, List, Mapping, Optional, Set, Tuple, Union
1718

1819
from pytkdocs.objects import Attribute, Class, Function, Method, Module, Object, Source
1920
from pytkdocs.parsers.attributes import get_class_attributes, get_instance_attributes, get_module_attributes, merge
@@ -508,6 +509,7 @@ def get_class_documentation(self, node: ObjectNode, select_members=None) -> Clas
508509
for attr_name, properties, add_method in (
509510
("__fields__", ["pydantic-model"], self.get_pydantic_field_documentation),
510511
("_declared_fields", ["marshmallow-model"], self.get_marshmallow_field_documentation),
512+
("_meta.get_fields", ["django-model"], self.get_django_field_documentation),
511513
("__dataclass_fields__", ["dataclass"], self.get_annotated_dataclass_field),
512514
):
513515
if self.detect_field_model(attr_name, direct_members, all_members):
@@ -530,14 +532,24 @@ def detect_field_model(self, attr_name: str, direct_members, all_members) -> boo
530532
Detect if an attribute is present in members.
531533
532534
Arguments:
533-
attr_name: The name of the attribute to detect.
535+
attr_name: The name of the attribute to detect, can contain dots.
534536
direct_members: The direct members of the class.
535537
all_members: All members of the class.
536538
537539
Returns:
538540
Whether the attribute is present.
539541
"""
540-
return attr_name in direct_members or (self.select_inherited_members and attr_name in all_members)
542+
543+
first_order_attr_name, remainder = split_attr_name(attr_name)
544+
if not (
545+
first_order_attr_name in direct_members
546+
or (self.select_inherited_members and first_order_attr_name in all_members)
547+
):
548+
return False
549+
550+
if remainder and not attrgetter(remainder)(all_members[first_order_attr_name]):
551+
return False
552+
return True
541553

542554
def add_fields(
543555
self,
@@ -561,7 +573,10 @@ def add_fields(
561573
base_class: The class declaring the fields.
562574
add_method: The method to add the children object.
563575
"""
564-
for field_name, field in members[attr_name].items():
576+
577+
fields = get_fields(attr_name, members=members)
578+
579+
for field_name, field in fields.items():
565580
select_field = self.select(field_name, select_members) # type: ignore
566581
is_inherited = field_is_inherited(field_name, attr_name, base_class)
567582

@@ -683,6 +698,35 @@ def get_pydantic_field_documentation(node: ObjectNode) -> Attribute:
683698
properties=properties,
684699
)
685700

701+
@staticmethod
702+
def get_django_field_documentation(node: ObjectNode) -> Attribute:
703+
"""
704+
Get the documentation for a Django Field.
705+
706+
Arguments:
707+
node: The node representing the Field and its parents.
708+
709+
Returns:
710+
The documented attribute object.
711+
"""
712+
prop = node.obj
713+
path = node.dotted_path
714+
properties = ["django-field"]
715+
716+
if prop.null:
717+
properties.append("nullable")
718+
if prop.blank:
719+
properties.append("blank")
720+
721+
return Attribute(
722+
name=node.name,
723+
path=path,
724+
file_path=node.file_path,
725+
docstring=prop.verbose_name,
726+
attr_type=prop.__class__,
727+
properties=properties,
728+
)
729+
686730
@staticmethod
687731
def get_marshmallow_field_documentation(node: ObjectNode) -> Attribute:
688732
"""
@@ -913,3 +957,39 @@ def field_is_inherited(field_name: str, fields_name: str, base_class: type) -> b
913957
*(getattr(parent_class, fields_name, {}).keys() for parent_class in base_class.__mro__[1:-1]),
914958
),
915959
)
960+
961+
962+
def split_attr_name(attr_name: str) -> Tuple[str, Optional[str]]:
963+
"""
964+
Split an attribute name into a first-order attribute name and remainder.
965+
966+
Args:
967+
attr_name: Attribute name (a)
968+
969+
Returns:
970+
Tuple containing:
971+
first_order_attr_name: Name of the first order attribute (a)
972+
remainder: The remainder (b.c)
973+
974+
"""
975+
first_order_attr_name, *remaining = attr_name.split(".", maxsplit=1)
976+
remainder = remaining[0] if remaining else None
977+
return first_order_attr_name, remainder
978+
979+
980+
def get_fields(attr_name: str, *, members: Mapping[str, Any] = None, class_obj=None) -> Dict[str, Any]:
981+
if not (bool(members) ^ bool(class_obj)):
982+
raise ValueError("Either members or class_obj is required.")
983+
first_order_attr_name, remainder = split_attr_name(attr_name)
984+
fields = members[first_order_attr_name] if members else dict(vars(class_obj)).get(first_order_attr_name, {})
985+
if remainder:
986+
fields = attrgetter(remainder)(fields)
987+
988+
if callable(fields):
989+
fields = fields()
990+
991+
if not isinstance(fields, dict):
992+
# Support Django models
993+
fields = {getattr(f, "name", str(f)): f for f in fields if not getattr(f, "auto_created", False)}
994+
995+
return fields

Diff for: tests/fixtures/django.py

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from django import setup
2+
from django.conf import settings
3+
from django.db import models
4+
5+
settings.configure()
6+
setup()
7+
8+
9+
class Person(models.Model):
10+
"""Simple Django Model for a person's information"""
11+
name = models.CharField(verbose_name='Name')
12+
age = models.IntegerField(verbose_name='Age')
13+
parent = models.ForeignKey(verbose_name='Parent', to='Child', on_delete=models.CASCADE)
14+
15+
class Meta:
16+
app_label = 'django'
17+
18+
19+
class Child(models.Model):
20+
name: str = models.CharField(verbose_name='Name')
21+
22+
class Meta:
23+
app_label = 'django'

Diff for: tests/test_loader.py

+11
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import Set
77

88
import pytest
9+
from django.db.models.fields import CharField
910
from marshmallow import fields
1011
from tests import FIXTURES_DIR
1112

@@ -230,6 +231,16 @@ def test_loading_pydantic_model():
230231
assert "pydantic-field" in labels_attr.properties
231232

232233

234+
def test_loading_django_model():
235+
"""Handle Django models"""
236+
loader = Loader()
237+
obj = loader.get_object_documentation("tests.fixtures.django.Person")
238+
assert obj.docstring == "Simple Django Model for a person's information"
239+
name_attr = next(attr for attr in obj.attributes if attr.name == "name")
240+
assert name_attr.type == CharField
241+
assert name_attr.docstring == "Name"
242+
243+
233244
def test_loading_marshmallow_model():
234245
"""Handle Marshmallow models."""
235246
loader = Loader()

0 commit comments

Comments
 (0)