Skip to content

Commit 1f5f312

Browse files
committed
Handle inner classes
1 parent 4270744 commit 1f5f312

8 files changed

Lines changed: 168 additions & 72 deletions

File tree

justfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,5 @@ test *args="":
3636
-uv run -m coverage report
3737
uv run -m coverage html
3838

39-
e2e:
40-
classify tests.dummy_class.DummyClass --django-settings classify.contrib.django.settings --console-theme dracula
39+
e2e *args="--console-theme dracula":
40+
classify tests.dummy_class.DummyClass --django-settings classify.contrib.django.settings {{ args }}

src/classify/library.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class Class:
3838
ancestors: list[str]
3939
parents: list[str]
4040
attributes: dict[str, list[Attribute]]
41+
classes: list["Class"]
4142
methods: dict[str, list["Method"]]
4243
properties: list = Factory(list)
4344

@@ -101,7 +102,7 @@ def build_methods(members: list[Member]) -> Generator[Method, None, None]:
101102
yield Method(
102103
name=member.name,
103104
docstring=pydoc.getdoc(member.obj),
104-
defining_class=member.cls,
105+
defining_class=member.cls.__name__,
105106
arguments=arguments,
106107
code="".join(lines),
107108
lines=Line(start=start_line, total=len(lines)),
@@ -117,16 +118,25 @@ def classify[C](obj: type[C]) -> Class:
117118
# build up dicts of attrs&methods, by name, because they can be defined on
118119
# more than one class in the MRO
119120
attributes = collections.defaultdict(list)
121+
classes = []
120122
methods = collections.defaultdict(list)
121123

122124
for cls in mro:
123125
members = list(get_members(cls))
124126

125127
## ATTRIBUTES
126-
class_attrs = [m for m in members if m.kind == "data"]
128+
class_attrs = [
129+
m for m in members if m.kind == "data" and not inspect.isclass(m.obj)
130+
]
127131
for attribute in build_attributes(class_attrs):
128132
attributes[attribute.name].append(attribute)
129133

134+
## CLASSES
135+
inner_classes = [
136+
m for m in members if m.kind == "data" and inspect.isclass(m.obj)
137+
]
138+
classes.extend(classify(c.obj) for c in inner_classes)
139+
130140
## METHODS
131141
instance_methods = [
132142
m for m in members if m.kind in ["method", "class method", "static method"]
@@ -137,9 +147,10 @@ def classify[C](obj: type[C]) -> Class:
137147
return Class(
138148
name=obj.__name__,
139149
docstring=pydoc.getdoc(obj),
140-
ancestors=[k.__name__ for k in mro],
141-
parents=inspect.getclasstree([obj])[-1][0][1],
150+
ancestors=[k.__name__ for k in mro[:-1]],
151+
parents=get_parents(obj),
142152
attributes=dict(sorted(attributes.items())),
153+
classes=sorted(classes),
143154
methods=dict(sorted(methods.items())),
144155
)
145156

@@ -171,6 +182,16 @@ def get_members(obj) -> list[Member]:
171182
]
172183

173184

185+
def get_parents[C](obj: type[C]) -> list[str]:
186+
tree = inspect.getclasstree([obj])
187+
188+
# getclasstree returns a list of tuples, containing a class, and tuple with
189+
# that classes parents. We just want the parents for the given obj.
190+
raw_parents = tree[-1][0][1]
191+
192+
return [c for c in raw_parents if c is not builtins.object]
193+
194+
174195
def resolve(thing: str) -> type[C]:
175196
"""Find the given thing and ensure it's a class"""
176197
sys.path.insert(0, "")

src/classify/renderers/string.py

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,36 @@
1-
from ..library import Class
1+
from ..library import Class, Method
22

33

4-
indent = " " * 4
4+
# define this here so we know what "1" indent is and can remove it for inner
5+
# class declarations
6+
DEFAULT_INDENT_WIDTH = 4
57

68

7-
def attributes(attributes):
9+
def attributes(attributes, indent) -> str:
810
attrs = []
911
for name, definitions in attributes.items():
1012
value = definitions[-1].value
1113
attrs.append(f"{indent}{name} = {value}\n")
1214
return "".join(attrs)
1315

1416

15-
def declaration(name, parents):
16-
parents = ", ".join([p.__name__ for p in parents])
17-
return f"class {name}({parents}):"
17+
def classes(classes, indent) -> str:
18+
content = [to_string(c, indent=indent + indent) for c in classes]
19+
return "".join(content)
1820

1921

20-
def docstring(docstring):
22+
def declaration(name, parents, indent) -> str:
23+
indent = indent[:-DEFAULT_INDENT_WIDTH]
24+
content = f"{indent}class {name}"
25+
26+
if parents:
27+
parents = ", ".join([p.__name__ for p in parents])
28+
content = f"{content}({parents})"
29+
30+
return f"{content}:"
31+
32+
33+
def docstring(docstring, indent) -> str:
2134
if not docstring:
2235
return ""
2336

@@ -27,23 +40,31 @@ def docstring(docstring):
2740
return f"{quotes}{block}{quotes}"
2841

2942

30-
def methods(methods):
43+
def methods(methods: dict[str, list[Method]], indent) -> str:
3144
content = ""
3245
for definitions in methods.values():
33-
for d in definitions:
34-
lines = d.code.split("\n")[:-1]
46+
for i, method in enumerate(definitions):
47+
if len(definitions) > 1 and i == 0:
48+
content += f"{indent}# Defined on: {method.defining_class}\n"
49+
lines = method.code.split("\n")[:-1]
3550
for line in lines:
51+
# TODO: dedent code at source so defined indent isn't tied to
52+
# presentation indent
3653
content += f"{indent}{line[4:]}\n"
3754
content += "\n"
38-
return content
55+
56+
# add strip to remove the trailing newline, rather than polluting the loop
57+
# with logic to work out if we're on the final loop iteration
58+
return content.strip("\n")
3959

4060

41-
def to_string(structure: Class) -> str:
42-
content = declaration(structure.name, structure.parents)
61+
def to_string(structure: Class, indent: str = " " * DEFAULT_INDENT_WIDTH) -> str:
62+
content = declaration(structure.name, structure.parents, indent)
4363
content += "\n"
44-
content += docstring(structure.docstring) if docstring else ""
45-
content += attributes(structure.attributes)
64+
content += docstring(structure.docstring, indent) if docstring else ""
65+
content += attributes(structure.attributes, indent)
4666
content += "\n"
47-
content += methods(structure.methods)
67+
content += classes(structure.classes, indent)
68+
content += methods(structure.methods, indent)
4869

4970
return content

src/classify/templates/class.html

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<h1>class {{ klass.name }}</h1>
2+
3+
{% if klass.docstring %}
4+
<pre>{{ klass.docstring }}</pre>
5+
{% endif %}
6+
7+
{% if klass.ancestors %}
8+
<h2>Ancestors (MRO)</h2>
9+
<ol>
10+
{% for ancestor in klass.ancestors %}
11+
<li>{{ ancestor }}</li>
12+
{% endfor %}
13+
</ol>
14+
{% endif %}
15+
16+
{% if klass.attributes %}
17+
<h2>Attributes</h2>
18+
<table>
19+
<thead>
20+
<tr>
21+
<th>&nbsp;</th>
22+
<th>Defined in</th>
23+
</tr>
24+
</thead>
25+
<tbody>
26+
{% for name, attributes in klass.attributes.items() %}
27+
{% for attribute in attributes %}
28+
<tr>
29+
<td>
30+
<code class="attribute{% if not loop.last %} overridden{% endif %}"{% if not loop.last %} style="text-decoration:line-through"{% endif %}>
31+
{{ name }} = {{ attribute.object|e }}
32+
</code>
33+
</td>
34+
<td>{{ attribute.defining_class }}</td>
35+
</tr>
36+
{% endfor %}
37+
{% endfor %}
38+
</tbody>
39+
</table>
40+
{% endif %}
41+
42+
{% if klass.classes %}
43+
<h2>Inner classes</h2>
44+
45+
{% for foo in klass.classes %}
46+
{% with klass=foo %}
47+
{% include "class.html" %}
48+
{% endwith %}
49+
{% endfor %}
50+
51+
{% endif %}
52+
53+
{% if klass.methods %}
54+
<h2>Methods</h2>
55+
{% for name, declarations in klass.methods.items() %}
56+
{% for declaration in declarations %}
57+
<div class="method">
58+
<h3>def {{ name }}{{ declaration.arguments }}: [{{ declaration.defining_class }}]</h3>
59+
<p>{{ declaration.docstring|e }}</p>
60+
<p>Found on lines {{ declaration.lines.start }} to {{ declaration.lines.start+declaration.lines.total }} of {{ declaration.file }}</p>
61+
<pre>{{ declaration.code }}</pre>
62+
</div>
63+
{% endfor %}
64+
{% endfor %}
65+
{% endif %}

src/classify/templates/web.html

Lines changed: 8 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,17 @@
11
<!DOCTYPE html>
22
<html lang="en">
3-
<head>
3+
<head>
44
<meta charset="utf-8">
55
<title>{{ klass.name }}</title>
66
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7-
</head>
8-
<body>
7+
</head>
8+
<body>
99
<div class="container">
10-
<article id="main">
11-
{% if klass.attributes %}
12-
<h2>Attributes</h2>
13-
<table>
14-
<thead>
15-
<tr>
16-
<th>&nbsp;</th>
17-
<th>Defined in</th>
18-
</tr>
19-
</thead>
20-
<tbody>
21-
{% for name, attributes in klass.attributes.items() %}
22-
{% for attribute in attributes %}
23-
<tr>
24-
<td>
25-
<code class="attribute{% if not loop.last %} overridden{% endif %}"{% if not loop.last %} style="text-decoration:line-through"{% endif %}>
26-
{{ name }} = {{ attribute.object|e }}
27-
</code>
28-
</td>
29-
<td>{{ attribute.defining_class }}</td>
30-
</tr>
31-
{% endfor %}
32-
{% endfor %}
33-
</tbody>
34-
</table>
35-
{% endif %}
10+
<article id="main">
3611

37-
{% if klass.methods %}
38-
<h2>Methods</h2>
39-
{% for name, declarations in klass.methods.items() %}
40-
{% for declaration in declarations %}
41-
<div class="method">
42-
<h3>def {{ name }}{{ declaration.arguments }}: [{{ declaration.defining_class.__name__ }}]</h3>
43-
<p>{{ declaration.docstring|e }}</p>
44-
<p>Found on lines {{ declaration.lines.start }} to {{ declaration.lines.start+declaration.lines.total }} of {{ declaration.file }}</p>
45-
<pre>{{ declaration.code }}</pre>
46-
</div>
47-
{% endfor %}
48-
{% endfor %}
49-
{% endif %}
50-
</article>
12+
{% include "class.html" %}
13+
14+
</article>
5115
</div> <!-- /container -->
52-
</body>
16+
</body>
5317
</html>

tests/conftest.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,27 @@
11
import pytest
22

3-
from classify.library import Class, Line, Method
3+
from classify.library import Attribute, Class, Line, Method
4+
5+
6+
def inner_class(name):
7+
return Class(
8+
name=name,
9+
docstring="",
10+
ancestors=[name],
11+
parents=[],
12+
attributes={
13+
"abc": [
14+
Attribute(
15+
name="abc",
16+
object="123",
17+
defining_class=name,
18+
value="123",
19+
)
20+
]
21+
},
22+
classes=[],
23+
methods={},
24+
)
425

526

627
def method(name, **kwargs):
@@ -18,14 +39,17 @@ def method(name, **kwargs):
1839
def dummy_class():
1940
return Class(
2041
name="MyClass",
42+
docstring="",
43+
ancestors=[],
44+
parents=["ParentClass"],
45+
attributes={},
46+
classes=[
47+
inner_class("Meta"),
48+
],
2149
methods={
2250
"one": [
2351
method("one", defining_class="ParentClass"),
2452
method("one", defining_class="MyClass"),
2553
]
2654
},
27-
docstring="",
28-
ancestors=[],
29-
parents=["ParentClass"],
30-
attributes={},
3155
)

tests/renderers/test_string.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33

44
def test_docstring():
5-
assert docstring("") == ""
5+
assert docstring("", indent=" ") == ""

tests/test_library.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
(
1313
DummyClass,
1414
[
15+
"Meta",
1516
"__init__",
1617
"class_method",
1718
"class_only_method",

0 commit comments

Comments
 (0)