classify 2026.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- classify/__init__.py +10 -0
- classify/__main__.py +5 -0
- classify/classification.py +113 -0
- classify/contrib/__init__.py +0 -0
- classify/contrib/django/__init__.py +0 -0
- classify/contrib/django/settings.py +1 -0
- classify/dataclasses.py +150 -0
- classify/django.py +16 -0
- classify/exceptions.py +2 -0
- classify/filters.py +45 -0
- classify/main.py +98 -0
- classify/renderers/__init__.py +21 -0
- classify/renderers/console.py +19 -0
- classify/renderers/html.py +75 -0
- classify/renderers/string.py +124 -0
- classify/resolution.py +17 -0
- classify/templates/class.html +65 -0
- classify/templates/web.html +17 -0
- classify-2026.7.1.dist-info/METADATA +56 -0
- classify-2026.7.1.dist-info/RECORD +22 -0
- classify-2026.7.1.dist-info/WHEEL +4 -0
- classify-2026.7.1.dist-info/entry_points.txt +3 -0
classify/__init__.py
ADDED
classify/__main__.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import builtins
|
|
2
|
+
import collections
|
|
3
|
+
import inspect
|
|
4
|
+
import pydoc
|
|
5
|
+
|
|
6
|
+
import structlog
|
|
7
|
+
|
|
8
|
+
from .dataclasses import Attribute, Class, DataDescriptor, Member, Method, SimpleClass
|
|
9
|
+
from .filters import (
|
|
10
|
+
is_attribute,
|
|
11
|
+
is_data_descriptor,
|
|
12
|
+
is_inner_class,
|
|
13
|
+
is_method,
|
|
14
|
+
is_property,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
logger = structlog.get_logger()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def classify[C](obj: type[C]) -> Class:
|
|
22
|
+
# flatten the MRO of the given class and flip the order so it's the first
|
|
23
|
+
# non-object class first
|
|
24
|
+
mro = [cls for cls in reversed(inspect.getmro(obj)) if cls is not builtins.object]
|
|
25
|
+
|
|
26
|
+
# build up dicts of attrs&methods, by name, because they can be defined on
|
|
27
|
+
# more than one class in the MRO
|
|
28
|
+
attributes = collections.defaultdict(list)
|
|
29
|
+
classes = []
|
|
30
|
+
data_descriptors = collections.defaultdict(list)
|
|
31
|
+
methods = collections.defaultdict(list)
|
|
32
|
+
properties = collections.defaultdict(list)
|
|
33
|
+
|
|
34
|
+
structlog.contextvars.clear_contextvars()
|
|
35
|
+
for cls in mro:
|
|
36
|
+
structlog.contextvars.bind_contextvars(**{"class": cls.__name__})
|
|
37
|
+
members = list(get_members(cls))
|
|
38
|
+
|
|
39
|
+
## ATTRIBUTES
|
|
40
|
+
class_attrs = [m for m in members if is_attribute(m)]
|
|
41
|
+
for member in class_attrs:
|
|
42
|
+
structlog.contextvars.bind_contextvars(member=member)
|
|
43
|
+
attributes[member.name].append(Attribute.from_member(member))
|
|
44
|
+
|
|
45
|
+
## CLASSES
|
|
46
|
+
inner_classes = [m for m in members if is_inner_class(m)]
|
|
47
|
+
classes.extend(classify(c.obj) for c in inner_classes)
|
|
48
|
+
|
|
49
|
+
## METHODS
|
|
50
|
+
instance_methods = [m for m in members if is_method(m)]
|
|
51
|
+
for member in instance_methods:
|
|
52
|
+
structlog.contextvars.bind_contextvars(member=member)
|
|
53
|
+
methods[member.name].append(Method.from_member(member))
|
|
54
|
+
|
|
55
|
+
## PROPERTIES
|
|
56
|
+
props = [m for m in members if is_property(m)]
|
|
57
|
+
for member in props:
|
|
58
|
+
logger.debug("extracting property", member=member)
|
|
59
|
+
prop = Method.from_func(member.obj.fget, member.cls)
|
|
60
|
+
properties[member.name].append(prop)
|
|
61
|
+
|
|
62
|
+
## DATA DESCRIPTORS
|
|
63
|
+
descriptors = [m for m in members if is_data_descriptor(m)]
|
|
64
|
+
for member in descriptors:
|
|
65
|
+
structlog.contextvars.bind_contextvars(member=member)
|
|
66
|
+
data_descriptors[member.name].append(DataDescriptor.from_member(member))
|
|
67
|
+
|
|
68
|
+
ancestors = [SimpleClass.from_class(c) for c in mro[:-1]]
|
|
69
|
+
|
|
70
|
+
return Class(
|
|
71
|
+
name=obj.__name__,
|
|
72
|
+
module=obj.__module__,
|
|
73
|
+
docstring=pydoc.getdoc(obj),
|
|
74
|
+
ancestors=ancestors,
|
|
75
|
+
parents=get_parents(obj),
|
|
76
|
+
attributes=dict(sorted(attributes.items())),
|
|
77
|
+
classes=sorted(classes, key=lambda c: c.name),
|
|
78
|
+
properties=dict(sorted(properties.items())),
|
|
79
|
+
data_descriptors=dict(sorted(data_descriptors.items())),
|
|
80
|
+
methods=dict(sorted(methods.items())),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_members(obj) -> list[Member]:
|
|
85
|
+
"""
|
|
86
|
+
Get members from the given object
|
|
87
|
+
|
|
88
|
+
classify_class_attrs returns a tuple of:
|
|
89
|
+
- name
|
|
90
|
+
- kind
|
|
91
|
+
- class
|
|
92
|
+
- object
|
|
93
|
+
"""
|
|
94
|
+
members = [
|
|
95
|
+
Member(name=name, kind=kind, cls=cls, obj=obj)
|
|
96
|
+
for name, kind, cls, obj in pydoc.classify_class_attrs(obj)
|
|
97
|
+
]
|
|
98
|
+
# filter down to non-private items and those defined on the given object
|
|
99
|
+
return [
|
|
100
|
+
member
|
|
101
|
+
for member in members
|
|
102
|
+
if pydoc.visiblename(member.name, obj=obj) and member.cls == obj
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_parents[C](obj: type[C]) -> list[str]:
|
|
107
|
+
tree = inspect.getclasstree([obj])
|
|
108
|
+
|
|
109
|
+
# getclasstree returns a list of tuples, containing a class, and tuple with
|
|
110
|
+
# that classes parents. We just want the parents for the given obj.
|
|
111
|
+
raw_parents = tree[-1][0][1]
|
|
112
|
+
|
|
113
|
+
return [c for c in raw_parents if c is not builtins.object]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
SECRET_KEY = "foo"
|
classify/dataclasses.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import pydoc
|
|
3
|
+
from typing import Any, Literal, Self
|
|
4
|
+
|
|
5
|
+
import structlog
|
|
6
|
+
from attrs import frozen
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
logger = structlog.get_logger()
|
|
10
|
+
|
|
11
|
+
Kind = Literal[
|
|
12
|
+
"class method",
|
|
13
|
+
"static method",
|
|
14
|
+
"property",
|
|
15
|
+
"method",
|
|
16
|
+
"data",
|
|
17
|
+
"data descriptor",
|
|
18
|
+
"readonly property",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@frozen
|
|
23
|
+
class Attribute:
|
|
24
|
+
name: str
|
|
25
|
+
defining_class: "SimpleClass"
|
|
26
|
+
value: Any
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_member(cls, member: "Member") -> Self:
|
|
30
|
+
logger.debug("extracting attribute", member=member)
|
|
31
|
+
return cls(
|
|
32
|
+
name=member.name,
|
|
33
|
+
defining_class=SimpleClass.from_class(member.cls),
|
|
34
|
+
value=member.obj,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@frozen
|
|
39
|
+
class Class:
|
|
40
|
+
name: str
|
|
41
|
+
module: str
|
|
42
|
+
docstring: str
|
|
43
|
+
ancestors: list[str]
|
|
44
|
+
parents: list[str]
|
|
45
|
+
attributes: dict[str, list[Attribute]]
|
|
46
|
+
classes: list["Class"]
|
|
47
|
+
properties: dict[str, list["Method"]]
|
|
48
|
+
data_descriptors: dict[str, list["DataDescriptor"]]
|
|
49
|
+
methods: dict[str, list["Method"]]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@frozen
|
|
53
|
+
class DataDescriptor:
|
|
54
|
+
name: str
|
|
55
|
+
getter: "Method | None"
|
|
56
|
+
setter: "Method | None"
|
|
57
|
+
deleter: "Method | None"
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_member(cls, member: "Member") -> Self:
|
|
61
|
+
logger.debug("extracting data descriptor")
|
|
62
|
+
|
|
63
|
+
getter = (
|
|
64
|
+
Method.from_func(member.obj.fget, member.cls)
|
|
65
|
+
if hasattr(member.obj, "fget")
|
|
66
|
+
else None
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
setter = None
|
|
70
|
+
if fset := getattr(member.obj, "fset", None):
|
|
71
|
+
setter = Method.from_func(fset, member.cls)
|
|
72
|
+
|
|
73
|
+
# property() creates an fdel with the value `None`
|
|
74
|
+
deleter = None
|
|
75
|
+
if fdel := getattr(member.obj, "fdel", None):
|
|
76
|
+
deleter = Method.from_func(fdel, member.cls)
|
|
77
|
+
|
|
78
|
+
return cls(name=member.name, getter=getter, setter=setter, deleter=deleter)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@frozen
|
|
82
|
+
class Line:
|
|
83
|
+
start: int
|
|
84
|
+
total: int
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@frozen
|
|
88
|
+
class Member[C]:
|
|
89
|
+
name: str
|
|
90
|
+
kind: Kind
|
|
91
|
+
cls: type[C]
|
|
92
|
+
obj: Any
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@frozen
|
|
96
|
+
class Method:
|
|
97
|
+
name: str
|
|
98
|
+
docstring: str
|
|
99
|
+
defining_class: "SimpleClass"
|
|
100
|
+
arguments: str
|
|
101
|
+
code: str
|
|
102
|
+
lines: Line
|
|
103
|
+
file: str | None = None
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def from_func(cls, func, defining_class) -> Self:
|
|
107
|
+
# get target of cached property decorators
|
|
108
|
+
if hasattr(func, "func"):
|
|
109
|
+
while getattr(func, "func", None):
|
|
110
|
+
func = func.func
|
|
111
|
+
|
|
112
|
+
# unwrap decorated methods and functions
|
|
113
|
+
if hasattr(func, "__wrapped__"): # decorated methods
|
|
114
|
+
while getattr(func, "__wrapped__", None):
|
|
115
|
+
func = func.__wrapped__
|
|
116
|
+
|
|
117
|
+
arguments = str(inspect.signature(func))
|
|
118
|
+
|
|
119
|
+
# Get source line details
|
|
120
|
+
lines, start_line = inspect.getsourcelines(func)
|
|
121
|
+
|
|
122
|
+
file = inspect.getsourcefile(func)
|
|
123
|
+
|
|
124
|
+
return cls(
|
|
125
|
+
name=func.__name__,
|
|
126
|
+
docstring=pydoc.getdoc(func),
|
|
127
|
+
defining_class=SimpleClass.from_class(defining_class),
|
|
128
|
+
arguments=arguments,
|
|
129
|
+
code="".join(lines),
|
|
130
|
+
lines=Line(start=start_line, total=len(lines)),
|
|
131
|
+
file=file,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_member(cls, member: "Member") -> Self:
|
|
136
|
+
logger.debug("extracting method")
|
|
137
|
+
return cls.from_func(member.obj, member.cls)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@frozen
|
|
141
|
+
class SimpleClass:
|
|
142
|
+
name: str
|
|
143
|
+
module: str
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def from_class(klass):
|
|
147
|
+
return SimpleClass(
|
|
148
|
+
name=klass.__name__,
|
|
149
|
+
module=klass.__module__,
|
|
150
|
+
)
|
classify/django.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def setup_django(settings_path: str) -> None:
|
|
5
|
+
"""
|
|
6
|
+
Bootstrap Django
|
|
7
|
+
|
|
8
|
+
When running classify against a Django project, rather than Django itself we
|
|
9
|
+
Django projects sometimes need
|
|
10
|
+
"""
|
|
11
|
+
# default = "classify.contrib.django.settings"
|
|
12
|
+
os.environ["DJANGO_SETTINGS_MODULE"] = settings_path
|
|
13
|
+
|
|
14
|
+
import django # noqa: PLC0415
|
|
15
|
+
|
|
16
|
+
django.setup()
|
classify/exceptions.py
ADDED
classify/filters.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
|
|
3
|
+
from .dataclasses import Member
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def is_attribute(member: Member) -> bool:
|
|
7
|
+
return member.kind == "data" and not is_inner_class(member)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_data_descriptor(member: Member) -> bool:
|
|
11
|
+
return (
|
|
12
|
+
member.kind == "data descriptor"
|
|
13
|
+
and not inspect.isgetsetdescriptor(member.obj)
|
|
14
|
+
and not inspect.ismemberdescriptor(member.obj)
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_inner_class(member: Member) -> bool:
|
|
19
|
+
if not inspect.isclass(member.obj):
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
# inner class' __qualname__ will reflect that of the class they are defined
|
|
23
|
+
# on, eg the.module.MyClass.Inner. This check uses member.cls to build up
|
|
24
|
+
# a prefix that can be removed from member.obj's __qualname__. If the
|
|
25
|
+
# remainder matches member.name then we have an inner class.
|
|
26
|
+
name = member.obj.__qualname__.removeprefix(f"{member.cls.__qualname__}.")
|
|
27
|
+
return name == member.name and member.kind == "data"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_method(member: Member) -> bool:
|
|
31
|
+
return (
|
|
32
|
+
member.kind
|
|
33
|
+
in [
|
|
34
|
+
"method",
|
|
35
|
+
"class method",
|
|
36
|
+
"static method",
|
|
37
|
+
]
|
|
38
|
+
and not inspect.ismethoddescriptor(member.obj)
|
|
39
|
+
and not inspect.isgetsetdescriptor(member.obj)
|
|
40
|
+
and not inspect.isbuiltin(member.obj)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_property(member: Member) -> bool:
|
|
45
|
+
return member.kind == "readonly property"
|
classify/main.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import pydoc
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
import structlog
|
|
8
|
+
from rich.syntax import DEFAULT_THEME
|
|
9
|
+
|
|
10
|
+
from . import renderers
|
|
11
|
+
from .classification import classify
|
|
12
|
+
from .django import setup_django
|
|
13
|
+
from .exceptions import NotAClassError
|
|
14
|
+
from .renderers import Renderer
|
|
15
|
+
from .resolution import resolve
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.command()
|
|
19
|
+
@click.argument("klass")
|
|
20
|
+
@click.option(
|
|
21
|
+
"--console-theme",
|
|
22
|
+
default=DEFAULT_THEME,
|
|
23
|
+
help="Pygments theme to render console output with",
|
|
24
|
+
)
|
|
25
|
+
@click.option("--debug", is_flag=True)
|
|
26
|
+
@click.option("--django-settings")
|
|
27
|
+
@click.option(
|
|
28
|
+
"--renderer",
|
|
29
|
+
default=Renderer.CONSOLE,
|
|
30
|
+
type=click.Choice(Renderer, case_sensitive=False),
|
|
31
|
+
)
|
|
32
|
+
@click.option(
|
|
33
|
+
"-o",
|
|
34
|
+
"--output",
|
|
35
|
+
"output_path",
|
|
36
|
+
default=None,
|
|
37
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
38
|
+
help="Relative path for output files to be saved",
|
|
39
|
+
)
|
|
40
|
+
@click.option("-p", "--port", default=8000, type=click.INT)
|
|
41
|
+
@click.option("-s", "--serve", is_flag=True)
|
|
42
|
+
@click.version_option()
|
|
43
|
+
def run(
|
|
44
|
+
klass,
|
|
45
|
+
console_theme,
|
|
46
|
+
debug,
|
|
47
|
+
django_settings,
|
|
48
|
+
renderer: Renderer,
|
|
49
|
+
output_path,
|
|
50
|
+
port,
|
|
51
|
+
serve,
|
|
52
|
+
) -> None:
|
|
53
|
+
if django_settings:
|
|
54
|
+
setup_django(django_settings)
|
|
55
|
+
|
|
56
|
+
default_log_level = logging.DEBUG if debug else logging.WARNING
|
|
57
|
+
structlog.configure(
|
|
58
|
+
processors=[
|
|
59
|
+
structlog.contextvars.merge_contextvars,
|
|
60
|
+
structlog.processors.StackInfoRenderer(),
|
|
61
|
+
structlog.dev.set_exc_info,
|
|
62
|
+
structlog.dev.ConsoleRenderer(),
|
|
63
|
+
],
|
|
64
|
+
wrapper_class=structlog.make_filtering_bound_logger(default_log_level),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
obj = resolve(klass)
|
|
69
|
+
except ImportError:
|
|
70
|
+
click.echo(f"Could not import: {klass}", err=True)
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
except pydoc.ErrorDuringImport as e:
|
|
73
|
+
click.echo(
|
|
74
|
+
f"Could not import '{klass}', the original error was:\n {e}", err=True
|
|
75
|
+
)
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
except NotAClassError:
|
|
78
|
+
click.echo(
|
|
79
|
+
f"{klass} doesn't look like a class, please specify the path to a class",
|
|
80
|
+
err=True,
|
|
81
|
+
)
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
|
|
84
|
+
structure = classify(obj)
|
|
85
|
+
|
|
86
|
+
match renderer:
|
|
87
|
+
case Renderer.CONSOLE:
|
|
88
|
+
renderers.to_console(structure, console_theme)
|
|
89
|
+
case Renderer.HTML:
|
|
90
|
+
renderers.to_html(structure, output_path, serve, port)
|
|
91
|
+
case Renderer.PAGER: # pragma: no branch
|
|
92
|
+
# unclear why coverage thinks run() doesn't return, so marking as
|
|
93
|
+
# no branch for now
|
|
94
|
+
renderers.to_pager(structure, console_theme)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__": # pragma: no cover
|
|
98
|
+
run()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
|
|
3
|
+
from .console import to_console, to_pager
|
|
4
|
+
from .html import to_html
|
|
5
|
+
from .string import to_string
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Renderer(enum.StrEnum):
|
|
9
|
+
CONSOLE = enum.auto()
|
|
10
|
+
HTML = enum.auto()
|
|
11
|
+
PAGER = enum.auto()
|
|
12
|
+
STRING = enum.auto()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Renderer",
|
|
17
|
+
"to_console",
|
|
18
|
+
"to_html",
|
|
19
|
+
"to_pager",
|
|
20
|
+
"to_string",
|
|
21
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from rich.console import Console
|
|
2
|
+
from rich.syntax import Syntax
|
|
3
|
+
|
|
4
|
+
from .string import to_string
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def to_console(structure, theme, console=None):
|
|
8
|
+
if not console:
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
content = to_string(structure)
|
|
12
|
+
syntax = Syntax(content, "python", theme=theme)
|
|
13
|
+
console.print(syntax)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def to_pager(structure, theme):
|
|
17
|
+
console = Console()
|
|
18
|
+
with console.pager(styles=True):
|
|
19
|
+
to_console(structure, theme, console=console)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import functools
|
|
3
|
+
import inspect
|
|
4
|
+
import os
|
|
5
|
+
import socketserver
|
|
6
|
+
import tempfile
|
|
7
|
+
import webbrowser
|
|
8
|
+
from collections.abc import Generator
|
|
9
|
+
from http.server import SimpleHTTPRequestHandler
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ..dataclasses import Class
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Handler(SimpleHTTPRequestHandler): # pragma: no cover
|
|
16
|
+
def do_GET(self):
|
|
17
|
+
self.path = "classify.html"
|
|
18
|
+
return SimpleHTTPRequestHandler.do_GET(self)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def attribute_value(value):
|
|
22
|
+
if isinstance(value, str):
|
|
23
|
+
return f'"{value}"'
|
|
24
|
+
|
|
25
|
+
if inspect.isclass(value):
|
|
26
|
+
return value.__name__
|
|
27
|
+
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@functools.singledispatch
|
|
32
|
+
@contextlib.contextmanager
|
|
33
|
+
def resolve_path(output_path: Path) -> Generator:
|
|
34
|
+
output_path.mkdir(exist_ok=True)
|
|
35
|
+
yield output_path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@resolve_path.register
|
|
39
|
+
@contextlib.contextmanager
|
|
40
|
+
def _(empty: None) -> Generator: # noqa: ARG001
|
|
41
|
+
directory = tempfile.TemporaryDirectory(prefix="classify")
|
|
42
|
+
|
|
43
|
+
yield Path(directory.name)
|
|
44
|
+
|
|
45
|
+
directory.cleanup()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def serve_output(port: int) -> None: # pragma: no cover
|
|
49
|
+
httpd = socketserver.TCPServer(("", port), Handler)
|
|
50
|
+
|
|
51
|
+
if not os.environ.get("TEST_MODE", None):
|
|
52
|
+
print(f"Serving on port: {port}")
|
|
53
|
+
webbrowser.open_new_tab(f"http://localhost:{port}/")
|
|
54
|
+
|
|
55
|
+
httpd.serve_forever()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def to_html(structure: Class, output_path: Path | None, serve: bool, port: int) -> None:
|
|
59
|
+
from jinja2 import Environment, PackageLoader # noqa: PLC0415
|
|
60
|
+
|
|
61
|
+
env = Environment(loader=PackageLoader("classify", "templates"))
|
|
62
|
+
env.filters["attribute"] = attribute_value
|
|
63
|
+
template = env.get_template("web.html")
|
|
64
|
+
output = template.render(klass=structure)
|
|
65
|
+
|
|
66
|
+
with resolve_path(output_path) as path:
|
|
67
|
+
full_path = path / "classify.html"
|
|
68
|
+
full_path.write_text(output)
|
|
69
|
+
|
|
70
|
+
if not serve:
|
|
71
|
+
print(f"Wrote: {full_path}")
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
with contextlib.chdir(path): # pragma: no cover
|
|
75
|
+
serve_output(port)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
|
|
3
|
+
from ..dataclasses import Class, DataDescriptor, Method
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# define this here so we know what "1" indent is and can remove it for inner
|
|
7
|
+
# class declarations
|
|
8
|
+
DEFAULT_INDENT_WIDTH = 4
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def attributes(attributes, indent) -> str:
|
|
12
|
+
attrs = []
|
|
13
|
+
for name, definitions in attributes.items():
|
|
14
|
+
value = definitions[-1].value
|
|
15
|
+
|
|
16
|
+
if isinstance(value, str):
|
|
17
|
+
value = f'"{value}"'
|
|
18
|
+
|
|
19
|
+
if inspect.isclass(value):
|
|
20
|
+
value = value.__name__
|
|
21
|
+
|
|
22
|
+
attrs.append(f"{indent}{name} = {value}\n")
|
|
23
|
+
return "".join(attrs)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def classes(classes, indent) -> str:
|
|
27
|
+
content = [to_string(c, indent=indent + indent) for c in classes]
|
|
28
|
+
return "".join(content)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def declaration(name, parents, indent) -> str:
|
|
32
|
+
indent = indent[:-DEFAULT_INDENT_WIDTH]
|
|
33
|
+
content = f"{indent}class {name}"
|
|
34
|
+
|
|
35
|
+
if parents:
|
|
36
|
+
parents = ", ".join([p.__name__ for p in parents])
|
|
37
|
+
content = f"{content}({parents})"
|
|
38
|
+
|
|
39
|
+
return f"{content}:"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def docstring(docstring, indent) -> str:
|
|
43
|
+
if not docstring:
|
|
44
|
+
return ""
|
|
45
|
+
|
|
46
|
+
quotes = f'{indent}"""\n'
|
|
47
|
+
lines = docstring.split("\n")
|
|
48
|
+
block = "".join([f"{indent}{line}\n" for line in lines])
|
|
49
|
+
return f"{quotes}{block}{quotes}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def methods(methods: dict[str, list[Method]], indent) -> str:
|
|
53
|
+
content = ""
|
|
54
|
+
for definitions in methods.values():
|
|
55
|
+
for i, method in enumerate(definitions):
|
|
56
|
+
if len(definitions) > 1 and i == 0:
|
|
57
|
+
content += f"{indent}# Defined on: {method.defining_class.name}\n"
|
|
58
|
+
lines = method.code.split("\n")[:-1]
|
|
59
|
+
for line in lines:
|
|
60
|
+
# TODO: dedent code at source so defined indent isn't tied to
|
|
61
|
+
# presentation indent
|
|
62
|
+
content += f"{indent}{line[4:]}\n"
|
|
63
|
+
content += "\n"
|
|
64
|
+
|
|
65
|
+
# add strip to remove the trailing newline, rather than polluting the loop
|
|
66
|
+
# with logic to work out if we're on the final loop iteration
|
|
67
|
+
return content.strip("\n")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def properties(properties: dict[str, list[Method]], indent) -> str:
|
|
71
|
+
content = ""
|
|
72
|
+
for definitions in properties.values():
|
|
73
|
+
for i, prop in enumerate(definitions):
|
|
74
|
+
if len(definitions) > 1 and i == 0:
|
|
75
|
+
content += f"{indent}# Defined on: {prop.defining_class.name}\n"
|
|
76
|
+
lines = prop.code.split("\n")[:-1]
|
|
77
|
+
for line in lines:
|
|
78
|
+
# TODO: dedent code at source so defined indent isn't tied to
|
|
79
|
+
# presentation indent
|
|
80
|
+
content += f"{indent}{line[4:]}\n"
|
|
81
|
+
content += "\n"
|
|
82
|
+
|
|
83
|
+
return content
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def data_descriptors(data_descriptors: dict[str, list[DataDescriptor]], indent) -> str:
|
|
87
|
+
"""
|
|
88
|
+
KISS to start: display any methods for a dd as a group
|
|
89
|
+
Loop the definitions of each name
|
|
90
|
+
try each of getter, setter, and deleter, outputting them as a method
|
|
91
|
+
"""
|
|
92
|
+
content = ""
|
|
93
|
+
for definitions in data_descriptors.values():
|
|
94
|
+
for i, descriptor in enumerate(definitions):
|
|
95
|
+
for name in ["getter", "setter", "deleter"]:
|
|
96
|
+
func = getattr(descriptor, name, None)
|
|
97
|
+
if func is None:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if len(definitions) > 1 and i == 0:
|
|
101
|
+
content += f"{indent}# Defined on: {func.defining_class.name}\n"
|
|
102
|
+
|
|
103
|
+
lines = func.code.split("\n")[:-1]
|
|
104
|
+
for line in lines:
|
|
105
|
+
# TODO: dedent code at source so defined indent isn't tied
|
|
106
|
+
# to presentation indent
|
|
107
|
+
content += f"{indent}{line[4:]}\n"
|
|
108
|
+
content += "\n"
|
|
109
|
+
|
|
110
|
+
return content
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def to_string(structure: Class, indent: str = " " * DEFAULT_INDENT_WIDTH) -> str:
|
|
114
|
+
content = declaration(structure.name, structure.parents, indent)
|
|
115
|
+
content += "\n"
|
|
116
|
+
content += docstring(structure.docstring, indent) if docstring else ""
|
|
117
|
+
content += attributes(structure.attributes, indent)
|
|
118
|
+
content += "\n"
|
|
119
|
+
content += classes(structure.classes, indent)
|
|
120
|
+
content += properties(structure.properties, indent)
|
|
121
|
+
content += data_descriptors(structure.data_descriptors, indent)
|
|
122
|
+
content += methods(structure.methods, indent)
|
|
123
|
+
|
|
124
|
+
return content
|
classify/resolution.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import pydoc
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .exceptions import NotAClassError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve[C](thing: str) -> type[C]:
|
|
9
|
+
"""Find the given thing and ensure it's a class"""
|
|
10
|
+
sys.path.insert(0, "")
|
|
11
|
+
|
|
12
|
+
obj, _ = pydoc.resolve(thing) # ty: ignore[not-iterable]
|
|
13
|
+
|
|
14
|
+
if not inspect.isclass(obj):
|
|
15
|
+
raise NotAClassError
|
|
16
|
+
|
|
17
|
+
return obj
|
|
@@ -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> </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.value|attribute }}
|
|
32
|
+
</code>
|
|
33
|
+
</td>
|
|
34
|
+
<td>{{ attribute.defining_class.name }}.{{ attribute.defining_class.name }}</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.name }}]</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 %}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>{{ klass.name }}</title>
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div class="container">
|
|
10
|
+
<article id="main">
|
|
11
|
+
|
|
12
|
+
{% include "class.html" %}
|
|
13
|
+
|
|
14
|
+
</article>
|
|
15
|
+
</div> <!-- /container -->
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: classify
|
|
3
|
+
Version: 2026.7.1
|
|
4
|
+
Summary: Generate concrete Class documentation for Python Classes
|
|
5
|
+
Author: George Hickman
|
|
6
|
+
Author-email: George Hickman <george@ghickman.co.uk>
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
+
Classifier: Topic :: Documentation
|
|
13
|
+
Requires-Dist: attrs>=25.4.0
|
|
14
|
+
Requires-Dist: click>=8.3.0
|
|
15
|
+
Requires-Dist: jinja2>=2.7
|
|
16
|
+
Requires-Dist: rich>=14.2.0
|
|
17
|
+
Requires-Dist: structlog>=25.5.0
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Project-URL: Repository, https://github.com/ghickman/classify/
|
|
20
|
+
Project-URL: Changelog, https://github.com/ghickman/classify/blob/main/CHANGELOG.md
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# Classify
|
|
24
|
+
Generate concrete class API documentation for python Classes
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
```bash
|
|
28
|
+
pip install classify
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
```bash
|
|
34
|
+
classify <path.to.Class>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This outputs the full class definition, including the methods defined on each parent class.
|
|
38
|
+
|
|
39
|
+
You can change the theme to any [Pygments theme](https://pygments.org/styles/) with `--console-theme`.
|
|
40
|
+
|
|
41
|
+
Output to your shell's pager with `--renderer pager`, or to [ccbv style pages](https://ccbv.co.uk) with `--renderer html`.
|
|
42
|
+
|
|
43
|
+
By default HTML documents are saved to a temporary directory.
|
|
44
|
+
To change this specify a relative location with the `--output` option.
|
|
45
|
+
You can serve the output, regardless of where its written to with `--serve`, and change the port with `--port`.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
classify <path.to.Class> --renderer html --output output --serve --port 8080
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
## Why?
|
|
53
|
+
[CCBV](https://ccbv.co.uk) has long been a part of my everyday toolkit for working with Django's generic class-based views.
|
|
54
|
+
It's a fantastic resource for quick reference, but it only covers Django's GCBVs.
|
|
55
|
+
|
|
56
|
+
Classify aims to provide this same level of utility for all your Python classes.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
classify/__init__.py,sha256=psLgRtKTm0YMV4ix3DcpdimKdHyE0pNjQo-BrW1zFYU,177
|
|
2
|
+
classify/__main__.py,sha256=NIMCUi-VOkr0HqqSZmRwl-zkyeXTuqPVNEsQStcahbI,61
|
|
3
|
+
classify/classification.py,sha256=WzcSWeIWQTak95LPCvnLc2f0-Ksy0LFATVcM9C3wRAQ,3708
|
|
4
|
+
classify/contrib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
classify/contrib/django/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
classify/contrib/django/settings.py,sha256=yZ9sk9lJ9DF39yadMr1iLzICopOJMUU51GOMGVmbRFk,19
|
|
7
|
+
classify/dataclasses.py,sha256=VRlmalt7-Ry1E6LQULMQ5CUi6FxINuCmmkk0E4tm4rM,3444
|
|
8
|
+
classify/django.py,sha256=9aq44rSVg0sPgKWQcEIGfDvFHq6PUoq1MTGGRqaBrEM,376
|
|
9
|
+
classify/exceptions.py,sha256=8mES7kAGfsbV9rNJHM6VQmle03cdIN_hrj40b_F49ok,42
|
|
10
|
+
classify/filters.py,sha256=xKvbpvQRbNypQITeA_3iOH-zb9v-m_zq90-m2j8Z_AM,1346
|
|
11
|
+
classify/main.py,sha256=wRv4D1f-AeB55STJ8XAiN351ISrVfvHI1zgzhbav3U0,2635
|
|
12
|
+
classify/renderers/__init__.py,sha256=M3MEjo9Qb787vf8fbF2NhiWzv1E2GtR4SH6ob8-f8Sc,339
|
|
13
|
+
classify/renderers/console.py,sha256=ZE_FqG2pLzOG10dLrc1a8SDoRlYshwYuFfauUyty-tQ,456
|
|
14
|
+
classify/renderers/html.py,sha256=f-bXhXVaH_3tvjpGfxW1TprFWFCo2qhuvVh70RHKqhA,1941
|
|
15
|
+
classify/renderers/string.py,sha256=lLROHZk_FALeu_4GCvatwrRYXunNwW1X2xec3Wwl8Q8,4170
|
|
16
|
+
classify/resolution.py,sha256=6BeSon5MLxu2LLsB2IWSmv65S5G8olFeMfAONALr0dY,344
|
|
17
|
+
classify/templates/class.html,sha256=WwlY5N59TBof-0zP5zGligITbUAOuqx-AeTdy3afId8,1755
|
|
18
|
+
classify/templates/web.html,sha256=ynhwyEWu-iUh7LSAvY1ZeuP0_kTKPKmURR-HRwALt9s,356
|
|
19
|
+
classify-2026.7.1.dist-info/WHEEL,sha256=r-Se0i_n47Mj8pdnVuq7W628oP9YAKMaTjp4l3lQcns,81
|
|
20
|
+
classify-2026.7.1.dist-info/entry_points.txt,sha256=OfzZe_nOc7EA4wbCvi_DtJgs77mkWlsp0NO7T49uHMw,48
|
|
21
|
+
classify-2026.7.1.dist-info/METADATA,sha256=VRqvM3Y_uv156CRBpKh4rlqsLs8jvHNMrDainF0XHQM,1966
|
|
22
|
+
classify-2026.7.1.dist-info/RECORD,,
|