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.
- simplibs/extractor_python/__init__.py +67 -0
- simplibs/extractor_python/actions/PythonOutline.py +23 -0
- simplibs/extractor_python/actions/RemovePythonComments.py +22 -0
- simplibs/extractor_python/actions/RemovePythonDecorators.py +22 -0
- simplibs/extractor_python/actions/RemovePythonDocstrings.py +22 -0
- simplibs/extractor_python/actions/RemovePythonImports.py +22 -0
- simplibs/extractor_python/actions/__init__.py +45 -0
- simplibs/extractor_python/annotations/OnError.py +25 -0
- simplibs/extractor_python/annotations/__init__.py +18 -0
- simplibs/extractor_python/constants/PYTHON_CONFIG_NAMES.py +27 -0
- simplibs/extractor_python/constants/PYTHON_OUTPUT_DIR_NAMES.py +19 -0
- simplibs/extractor_python/constants/PYTHON_SUFFIXES.py +17 -0
- simplibs/extractor_python/constants/PYTHON_TEST_DIR_NAMES.py +18 -0
- simplibs/extractor_python/constants/PYTHON_VENV_DIR_NAMES.py +18 -0
- simplibs/extractor_python/constants/__init__.py +26 -0
- simplibs/extractor_python/file_rules/__init__.py +62 -0
- simplibs/extractor_python/file_rules/atomic/DOT_NAME.py +19 -0
- simplibs/extractor_python/file_rules/atomic/INIT_NAME.py +17 -0
- simplibs/extractor_python/file_rules/atomic/IN_DUNDER_DIR.py +16 -0
- simplibs/extractor_python/file_rules/atomic/MAIN_NAME.py +16 -0
- simplibs/extractor_python/file_rules/atomic/OUTPUT_DIRS.py +17 -0
- simplibs/extractor_python/file_rules/atomic/VCS_DIRS.py +26 -0
- simplibs/extractor_python/file_rules/atomic/VENV_DIRS.py +18 -0
- simplibs/extractor_python/file_rules/atomic/__init__.py +30 -0
- simplibs/extractor_python/file_rules/python/PYTHON_BASE.py +17 -0
- simplibs/extractor_python/file_rules/python/PYTHON_CONFIG.py +18 -0
- simplibs/extractor_python/file_rules/python/PYTHON_STRICT_EXCLUDE.py +24 -0
- simplibs/extractor_python/file_rules/python/PYTHON_TESTS.py +29 -0
- simplibs/extractor_python/file_rules/python/__init__.py +22 -0
- simplibs/extractor_python/file_rules/python/build_python_base.py +58 -0
- simplibs/extractor_python/logic/__init__.py +32 -0
- simplibs/extractor_python/logic/_helpers/SourceOffsets.py +82 -0
- simplibs/extractor_python/logic/_helpers/__init__.py +36 -0
- simplibs/extractor_python/logic/_helpers/collect_definitions.py +60 -0
- simplibs/extractor_python/logic/_helpers/cut_statements.py +126 -0
- simplibs/extractor_python/logic/_helpers/describe_source_failure.py +34 -0
- simplibs/extractor_python/logic/_helpers/find_decorator_spans.py +107 -0
- simplibs/extractor_python/logic/_helpers/find_docstrings.py +46 -0
- simplibs/extractor_python/logic/_helpers/find_imports.py +33 -0
- simplibs/extractor_python/logic/_helpers/first_doc_line.py +37 -0
- simplibs/extractor_python/logic/_helpers/format_type_params.py +29 -0
- simplibs/extractor_python/logic/_helpers/guarded_parse.py +64 -0
- simplibs/extractor_python/logic/_helpers/outline_node.py +72 -0
- simplibs/extractor_python/logic/_helpers/parse_python.py +32 -0
- simplibs/extractor_python/logic/_validations/__init__.py +14 -0
- simplibs/extractor_python/logic/_validations/raise_python_source_error.py +55 -0
- simplibs/extractor_python/logic/python_comment_spans.py +73 -0
- simplibs/extractor_python/logic/python_outline.py +63 -0
- simplibs/extractor_python/logic/remove_python_comments.py +44 -0
- simplibs/extractor_python/logic/remove_python_decorators.py +48 -0
- simplibs/extractor_python/logic/remove_python_docstrings.py +50 -0
- simplibs/extractor_python/logic/remove_python_imports.py +48 -0
- simplibs/extractor_python/plugin/__init__.py +14 -0
- simplibs/extractor_python/plugin/register_all.py +45 -0
- simplibs/extractor_python/presets/PYTHON_CLEAN.py +29 -0
- simplibs/extractor_python/presets/PYTHON_NO_IMPORTS.py +32 -0
- simplibs/extractor_python/presets/__init__.py +30 -0
- simplibs/extractor_python/profiles/PYTHON_CODE_CLEAN.py +29 -0
- simplibs/extractor_python/profiles/PYTHON_CODE_NO_IMPORTS.py +29 -0
- simplibs/extractor_python/profiles/PYTHON_DATA_ALL.py +26 -0
- simplibs/extractor_python/profiles/PYTHON_OUTLINE.py +30 -0
- simplibs/extractor_python/profiles/PYTHON_PROFILES.py +26 -0
- simplibs/extractor_python/profiles/PYTHON_SRC_CLEAN.py +30 -0
- simplibs/extractor_python/profiles/__init__.py +47 -0
- simplibs/extractor_python/py.typed +1 -0
- simplibs_extractor_python-0.1.0.dist-info/METADATA +457 -0
- simplibs_extractor_python-0.1.0.dist-info/RECORD +71 -0
- simplibs_extractor_python-0.1.0.dist-info/WHEEL +5 -0
- simplibs_extractor_python-0.1.0.dist-info/entry_points.txt +2 -0
- simplibs_extractor_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
"""
|