simplibs-extractor-python 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.
Files changed (71) hide show
  1. simplibs/extractor_python/__init__.py +67 -0
  2. simplibs/extractor_python/actions/PythonOutline.py +23 -0
  3. simplibs/extractor_python/actions/RemovePythonComments.py +22 -0
  4. simplibs/extractor_python/actions/RemovePythonDecorators.py +22 -0
  5. simplibs/extractor_python/actions/RemovePythonDocstrings.py +22 -0
  6. simplibs/extractor_python/actions/RemovePythonImports.py +22 -0
  7. simplibs/extractor_python/actions/__init__.py +45 -0
  8. simplibs/extractor_python/annotations/OnError.py +25 -0
  9. simplibs/extractor_python/annotations/__init__.py +18 -0
  10. simplibs/extractor_python/constants/PYTHON_CONFIG_NAMES.py +27 -0
  11. simplibs/extractor_python/constants/PYTHON_OUTPUT_DIR_NAMES.py +19 -0
  12. simplibs/extractor_python/constants/PYTHON_SUFFIXES.py +17 -0
  13. simplibs/extractor_python/constants/PYTHON_TEST_DIR_NAMES.py +18 -0
  14. simplibs/extractor_python/constants/PYTHON_VENV_DIR_NAMES.py +18 -0
  15. simplibs/extractor_python/constants/__init__.py +26 -0
  16. simplibs/extractor_python/file_rules/__init__.py +62 -0
  17. simplibs/extractor_python/file_rules/atomic/DOT_NAME.py +19 -0
  18. simplibs/extractor_python/file_rules/atomic/INIT_NAME.py +17 -0
  19. simplibs/extractor_python/file_rules/atomic/IN_DUNDER_DIR.py +16 -0
  20. simplibs/extractor_python/file_rules/atomic/MAIN_NAME.py +16 -0
  21. simplibs/extractor_python/file_rules/atomic/OUTPUT_DIRS.py +17 -0
  22. simplibs/extractor_python/file_rules/atomic/VCS_DIRS.py +26 -0
  23. simplibs/extractor_python/file_rules/atomic/VENV_DIRS.py +18 -0
  24. simplibs/extractor_python/file_rules/atomic/__init__.py +30 -0
  25. simplibs/extractor_python/file_rules/python/PYTHON_BASE.py +17 -0
  26. simplibs/extractor_python/file_rules/python/PYTHON_CONFIG.py +18 -0
  27. simplibs/extractor_python/file_rules/python/PYTHON_STRICT_EXCLUDE.py +24 -0
  28. simplibs/extractor_python/file_rules/python/PYTHON_TESTS.py +29 -0
  29. simplibs/extractor_python/file_rules/python/__init__.py +22 -0
  30. simplibs/extractor_python/file_rules/python/build_python_base.py +58 -0
  31. simplibs/extractor_python/logic/__init__.py +32 -0
  32. simplibs/extractor_python/logic/_helpers/SourceOffsets.py +82 -0
  33. simplibs/extractor_python/logic/_helpers/__init__.py +36 -0
  34. simplibs/extractor_python/logic/_helpers/collect_definitions.py +60 -0
  35. simplibs/extractor_python/logic/_helpers/cut_statements.py +126 -0
  36. simplibs/extractor_python/logic/_helpers/describe_source_failure.py +34 -0
  37. simplibs/extractor_python/logic/_helpers/find_decorator_spans.py +107 -0
  38. simplibs/extractor_python/logic/_helpers/find_docstrings.py +46 -0
  39. simplibs/extractor_python/logic/_helpers/find_imports.py +33 -0
  40. simplibs/extractor_python/logic/_helpers/first_doc_line.py +37 -0
  41. simplibs/extractor_python/logic/_helpers/format_type_params.py +29 -0
  42. simplibs/extractor_python/logic/_helpers/guarded_parse.py +64 -0
  43. simplibs/extractor_python/logic/_helpers/outline_node.py +72 -0
  44. simplibs/extractor_python/logic/_helpers/parse_python.py +32 -0
  45. simplibs/extractor_python/logic/_validations/__init__.py +14 -0
  46. simplibs/extractor_python/logic/_validations/raise_python_source_error.py +55 -0
  47. simplibs/extractor_python/logic/python_comment_spans.py +73 -0
  48. simplibs/extractor_python/logic/python_outline.py +63 -0
  49. simplibs/extractor_python/logic/remove_python_comments.py +44 -0
  50. simplibs/extractor_python/logic/remove_python_decorators.py +48 -0
  51. simplibs/extractor_python/logic/remove_python_docstrings.py +50 -0
  52. simplibs/extractor_python/logic/remove_python_imports.py +48 -0
  53. simplibs/extractor_python/plugin/__init__.py +14 -0
  54. simplibs/extractor_python/plugin/register_all.py +45 -0
  55. simplibs/extractor_python/presets/PYTHON_CLEAN.py +29 -0
  56. simplibs/extractor_python/presets/PYTHON_NO_IMPORTS.py +32 -0
  57. simplibs/extractor_python/presets/__init__.py +30 -0
  58. simplibs/extractor_python/profiles/PYTHON_CODE_CLEAN.py +29 -0
  59. simplibs/extractor_python/profiles/PYTHON_CODE_NO_IMPORTS.py +29 -0
  60. simplibs/extractor_python/profiles/PYTHON_DATA_ALL.py +26 -0
  61. simplibs/extractor_python/profiles/PYTHON_OUTLINE.py +30 -0
  62. simplibs/extractor_python/profiles/PYTHON_PROFILES.py +26 -0
  63. simplibs/extractor_python/profiles/PYTHON_SRC_CLEAN.py +30 -0
  64. simplibs/extractor_python/profiles/__init__.py +47 -0
  65. simplibs/extractor_python/py.typed +1 -0
  66. simplibs_extractor_python-0.1.0.dist-info/METADATA +457 -0
  67. simplibs_extractor_python-0.1.0.dist-info/RECORD +71 -0
  68. simplibs_extractor_python-0.1.0.dist-info/WHEEL +5 -0
  69. simplibs_extractor_python-0.1.0.dist-info/entry_points.txt +2 -0
  70. simplibs_extractor_python-0.1.0.dist-info/licenses/LICENSE +21 -0
  71. simplibs_extractor_python-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,46 @@
1
+ import ast
2
+
3
+
4
+ def find_docstrings(tree: ast.Module) -> set[ast.stmt]:
5
+ """Find the docstring statements of a module, its classes and its functions.
6
+
7
+ Args:
8
+ tree: The parsed module.
9
+
10
+ Returns:
11
+ The `Expr` statements that are docstrings.
12
+ """
13
+
14
+ # 1. A docstring is a string constant that is the FIRST statement of a module, class or function
15
+ found: set[ast.stmt] = set()
16
+ for node in ast.walk(tree):
17
+ if not isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
18
+ continue
19
+ if not node.body:
20
+ continue
21
+ first = node.body[0]
22
+ if (
23
+ isinstance(first, ast.Expr)
24
+ and isinstance(first.value, ast.Constant)
25
+ and isinstance(first.value.value, str)
26
+ ):
27
+ found.add(first)
28
+
29
+ return found
30
+
31
+
32
+ _DESIGN_NOTES = """
33
+ # find_docstrings — What Python Itself Calls a Docstring
34
+
35
+ ## Purpose
36
+ The same rule as `ast.get_docstring`, applied to the whole tree at once.
37
+
38
+ ## Only the First Statement
39
+ A string in the middle of a body is an expression nobody uses, not a
40
+ docstring, and stays. An f-string is not a docstring either (it is built at
41
+ run time), and stays.
42
+
43
+ ## A Statement, Not a Line
44
+ A docstring may share its line (`def f(): '''doc'''`) or be wrapped in
45
+ parentheses; the statement's own span covers both.
46
+ """
@@ -0,0 +1,33 @@
1
+ import ast
2
+
3
+
4
+ def find_imports(tree: ast.Module) -> set[ast.stmt]:
5
+ """Find every import statement of a module, at any depth.
6
+
7
+ Args:
8
+ tree: The parsed module.
9
+
10
+ Returns:
11
+ The `Import` and `ImportFrom` statements: at the top, inside functions and classes,
12
+ in `try` and `if` blocks.
13
+ """
14
+
15
+ # 1. Both kinds, wherever they stand
16
+ return {node for node in ast.walk(tree) if isinstance(node, (ast.Import, ast.ImportFrom))}
17
+
18
+
19
+ _DESIGN_NOTES = """
20
+ # find_imports — Every Import, Not Only the Top Ones
21
+
22
+ ## Purpose
23
+ `remove_python_imports` takes out what this finds.
24
+
25
+ ## Depth
26
+ `try: import ujson / except ImportError: import json`, `if TYPE_CHECKING:` and
27
+ imports inside a function are all imports. Leaving the nested ones would leave
28
+ a file that still names its dependencies.
29
+
30
+ ## `from __future__`
31
+ It is an `ImportFrom` and goes with the rest. For reading the code, a
32
+ `from __future__ import annotations` carries no information.
33
+ """
@@ -0,0 +1,37 @@
1
+ import ast
2
+
3
+
4
+ def first_doc_line(node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef) -> str:
5
+ """Make a trailing comment out of the first line of a docstring.
6
+
7
+ Args:
8
+ node: A class or a function.
9
+
10
+ Returns:
11
+ `" # First line"`, or an empty string if the node has no docstring.
12
+ """
13
+
14
+ # 1. The cleaned docstring, or nothing
15
+ doc = ast.get_docstring(node)
16
+ if not doc:
17
+ return ""
18
+
19
+ # 2. Its first non-empty line; every kind of line end counts, so a comment can never run on
20
+ lines = doc.strip().splitlines()
21
+ first = lines[0].strip() if lines else ""
22
+
23
+ return f" # {first}" if first else ""
24
+
25
+
26
+ _DESIGN_NOTES = """
27
+ # first_doc_line — A Docstring as a Comment
28
+
29
+ ## Purpose
30
+ The outline keeps what a docstring says in one line, without a string that
31
+ would have to be quoted and escaped.
32
+
33
+ ## `splitlines`, Not `split("\\n")`
34
+ A docstring can hold a `\\r` or a form feed written as an escape. Cut by
35
+ `\\n` only, such a character would end up in the output and `ast` would take it
36
+ for a line end, turning the rest of the comment into code.
37
+ """
@@ -0,0 +1,29 @@
1
+ import ast
2
+
3
+
4
+ def format_type_params(node: ast.AST) -> str:
5
+ """Write the type parameters of a class or function (`def f[T](x: T)`, Python 3.12+).
6
+
7
+ Args:
8
+ node: A class or a function definition.
9
+
10
+ Returns:
11
+ `"[T, U: int]"`, or an empty string if there are none (and always on older Pythons).
12
+ """
13
+
14
+ # 1. `type_params` exists since Python 3.12; before that there is nothing to write
15
+ params = getattr(node, "type_params", None)
16
+ if not params:
17
+ return ""
18
+
19
+ return "[" + ", ".join(ast.unparse(param) for param in params) + "]"
20
+
21
+
22
+ _DESIGN_NOTES = """
23
+ # format_type_params — The Brackets After the Name
24
+
25
+ ## Purpose
26
+ `class Box[T]:` and `def first[T](items: list[T]) -> T:` carry their type
27
+ parameters in a field that older Pythons do not have. Without them the outline
28
+ would name a different signature than the source.
29
+ """
@@ -0,0 +1,64 @@
1
+ import tokenize
2
+ from collections.abc import Callable
3
+ from typing import TypeVar
4
+ # Outers
5
+ from ...annotations import OnError
6
+ from .._validations import raise_python_source_error
7
+ # Inners
8
+ from .describe_source_failure import describe_source_failure
9
+
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ def guarded_parse(text: str, on_error: OnError, parser: Callable[[str], T]) -> T | None:
15
+ """Run a parser over a text and decide what a failure means.
16
+
17
+ Args:
18
+ text: The Python source.
19
+ on_error: `"raise"` for a card, `"keep"` for `None`.
20
+ parser: What reads the text (`ast.parse`, a tokenizer loop).
21
+
22
+ Returns:
23
+ What the parser returned; `None` if it could not read the text and `on_error` is `"keep"`.
24
+
25
+ Raises:
26
+ ParamError: The text cannot be read and `on_error` is `"raise"`.
27
+ """
28
+
29
+ # 1. Only `\n` line endings: `ast` and `tokenize` would count lines differently from the offsets
30
+ failure: tuple[str, int | None] | None = None
31
+ if "\r" in text:
32
+ failure = ("it has line endings other than \\n", None)
33
+
34
+ # 2. Read it
35
+ else:
36
+ try:
37
+ return parser(text)
38
+ except (SyntaxError, ValueError, RecursionError, tokenize.TokenError) as exc:
39
+ failure = describe_source_failure(exc)
40
+
41
+ # 3. What a failure means
42
+ if on_error == "keep":
43
+ return None
44
+ raise_python_source_error(text, *failure)
45
+
46
+
47
+ _DESIGN_NOTES = """
48
+ # guarded_parse — One Place Where Reading Fails
49
+
50
+ ## Purpose
51
+ The steps of this domain all need a reader (`ast.parse`, `tokenize`) and all
52
+ have the same question when it fails. This is where it is answered, once.
53
+
54
+ ## The Exceptions
55
+ `SyntaxError` (and its `IndentationError`), `ValueError` (a null byte, before
56
+ Python 3.12), `RecursionError` (a source nested deeper than the parser
57
+ goes) and `tokenize.TokenError` (an unterminated string or bracket).
58
+ Anything else is a bug and is not hidden.
59
+
60
+ ## Why `\\r` Is a Failure
61
+ `ast` counts a lone `\\r` as a line end, the offset table does not. A cut by
62
+ such a position would land on a wrong line, so the text is refused. The
63
+ extractor never gives it one: the reader hands over `\\n` only.
64
+ """
@@ -0,0 +1,72 @@
1
+ import ast
2
+ # Inners
3
+ from .collect_definitions import collect_definitions
4
+ from .first_doc_line import first_doc_line
5
+ from .format_type_params import format_type_params
6
+
7
+
8
+ def outline_node(
9
+ node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef,
10
+ depth: int,
11
+ docstrings: bool,
12
+ ) -> list[str]:
13
+ """Write the outline of one class or function, with the definitions inside a class.
14
+
15
+ Args:
16
+ node: The definition.
17
+ depth: How deeply it is indented.
18
+ docstrings: Add the first line of the docstring as a comment.
19
+
20
+ Returns:
21
+ The lines of the outline; always valid Python when put together.
22
+ """
23
+
24
+ # 1. The decorators, as written
25
+ pad = " " * depth
26
+ lines = [f"{pad}@{ast.unparse(decorator)}" for decorator in node.decorator_list]
27
+ note = first_doc_line(node) if docstrings else ""
28
+ params = format_type_params(node)
29
+
30
+ # 2. A function: its signature, no body
31
+ if not isinstance(node, ast.ClassDef):
32
+ keyword = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
33
+ returns = f" -> {ast.unparse(node.returns)}" if node.returns else ""
34
+ lines.append(f"{pad}{keyword} {node.name}{params}({ast.unparse(node.args)}){returns}: ...{note}")
35
+ return lines
36
+
37
+ # 3. A class: its bases and keywords (`metaclass=M`, `**options`)
38
+ bases = [ast.unparse(base) for base in node.bases]
39
+ bases += [
40
+ f"{keyword.arg}={ast.unparse(keyword.value)}" if keyword.arg else f"**{ast.unparse(keyword.value)}"
41
+ for keyword in node.keywords
42
+ ]
43
+ head = f"class {node.name}{params}" + (f"({', '.join(bases)})" if bases else "")
44
+
45
+ # 4. The members are listed under it; a class with none is one line
46
+ members = collect_definitions(node.body)
47
+ if not members:
48
+ lines.append(f"{pad}{head}: ...{note}")
49
+ return lines
50
+ lines.append(f"{pad}{head}:{note}")
51
+ for member in members:
52
+ lines.extend(outline_node(member, depth + 1, docstrings))
53
+
54
+ return lines
55
+
56
+
57
+ _DESIGN_NOTES = """
58
+ # outline_node — One Definition Without Its Body
59
+
60
+ ## Purpose
61
+ The signature of a class or a function, written by `ast.unparse` from the
62
+ parsed tree, so a default value, an annotation or a keyword is always what the
63
+ source says and never a guess of a regex.
64
+
65
+ ## Always Valid Python
66
+ `def f(...): ...` and `class C: ...` are complete statements; a class with
67
+ members is a header followed by an indented block of members. The docstring
68
+ note is a comment, which cannot break anything.
69
+
70
+ ## The Keywords of a Class
71
+ `class A(B, metaclass=M, **options)`: a keyword without a name is `**options`.
72
+ """
@@ -0,0 +1,32 @@
1
+ import ast
2
+ # Outers
3
+ from ...annotations import OnError
4
+ # Inners
5
+ from .guarded_parse import guarded_parse
6
+
7
+
8
+ def parse_python(text: str, on_error: OnError) -> ast.Module | None:
9
+ """Parse a text as Python source.
10
+
11
+ Args:
12
+ text: The Python source.
13
+ on_error: `"raise"` for a card, `"keep"` for `None`.
14
+
15
+ Returns:
16
+ The syntax tree; `None` if the text cannot be parsed and `on_error` is `"keep"`.
17
+
18
+ Raises:
19
+ ParamError: The text cannot be parsed and `on_error` is `"raise"`.
20
+ """
21
+
22
+ # 1. `ast.parse` under the guard
23
+ return guarded_parse(text, on_error, ast.parse)
24
+
25
+
26
+ _DESIGN_NOTES = """
27
+ # parse_python — The Tree or Nothing
28
+
29
+ ## Purpose
30
+ The first line of every step that works on the structure of a module.
31
+ `None` means "leave the text alone" and the caller returns it as it was.
32
+ """
@@ -0,0 +1,14 @@
1
+ from .raise_python_source_error import raise_python_source_error
2
+
3
+
4
+ _DESIGN_NOTES = """
5
+ # _validations — Cards of the Python Steps
6
+
7
+ Private. Plain imports, no `__all__`.
8
+
9
+ ## Internal Components Registry
10
+
11
+ | Component | Type | Description |
12
+ | :--------------------------- | :------- | :------------------------------------------ |
13
+ | `raise_python_source_error` | Function | The text cannot be read as Python source. |
14
+ """
@@ -0,0 +1,55 @@
1
+ from typing import NoReturn
2
+ from simplibs.exception import ParamError
3
+
4
+
5
+ def raise_python_source_error(text: str, reason: str, line: int | None = None) -> NoReturn:
6
+ """Raise ParamError when a text cannot be read as Python source.
7
+
8
+ Args:
9
+ text: The text that could not be read.
10
+ reason: What is wrong with it, in words.
11
+ line: The line the problem was found on (1-based), if known.
12
+ """
13
+
14
+ # 1. The line of the problem is the best value to show; without it, the start of the text
15
+ lines = text.split("\n")
16
+ if line is not None and 1 <= line <= len(lines):
17
+ shown = lines[line - 1].strip()
18
+ where = f" (line {line})"
19
+ else:
20
+ shown = text[:80]
21
+ where = ""
22
+
23
+ # 2. Raise structured ParamError wrapping a ValueError
24
+ raise ParamError(
25
+ error_name="CODE_EXTRACTOR_PYTHON_SOURCE_INVALID",
26
+ label="text",
27
+ value=shown,
28
+ problem=f"The text is not Python source I can read: {reason}{where}.",
29
+ expected="Valid Python 3 source with `\\n` line endings.",
30
+ how_to_fix=(
31
+ "Fix the file, or leave it out of the extraction (an `exclude` rule of the profile).",
32
+ "A text with `\\r\\n` or `\\r` endings has to be normalized to `\\n` first.",
33
+ "Pass on_error='keep' to get such a file back unchanged instead of an error.",
34
+ ),
35
+ exception=ValueError,
36
+ )
37
+
38
+
39
+ _DESIGN_NOTES = """
40
+ # raise_python_source_error — A File That Python Cannot Read
41
+
42
+ ## Purpose
43
+ One card for every way a Python step can fail to read its text: a syntax
44
+ error, a bad indentation, an unterminated string, a null character, line
45
+ endings other than `\\n`.
46
+
47
+ ## What the Extractor Does With It
48
+ A step that raises marks THAT file `error` (`transform failed: ...`) and the
49
+ run goes on with the next file; the run is unsuccessful. With
50
+ `on_error="keep"` the step never gets here: the text comes back as it is.
51
+
52
+ ## The Value
53
+ The offending line, not the whole text: a card that prints a whole file is not
54
+ a card anybody reads.
55
+ """
@@ -0,0 +1,73 @@
1
+ import io
2
+ import tokenize
3
+ from simplibs.text import Span
4
+ from simplibs.validate import validate_call
5
+ # Outers
6
+ from ..annotations import OnError
7
+ # Inners
8
+ from ._helpers import SourceOffsets, guarded_parse
9
+
10
+
11
+ def _collect_comment_spans(text: str) -> list[Span]:
12
+ """Run the tokenizer and turn every comment token into a span."""
13
+
14
+ # 1. Every COMMENT token, with the blanks in front of it
15
+ offsets = SourceOffsets(text)
16
+ spans: list[Span] = []
17
+ for token in tokenize.generate_tokens(io.StringIO(text).readline):
18
+ if token.type != tokenize.COMMENT:
19
+ continue
20
+ start = offsets.token_offset(*token.start)
21
+ end = offsets.token_offset(*token.end)
22
+ while start > 0 and text[start - 1] in " \t":
23
+ start -= 1
24
+ spans.append((start, end))
25
+
26
+ return spans
27
+
28
+
29
+ @validate_call
30
+ def python_comment_spans(text: str, *, on_error: OnError = "raise") -> list[Span]:
31
+ """Find the comments of a Python source.
32
+
33
+ Args:
34
+ text: The Python source.
35
+ on_error: `"raise"` for a card when the text cannot be tokenized, `"keep"` for no spans.
36
+
37
+ Returns:
38
+ A span for each comment, from the blanks in front of it to the end of the comment.
39
+
40
+ Raises:
41
+ ParamError: The text cannot be tokenized and `on_error` is `"raise"`.
42
+ """
43
+
44
+ # 1. Tokenize under the guard
45
+ spans = guarded_parse(text, on_error, _collect_comment_spans)
46
+
47
+ # 2. A text that could not be read has no spans
48
+ return spans or []
49
+
50
+
51
+ _DESIGN_NOTES = """
52
+ # python_comment_spans — Comments Found by the Tokenizer
53
+
54
+ ## Purpose
55
+ Where the comments are, as spans that `remove_spans` can cut.
56
+
57
+ ## Why `tokenize` and Not a Regex
58
+ A `#` inside a string (`"a # b"`, `'''# not a comment'''`) is not a comment, and
59
+ telling the two apart is the tokenizer's job. A regex gets it wrong exactly in
60
+ the files where it matters.
61
+
62
+ ## The Blanks in Front
63
+ `x = 1 # note` is cut to `x = 1`, not to `x = 1 `. A comment on a line of its
64
+ own is taken with its indentation, and the cut removes the empty line.
65
+
66
+ ## Shebang and Coding Line
67
+ `#!/usr/bin/env python` and `# -*- coding: utf-8 -*-` are comments to the
68
+ tokenizer and go with the rest: the output is for reading, not for running.
69
+
70
+ ## What It Needs
71
+ Only that the text can be TOKENIZED, not that it is valid Python 3: a file that
72
+ `ast` refuses may still have its comments found.
73
+ """
@@ -0,0 +1,63 @@
1
+ from simplibs.validate import validate_call
2
+ # Outers
3
+ from ..annotations import OnError
4
+ # Inners
5
+ from ._helpers import collect_definitions, outline_node, parse_python
6
+
7
+
8
+ @validate_call
9
+ def python_outline(text: str, *, docstrings: bool = True, on_error: OnError = "raise") -> str:
10
+ """Write an overview of a Python source: its classes and functions as signatures without bodies.
11
+
12
+ Args:
13
+ text: The Python source.
14
+ docstrings: Add the first line of each docstring as a trailing comment.
15
+ on_error: `"raise"` for a card when the text cannot be parsed, `"keep"` to return it unchanged
16
+ (the whole file, not an outline).
17
+
18
+ Returns:
19
+ The outline, valid Python; an empty string for a file that defines no class or function.
20
+
21
+ Raises:
22
+ ParamError: The text cannot be parsed and `on_error` is `"raise"`.
23
+
24
+ Example:
25
+ python_outline("@dec\\nclass A(B):\\n def m(self, a: int = 1) -> str:\\n return ''\\n")
26
+ # '@dec\\nclass A(B):\\n def m(self, a: int=1) -> str: ...\\n'
27
+ """
28
+
29
+ # 1. Parse; a text that could not be read is not touched
30
+ tree = parse_python(text, on_error)
31
+ if tree is None:
32
+ return text
33
+
34
+ # 2. The outline of every definition of the module
35
+ lines: list[str] = []
36
+ for definition in collect_definitions(tree.body):
37
+ lines.extend(outline_node(definition, 0, docstrings))
38
+
39
+ return "\n".join(lines) + "\n" if lines else ""
40
+
41
+
42
+ _DESIGN_NOTES = """
43
+ # python_outline — The Shape of a Module in a Few Lines
44
+
45
+ ## Purpose
46
+ The step of the profile `python/outline`: what a file offers, without how it
47
+ does it. For giving a large project to a reader with a limited window, the
48
+ signatures are most of what is needed to ask the right questions.
49
+
50
+ ## What Is Listed
51
+ Classes and functions of the module, methods and nested classes of a class,
52
+ looking through `if`, `try` and `with` blocks. A function inside a function is
53
+ not listed: it is the function's own detail.
54
+
55
+ ## Written From the Tree
56
+ Signatures come from `ast.unparse`, not from the source text, so spacing and
57
+ line breaks are normalized (`a: int = 1` is written `a: int=1`) and the output
58
+ is always valid Python.
59
+
60
+ ## Docstring as a Comment
61
+ `# First line of the docstring` after the signature. No string means nothing
62
+ to quote, escape or close.
63
+ """
@@ -0,0 +1,44 @@
1
+ from simplibs.text import remove_spans
2
+ from simplibs.validate import validate_call
3
+ # Outers
4
+ from ..annotations import OnError
5
+ # Inners
6
+ from .python_comment_spans import python_comment_spans
7
+
8
+
9
+ @validate_call
10
+ def remove_python_comments(text: str, *, on_error: OnError = "raise") -> str:
11
+ """Remove all comments from a Python source.
12
+
13
+ Args:
14
+ text: The Python source.
15
+ on_error: `"raise"` for a card when the text cannot be tokenized, `"keep"` to return it unchanged.
16
+
17
+ Returns:
18
+ The text without comments. A line that held only a comment disappears.
19
+
20
+ Raises:
21
+ ParamError: The text cannot be tokenized and `on_error` is `"raise"`.
22
+ """
23
+
24
+ # 1. Find the comments
25
+ spans = python_comment_spans(text, on_error=on_error)
26
+
27
+ # 2. Cut them; strings that merely contain a `#` were never found
28
+ return remove_spans(text, spans)
29
+
30
+
31
+ _DESIGN_NOTES = """
32
+ # remove_python_comments — The Comments Out, The Code Untouched
33
+
34
+ ## Purpose
35
+ A step of the profiles `python/code-clean` and `python/code-no-imports`.
36
+
37
+ ## Guarantee
38
+ For a text that parses, the syntax tree of the result is the same as the tree
39
+ of the original: nothing but comments (and the blanks and empty lines they
40
+ left) is removed.
41
+
42
+ ## Idempotent
43
+ A second run finds nothing and returns the very same text.
44
+ """
@@ -0,0 +1,48 @@
1
+ from simplibs.text import remove_spans
2
+ from simplibs.validate import validate_call
3
+ # Outers
4
+ from ..annotations import OnError
5
+ # Inners
6
+ from ._helpers import SourceOffsets, find_decorator_spans, parse_python
7
+
8
+
9
+ @validate_call
10
+ def remove_python_decorators(text: str, *, on_error: OnError = "raise") -> str:
11
+ """Remove the decorators of every class and function of a Python source.
12
+
13
+ Args:
14
+ text: The Python source.
15
+ on_error: `"raise"` for a card when the text cannot be parsed, `"keep"` to return it unchanged.
16
+
17
+ Returns:
18
+ The text without decorators; the lines they stood on disappear.
19
+
20
+ Raises:
21
+ ParamError: The text cannot be parsed and `on_error` is `"raise"`.
22
+ """
23
+
24
+ # 1. Parse; a text that could not be read is not touched
25
+ tree = parse_python(text, on_error)
26
+ if tree is None:
27
+ return text
28
+
29
+ # 2. Cut each decorator from its `@`
30
+ return remove_spans(text, find_decorator_spans(text, tree, SourceOffsets(text)))
31
+
32
+
33
+ _DESIGN_NOTES = """
34
+ # remove_python_decorators — The Function, Not How It Is Wrapped
35
+
36
+ ## Purpose
37
+ A step of the profile `python/code-no-imports`: `@app.route(...)`,
38
+ `@dataclass`, `@property` describe how a function is registered or wrapped,
39
+ not what it does.
40
+
41
+ ## Guarantee
42
+ For a text that parses, the syntax tree of the result equals the tree of the
43
+ original with every `decorator_list` emptied.
44
+
45
+ ## Semantics Change
46
+ A decorator can change what a function IS (`@property`, `@staticmethod`,
47
+ `@contextmanager`). The output is for reading; it is not meant to be run.
48
+ """
@@ -0,0 +1,50 @@
1
+ from simplibs.validate import validate_call
2
+ # Outers
3
+ from ..annotations import OnError
4
+ # Inners
5
+ from ._helpers import SourceOffsets, cut_statements, find_docstrings, parse_python
6
+
7
+
8
+ @validate_call
9
+ def remove_python_docstrings(text: str, *, on_error: OnError = "raise") -> str:
10
+ """Remove the docstrings of a Python source: of the module, its classes and its functions.
11
+
12
+ Args:
13
+ text: The Python source.
14
+ on_error: `"raise"` for a card when the text cannot be parsed, `"keep"` to return it unchanged.
15
+
16
+ Returns:
17
+ The text without docstrings. A body left empty gets a `pass`.
18
+
19
+ Raises:
20
+ ParamError: The text cannot be parsed and `on_error` is `"raise"`.
21
+ """
22
+
23
+ # 1. Parse; a text that could not be read is not touched
24
+ tree = parse_python(text, on_error)
25
+ if tree is None:
26
+ return text
27
+
28
+ # 2. Cut the docstring statements
29
+ return cut_statements(text, tree, find_docstrings(tree), SourceOffsets(text))
30
+
31
+
32
+ _DESIGN_NOTES = """
33
+ # remove_python_docstrings — Documentation Out, Structure Kept
34
+
35
+ ## Purpose
36
+ A step of the profiles `python/code-clean` and `python/code-no-imports`.
37
+
38
+ ## What Is a Docstring
39
+ The string that is the first statement of a module, a class or a function.
40
+ Other strings, even alone on their line, are left.
41
+
42
+ ## A Body Needs a Statement
43
+ `def f():` with only a docstring is `def f(): pass` afterwards; so is a class.
44
+ A module may be empty.
45
+
46
+ ## Guarantee
47
+ For a text that parses, the syntax tree of the result equals the tree of the
48
+ original with the docstring statements taken out (and a `Pass` where a body
49
+ would be empty).
50
+ """