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 ADDED
@@ -0,0 +1,10 @@
1
+ from .classification import classify
2
+ from .exceptions import NotAClassError
3
+ from .resolution import resolve
4
+
5
+
6
+ __all__ = [
7
+ "NotAClassError",
8
+ "classify",
9
+ "resolve",
10
+ ]
classify/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .main import run
2
+
3
+
4
+ if __name__ == "__main__":
5
+ run()
@@ -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"
@@ -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
@@ -0,0 +1,2 @@
1
+ class NotAClassError(Exception):
2
+ pass
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>&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.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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.31
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ classify = classify.main:run
3
+