markdownizer 0.2.1__tar.gz → 0.3.0__tar.gz

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.
Files changed (39) hide show
  1. {markdownizer-0.2.1 → markdownizer-0.3.0}/PKG-INFO +66 -5
  2. {markdownizer-0.2.1 → markdownizer-0.3.0}/README.md +65 -4
  3. {markdownizer-0.2.1 → markdownizer-0.3.0}/markdownizer/__init__.py +3 -2
  4. markdownizer-0.3.0/markdownizer/backends/__init__.py +26 -0
  5. markdownizer-0.3.0/markdownizer/backends/base.py +36 -0
  6. markdownizer-0.3.0/markdownizer/backends/compact.py +75 -0
  7. markdownizer-0.3.0/markdownizer/backends/json.py +19 -0
  8. markdownizer-0.3.0/markdownizer/backends/markdown.py +62 -0
  9. {markdownizer-0.2.1 → markdownizer-0.3.0}/markdownizer/classifier.py +3 -3
  10. {markdownizer-0.2.1 → markdownizer-0.3.0}/markdownizer/cli.py +63 -16
  11. markdownizer-0.3.0/markdownizer/extractor.py +88 -0
  12. markdownizer-0.3.0/markdownizer/imports.py +146 -0
  13. markdownizer-0.3.0/markdownizer/ir.py +426 -0
  14. {markdownizer-0.2.1 → markdownizer-0.3.0}/markdownizer/parser.py +66 -4
  15. {markdownizer-0.2.1 → markdownizer-0.3.0}/markdownizer/renderer.py +57 -19
  16. {markdownizer-0.2.1 → markdownizer-0.3.0}/markdownizer.egg-info/PKG-INFO +66 -5
  17. {markdownizer-0.2.1 → markdownizer-0.3.0}/markdownizer.egg-info/SOURCES.txt +10 -0
  18. markdownizer-0.3.0/tests/test_backends.py +241 -0
  19. markdownizer-0.3.0/tests/test_cli.py +161 -0
  20. markdownizer-0.3.0/tests/test_imports.py +146 -0
  21. markdownizer-0.3.0/tests/test_ir.py +247 -0
  22. {markdownizer-0.2.1 → markdownizer-0.3.0}/tests/test_packaging.py +11 -2
  23. markdownizer-0.2.1/markdownizer/extractor.py +0 -96
  24. markdownizer-0.2.1/tests/test_cli.py +0 -83
  25. {markdownizer-0.2.1 → markdownizer-0.3.0}/LICENSE +0 -0
  26. {markdownizer-0.2.1 → markdownizer-0.3.0}/markdownizer/__main__.py +0 -0
  27. {markdownizer-0.2.1 → markdownizer-0.3.0}/markdownizer/py.typed +0 -0
  28. {markdownizer-0.2.1 → markdownizer-0.3.0}/markdownizer/scanner.py +0 -0
  29. {markdownizer-0.2.1 → markdownizer-0.3.0}/markdownizer.egg-info/dependency_links.txt +0 -0
  30. {markdownizer-0.2.1 → markdownizer-0.3.0}/markdownizer.egg-info/entry_points.txt +0 -0
  31. {markdownizer-0.2.1 → markdownizer-0.3.0}/markdownizer.egg-info/requires.txt +0 -0
  32. {markdownizer-0.2.1 → markdownizer-0.3.0}/markdownizer.egg-info/top_level.txt +0 -0
  33. {markdownizer-0.2.1 → markdownizer-0.3.0}/pyproject.toml +0 -0
  34. {markdownizer-0.2.1 → markdownizer-0.3.0}/setup.cfg +0 -0
  35. {markdownizer-0.2.1 → markdownizer-0.3.0}/tests/test_classifier.py +0 -0
  36. {markdownizer-0.2.1 → markdownizer-0.3.0}/tests/test_extractor.py +0 -0
  37. {markdownizer-0.2.1 → markdownizer-0.3.0}/tests/test_parser.py +0 -0
  38. {markdownizer-0.2.1 → markdownizer-0.3.0}/tests/test_renderer.py +0 -0
  39. {markdownizer-0.2.1 → markdownizer-0.3.0}/tests/test_scanner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdownizer
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Extract documentation from Python projects into Markdown without rewriting it
5
5
  Author-email: Mohammad Hasan Khoddami <mohammadh.khoddami@gmail.com>
6
6
  License: MIT License
@@ -89,8 +89,17 @@ Requires Python 3.9+. No runtime dependencies.
89
89
  markdownizer /path/to/project -o ./docs
90
90
  ```
91
91
 
92
- This recursively scans the project, parses every Python file with the AST, and
93
- writes one Markdown file per package into `./docs`.
92
+ This recursively scans the project, parses every Python file with the AST,
93
+ builds a deterministic Project IR, and writes one Markdown file per package
94
+ into `./docs`.
95
+
96
+ The compiler-style form is equivalent and supports format selection:
97
+
98
+ ```bash
99
+ markdownizer build /path/to/project -o ./docs --format markdown
100
+ markdownizer build /path/to/project -o ./docs --format json
101
+ markdownizer build /path/to/project -o ./docs --format compact
102
+ ```
94
103
 
95
104
  Common options:
96
105
 
@@ -105,6 +114,7 @@ markdownizer . -o ./docs \
105
114
  | `-o, --output DIR` | Output directory (default: `./docs`) |
106
115
  | `--root-name NAME` | Filename for files at the project root (default: `_root`) |
107
116
  | `--exclude GLOB` | Skip matching paths; may be repeated |
117
+ | `--format FMT` | Output backend: `markdown`, `json`, or `compact` |
108
118
  | `--no-source` | Omit the `## Source Code` section |
109
119
  | `--no-comments` | Omit the `## Comments` section |
110
120
  | `--only-documented` | Only include objects with a docstring |
@@ -118,7 +128,7 @@ Run `markdownizer --help` for the full list.
118
128
 
119
129
  ```python
120
130
  from pathlib import Path
121
- from markdownizer import extract_project
131
+ from markdownizer import extract_project, build_project_ir
122
132
 
123
133
  written = extract_project(
124
134
  Path("."),
@@ -126,9 +136,60 @@ written = extract_project(
126
136
  exclude=["tests/*"],
127
137
  include_source=False,
128
138
  )
129
- print(written) # list of written Markdown files
139
+ print(written) # list of written output files
140
+
141
+ # Or build the Project IR directly:
142
+ ir = build_project_ir(Path("."), exclude=["tests/*"])
143
+ print(ir.ir_version, ir.hash, ir.stats.symbol_count)
144
+ ```
145
+
146
+ ### Output formats
147
+
148
+ The pipeline builds a deterministic **Project IR** (packages → modules →
149
+ symbols, plus import/inherit/define edges) and renders it through a backend:
150
+
151
+ | Format | Command | Output |
152
+ | --- | --- | --- |
153
+ | `markdown` (default) | `markdownizer build . -o ./docs` | One `.md` file per package |
154
+ | `json` | `markdownizer build . -o ./docs --format json` | `project.json` — full IR serialization |
155
+ | `compact` | `markdownizer build . -o ./docs --format compact` | `context.compact.md` — signatures, docstrings, inheritance, decorators (no bodies) |
156
+
157
+ The legacy invocation `markdownizer <project> -o <out>` is kept as a
158
+ compatibility alias for `markdownizer build <project> --format markdown`.
159
+
160
+ ### Signature mode
161
+
162
+ Instead of full source or no source, `extract_project()` accepts
163
+ `include_source="signature"` to emit only declaration lines:
164
+
165
+ ```python
166
+ extract_project(Path("."), Path("docs"), include_source="signature")
130
167
  ```
131
168
 
169
+ Functions render as `def foo(x: int = 1) -> str:`, async functions as
170
+ `async def ...`, classes as `class User(models.Model):` (with base classes),
171
+ and methods with their parameters. Modules render without source. The
172
+ boolean modes (`True`/`False`) are unchanged.
173
+
174
+ ### Project IR
175
+
176
+ `build_project_ir(project_root, exclude=None)` returns a `ProjectIR` with:
177
+
178
+ - `ir_version` — schema version (currently `1`), independent of the package version
179
+ - `packages`, `modules`, `symbols` — the project hierarchy
180
+ - `imports`, `inherits`, `defines` — relationship edges
181
+ - `stats` — file/module/symbol counts
182
+ - `hash` — deterministic `blake2b` of the canonical IR content
183
+
184
+ The hash and JSON serialization are deterministic: the same repository
185
+ content always produces the same hash and the same `project.json`, making
186
+ the output suitable for version control and caching. Machine-specific
187
+ metadata (`root`, `python_version`, `git`) is excluded from the hash.
188
+
189
+ Import resolution is conservative and fully static: project code is never
190
+ imported or executed. Imports that cannot be resolved to a project module
191
+ are marked `external`.
192
+
132
193
  ## What is extracted
133
194
 
134
195
  For every documented object (modules, packages, classes, dataclasses, enums,
@@ -33,8 +33,17 @@ Requires Python 3.9+. No runtime dependencies.
33
33
  markdownizer /path/to/project -o ./docs
34
34
  ```
35
35
 
36
- This recursively scans the project, parses every Python file with the AST, and
37
- writes one Markdown file per package into `./docs`.
36
+ This recursively scans the project, parses every Python file with the AST,
37
+ builds a deterministic Project IR, and writes one Markdown file per package
38
+ into `./docs`.
39
+
40
+ The compiler-style form is equivalent and supports format selection:
41
+
42
+ ```bash
43
+ markdownizer build /path/to/project -o ./docs --format markdown
44
+ markdownizer build /path/to/project -o ./docs --format json
45
+ markdownizer build /path/to/project -o ./docs --format compact
46
+ ```
38
47
 
39
48
  Common options:
40
49
 
@@ -49,6 +58,7 @@ markdownizer . -o ./docs \
49
58
  | `-o, --output DIR` | Output directory (default: `./docs`) |
50
59
  | `--root-name NAME` | Filename for files at the project root (default: `_root`) |
51
60
  | `--exclude GLOB` | Skip matching paths; may be repeated |
61
+ | `--format FMT` | Output backend: `markdown`, `json`, or `compact` |
52
62
  | `--no-source` | Omit the `## Source Code` section |
53
63
  | `--no-comments` | Omit the `## Comments` section |
54
64
  | `--only-documented` | Only include objects with a docstring |
@@ -62,7 +72,7 @@ Run `markdownizer --help` for the full list.
62
72
 
63
73
  ```python
64
74
  from pathlib import Path
65
- from markdownizer import extract_project
75
+ from markdownizer import extract_project, build_project_ir
66
76
 
67
77
  written = extract_project(
68
78
  Path("."),
@@ -70,9 +80,60 @@ written = extract_project(
70
80
  exclude=["tests/*"],
71
81
  include_source=False,
72
82
  )
73
- print(written) # list of written Markdown files
83
+ print(written) # list of written output files
84
+
85
+ # Or build the Project IR directly:
86
+ ir = build_project_ir(Path("."), exclude=["tests/*"])
87
+ print(ir.ir_version, ir.hash, ir.stats.symbol_count)
88
+ ```
89
+
90
+ ### Output formats
91
+
92
+ The pipeline builds a deterministic **Project IR** (packages → modules →
93
+ symbols, plus import/inherit/define edges) and renders it through a backend:
94
+
95
+ | Format | Command | Output |
96
+ | --- | --- | --- |
97
+ | `markdown` (default) | `markdownizer build . -o ./docs` | One `.md` file per package |
98
+ | `json` | `markdownizer build . -o ./docs --format json` | `project.json` — full IR serialization |
99
+ | `compact` | `markdownizer build . -o ./docs --format compact` | `context.compact.md` — signatures, docstrings, inheritance, decorators (no bodies) |
100
+
101
+ The legacy invocation `markdownizer <project> -o <out>` is kept as a
102
+ compatibility alias for `markdownizer build <project> --format markdown`.
103
+
104
+ ### Signature mode
105
+
106
+ Instead of full source or no source, `extract_project()` accepts
107
+ `include_source="signature"` to emit only declaration lines:
108
+
109
+ ```python
110
+ extract_project(Path("."), Path("docs"), include_source="signature")
74
111
  ```
75
112
 
113
+ Functions render as `def foo(x: int = 1) -> str:`, async functions as
114
+ `async def ...`, classes as `class User(models.Model):` (with base classes),
115
+ and methods with their parameters. Modules render without source. The
116
+ boolean modes (`True`/`False`) are unchanged.
117
+
118
+ ### Project IR
119
+
120
+ `build_project_ir(project_root, exclude=None)` returns a `ProjectIR` with:
121
+
122
+ - `ir_version` — schema version (currently `1`), independent of the package version
123
+ - `packages`, `modules`, `symbols` — the project hierarchy
124
+ - `imports`, `inherits`, `defines` — relationship edges
125
+ - `stats` — file/module/symbol counts
126
+ - `hash` — deterministic `blake2b` of the canonical IR content
127
+
128
+ The hash and JSON serialization are deterministic: the same repository
129
+ content always produces the same hash and the same `project.json`, making
130
+ the output suitable for version control and caching. Machine-specific
131
+ metadata (`root`, `python_version`, `git`) is excluded from the hash.
132
+
133
+ Import resolution is conservative and fully static: project code is never
134
+ imported or executed. Imports that cannot be resolved to a project module
135
+ are marked `external`.
136
+
76
137
  ## What is extracted
77
138
 
78
139
  For every documented object (modules, packages, classes, dataclasses, enums,
@@ -5,6 +5,7 @@ It only extracts what already exists in the source code.
5
5
  """
6
6
 
7
7
  from markdownizer.extractor import extract_project
8
+ from markdownizer.ir import IR_VERSION, ProjectIR, build_project_ir
8
9
 
9
- __version__ = "0.2.1"
10
- __all__ = ["extract_project"]
10
+ __version__ = "0.3.0"
11
+ __all__ = ["extract_project", "build_project_ir", "IR_VERSION", "ProjectIR"]
@@ -0,0 +1,26 @@
1
+ """Output backends for the Project IR."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from markdownizer.backends.base import Backend, RenderOptions
6
+ from markdownizer.backends.compact import CompactBackend
7
+ from markdownizer.backends.json import JSONBackend
8
+ from markdownizer.backends.markdown import MarkdownBackend
9
+
10
+ _BACKENDS: dict[str, type[Backend]] = {
11
+ "markdown": MarkdownBackend,
12
+ "json": JSONBackend,
13
+ "compact": CompactBackend,
14
+ }
15
+
16
+
17
+ def get_backend(name: str) -> Backend:
18
+ """Instantiate a backend by name (``markdown``, ``json``, or ``compact``)."""
19
+ try:
20
+ return _BACKENDS[name]()
21
+ except KeyError:
22
+ choices = ", ".join(sorted(_BACKENDS))
23
+ raise ValueError(f"unknown format: {name!r} (expected one of: {choices})") from None
24
+
25
+
26
+ __all__ = ["Backend", "RenderOptions", "get_backend"]
@@ -0,0 +1,36 @@
1
+ """Backend abstraction: deterministic rendering of a ProjectIR into output files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal, Protocol, Union
7
+
8
+ from markdownizer.ir import ProjectIR
9
+
10
+ SourceMode = Union[bool, Literal["signature"]]
11
+
12
+
13
+ @dataclass
14
+ class RenderOptions:
15
+ """Options controlling how a backend renders an IR.
16
+
17
+ ``include_source`` may be ``True`` (full source), ``False`` (no source),
18
+ or ``"signature"`` (declaration line only).
19
+ """
20
+
21
+ root_name: str = "_root"
22
+ include_source: SourceMode = True
23
+ include_comments: bool = True
24
+ include_undocumented: bool = True
25
+
26
+
27
+ class Backend(Protocol):
28
+ """A backend renders a ProjectIR into ``{filename: content}`` pairs.
29
+
30
+ Implementations must be deterministic: the same IR always produces the
31
+ same output.
32
+ """
33
+
34
+ name: str
35
+
36
+ def render(self, ir: ProjectIR, options: RenderOptions) -> dict[str, str]: ...
@@ -0,0 +1,75 @@
1
+ """Compact backend: a low-volume, structure-oriented representation.
2
+
3
+ Contains packages, modules, public symbols, signatures, inheritance,
4
+ decorators, and docstrings — without full source bodies. The output is
5
+ deterministic and intended as a high-signal overview for AI consumption.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from markdownizer.backends.base import RenderOptions
11
+ from markdownizer.ir import ModuleInfo, ProjectIR, Symbol
12
+ from markdownizer.renderer import format_signature
13
+
14
+
15
+ def _symbol_line(symbol: Symbol, options: RenderOptions) -> list[str]:
16
+ lines = [f"# {symbol.framework}: {symbol.qualified_name} — {symbol.file_path}:{symbol.lineno}"]
17
+ if options.include_source is not False:
18
+ signature = format_signature(symbol)
19
+ if signature:
20
+ lines.append(f"```{signature}```")
21
+ if symbol.base_classes:
22
+ lines.append(f"inherits: {', '.join(symbol.base_classes)}")
23
+ if symbol.decorators:
24
+ lines.append(f"decorators: {', '.join(symbol.decorators)}")
25
+ if symbol.docstring not in (None, ""):
26
+ lines.append("")
27
+ lines.append(symbol.docstring.rstrip())
28
+ lines.append("")
29
+ return lines
30
+
31
+
32
+ class CompactBackend:
33
+ """Render a single structure-oriented ``context.compact.md`` file."""
34
+
35
+ name = "compact"
36
+
37
+ def render(self, ir: ProjectIR, options: RenderOptions) -> dict[str, str]:
38
+ lines = [
39
+ f"# Project: {ir.name} (IR v{ir.ir_version})",
40
+ "",
41
+ (
42
+ f"# Packages: {ir.stats.package_count} | "
43
+ f"Modules: {ir.stats.module_count} | "
44
+ f"Symbols: {ir.stats.symbol_count} | "
45
+ f"Lines: {ir.stats.line_count}"
46
+ ),
47
+ "",
48
+ ]
49
+
50
+ modules_by_package: dict[str, list[ModuleInfo]] = {}
51
+ symbols_by_package: dict[str, list[Symbol]] = {}
52
+ for module in ir.modules:
53
+ modules_by_package.setdefault(module.package, []).append(module)
54
+ for symbol in ir.symbols:
55
+ package = symbol.file_path.rsplit("/", 1)[0] if "/" in symbol.file_path else ""
56
+ symbols_by_package.setdefault(package, []).append(symbol)
57
+
58
+ for package in sorted(set(modules_by_package) | set(symbols_by_package)):
59
+ lines.append(f"# Package: {package if package else options.root_name}")
60
+ lines.append("")
61
+ for module in modules_by_package.get(package, []):
62
+ lines.append(f"# Module: {module.path}")
63
+ if module.docstring not in (None, ""):
64
+ lines.append("")
65
+ lines.append(module.docstring.rstrip())
66
+ lines.append("")
67
+ symbols = symbols_by_package.get(package, [])
68
+ if not options.include_undocumented:
69
+ symbols = [s for s in symbols if s.docstring not in (None, "")]
70
+ for symbol in symbols:
71
+ if not symbol.is_public:
72
+ continue
73
+ lines.extend(_symbol_line(symbol, options))
74
+
75
+ return {"context.compact.md": "\n".join(lines).rstrip() + "\n"}
@@ -0,0 +1,19 @@
1
+ """JSON backend: deterministic serialization of the Project IR."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from markdownizer.backends.base import RenderOptions
6
+ from markdownizer.ir import ProjectIR
7
+
8
+
9
+ class JSONBackend:
10
+ """Render the full ProjectIR as ``project.json``.
11
+
12
+ This backend serializes the complete IR regardless of ``RenderOptions``;
13
+ it is a faithful machine-readable representation of the analysis.
14
+ """
15
+
16
+ name = "json"
17
+
18
+ def render(self, ir: ProjectIR, options: RenderOptions) -> dict[str, str]:
19
+ return {"project.json": ir.to_json() + "\n"}
@@ -0,0 +1,62 @@
1
+ """Markdown backend: one Markdown file per package (the historical output).
2
+
3
+ The rendering logic lives in :mod:`markdownizer.renderer`; this backend
4
+ groups the IR by package and drives it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from markdownizer.backends.base import RenderOptions
10
+ from markdownizer.ir import ModuleInfo, ProjectIR, Symbol
11
+ from markdownizer.parser import DocObject
12
+ from markdownizer.renderer import render_package_markdown
13
+
14
+
15
+ def _module_docobject(module: ModuleInfo) -> DocObject:
16
+ """Adapt an IR module back into a DocObject for the renderer."""
17
+ return DocObject(
18
+ name=module.path,
19
+ qualified_name=module.path,
20
+ kind="module",
21
+ lineno=1,
22
+ end_lineno=module.line_count,
23
+ anchor_lineno=1,
24
+ docstring=module.docstring,
25
+ source=module.source,
26
+ inline_comments=list(module.inline_comments),
27
+ file_path=module.path,
28
+ )
29
+
30
+
31
+ class MarkdownBackend:
32
+ """Render one ``{package}.md`` file per package."""
33
+
34
+ name = "markdown"
35
+
36
+ def render(self, ir: ProjectIR, options: RenderOptions) -> dict[str, str]:
37
+ modules_by_package: dict[str, list[ModuleInfo]] = {}
38
+ symbols_by_package: dict[str, list[Symbol]] = {}
39
+
40
+ for module in ir.modules:
41
+ modules_by_package.setdefault(module.package, []).append(module)
42
+ for symbol in ir.symbols:
43
+ package = symbol.file_path.rsplit("/", 1)[0] if "/" in symbol.file_path else ""
44
+ symbols_by_package.setdefault(package, []).append(symbol)
45
+
46
+ out: dict[str, str] = {}
47
+ for package in sorted(set(modules_by_package) | set(symbols_by_package)):
48
+ label = package.replace("/", ".") if package else options.root_name
49
+ modules = modules_by_package.get(package, [])
50
+ symbols = symbols_by_package.get(package, [])
51
+ if not options.include_undocumented:
52
+ symbols = [s for s in symbols if s.docstring not in (None, "")]
53
+ markdown = render_package_markdown(
54
+ label,
55
+ [_module_docobject(m) for m in modules],
56
+ symbols, # Symbols duck-type DocObject for the renderer
57
+ None,
58
+ include_source=options.include_source,
59
+ include_comments=options.include_comments,
60
+ )
61
+ out[f"{label}.md"] = markdown
62
+ return out
@@ -1,8 +1,8 @@
1
- """Classify a DocObject into a specialized documentation header kind."""
1
+ """Classify a renderable object into a specialized documentation header kind."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from markdownizer.parser import DocObject
5
+ from markdownizer.parser import Renderable
6
6
 
7
7
  _DJANGO_MODEL_BASES = {"Model", "AbstractUser", "AbstractBaseUser", "PermissionsMixin"}
8
8
 
@@ -69,7 +69,7 @@ def _decorator_base(dec: str) -> str:
69
69
  return _rightmost(head)
70
70
 
71
71
 
72
- def classify(obj: DocObject) -> str:
72
+ def classify(obj: Renderable) -> str:
73
73
  """Return a human-readable header label like 'DRF Serializer' or 'Async Function'."""
74
74
  if obj.kind == "module":
75
75
  if obj.file_path.endswith("__init__.py"):
@@ -13,22 +13,13 @@ from markdownizer.extractor import extract_project
13
13
  logger = logging.getLogger(__name__)
14
14
 
15
15
 
16
- def build_parser() -> argparse.ArgumentParser:
17
- parser = argparse.ArgumentParser(
18
- prog="markdownizer",
19
- description="Extract existing documentation from a Python project into Markdown files.",
20
- )
21
- parser.add_argument(
22
- "project",
23
- type=Path,
24
- help="Path to the Python project root to scan.",
25
- )
16
+ def _add_common_arguments(parser: argparse.ArgumentParser) -> None:
26
17
  parser.add_argument(
27
18
  "-o",
28
19
  "--output",
29
20
  type=Path,
30
21
  default=Path("docs"),
31
- help="Directory where generated Markdown files will be written (default: ./docs).",
22
+ help="Directory where generated files will be written (default: ./docs).",
32
23
  )
33
24
  parser.add_argument(
34
25
  "--root-name",
@@ -59,6 +50,15 @@ def build_parser() -> argparse.ArgumentParser:
59
50
  action="store_true",
60
51
  help="Only include objects that have a docstring.",
61
52
  )
53
+ parser.add_argument(
54
+ "--format",
55
+ choices=["markdown", "json", "compact"],
56
+ default="markdown",
57
+ help="Output backend (default: markdown).",
58
+ )
59
+
60
+
61
+ def _add_verbose_arguments(parser: argparse.ArgumentParser) -> None:
62
62
  parser.add_argument(
63
63
  "-v",
64
64
  "--verbose",
@@ -72,6 +72,25 @@ def build_parser() -> argparse.ArgumentParser:
72
72
  action="store_true",
73
73
  help="Suppress non-error output.",
74
74
  )
75
+
76
+
77
+ def build_parser() -> argparse.ArgumentParser:
78
+ """Parser for the legacy invocation ``markdownizer <project> -o <out>``."""
79
+ parser = argparse.ArgumentParser(
80
+ prog="markdownizer",
81
+ description=(
82
+ "Extract existing documentation from a Python project into "
83
+ "Markdown files. Use 'markdownizer build <project>' for format "
84
+ "selection; the legacy form below is kept as a compatibility alias."
85
+ ),
86
+ )
87
+ parser.add_argument(
88
+ "project",
89
+ type=Path,
90
+ help="Path to the Python project root to scan.",
91
+ )
92
+ _add_common_arguments(parser)
93
+ _add_verbose_arguments(parser)
75
94
  parser.add_argument(
76
95
  "--version",
77
96
  action="version",
@@ -80,10 +99,26 @@ def build_parser() -> argparse.ArgumentParser:
80
99
  return parser
81
100
 
82
101
 
83
- def main(argv: list[str] | None = None) -> int:
84
- parser = build_parser()
85
- args = parser.parse_args(argv)
102
+ def build_build_parser() -> argparse.ArgumentParser:
103
+ """Parser for ``markdownizer build <project>``."""
104
+ parser = argparse.ArgumentParser(
105
+ prog="markdownizer build",
106
+ description=(
107
+ "Scan a project and emit a deterministic representation: "
108
+ "Markdown (default), JSON project IR, or compact context."
109
+ ),
110
+ )
111
+ parser.add_argument(
112
+ "project",
113
+ type=Path,
114
+ help="Path to the Python project root to scan.",
115
+ )
116
+ _add_common_arguments(parser)
117
+ _add_verbose_arguments(parser)
118
+ return parser
86
119
 
120
+
121
+ def _run(args: argparse.Namespace) -> int:
87
122
  if args.quiet:
88
123
  level = logging.ERROR
89
124
  elif args.verbose >= 2:
@@ -113,17 +148,29 @@ def main(argv: list[str] | None = None) -> int:
113
148
  include_source=not args.no_source,
114
149
  include_comments=not args.no_comments,
115
150
  include_undocumented=not args.only_documented,
151
+ format=args.format,
116
152
  )
117
- except OSError as exc:
153
+ except (OSError, ValueError) as exc:
118
154
  print(f"error: {exc}", file=sys.stderr)
119
155
  return 1
120
156
 
121
157
  if not args.quiet:
122
- print(f"Wrote {len(written)} Markdown file(s) to {output}")
158
+ print(f"Wrote {len(written)} file(s) to {output}")
123
159
  for path in written:
124
160
  print(f" - {path}")
125
161
  return 0
126
162
 
127
163
 
164
+ def main(argv: list[str] | None = None) -> int:
165
+ args_list = list(sys.argv[1:] if argv is None else argv)
166
+ if args_list and args_list[0] == "build":
167
+ parser = build_build_parser()
168
+ args = parser.parse_args(args_list[1:])
169
+ else:
170
+ parser = build_parser()
171
+ args = parser.parse_args(args_list)
172
+ return _run(args)
173
+
174
+
128
175
  if __name__ == "__main__":
129
176
  sys.exit(main())
@@ -0,0 +1,88 @@
1
+ """Orchestrates scanning, parsing, IR building, and backend output writing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ from markdownizer.backends import RenderOptions, get_backend
9
+ from markdownizer.ir import build_project_ir
10
+ from markdownizer.renderer import SourceMode
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def _package_key(file_path: Path, project_root: Path) -> str:
16
+ try:
17
+ rel = file_path.resolve().relative_to(project_root.resolve())
18
+ except ValueError:
19
+ rel = Path(file_path)
20
+ parent = rel.parent
21
+ if parent == Path("."):
22
+ return ""
23
+ return str(parent)
24
+
25
+
26
+ def _package_label(key: str, root_name: str) -> str:
27
+ if key == "":
28
+ return root_name
29
+ return key.replace("/", ".").replace("\\", ".")
30
+
31
+
32
+ def extract_project(
33
+ project_root: Path,
34
+ output_dir: Path,
35
+ root_name: str = "_root",
36
+ exclude: list[str] | None = None,
37
+ include_source: SourceMode = True,
38
+ include_comments: bool = True,
39
+ include_undocumented: bool = True,
40
+ format: str = "markdown",
41
+ ) -> list[Path]:
42
+ """Scan the project, build a Project IR, and write backend output.
43
+
44
+ Args:
45
+ project_root: Directory to scan recursively.
46
+ output_dir: Directory where generated files will be written.
47
+ root_name: Filename (without extension) for files that live at the
48
+ project root.
49
+ exclude: Optional glob patterns (e.g. ``"tests/*"``, ``"migrations"``)
50
+ matched against paths relative to ``project_root``.
51
+ include_source: Include source: ``True`` (full), ``False`` (none), or
52
+ ``"signature"`` (declaration line only).
53
+ include_comments: Include the ``## Comments`` section.
54
+ include_undocumented: Keep objects that have no docstring.
55
+ format: Output backend: ``"markdown"`` (default), ``"json"``, or
56
+ ``"compact"``.
57
+ """
58
+ project_root = project_root.resolve()
59
+ output_dir = output_dir.resolve()
60
+ output_dir.mkdir(parents=True, exist_ok=True)
61
+
62
+ ir = build_project_ir(project_root, exclude=exclude)
63
+ backend = get_backend(format)
64
+ options = RenderOptions(
65
+ root_name=root_name,
66
+ include_source=include_source,
67
+ include_comments=include_comments,
68
+ include_undocumented=include_undocumented,
69
+ )
70
+ files = backend.render(ir, options)
71
+
72
+ written: list[Path] = []
73
+ for filename, content in files.items():
74
+ out_path = output_dir / filename
75
+ try:
76
+ out_path.write_text(content, encoding="utf-8")
77
+ except OSError as exc:
78
+ logger.error("could not write %s: %s", out_path, exc)
79
+ raise
80
+ written.append(out_path)
81
+
82
+ logger.info(
83
+ "Wrote %d file(s) to %s (%d Python file(s) scanned)",
84
+ len(written),
85
+ output_dir,
86
+ len(ir.modules),
87
+ )
88
+ return written