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.
Files changed (44) hide show
  1. {markdownizer-0.4.1 → markdownizer-0.4.2}/PKG-INFO +1 -1
  2. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/__init__.py +1 -1
  3. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/imports.py +10 -2
  4. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/ir.py +2 -2
  5. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/parser.py +15 -2
  6. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer.egg-info/PKG-INFO +1 -1
  7. {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_extractor.py +12 -0
  8. {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_imports.py +39 -0
  9. {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_ir.py +31 -0
  10. {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_parser.py +24 -0
  11. {markdownizer-0.4.1 → markdownizer-0.4.2}/LICENSE +0 -0
  12. {markdownizer-0.4.1 → markdownizer-0.4.2}/README.md +0 -0
  13. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/__main__.py +0 -0
  14. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/backends/__init__.py +0 -0
  15. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/backends/base.py +0 -0
  16. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/backends/compact.py +0 -0
  17. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/backends/json.py +0 -0
  18. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/backends/markdown.py +0 -0
  19. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/classifier.py +0 -0
  20. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/cli.py +0 -0
  21. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/extractor.py +0 -0
  22. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/optimizer/__init__.py +0 -0
  23. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/optimizer/profiles.py +0 -0
  24. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/optimizer/rank.py +0 -0
  25. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/optimizer/slice.py +0 -0
  26. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/optimizer/tokens.py +0 -0
  27. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/py.typed +0 -0
  28. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/renderer.py +0 -0
  29. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer/scanner.py +0 -0
  30. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer.egg-info/SOURCES.txt +0 -0
  31. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer.egg-info/dependency_links.txt +0 -0
  32. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer.egg-info/entry_points.txt +0 -0
  33. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer.egg-info/requires.txt +0 -0
  34. {markdownizer-0.4.1 → markdownizer-0.4.2}/markdownizer.egg-info/top_level.txt +0 -0
  35. {markdownizer-0.4.1 → markdownizer-0.4.2}/pyproject.toml +0 -0
  36. {markdownizer-0.4.1 → markdownizer-0.4.2}/setup.cfg +0 -0
  37. {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_backends.py +0 -0
  38. {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_classifier.py +0 -0
  39. {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_cli.py +0 -0
  40. {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_optimizer.py +0 -0
  41. {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_packaging.py +0 -0
  42. {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_ranking.py +0 -0
  43. {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_renderer.py +0 -0
  44. {markdownizer-0.4.1 → markdownizer-0.4.2}/tests/test_scanner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdownizer
3
- Version: 0.4.1
3
+ Version: 0.4.2
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
@@ -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.1"
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
- node: ast.AST, module_map: dict[str, str], from_rel_path: str
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 ast.walk(node):
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.read_text(encoding="utf-8")
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
- with open(file_path, encoding="utf-8") as fh:
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdownizer
3
- Version: 0.4.1
3
+ Version: 0.4.2
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
@@ -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