pydoc2markdown 0.1.0__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.
- pydoc2markdown/__init__.py +26 -0
- pydoc2markdown/cli.py +131 -0
- pydoc2markdown/core/__init__.py +1 -0
- pydoc2markdown/core/config.py +39 -0
- pydoc2markdown/core/generator.py +271 -0
- pydoc2markdown/core/parser.py +396 -0
- pydoc2markdown/core/watcher.py +88 -0
- pydoc2markdown/templates/minimal.md +31 -0
- pydoc2markdown/templates/module.md +137 -0
- pydoc2markdown/utils/__init__.py +1 -0
- pydoc2markdown/utils/helpers.py +34 -0
- pydoc2markdown-0.1.0.dist-info/METADATA +104 -0
- pydoc2markdown-0.1.0.dist-info/RECORD +16 -0
- pydoc2markdown-0.1.0.dist-info/WHEEL +4 -0
- pydoc2markdown-0.1.0.dist-info/entry_points.txt +2 -0
- pydoc2markdown-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""PyDoc2Markdown - Convert Python docstrings to Markdown documentation."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
__author__ = "f1sherFM"
|
|
5
|
+
|
|
6
|
+
from pydoc2markdown.core.generator import MarkdownGenerator
|
|
7
|
+
from pydoc2markdown.core.parser import (
|
|
8
|
+
ClassDoc,
|
|
9
|
+
DocstringParser,
|
|
10
|
+
FunctionDoc,
|
|
11
|
+
ModuleDoc,
|
|
12
|
+
Parameter,
|
|
13
|
+
RaisesInfo,
|
|
14
|
+
ReturnsInfo,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ClassDoc",
|
|
19
|
+
"DocstringParser",
|
|
20
|
+
"FunctionDoc",
|
|
21
|
+
"MarkdownGenerator",
|
|
22
|
+
"ModuleDoc",
|
|
23
|
+
"Parameter",
|
|
24
|
+
"RaisesInfo",
|
|
25
|
+
"ReturnsInfo",
|
|
26
|
+
]
|
pydoc2markdown/cli.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Command-line interface for PyDoc2Markdown."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydoc2markdown.core.config import load_config
|
|
9
|
+
from pydoc2markdown.core.generator import MarkdownGenerator
|
|
10
|
+
from pydoc2markdown.core.parser import DocstringParser
|
|
11
|
+
from pydoc2markdown.core.watcher import watch_and_generate
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
17
|
+
"""Create and configure the argument parser."""
|
|
18
|
+
defaults = load_config()
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="pydoc2markdown",
|
|
21
|
+
description="Convert Python docstrings to Markdown documentation.",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"source",
|
|
25
|
+
type=Path,
|
|
26
|
+
help="Path to a Python file or directory to process.",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"-o",
|
|
30
|
+
"--output",
|
|
31
|
+
type=Path,
|
|
32
|
+
default=Path(str(defaults.get("output", "docs"))),
|
|
33
|
+
help="Output directory for generated Markdown files (default: docs).",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--recursive",
|
|
37
|
+
action="store_true",
|
|
38
|
+
default=defaults.get("recursive", False),
|
|
39
|
+
help="Recursively process subdirectories.",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--template",
|
|
43
|
+
type=Path,
|
|
44
|
+
default=None,
|
|
45
|
+
help="Path to a custom Jinja2 template for Markdown generation.",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--theme",
|
|
49
|
+
choices=["default", "minimal"],
|
|
50
|
+
default=defaults.get("theme", "default"),
|
|
51
|
+
help="Built-in theme/template to use (default: default).",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--watch",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="Watch source files and regenerate docs on change.",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"-v",
|
|
60
|
+
"--verbose",
|
|
61
|
+
action="count",
|
|
62
|
+
default=0,
|
|
63
|
+
help="Increase verbosity (-v for INFO, -vv for DEBUG).",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--version",
|
|
67
|
+
action="version",
|
|
68
|
+
version="%(prog)s 0.1.0",
|
|
69
|
+
)
|
|
70
|
+
return parser
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _setup_logging(verbosity: int) -> None:
|
|
74
|
+
"""Configure logging level based on verbosity count."""
|
|
75
|
+
level = logging.WARNING
|
|
76
|
+
if verbosity >= 2:
|
|
77
|
+
level = logging.DEBUG
|
|
78
|
+
elif verbosity == 1:
|
|
79
|
+
level = logging.INFO
|
|
80
|
+
logging.basicConfig(
|
|
81
|
+
level=level,
|
|
82
|
+
format="%(name)s - %(levelname)s - %(message)s",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main(args: list[str] | None = None) -> int:
|
|
87
|
+
"""Main entry point for the CLI."""
|
|
88
|
+
parser = create_parser()
|
|
89
|
+
parsed_args = parser.parse_args(args)
|
|
90
|
+
_setup_logging(parsed_args.verbose)
|
|
91
|
+
|
|
92
|
+
if not parsed_args.source.exists():
|
|
93
|
+
logger.error("Source path does not exist: %s", parsed_args.source)
|
|
94
|
+
return 1
|
|
95
|
+
|
|
96
|
+
if parsed_args.watch:
|
|
97
|
+
return watch_and_generate(
|
|
98
|
+
source=parsed_args.source,
|
|
99
|
+
output_dir=parsed_args.output,
|
|
100
|
+
recursive=parsed_args.recursive,
|
|
101
|
+
theme=parsed_args.theme,
|
|
102
|
+
template_path=parsed_args.template,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
logger.info("Parsing source: %s (recursive=%s)", parsed_args.source, parsed_args.recursive)
|
|
106
|
+
doc_parser = DocstringParser()
|
|
107
|
+
md_generator = MarkdownGenerator(
|
|
108
|
+
template_path=parsed_args.template,
|
|
109
|
+
theme=parsed_args.theme,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
modules = doc_parser.parse(
|
|
114
|
+
source=parsed_args.source,
|
|
115
|
+
recursive=parsed_args.recursive,
|
|
116
|
+
)
|
|
117
|
+
logger.info("Parsed %d module(s)", len(modules))
|
|
118
|
+
generated = md_generator.generate(
|
|
119
|
+
modules=modules,
|
|
120
|
+
output_dir=parsed_args.output,
|
|
121
|
+
)
|
|
122
|
+
logger.info("Generated %d Markdown file(s) in %s", len(generated), parsed_args.output)
|
|
123
|
+
except Exception:
|
|
124
|
+
logger.exception("Documentation generation failed")
|
|
125
|
+
return 1
|
|
126
|
+
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
sys.exit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core logic for PyDoc2Markdown."""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Configuration loader for PyDoc2Markdown."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_config(
|
|
11
|
+
cwd: Path | None = None,
|
|
12
|
+
) -> dict[str, Any]:
|
|
13
|
+
"""Load [tool.pydoc2markdown] from pyproject.toml.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
cwd: Directory to search for pyproject.toml. Defaults to current dir.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Dictionary with parsed config values.
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
import tomllib
|
|
23
|
+
except ModuleNotFoundError:
|
|
24
|
+
import tomli as tomllib
|
|
25
|
+
|
|
26
|
+
if cwd is None:
|
|
27
|
+
cwd = Path.cwd()
|
|
28
|
+
|
|
29
|
+
pyproject = cwd / "pyproject.toml"
|
|
30
|
+
if not pyproject.exists():
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
with pyproject.open("rb") as f:
|
|
35
|
+
data = tomllib.load(f)
|
|
36
|
+
return cast(dict[str, Any], data.get("tool", {}).get("pydoc2markdown", {}))
|
|
37
|
+
except Exception:
|
|
38
|
+
logger.warning("Failed to load pyproject.toml config")
|
|
39
|
+
return {}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Markdown documentation generator."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from jinja2 import Environment, FileSystemLoader, PackageLoader, select_autoescape
|
|
7
|
+
|
|
8
|
+
from pydoc2markdown.core.parser import ModuleDoc
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MarkdownGenerator:
|
|
14
|
+
"""Generate Markdown files from parsed Python documentation."""
|
|
15
|
+
|
|
16
|
+
DEFAULT_TEMPLATE = """# {{ module.name }}
|
|
17
|
+
|
|
18
|
+
{% if module.docstring %}
|
|
19
|
+
{{ module.docstring }}
|
|
20
|
+
{% endif %}
|
|
21
|
+
|
|
22
|
+
{% if module.classes or module.functions %}
|
|
23
|
+
## Table of Contents
|
|
24
|
+
|
|
25
|
+
{% if module.classes %}
|
|
26
|
+
- [Classes](#classes)
|
|
27
|
+
{% for class in module.classes %}
|
|
28
|
+
- [`{{ class.name }}`](#{{ class.name | lower | replace(' ', '-') }})
|
|
29
|
+
{% if class.methods %}
|
|
30
|
+
{% for method in class.methods %}
|
|
31
|
+
- [
|
|
32
|
+
{% if method.is_property %}@property {% elif method.is_classmethod %}@classmethod
|
|
33
|
+
{% elif method.is_staticmethod %}@staticmethod {% endif %}
|
|
34
|
+
`{{ method.name }}`](#{{ method.name | lower | replace(' ', '-') }})
|
|
35
|
+
{% endfor %}
|
|
36
|
+
{% endif %}
|
|
37
|
+
{% endfor %}
|
|
38
|
+
{% endif %}
|
|
39
|
+
{% if module.functions %}
|
|
40
|
+
- [Functions](#functions)
|
|
41
|
+
{% for func in module.functions %}
|
|
42
|
+
- [`{{ func.name }}`](#{{ func.name | lower | replace(' ', '-') }})
|
|
43
|
+
{% endfor %}
|
|
44
|
+
{% endif %}
|
|
45
|
+
{% endif %}
|
|
46
|
+
|
|
47
|
+
{% if module.public_api %}
|
|
48
|
+
**Public API:**
|
|
49
|
+
{% for name in module.public_api %}
|
|
50
|
+
- `{{ name }}`
|
|
51
|
+
{% endfor %}
|
|
52
|
+
{% endif %}
|
|
53
|
+
|
|
54
|
+
{% if module.classes %}
|
|
55
|
+
## Classes
|
|
56
|
+
|
|
57
|
+
{% for class in module.classes %}
|
|
58
|
+
### `{{ class.name }}`{% if class.class_type != "class" %} ({{ class.class_type }}){% endif %}
|
|
59
|
+
|
|
60
|
+
{% if class.bases %}
|
|
61
|
+
**Bases:** `{{ class.bases | join(", ") }}`
|
|
62
|
+
{% endif %}
|
|
63
|
+
|
|
64
|
+
{% if class.docstring %}
|
|
65
|
+
{{ class.docstring }}
|
|
66
|
+
{% endif %}
|
|
67
|
+
|
|
68
|
+
{% if class.attributes %}
|
|
69
|
+
#### Attributes
|
|
70
|
+
|
|
71
|
+
| Name | Type | Description |
|
|
72
|
+
|------|------|-------------|
|
|
73
|
+
{% for attr in class.attributes %}
|
|
74
|
+
| `{{ attr.name }}` |
|
|
75
|
+
{% if attr.type_hint %}`{{ attr.type_hint }}`{% else %}-{% endif %} |
|
|
76
|
+
{% if attr.description %}{{ attr.description }}{% else %}-{% endif %} |
|
|
77
|
+
{% endfor %}
|
|
78
|
+
{% endif %}
|
|
79
|
+
|
|
80
|
+
{% if class.methods %}
|
|
81
|
+
#### Methods
|
|
82
|
+
|
|
83
|
+
{% for method in class.methods %}
|
|
84
|
+
{% if method.is_property %}##### @property `{{ method.name }}`
|
|
85
|
+
{% elif method.is_classmethod %}##### @classmethod `{{ method.name }}`
|
|
86
|
+
{% elif method.is_staticmethod %}##### @staticmethod `{{ method.name }}`
|
|
87
|
+
{% else %}##### `{{ method.name }}`
|
|
88
|
+
{% endif %}
|
|
89
|
+
|
|
90
|
+
{% if method.docstring %}
|
|
91
|
+
{{ method.docstring }}
|
|
92
|
+
{% endif %}
|
|
93
|
+
|
|
94
|
+
{% if method.params %}
|
|
95
|
+
**Parameters:**
|
|
96
|
+
|
|
97
|
+
| Name | Type | Description |
|
|
98
|
+
|------|------|-------------|
|
|
99
|
+
{% for param in method.params %}
|
|
100
|
+
| `{{ param.name }}` |
|
|
101
|
+
{% if param.type_hint %}`{{ param.type_hint }}`{% else %}-{% endif %} |
|
|
102
|
+
{% if param.description %}{{ param.description }}{% else %}-{% endif %} |
|
|
103
|
+
{% endfor %}
|
|
104
|
+
{% endif %}
|
|
105
|
+
|
|
106
|
+
{% if method.returns %}
|
|
107
|
+
**Returns:**{% if method.returns.type_hint %} `{{ method.returns.type_hint }}`{% endif %}
|
|
108
|
+
|
|
109
|
+
{% if method.returns.description %}{{ method.returns.description }}{% endif %}
|
|
110
|
+
{% endif %}
|
|
111
|
+
|
|
112
|
+
{% if method.raises %}
|
|
113
|
+
**Raises:**
|
|
114
|
+
{% for raise in method.raises %}
|
|
115
|
+
- {% if raise.type_name %}`{{ raise.type_name }}`{% endif %}
|
|
116
|
+
{% if raise.description %}: {{ raise.description }}{% endif %}
|
|
117
|
+
{% endfor %}
|
|
118
|
+
{% endif %}
|
|
119
|
+
|
|
120
|
+
{% endfor %}
|
|
121
|
+
{% endif %}
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
{% endfor %}
|
|
125
|
+
{% endif %}
|
|
126
|
+
|
|
127
|
+
{% if module.functions %}
|
|
128
|
+
## Functions
|
|
129
|
+
|
|
130
|
+
{% for func in module.functions %}
|
|
131
|
+
### `{{ func.name }}`
|
|
132
|
+
|
|
133
|
+
{% if func.docstring %}
|
|
134
|
+
{{ func.docstring }}
|
|
135
|
+
{% endif %}
|
|
136
|
+
|
|
137
|
+
{% if func.params %}
|
|
138
|
+
**Parameters:**
|
|
139
|
+
|
|
140
|
+
| Name | Type | Description |
|
|
141
|
+
|------|------|-------------|
|
|
142
|
+
{% for param in func.params %}
|
|
143
|
+
| `{{ param.name }}` |
|
|
144
|
+
{% if param.type_hint %}`{{ param.type_hint }}`{% else %}-{% endif %} |
|
|
145
|
+
{% if param.description %}{{ param.description }}{% else %}-{% endif %} |
|
|
146
|
+
{% endfor %}
|
|
147
|
+
{% endif %}
|
|
148
|
+
|
|
149
|
+
{% if func.returns %}
|
|
150
|
+
**Returns:**{% if func.returns.type_hint %} `{{ func.returns.type_hint }}`{% endif %}
|
|
151
|
+
|
|
152
|
+
{% if func.returns.description %}{{ func.returns.description }}{% endif %}
|
|
153
|
+
{% endif %}
|
|
154
|
+
|
|
155
|
+
{% if func.raises %}
|
|
156
|
+
**Raises:**
|
|
157
|
+
{% for raise in func.raises %}
|
|
158
|
+
- {% if raise.type_name %}`{{ raise.type_name }}`{% endif %}
|
|
159
|
+
{% if raise.description %}: {{ raise.description }}{% endif %}
|
|
160
|
+
{% endfor %}
|
|
161
|
+
{% endif %}
|
|
162
|
+
|
|
163
|
+
{% endfor %}
|
|
164
|
+
{% endif %}
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
template_path: Path | None = None,
|
|
170
|
+
theme: str = "default",
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Initialize the generator with an optional custom template or theme."""
|
|
173
|
+
self._template_path = template_path
|
|
174
|
+
self._theme = theme
|
|
175
|
+
self._env = self._create_environment()
|
|
176
|
+
|
|
177
|
+
def _create_environment(self) -> Environment:
|
|
178
|
+
"""Create and configure the Jinja2 environment."""
|
|
179
|
+
if self._template_path and self._template_path.exists():
|
|
180
|
+
return Environment(
|
|
181
|
+
loader=FileSystemLoader(str(self._template_path.parent)),
|
|
182
|
+
autoescape=select_autoescape(),
|
|
183
|
+
)
|
|
184
|
+
return Environment(
|
|
185
|
+
loader=PackageLoader("pydoc2markdown", "templates"),
|
|
186
|
+
autoescape=select_autoescape(),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def _resolve_template_name(self) -> str:
|
|
190
|
+
"""Resolve the template file name based on theme or custom path."""
|
|
191
|
+
if self._template_path:
|
|
192
|
+
return self._template_path.name
|
|
193
|
+
if self._theme == "default":
|
|
194
|
+
return "module.md"
|
|
195
|
+
return f"{self._theme}.md"
|
|
196
|
+
|
|
197
|
+
def generate(
|
|
198
|
+
self,
|
|
199
|
+
modules: list[ModuleDoc],
|
|
200
|
+
output_dir: Path,
|
|
201
|
+
) -> list[Path]:
|
|
202
|
+
"""Generate Markdown files for the given modules."""
|
|
203
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
generated: list[Path] = []
|
|
205
|
+
|
|
206
|
+
template_name = self._resolve_template_name()
|
|
207
|
+
logger.debug("Using template: %s", template_name)
|
|
208
|
+
template = self._env.get_template(template_name)
|
|
209
|
+
|
|
210
|
+
for module in modules:
|
|
211
|
+
if module.package:
|
|
212
|
+
module_dir = output_dir / module.package.replace(".", "/")
|
|
213
|
+
module_dir.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
output_path = module_dir / f"{module.name}.md"
|
|
215
|
+
else:
|
|
216
|
+
output_path = output_dir / f"{module.name}.md"
|
|
217
|
+
logger.debug("Generating %s", output_path)
|
|
218
|
+
content = template.render(module=module)
|
|
219
|
+
output_path.write_text(content, encoding="utf-8")
|
|
220
|
+
generated.append(output_path)
|
|
221
|
+
|
|
222
|
+
index_path = self._generate_index(modules, output_dir)
|
|
223
|
+
if index_path:
|
|
224
|
+
generated.append(index_path)
|
|
225
|
+
|
|
226
|
+
return generated
|
|
227
|
+
|
|
228
|
+
def _generate_index(
|
|
229
|
+
self,
|
|
230
|
+
modules: list[ModuleDoc],
|
|
231
|
+
output_dir: Path,
|
|
232
|
+
) -> Path | None:
|
|
233
|
+
"""Generate an index.md with links to all module docs, grouped by package."""
|
|
234
|
+
if not modules:
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
from collections import OrderedDict
|
|
238
|
+
|
|
239
|
+
groups: OrderedDict[str, list[ModuleDoc]] = OrderedDict()
|
|
240
|
+
for module in sorted(modules, key=lambda m: (m.package, m.name)):
|
|
241
|
+
groups.setdefault(module.package, []).append(module)
|
|
242
|
+
|
|
243
|
+
lines = ["# Index", ""]
|
|
244
|
+
for package, mods in groups.items():
|
|
245
|
+
if package:
|
|
246
|
+
lines.append(f"## {package}")
|
|
247
|
+
else:
|
|
248
|
+
lines.append("## Modules")
|
|
249
|
+
for module in mods:
|
|
250
|
+
rel_path = (
|
|
251
|
+
f"{module.package.replace('.', '/')}/{module.name}.md"
|
|
252
|
+
if module.package
|
|
253
|
+
else f"{module.name}.md"
|
|
254
|
+
)
|
|
255
|
+
lines.append(f"- [{module.name}]({rel_path})")
|
|
256
|
+
if module.docstring:
|
|
257
|
+
first_line = module.docstring.split("\n")[0]
|
|
258
|
+
lines.append(f" - {first_line}")
|
|
259
|
+
lines.append("")
|
|
260
|
+
|
|
261
|
+
index_path = output_dir / "index.md"
|
|
262
|
+
logger.debug("Generating %s", index_path)
|
|
263
|
+
index_path.write_text("\n".join(lines), encoding="utf-8")
|
|
264
|
+
return index_path
|
|
265
|
+
|
|
266
|
+
def generate_string(self, module: ModuleDoc) -> str:
|
|
267
|
+
"""Generate Markdown content as a string for a single module."""
|
|
268
|
+
from jinja2 import Template
|
|
269
|
+
|
|
270
|
+
template = Template(self.DEFAULT_TEMPLATE)
|
|
271
|
+
return template.render(module=module)
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Python docstring parser with structured docstring support."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import contextlib
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import docstring_parser
|
|
10
|
+
from docstring_parser import Docstring as ParsedDocstring
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Parameter:
|
|
17
|
+
"""Represents a function/method parameter."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
type_hint: str | None = None
|
|
21
|
+
default: str | None = None
|
|
22
|
+
description: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ReturnsInfo:
|
|
27
|
+
"""Structured return type and description."""
|
|
28
|
+
|
|
29
|
+
type_hint: str | None = None
|
|
30
|
+
description: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class RaisesInfo:
|
|
35
|
+
"""Structured exception type and description."""
|
|
36
|
+
|
|
37
|
+
type_name: str | None = None
|
|
38
|
+
description: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class FunctionDoc:
|
|
43
|
+
"""Represents extracted documentation for a function or method."""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
docstring: str | None = None
|
|
47
|
+
params: list[Parameter] = field(default_factory=list)
|
|
48
|
+
returns: ReturnsInfo | None = None
|
|
49
|
+
raises: list[RaisesInfo] = field(default_factory=list)
|
|
50
|
+
is_method: bool = False
|
|
51
|
+
is_async: bool = False
|
|
52
|
+
is_property: bool = False
|
|
53
|
+
is_classmethod: bool = False
|
|
54
|
+
is_staticmethod: bool = False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ClassDoc:
|
|
59
|
+
"""Represents extracted documentation for a class."""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
docstring: str | None = None
|
|
63
|
+
methods: list[FunctionDoc] = field(default_factory=list)
|
|
64
|
+
attributes: list[Parameter] = field(default_factory=list)
|
|
65
|
+
bases: list[str] = field(default_factory=list)
|
|
66
|
+
class_type: str = "class"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ModuleDoc:
|
|
71
|
+
"""Represents extracted documentation for a module."""
|
|
72
|
+
|
|
73
|
+
name: str
|
|
74
|
+
path: Path
|
|
75
|
+
docstring: str | None = None
|
|
76
|
+
classes: list[ClassDoc] = field(default_factory=list)
|
|
77
|
+
functions: list[FunctionDoc] = field(default_factory=list)
|
|
78
|
+
public_api: list[str] = field(default_factory=list)
|
|
79
|
+
package: str = ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class DocstringParser:
|
|
83
|
+
"""Parse Python source files and extract structured docstrings."""
|
|
84
|
+
|
|
85
|
+
def __init__(self) -> None:
|
|
86
|
+
self._modules: list[ModuleDoc] = []
|
|
87
|
+
self._source: Path = Path()
|
|
88
|
+
|
|
89
|
+
def parse(
|
|
90
|
+
self,
|
|
91
|
+
source: Path,
|
|
92
|
+
recursive: bool = False,
|
|
93
|
+
) -> list[ModuleDoc]:
|
|
94
|
+
"""Parse a file or directory and return extracted documentation."""
|
|
95
|
+
self._modules.clear()
|
|
96
|
+
self._source = source
|
|
97
|
+
|
|
98
|
+
if source.is_file() and source.suffix == ".py":
|
|
99
|
+
self._parse_file(source)
|
|
100
|
+
elif source.is_dir():
|
|
101
|
+
pattern = "**/*.py" if recursive else "*.py"
|
|
102
|
+
for file_path in sorted(source.glob(pattern)):
|
|
103
|
+
self._parse_file(file_path)
|
|
104
|
+
else:
|
|
105
|
+
raise ValueError(f"Invalid source: {source}")
|
|
106
|
+
|
|
107
|
+
return self._modules
|
|
108
|
+
|
|
109
|
+
def _parse_file(self, path: Path) -> None:
|
|
110
|
+
"""Parse a single Python file."""
|
|
111
|
+
logger.debug("Parsing file: %s", path)
|
|
112
|
+
try:
|
|
113
|
+
source_text = path.read_text(encoding="utf-8")
|
|
114
|
+
except UnicodeDecodeError:
|
|
115
|
+
source_text = path.read_text(encoding="latin-1")
|
|
116
|
+
|
|
117
|
+
tree = ast.parse(source_text)
|
|
118
|
+
module_doc = self._extract_module(path, tree)
|
|
119
|
+
logger.debug(
|
|
120
|
+
"Extracted %d classes and %d functions from %s",
|
|
121
|
+
len(module_doc.classes),
|
|
122
|
+
len(module_doc.functions),
|
|
123
|
+
path,
|
|
124
|
+
)
|
|
125
|
+
self._modules.append(module_doc)
|
|
126
|
+
|
|
127
|
+
def _extract_module(self, path: Path, tree: ast.AST) -> ModuleDoc:
|
|
128
|
+
"""Extract module-level documentation."""
|
|
129
|
+
assert isinstance(tree, ast.Module)
|
|
130
|
+
package = ""
|
|
131
|
+
if self._source.is_dir():
|
|
132
|
+
try:
|
|
133
|
+
rel = path.parent.relative_to(self._source)
|
|
134
|
+
if rel != Path("."):
|
|
135
|
+
package = str(rel).replace("\\", ".").replace("/", ".")
|
|
136
|
+
except ValueError:
|
|
137
|
+
package = ""
|
|
138
|
+
module = ModuleDoc(
|
|
139
|
+
name=path.stem,
|
|
140
|
+
path=path,
|
|
141
|
+
docstring=ast.get_docstring(tree),
|
|
142
|
+
package=package,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
for node in ast.iter_child_nodes(tree):
|
|
146
|
+
if isinstance(node, ast.ClassDef):
|
|
147
|
+
module.classes.append(self._extract_class(node))
|
|
148
|
+
elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
149
|
+
module.functions.append(self._extract_function(node))
|
|
150
|
+
elif isinstance(node, ast.Assign | ast.AnnAssign):
|
|
151
|
+
module.public_api.extend(self._extract_public_api(node))
|
|
152
|
+
|
|
153
|
+
return module
|
|
154
|
+
|
|
155
|
+
def _extract_class(self, node: ast.ClassDef) -> ClassDoc:
|
|
156
|
+
"""Extract documentation from a class definition."""
|
|
157
|
+
bases = [self._format_base(base) for base in node.bases]
|
|
158
|
+
class_doc = ClassDoc(
|
|
159
|
+
name=node.name,
|
|
160
|
+
docstring=ast.get_docstring(node),
|
|
161
|
+
bases=bases,
|
|
162
|
+
class_type=self._resolve_class_type(node, bases),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
for item in node.body:
|
|
166
|
+
if isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
167
|
+
if item.name == "__init__":
|
|
168
|
+
class_doc.attributes.extend(self._extract_attributes(item))
|
|
169
|
+
else:
|
|
170
|
+
class_doc.methods.append(self._extract_function(item, is_method=True))
|
|
171
|
+
|
|
172
|
+
return class_doc
|
|
173
|
+
|
|
174
|
+
def _extract_function(
|
|
175
|
+
self,
|
|
176
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
177
|
+
is_method: bool = False,
|
|
178
|
+
) -> FunctionDoc:
|
|
179
|
+
"""Extract documentation from a function definition."""
|
|
180
|
+
raw_docstring = ast.get_docstring(node)
|
|
181
|
+
params = self._extract_ast_params(node)
|
|
182
|
+
returns: ReturnsInfo | None = None
|
|
183
|
+
raises: list[RaisesInfo] = []
|
|
184
|
+
|
|
185
|
+
if raw_docstring:
|
|
186
|
+
with contextlib.suppress(Exception):
|
|
187
|
+
parsed = docstring_parser.parse(raw_docstring)
|
|
188
|
+
self._merge_param_descriptions(params, parsed)
|
|
189
|
+
returns = self._extract_returns(parsed)
|
|
190
|
+
raises = self._extract_raises(parsed)
|
|
191
|
+
|
|
192
|
+
# Fallback to AST return annotation if docstring did not provide type
|
|
193
|
+
ast_return_type = ast.unparse(node.returns) if node.returns else None
|
|
194
|
+
if returns is None:
|
|
195
|
+
returns = ReturnsInfo(type_hint=ast_return_type)
|
|
196
|
+
elif ast_return_type and not returns.type_hint:
|
|
197
|
+
returns.type_hint = ast_return_type
|
|
198
|
+
|
|
199
|
+
decorators = self._extract_decorator_names(node)
|
|
200
|
+
is_property = any(d in ("property", "cached_property") for d in decorators)
|
|
201
|
+
is_classmethod = "classmethod" in decorators
|
|
202
|
+
is_staticmethod = "staticmethod" in decorators
|
|
203
|
+
|
|
204
|
+
return FunctionDoc(
|
|
205
|
+
name=node.name,
|
|
206
|
+
docstring=raw_docstring,
|
|
207
|
+
params=params,
|
|
208
|
+
returns=returns,
|
|
209
|
+
raises=raises,
|
|
210
|
+
is_method=is_method,
|
|
211
|
+
is_async=isinstance(node, ast.AsyncFunctionDef),
|
|
212
|
+
is_property=is_property,
|
|
213
|
+
is_classmethod=is_classmethod,
|
|
214
|
+
is_staticmethod=is_staticmethod,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _extract_ast_params(
|
|
218
|
+
self,
|
|
219
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
220
|
+
) -> list[Parameter]:
|
|
221
|
+
"""Extract parameter names and type hints from AST."""
|
|
222
|
+
params: list[Parameter] = []
|
|
223
|
+
args = node.args
|
|
224
|
+
|
|
225
|
+
# Determine start index (skip self/cls for methods)
|
|
226
|
+
start = 0
|
|
227
|
+
if args.args and args.args[0].arg in ("self", "cls"):
|
|
228
|
+
start = 1
|
|
229
|
+
|
|
230
|
+
# Positional args
|
|
231
|
+
for _idx, arg in enumerate(args.args[start:], start=start):
|
|
232
|
+
param = Parameter(
|
|
233
|
+
name=arg.arg,
|
|
234
|
+
type_hint=ast.unparse(arg.annotation) if arg.annotation else None,
|
|
235
|
+
)
|
|
236
|
+
params.append(param)
|
|
237
|
+
|
|
238
|
+
# *args
|
|
239
|
+
if args.vararg:
|
|
240
|
+
params.append(
|
|
241
|
+
Parameter(
|
|
242
|
+
name=f"*{args.vararg.arg}",
|
|
243
|
+
type_hint=ast.unparse(args.vararg.annotation)
|
|
244
|
+
if args.vararg.annotation
|
|
245
|
+
else None,
|
|
246
|
+
),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Keyword-only args
|
|
250
|
+
for arg in args.kwonlyargs:
|
|
251
|
+
params.append(
|
|
252
|
+
Parameter(
|
|
253
|
+
name=arg.arg,
|
|
254
|
+
type_hint=ast.unparse(arg.annotation) if arg.annotation else None,
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# **kwargs
|
|
259
|
+
if args.kwarg:
|
|
260
|
+
params.append(
|
|
261
|
+
Parameter(
|
|
262
|
+
name=f"**{args.kwarg.arg}",
|
|
263
|
+
type_hint=ast.unparse(args.kwarg.annotation) if args.kwarg.annotation else None,
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return params
|
|
268
|
+
|
|
269
|
+
def _merge_param_descriptions(
|
|
270
|
+
self,
|
|
271
|
+
params: list[Parameter],
|
|
272
|
+
parsed: ParsedDocstring,
|
|
273
|
+
) -> None:
|
|
274
|
+
"""Merge docstring parameter descriptions into AST-extracted parameters."""
|
|
275
|
+
for param in params:
|
|
276
|
+
doc_param = next(
|
|
277
|
+
(p for p in parsed.params if p.arg_name == param.name.lstrip("*")),
|
|
278
|
+
None,
|
|
279
|
+
)
|
|
280
|
+
if doc_param and doc_param.description:
|
|
281
|
+
param.description = doc_param.description
|
|
282
|
+
|
|
283
|
+
def _extract_returns(self, parsed: ParsedDocstring) -> ReturnsInfo | None:
|
|
284
|
+
"""Extract structured return info from parsed docstring."""
|
|
285
|
+
if parsed.returns:
|
|
286
|
+
return ReturnsInfo(
|
|
287
|
+
type_hint=parsed.returns.type_name,
|
|
288
|
+
description=parsed.returns.description,
|
|
289
|
+
)
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
def _extract_raises(self, parsed: ParsedDocstring) -> list[RaisesInfo]:
|
|
293
|
+
"""Extract structured raises info from parsed docstring."""
|
|
294
|
+
return [
|
|
295
|
+
RaisesInfo(
|
|
296
|
+
type_name=r.type_name,
|
|
297
|
+
description=r.description,
|
|
298
|
+
)
|
|
299
|
+
for r in parsed.raises
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
def _extract_attributes(
|
|
303
|
+
self,
|
|
304
|
+
init_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
305
|
+
) -> list[Parameter]:
|
|
306
|
+
"""Extract instance attributes from __init__ method."""
|
|
307
|
+
attributes: list[Parameter] = []
|
|
308
|
+
|
|
309
|
+
# Try to enrich with __init__ docstring parameter descriptions
|
|
310
|
+
init_docstring = ast.get_docstring(init_node)
|
|
311
|
+
parsed_init: ParsedDocstring | None = None
|
|
312
|
+
if init_docstring:
|
|
313
|
+
with contextlib.suppress(Exception):
|
|
314
|
+
parsed_init = docstring_parser.parse(init_docstring)
|
|
315
|
+
|
|
316
|
+
for node in ast.walk(init_node):
|
|
317
|
+
if (
|
|
318
|
+
isinstance(node, ast.AnnAssign)
|
|
319
|
+
and isinstance(node.target, ast.Attribute)
|
|
320
|
+
and isinstance(node.target.value, ast.Name)
|
|
321
|
+
and node.target.value.id == "self"
|
|
322
|
+
):
|
|
323
|
+
attr_name = node.target.attr
|
|
324
|
+
attr = Parameter(
|
|
325
|
+
name=attr_name,
|
|
326
|
+
type_hint=ast.unparse(node.annotation) if node.annotation else None,
|
|
327
|
+
)
|
|
328
|
+
if parsed_init:
|
|
329
|
+
doc_attr = next(
|
|
330
|
+
(p for p in parsed_init.params if p.arg_name == attr_name),
|
|
331
|
+
None,
|
|
332
|
+
)
|
|
333
|
+
if doc_attr and doc_attr.description:
|
|
334
|
+
attr.description = doc_attr.description
|
|
335
|
+
attributes.append(attr)
|
|
336
|
+
|
|
337
|
+
return attributes
|
|
338
|
+
|
|
339
|
+
def _format_base(self, base: ast.expr) -> str:
|
|
340
|
+
"""Format a base class expression as a string."""
|
|
341
|
+
return ast.unparse(base)
|
|
342
|
+
|
|
343
|
+
def _extract_decorator_names(
|
|
344
|
+
self,
|
|
345
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
|
|
346
|
+
) -> list[str]:
|
|
347
|
+
"""Extract decorator names from a function or class definition."""
|
|
348
|
+
names: list[str] = []
|
|
349
|
+
for dec in node.decorator_list:
|
|
350
|
+
if isinstance(dec, ast.Name):
|
|
351
|
+
names.append(dec.id)
|
|
352
|
+
elif isinstance(dec, ast.Attribute):
|
|
353
|
+
names.append(f"{ast.unparse(dec.value)}.{dec.attr}")
|
|
354
|
+
elif isinstance(dec, ast.Call):
|
|
355
|
+
if isinstance(dec.func, ast.Name):
|
|
356
|
+
names.append(dec.func.id)
|
|
357
|
+
elif isinstance(dec.func, ast.Attribute):
|
|
358
|
+
names.append(f"{ast.unparse(dec.func.value)}.{dec.func.attr}")
|
|
359
|
+
return names
|
|
360
|
+
|
|
361
|
+
def _resolve_class_type(
|
|
362
|
+
self,
|
|
363
|
+
node: ast.ClassDef,
|
|
364
|
+
bases: list[str],
|
|
365
|
+
) -> str:
|
|
366
|
+
"""Determine if a class is a dataclass, enum, typeddict, or plain class."""
|
|
367
|
+
decorators = self._extract_decorator_names(node)
|
|
368
|
+
if any(d in ("dataclass", "dataclasses.dataclass") for d in decorators):
|
|
369
|
+
return "dataclass"
|
|
370
|
+
if any(base.rsplit(".", 1)[-1] in ("Enum", "IntEnum", "Flag", "IntFlag") for base in bases):
|
|
371
|
+
return "enum"
|
|
372
|
+
if any(base.rsplit(".", 1)[-1] in ("TypedDict", "typing.TypedDict") for base in bases):
|
|
373
|
+
return "typeddict"
|
|
374
|
+
return "class"
|
|
375
|
+
|
|
376
|
+
def _extract_public_api(
|
|
377
|
+
self,
|
|
378
|
+
node: ast.Assign | ast.AnnAssign,
|
|
379
|
+
) -> list[str]:
|
|
380
|
+
"""Extract names from __all__ assignment."""
|
|
381
|
+
target = node.targets[0] if isinstance(node, ast.Assign) else node.target
|
|
382
|
+
if not (isinstance(target, ast.Name) and target.id == "__all__"):
|
|
383
|
+
return []
|
|
384
|
+
|
|
385
|
+
value = node.value
|
|
386
|
+
if value is None:
|
|
387
|
+
return []
|
|
388
|
+
|
|
389
|
+
names: list[str] = []
|
|
390
|
+
if isinstance(value, (ast.List, ast.Tuple)):
|
|
391
|
+
for elt in value.elts:
|
|
392
|
+
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
|
393
|
+
names.append(elt.value)
|
|
394
|
+
elif hasattr(ast, "Str") and isinstance(elt, ast.Str): # noqa: UP023
|
|
395
|
+
names.append(str(elt.s))
|
|
396
|
+
return names
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""File watcher for auto-regenerating documentation."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def watch_and_generate(
|
|
10
|
+
source: Path,
|
|
11
|
+
output_dir: Path,
|
|
12
|
+
recursive: bool,
|
|
13
|
+
theme: str,
|
|
14
|
+
template_path: Path | None,
|
|
15
|
+
) -> int:
|
|
16
|
+
"""Watch source files and regenerate docs on change.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
source: Path to Python file or directory.
|
|
20
|
+
output_dir: Output directory for Markdown files.
|
|
21
|
+
recursive: Whether to scan subdirectories recursively.
|
|
22
|
+
theme: Built-in theme name.
|
|
23
|
+
template_path: Optional custom template path.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Exit code (0 for success, 1 for error).
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
30
|
+
from watchdog.observers import Observer
|
|
31
|
+
except ImportError:
|
|
32
|
+
logger.error(
|
|
33
|
+
"watchdog is required for --watch. Install it with: pip install pydoc2markdown[watch]"
|
|
34
|
+
)
|
|
35
|
+
return 1
|
|
36
|
+
|
|
37
|
+
from pydoc2markdown.core.generator import MarkdownGenerator
|
|
38
|
+
from pydoc2markdown.core.parser import DocstringParser
|
|
39
|
+
|
|
40
|
+
generator = MarkdownGenerator(
|
|
41
|
+
template_path=template_path,
|
|
42
|
+
theme=theme,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
class _RebuildHandler(FileSystemEventHandler):
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self._parser = DocstringParser()
|
|
48
|
+
|
|
49
|
+
def on_any_event(self, event: FileSystemEvent) -> None:
|
|
50
|
+
if event.is_directory:
|
|
51
|
+
return
|
|
52
|
+
src = getattr(event, "src_path", "")
|
|
53
|
+
if isinstance(src, str) and not src.endswith(".py"):
|
|
54
|
+
return
|
|
55
|
+
logger.info("Change detected, regenerating docs...")
|
|
56
|
+
try:
|
|
57
|
+
modules = self._parser.parse(source, recursive=recursive)
|
|
58
|
+
generator.generate(modules, output_dir)
|
|
59
|
+
logger.info("Docs regenerated in %s", output_dir)
|
|
60
|
+
except Exception:
|
|
61
|
+
logger.exception("Regeneration failed")
|
|
62
|
+
|
|
63
|
+
handler = _RebuildHandler()
|
|
64
|
+
watch_path = source if source.is_dir() else source.parent
|
|
65
|
+
observer = Observer()
|
|
66
|
+
observer.schedule(handler, str(watch_path), recursive=True)
|
|
67
|
+
observer.start()
|
|
68
|
+
|
|
69
|
+
logger.info("Watching %s for changes. Press Ctrl+C to stop.", watch_path)
|
|
70
|
+
|
|
71
|
+
# Generate once before watching
|
|
72
|
+
try:
|
|
73
|
+
modules = handler._parser.parse(source, recursive=recursive)
|
|
74
|
+
generator.generate(modules, output_dir)
|
|
75
|
+
logger.info("Initial docs generated in %s", output_dir)
|
|
76
|
+
except Exception:
|
|
77
|
+
logger.exception("Initial generation failed")
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
while observer.is_alive():
|
|
81
|
+
observer.join(1)
|
|
82
|
+
except KeyboardInterrupt:
|
|
83
|
+
logger.info("Stopping watcher...")
|
|
84
|
+
finally:
|
|
85
|
+
observer.stop()
|
|
86
|
+
observer.join()
|
|
87
|
+
|
|
88
|
+
return 0
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# {{ module.name }}
|
|
2
|
+
|
|
3
|
+
{% if module.docstring %}
|
|
4
|
+
{{ module.docstring }}
|
|
5
|
+
{% endif %}
|
|
6
|
+
|
|
7
|
+
{% if module.classes %}
|
|
8
|
+
## Classes
|
|
9
|
+
|
|
10
|
+
{% for class in module.classes %}
|
|
11
|
+
### `{{ class.name }}`{% if class.class_type != "class" %} ({{ class.class_type }}){% endif %}
|
|
12
|
+
|
|
13
|
+
{% if class.docstring %}
|
|
14
|
+
{{ class.docstring }}
|
|
15
|
+
{% endif %}
|
|
16
|
+
|
|
17
|
+
{% endfor %}
|
|
18
|
+
{% endif %}
|
|
19
|
+
|
|
20
|
+
{% if module.functions %}
|
|
21
|
+
## Functions
|
|
22
|
+
|
|
23
|
+
{% for func in module.functions %}
|
|
24
|
+
### `{{ func.name }}`
|
|
25
|
+
|
|
26
|
+
{% if func.docstring %}
|
|
27
|
+
{{ func.docstring }}
|
|
28
|
+
{% endif %}
|
|
29
|
+
|
|
30
|
+
{% endfor %}
|
|
31
|
+
{% endif %}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# {{ module.name }}
|
|
2
|
+
|
|
3
|
+
{% if module.docstring %}
|
|
4
|
+
{{ module.docstring }}
|
|
5
|
+
{% endif %}
|
|
6
|
+
|
|
7
|
+
{% if module.classes or module.functions %}
|
|
8
|
+
## Table of Contents
|
|
9
|
+
|
|
10
|
+
{% if module.classes %}
|
|
11
|
+
- [Classes](#classes)
|
|
12
|
+
{% for class in module.classes %}
|
|
13
|
+
- [`{{ class.name }}`](#{{ class.name | lower | replace(' ', '-') }})
|
|
14
|
+
{% if class.methods %}
|
|
15
|
+
{% for method in class.methods %}
|
|
16
|
+
- [
|
|
17
|
+
{% if method.is_property %}@property {% elif method.is_classmethod %}@classmethod
|
|
18
|
+
{% elif method.is_staticmethod %}@staticmethod {% endif %}
|
|
19
|
+
`{{ method.name }}`](#{{ method.name | lower | replace(' ', '-') }})
|
|
20
|
+
{% endfor %}
|
|
21
|
+
{% endif %}
|
|
22
|
+
{% endfor %}
|
|
23
|
+
{% endif %}
|
|
24
|
+
{% if module.functions %}
|
|
25
|
+
- [Functions](#functions)
|
|
26
|
+
{% for func in module.functions %}
|
|
27
|
+
- [`{{ func.name }}`](#{{ func.name | lower | replace(' ', '-') }})
|
|
28
|
+
{% endfor %}
|
|
29
|
+
{% endif %}
|
|
30
|
+
{% endif %}
|
|
31
|
+
|
|
32
|
+
{% if module.public_api %}
|
|
33
|
+
**Public API:**
|
|
34
|
+
{% for name in module.public_api %}
|
|
35
|
+
- `{{ name }}`
|
|
36
|
+
{% endfor %}
|
|
37
|
+
{% endif %}
|
|
38
|
+
|
|
39
|
+
{% if module.classes %}
|
|
40
|
+
## Classes
|
|
41
|
+
|
|
42
|
+
{% for class in module.classes %}
|
|
43
|
+
### `{{ class.name }}`{% if class.class_type != "class" %} ({{ class.class_type }}){% endif %}
|
|
44
|
+
|
|
45
|
+
{% if class.bases %}
|
|
46
|
+
**Bases:** `{{ class.bases | join(", ") }}`
|
|
47
|
+
{% endif %}
|
|
48
|
+
|
|
49
|
+
{% if class.docstring %}
|
|
50
|
+
{{ class.docstring }}
|
|
51
|
+
{% endif %}
|
|
52
|
+
|
|
53
|
+
{% if class.attributes %}
|
|
54
|
+
#### Attributes
|
|
55
|
+
|
|
56
|
+
| Name | Type | Description |
|
|
57
|
+
|------|------|-------------|
|
|
58
|
+
{% for attr in class.attributes %}
|
|
59
|
+
| `{{ attr.name }}` | {% if attr.type_hint %}`{{ attr.type_hint }}`{% else %}-{% endif %} | {% if attr.description %}{{ attr.description }}{% else %}-{% endif %} |
|
|
60
|
+
{% endfor %}
|
|
61
|
+
{% endif %}
|
|
62
|
+
|
|
63
|
+
{% if class.methods %}
|
|
64
|
+
#### Methods
|
|
65
|
+
|
|
66
|
+
{% for method in class.methods %}
|
|
67
|
+
##### {% if method.is_property %}@property {% elif method.is_classmethod %}@classmethod {% elif method.is_staticmethod %}@staticmethod {% endif %}`{{ method.name }}`
|
|
68
|
+
|
|
69
|
+
{% if method.docstring %}
|
|
70
|
+
{{ method.docstring }}
|
|
71
|
+
{% endif %}
|
|
72
|
+
|
|
73
|
+
{% if method.params %}
|
|
74
|
+
**Parameters:**
|
|
75
|
+
|
|
76
|
+
| Name | Type | Description |
|
|
77
|
+
|------|------|-------------|
|
|
78
|
+
{% for param in method.params %}
|
|
79
|
+
| `{{ param.name }}` | {% if param.type_hint %}`{{ param.type_hint }}`{% else %}-{% endif %} | {% if param.description %}{{ param.description }}{% else %}-{% endif %} |
|
|
80
|
+
{% endfor %}
|
|
81
|
+
{% endif %}
|
|
82
|
+
|
|
83
|
+
{% if method.returns %}
|
|
84
|
+
**Returns:**{% if method.returns.type_hint %} `{{ method.returns.type_hint }}`{% endif %}
|
|
85
|
+
|
|
86
|
+
{% if method.returns.description %}{{ method.returns.description }}{% endif %}
|
|
87
|
+
{% endif %}
|
|
88
|
+
|
|
89
|
+
{% if method.raises %}
|
|
90
|
+
**Raises:**
|
|
91
|
+
{% for raise in method.raises %}
|
|
92
|
+
- {% if raise.type_name %}`{{ raise.type_name }}`{% endif %}{% if raise.description %}: {{ raise.description }}{% endif %}
|
|
93
|
+
{% endfor %}
|
|
94
|
+
{% endif %}
|
|
95
|
+
|
|
96
|
+
{% endfor %}
|
|
97
|
+
{% endif %}
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
{% endfor %}
|
|
101
|
+
{% endif %}
|
|
102
|
+
|
|
103
|
+
{% if module.functions %}
|
|
104
|
+
## Functions
|
|
105
|
+
|
|
106
|
+
{% for func in module.functions %}
|
|
107
|
+
### `{{ func.name }}`
|
|
108
|
+
|
|
109
|
+
{% if func.docstring %}
|
|
110
|
+
{{ func.docstring }}
|
|
111
|
+
{% endif %}
|
|
112
|
+
|
|
113
|
+
{% if func.params %}
|
|
114
|
+
**Parameters:**
|
|
115
|
+
|
|
116
|
+
| Name | Type | Description |
|
|
117
|
+
|------|------|-------------|
|
|
118
|
+
{% for param in func.params %}
|
|
119
|
+
| `{{ param.name }}` | {% if param.type_hint %}`{{ param.type_hint }}`{% else %}-{% endif %} | {% if param.description %}{{ param.description }}{% else %}-{% endif %} |
|
|
120
|
+
{% endfor %}
|
|
121
|
+
{% endif %}
|
|
122
|
+
|
|
123
|
+
{% if func.returns %}
|
|
124
|
+
**Returns:**{% if func.returns.type_hint %} `{{ func.returns.type_hint }}`{% endif %}
|
|
125
|
+
|
|
126
|
+
{% if func.returns.description %}{{ func.returns.description }}{% endif %}
|
|
127
|
+
{% endif %}
|
|
128
|
+
|
|
129
|
+
{% if func.raises %}
|
|
130
|
+
**Raises:**
|
|
131
|
+
{% for raise in func.raises %}
|
|
132
|
+
- {% if raise.type_name %}`{{ raise.type_name }}`{% endif %}{% if raise.description %}: {{ raise.description }}{% endif %}
|
|
133
|
+
{% endfor %}
|
|
134
|
+
{% endif %}
|
|
135
|
+
|
|
136
|
+
{% endfor %}
|
|
137
|
+
{% endif %}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utility modules for PyDoc2Markdown."""
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Helper utilities for PyDoc2Markdown."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def clean_docstring(docstring: str | None) -> str | None:
|
|
7
|
+
"""Clean and normalize a docstring."""
|
|
8
|
+
if not docstring:
|
|
9
|
+
return None
|
|
10
|
+
lines = docstring.expandtabs().splitlines()
|
|
11
|
+
if not lines:
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
# Find minimum indentation
|
|
15
|
+
indent = None
|
|
16
|
+
for line in lines[1:]:
|
|
17
|
+
stripped = line.lstrip()
|
|
18
|
+
if stripped:
|
|
19
|
+
line_indent = len(line) - len(stripped)
|
|
20
|
+
if indent is None or line_indent < indent:
|
|
21
|
+
indent = line_indent
|
|
22
|
+
|
|
23
|
+
if indent:
|
|
24
|
+
lines[1:] = [line[indent:] for line in lines[1:]]
|
|
25
|
+
|
|
26
|
+
return "\n".join(lines).strip()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def slugify(text: str) -> str:
|
|
30
|
+
"""Convert text to a URL-friendly slug."""
|
|
31
|
+
text = text.lower().strip()
|
|
32
|
+
text = re.sub(r"[^\w\s-]", "", text)
|
|
33
|
+
text = re.sub(r"[\s_]+", "-", text)
|
|
34
|
+
return re.sub(r"-+", "-", text)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pydoc2markdown
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert Python docstrings to Markdown documentation.
|
|
5
|
+
Project-URL: Homepage, https://github.com/f1sherFM/PyDoc2Markdown
|
|
6
|
+
Project-URL: Repository, https://github.com/f1sherFM/PyDoc2Markdown
|
|
7
|
+
Project-URL: Issues, https://github.com/f1sherFM/PyDoc2Markdown/issues
|
|
8
|
+
Author-email: f1sherFM <f1sherFM@example.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: docstring,documentation,markdown,python
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Documentation
|
|
20
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: docstring-parser>=0.15
|
|
24
|
+
Requires-Dist: jinja2>=3.1.0
|
|
25
|
+
Requires-Dist: tomli>=1.2.0; python_version < '3.11'
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy>=1.5.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pre-commit>=3.5.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=7.4.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: watchdog>=3.0; extra == 'dev'
|
|
33
|
+
Provides-Extra: watch
|
|
34
|
+
Requires-Dist: watchdog>=3.0; extra == 'watch'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# PyDoc2Markdown
|
|
38
|
+
|
|
39
|
+
[](https://github.com/f1sherFM/PyDoc2Markdown/actions/workflows/ci.yml)
|
|
40
|
+
[](https://codecov.io/gh/f1sherFM/PyDoc2Markdown)
|
|
41
|
+
[](https://pypi.org/project/pydoc2markdown/)
|
|
42
|
+
|
|
43
|
+
> Convert Python docstrings into clean, structured Markdown documentation.
|
|
44
|
+
|
|
45
|
+
## Features
|
|
46
|
+
|
|
47
|
+
- **Docstring parsing** — Extract Google, NumPy, and reStructuredText style docstrings with structured params, returns, and raises.
|
|
48
|
+
- **Markdown generation** — Produce beautiful Markdown files with customizable Jinja2 templates.
|
|
49
|
+
- **Auto-generated index & TOC** — Each module gets a Table of Contents; an `index.md` with package grouping is created automatically.
|
|
50
|
+
- **Package grouping** — Output files are organized into subdirectories matching the package structure.
|
|
51
|
+
- **Built-in themes** — Choose between `default` (detailed) and `minimal` themes, or supply your own template.
|
|
52
|
+
- **CLI & API** — Use via command line or import as a Python library.
|
|
53
|
+
- **Recursive processing** — Scan entire packages in one command.
|
|
54
|
+
- **Type-aware** — Respects type hints and annotations.
|
|
55
|
+
- **Advanced constructs** — Supports `property`, `classmethod`, `staticmethod`, `dataclass`, `Enum`, `TypedDict`, and `__all__`.
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install pydoc2markdown
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Quick Start
|
|
64
|
+
|
|
65
|
+
### CLI Usage
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# Generate docs for a single file
|
|
69
|
+
pydoc2markdown my_module.py -o docs
|
|
70
|
+
|
|
71
|
+
# Recursively process a package
|
|
72
|
+
pydoc2markdown src/my_package --recursive -o docs
|
|
73
|
+
|
|
74
|
+
# Use a built-in theme
|
|
75
|
+
pydoc2markdown src/my_package --recursive --theme minimal -o docs
|
|
76
|
+
|
|
77
|
+
# Use a custom template
|
|
78
|
+
pydoc2markdown src/my_package --recursive --template custom.md.j2 -o docs
|
|
79
|
+
|
|
80
|
+
# Enable verbose logging
|
|
81
|
+
pydoc2markdown src/my_package --recursive -vv -o docs
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Library Usage
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from pathlib import Path
|
|
88
|
+
from pydoc2markdown import DocstringParser, MarkdownGenerator
|
|
89
|
+
|
|
90
|
+
parser = DocstringParser()
|
|
91
|
+
modules = parser.parse(Path("src/my_package"), recursive=True)
|
|
92
|
+
|
|
93
|
+
generator = MarkdownGenerator(theme="default")
|
|
94
|
+
generator.generate(modules, output_dir=Path("docs"))
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Documentation
|
|
98
|
+
|
|
99
|
+
- [Contributing Guide](CONTRIBUTING.md)
|
|
100
|
+
- [Project Structure](PROJECT_STRUCTURE.md)
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT License — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
pydoc2markdown/__init__.py,sha256=VFhL3E7U4NYG0gOjsyIfwsFFcu9CezjpqzdSdfB5cbg,511
|
|
2
|
+
pydoc2markdown/cli.py,sha256=00ssjGZDCX6CRiMRqRPZ8IdavwSSpzTFjUcA-TW0YDs,3826
|
|
3
|
+
pydoc2markdown/core/__init__.py,sha256=2L8gqlhFk5aUEdVweMh6n14qBP3otOvaqY9HFzuItZE,37
|
|
4
|
+
pydoc2markdown/core/config.py,sha256=7a4Xkt9V6wSqlZFXcAxt5UE0KvHPtf-dAtcktrEmE20,939
|
|
5
|
+
pydoc2markdown/core/generator.py,sha256=iSROflUXlqjNnCHTLVVe2yyhfD7ciZORN8fs56bCwnc,7979
|
|
6
|
+
pydoc2markdown/core/parser.py,sha256=gYmFR-iHI69I3fEbo-xne249ftI1aExjMG84dXTeobI,13595
|
|
7
|
+
pydoc2markdown/core/watcher.py,sha256=vYGUi6qzXyO0EpHYpZug8fVbOLsyzdRRR-_d71Qnj6U,2774
|
|
8
|
+
pydoc2markdown/templates/minimal.md,sha256=QywFgdjpYOYgKQC_DGiWYXgAiAa_lsq_yj0FSvkWDdY,516
|
|
9
|
+
pydoc2markdown/templates/module.md,sha256=Hkp-vi02pErjhz4V5iodDmWmqXjfJxU15ruuP6Hhruw,3596
|
|
10
|
+
pydoc2markdown/utils/__init__.py,sha256=MCa1nvJ2iCaeED3zbqu3_2rPiUm19biNiFWxPiACyqU,42
|
|
11
|
+
pydoc2markdown/utils/helpers.py,sha256=HMyAr_pJ0Zz2HlqbyBsMTTCIQmVmvXtDM5Gj4lVHU_c,900
|
|
12
|
+
pydoc2markdown-0.1.0.dist-info/METADATA,sha256=6EgH34a8hNuHvM3OGddTMMeA6SP_Ig6z4WG_8UFibn8,3885
|
|
13
|
+
pydoc2markdown-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
14
|
+
pydoc2markdown-0.1.0.dist-info/entry_points.txt,sha256=bfIiW3kqPCQExc2kQREAE2ExHDJ1HMA3x1JP6hXDr1w,59
|
|
15
|
+
pydoc2markdown-0.1.0.dist-info/licenses/LICENSE,sha256=aZoyljz1cF1y9zrTZmigukeEoCIdOoWiQ6P4F7FVSgs,1065
|
|
16
|
+
pydoc2markdown-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 f1sherFM
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|