markdownizer 0.4.1__tar.gz → 0.4.2__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.
- {markdownizer-0.4.1 → markdownizer-0.4.2}/PKG-INFO +1 -1
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/__init__.py +1 -1
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/imports.py +10 -2
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/ir.py +2 -2
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/parser.py +15 -2
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer.egg-info/PKG-INFO +1 -1
- {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_extractor.py +12 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_imports.py +39 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_ir.py +31 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_parser.py +24 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/LICENSE +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/README.md +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/__main__.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/backends/__init__.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/backends/base.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/backends/compact.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/backends/json.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/backends/markdown.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/classifier.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/cli.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/extractor.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/optimizer/__init__.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/optimizer/profiles.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/optimizer/rank.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/optimizer/slice.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/optimizer/tokens.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/py.typed +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/renderer.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/scanner.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer.egg-info/SOURCES.txt +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer.egg-info/dependency_links.txt +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer.egg-info/entry_points.txt +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer.egg-info/requires.txt +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer.egg-info/top_level.txt +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/pyproject.toml +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/setup.cfg +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_backends.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_classifier.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_cli.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_optimizer.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_packaging.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_ranking.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_renderer.py +0 -0
- {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_scanner.py +0 -0
|
@@ -8,7 +8,7 @@ from markdownizer.extractor import extract_project
|
|
|
8
8
|
from markdownizer.ir import IR_VERSION, ProjectIR, build_project_ir
|
|
9
9
|
from markdownizer.optimizer import OptimizedContext, optimize_context
|
|
10
10
|
|
|
11
|
-
__version__ = "0.4.
|
|
11
|
+
__version__ = "0.4.2"
|
|
12
12
|
__all__ = [
|
|
13
13
|
"extract_project",
|
|
14
14
|
"build_project_ir",
|
|
@@ -67,11 +67,17 @@ def _resolve_relative(
|
|
|
67
67
|
|
|
68
68
|
|
|
69
69
|
def _collect_statement_imports(
|
|
70
|
-
|
|
70
|
+
module: ast.Module, module_map: dict[str, str], from_rel_path: str
|
|
71
71
|
) -> list[ImportEdge]:
|
|
72
|
+
"""Collect module-level import statements.
|
|
73
|
+
|
|
74
|
+
Only top-level statements count: imports inside function bodies, class
|
|
75
|
+
bodies, or ``if __name__ == "__main__":`` blocks are not structural
|
|
76
|
+
module dependencies. ``from __future__`` imports are filtered out.
|
|
77
|
+
"""
|
|
72
78
|
edges: list[ImportEdge] = []
|
|
73
79
|
|
|
74
|
-
for child in
|
|
80
|
+
for child in module.body:
|
|
75
81
|
if isinstance(child, ast.Import):
|
|
76
82
|
for alias in child.names:
|
|
77
83
|
target = _resolve_absolute(alias.name, module_map)
|
|
@@ -87,6 +93,8 @@ def _collect_statement_imports(
|
|
|
87
93
|
)
|
|
88
94
|
)
|
|
89
95
|
elif isinstance(child, ast.ImportFrom):
|
|
96
|
+
if child.module == "__future__":
|
|
97
|
+
continue
|
|
90
98
|
if child.module is None:
|
|
91
99
|
# ``from . import x`` — the imported names are resolved
|
|
92
100
|
# relative to the current package.
|
|
@@ -27,7 +27,7 @@ from typing import Any
|
|
|
27
27
|
|
|
28
28
|
from markdownizer.classifier import classify
|
|
29
29
|
from markdownizer.imports import ImportEdge, collect_import_edges
|
|
30
|
-
from markdownizer.parser import DocObject, parse_file
|
|
30
|
+
from markdownizer.parser import DocObject, parse_file, read_python_source
|
|
31
31
|
from markdownizer.scanner import scan_python_files
|
|
32
32
|
|
|
33
33
|
IR_VERSION = 1
|
|
@@ -336,7 +336,7 @@ def build_project_ir(project_root: Path, exclude: list[str] | None = None) -> Pr
|
|
|
336
336
|
for py_file in files:
|
|
337
337
|
rel = py_file.relative_to(project_root).as_posix()
|
|
338
338
|
dotted = _module_dotted_name(rel)
|
|
339
|
-
source = py_file
|
|
339
|
+
source = read_python_source(py_file)
|
|
340
340
|
module_obj, objects = parse_file(str(py_file), source=source)
|
|
341
341
|
|
|
342
342
|
module_symbols = [_symbol_from_docobject(o, rel) for o in objects]
|
|
@@ -6,6 +6,7 @@ import ast
|
|
|
6
6
|
import io
|
|
7
7
|
import tokenize
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
9
10
|
from typing import Protocol
|
|
10
11
|
|
|
11
12
|
|
|
@@ -72,6 +73,19 @@ def _rightmost_name(name: str) -> str:
|
|
|
72
73
|
return name.rsplit(".", 1)[-1] if name else ""
|
|
73
74
|
|
|
74
75
|
|
|
76
|
+
def read_python_source(path: Path) -> str:
|
|
77
|
+
"""Read a Python source file, tolerating non-UTF-8 encodings.
|
|
78
|
+
|
|
79
|
+
Uses ``utf-8-sig`` (which also strips a UTF-8 BOM) and falls back to
|
|
80
|
+
latin-1, which can decode every byte sequence without raising. A single
|
|
81
|
+
legacy-encoded file must never abort an entire project extraction.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
return path.read_text(encoding="utf-8-sig")
|
|
85
|
+
except UnicodeDecodeError:
|
|
86
|
+
return path.read_text(encoding="latin-1")
|
|
87
|
+
|
|
88
|
+
|
|
75
89
|
def _unparse_or_empty(node: ast.AST | None) -> str:
|
|
76
90
|
"""Render an AST node back to source text, or ``""`` if unavailable."""
|
|
77
91
|
if node is None:
|
|
@@ -337,8 +351,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[DocObject, li
|
|
|
337
351
|
``source`` may be passed in to avoid re-reading the file from disk.
|
|
338
352
|
"""
|
|
339
353
|
if source is None:
|
|
340
|
-
|
|
341
|
-
source = fh.read()
|
|
354
|
+
source = read_python_source(Path(file_path))
|
|
342
355
|
|
|
343
356
|
source_lines = source.splitlines()
|
|
344
357
|
all_comments = _collect_comments(source)
|
|
@@ -119,3 +119,15 @@ def test_extract_error_on_unwritable_output(sample_project, tmp_path):
|
|
|
119
119
|
pytest.skip("directory is writable; cannot simulate")
|
|
120
120
|
with pytest.raises(OSError):
|
|
121
121
|
extract_project(sample_project, out / "sub" / "nested")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_extract_survives_non_utf8_file(tmp_path):
|
|
125
|
+
"""End-to-end: a legacy-encoded file does not abort extraction."""
|
|
126
|
+
(tmp_path / "good.py").write_text('"""Fine."""\ndef ok():\n pass\n')
|
|
127
|
+
(tmp_path / "legacy.py").write_bytes(b'"""caf\xe9"""\ndef old():\n pass\n')
|
|
128
|
+
out = tmp_path / "out"
|
|
129
|
+
written = extract_project(tmp_path, out)
|
|
130
|
+
assert sorted(p.name for p in written) == ["_root.md"]
|
|
131
|
+
md = (out / "_root.md").read_text(encoding="utf-8")
|
|
132
|
+
assert "# Function: old" in md
|
|
133
|
+
assert "café" in md
|
|
@@ -144,3 +144,42 @@ def test_dotted_import_resolution_to_init():
|
|
|
144
144
|
|
|
145
145
|
def test_empty_source():
|
|
146
146
|
assert _edges("") == []
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_nested_function_import_not_collected():
|
|
150
|
+
source = "def f():\n import os\n return os.path\n"
|
|
151
|
+
assert _edges(source) == []
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_main_guard_import_not_collected():
|
|
155
|
+
source = "import pkg.models\n\nif __name__ == '__main__':\n import sys\n"
|
|
156
|
+
edges = _edges(source)
|
|
157
|
+
assert [e.imported_module for e in edges] == ["pkg.models"]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_class_body_import_not_collected():
|
|
161
|
+
source = "class C:\n def m(self):\n import os\n"
|
|
162
|
+
assert _edges(source) == []
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_future_import_filtered():
|
|
166
|
+
source = "from __future__ import annotations\nimport pkg.models\n"
|
|
167
|
+
edges = _edges(source)
|
|
168
|
+
assert [e.imported_module for e in edges] == ["pkg.models"]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_try_wrapped_import_not_collected():
|
|
172
|
+
"""try/except-wrapped imports are conservatively treated as external."""
|
|
173
|
+
source = "try:\n import pkg.models\nexcept ImportError:\n pass\n"
|
|
174
|
+
assert _edges(source) == []
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_latin1_source_decodes():
|
|
178
|
+
from markdownizer.imports import collect_import_edges
|
|
179
|
+
|
|
180
|
+
edges = collect_import_edges(
|
|
181
|
+
'"""caf\xe9"""\nimport os\n'.encode("latin-1").decode("latin-1"),
|
|
182
|
+
"mod.py",
|
|
183
|
+
{"mod.py": "mod.py"},
|
|
184
|
+
)
|
|
185
|
+
assert [e.imported_module for e in edges] == ["os"]
|
|
@@ -245,3 +245,34 @@ def test_unsorted_input_still_sorted(tmp_path):
|
|
|
245
245
|
ir = build_project_ir(tmp_path)
|
|
246
246
|
assert [m.path for m in ir.modules] == ["a/y.py", "b/z.py"]
|
|
247
247
|
assert [s.qualified_name for s in ir.symbols] == ["y", "z"]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_build_survives_non_utf8_file(tmp_path):
|
|
251
|
+
"""A single latin-1 encoded file must not abort the whole build."""
|
|
252
|
+
from tests.conftest import write_py
|
|
253
|
+
|
|
254
|
+
write_py(tmp_path / "good.py", '"""Fine."""\ndef ok():\n pass\n')
|
|
255
|
+
(tmp_path / "legacy.py").write_bytes(b'"""caf\xe9 latin-1"""\ndef old():\n pass\n')
|
|
256
|
+
ir = build_project_ir(tmp_path)
|
|
257
|
+
assert [m.path for m in ir.modules] == ["good.py", "legacy.py"]
|
|
258
|
+
legacy = next(m for m in ir.modules if m.path == "legacy.py")
|
|
259
|
+
assert legacy.docstring == "café latin-1"
|
|
260
|
+
assert ir.stats.symbol_count == 2
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_build_survives_utf8_bom_file(tmp_path):
|
|
264
|
+
from tests.conftest import write_py
|
|
265
|
+
|
|
266
|
+
write_py(tmp_path / "a.py", "def f():\n pass\n")
|
|
267
|
+
(tmp_path / "bom.py").write_bytes(b'\xef\xbb\xbf"""BOM doc."""\ndef g():\n pass\n')
|
|
268
|
+
ir = build_project_ir(tmp_path)
|
|
269
|
+
assert [m.path for m in ir.modules] == ["a.py", "bom.py"]
|
|
270
|
+
bom = next(m for m in ir.modules if m.path == "bom.py")
|
|
271
|
+
assert bom.docstring == "BOM doc."
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_non_utf8_hash_stable(tmp_path):
|
|
275
|
+
(tmp_path / "legacy.py").write_bytes(b'"""caf\xe9"""\ndef old():\n pass\n')
|
|
276
|
+
ir1 = build_project_ir(tmp_path)
|
|
277
|
+
ir2 = build_project_ir(tmp_path)
|
|
278
|
+
assert ir1.hash == ir2.hash
|
|
@@ -232,3 +232,27 @@ def test_file_path_populated(write_file):
|
|
|
232
232
|
path = write_file("mod.py", "def f():\n pass\n")
|
|
233
233
|
_, objects = parse_file(str(path))
|
|
234
234
|
assert Path(objects[0].file_path) == path
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_latin1_file_parses(tmp_path):
|
|
238
|
+
from markdownizer.parser import read_python_source
|
|
239
|
+
|
|
240
|
+
path = tmp_path / "legacy.py"
|
|
241
|
+
path.write_bytes(b'"""caf\xe9"""\ndef old():\n pass\n')
|
|
242
|
+
source = read_python_source(path)
|
|
243
|
+
assert "café" in source
|
|
244
|
+
module, objects = parse_file(str(path))
|
|
245
|
+
assert module.docstring == "café"
|
|
246
|
+
assert objects[0].name == "old"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_utf8_bom_file_parses(tmp_path):
|
|
250
|
+
from markdownizer.parser import read_python_source
|
|
251
|
+
|
|
252
|
+
path = tmp_path / "bom.py"
|
|
253
|
+
path.write_bytes(b'\xef\xbb\xbf"""BOM."""\ndef g():\n pass\n')
|
|
254
|
+
source = read_python_source(path)
|
|
255
|
+
assert not source.startswith("\ufeff")
|
|
256
|
+
module, objects = parse_file(str(path))
|
|
257
|
+
assert module.docstring == "BOM."
|
|
258
|
+
assert objects[0].name == "g"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|