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,18 @@
|
|
|
1
|
+
from typing import Final
|
|
2
|
+
from simplibs.path.rules import NameIs
|
|
3
|
+
from simplibs.rules import IsIn, Rule
|
|
4
|
+
# Outers
|
|
5
|
+
from ...constants import PYTHON_CONFIG_NAMES
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
PYTHON_CONFIG: Final[Rule] = NameIs(IsIn(PYTHON_CONFIG_NAMES))
|
|
9
|
+
"""The file is Python project configuration (`setup.py`, `tox.ini`, `requirements.txt`...)."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_DESIGN_NOTES = """
|
|
13
|
+
# PYTHON_CONFIG — How the Project Is Built
|
|
14
|
+
|
|
15
|
+
## Purpose
|
|
16
|
+
Part of the strict Python exclusion. `setup.py` is Python source, but it
|
|
17
|
+
describes packaging; for reading what the project does, it is noise.
|
|
18
|
+
"""
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from typing import Final
|
|
2
|
+
from simplibs.rules import Rule
|
|
3
|
+
# Outers
|
|
4
|
+
from ..atomic import OUTPUT_DIRS, VCS_DIRS, VENV_DIRS
|
|
5
|
+
# Inners
|
|
6
|
+
from .PYTHON_CONFIG import PYTHON_CONFIG
|
|
7
|
+
from .PYTHON_TESTS import PYTHON_TESTS
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
PYTHON_STRICT_EXCLUDE: Final[Rule] = PYTHON_TESTS | PYTHON_CONFIG | VENV_DIRS | OUTPUT_DIRS | VCS_DIRS
|
|
11
|
+
"""Everything that is Python-looking but not the project's own logic: tests, configuration, environments, build output."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_DESIGN_NOTES = """
|
|
15
|
+
# PYTHON_STRICT_EXCLUDE — Only What the Project Does
|
|
16
|
+
|
|
17
|
+
## Purpose
|
|
18
|
+
The `exclude` of the profile `python/src-clean`. The old code had a strict
|
|
19
|
+
filter like this, but no profile ever used it.
|
|
20
|
+
|
|
21
|
+
## How It Combines
|
|
22
|
+
Used together with `PYTHON_BASE` as `include`: a file must be Python source
|
|
23
|
+
and must not be any of these.
|
|
24
|
+
"""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from typing import Final
|
|
2
|
+
from simplibs.path.rules import AnyParentNameIs, NameIs
|
|
3
|
+
from simplibs.rules import IsIn, Regex, Rule
|
|
4
|
+
# Outers
|
|
5
|
+
from ...constants import PYTHON_TEST_DIR_NAMES
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
PYTHON_TESTS: Final[Rule] = (
|
|
9
|
+
NameIs(Regex(r"^(test_.*|.*_tests?|tests?|conftest)\.py$"))
|
|
10
|
+
| AnyParentNameIs(IsIn(PYTHON_TEST_DIR_NAMES))
|
|
11
|
+
)
|
|
12
|
+
"""The file is a Python test: by its name (`test_a.py`, `a_test.py`, `tests.py`, `conftest.py`) or by its folder (`tests/`)."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_DESIGN_NOTES = """
|
|
16
|
+
# PYTHON_TESTS — Recognising a Test by Its Name, Not by a Piece of It
|
|
17
|
+
|
|
18
|
+
## Purpose
|
|
19
|
+
Part of the strict Python exclusion.
|
|
20
|
+
|
|
21
|
+
## Why a Pattern on the Whole Name
|
|
22
|
+
The old rule asked whether the path CONTAINED `test`, and it threw away
|
|
23
|
+
`latest_data.py`, `contest.py` and `attest.py`. The pattern is anchored at both
|
|
24
|
+
ends: the name starts with `test_`, ends with `_test` or `_tests`, or is
|
|
25
|
+
exactly `test`, `tests` or `conftest`, followed by `.py`.
|
|
26
|
+
|
|
27
|
+
## Or Anywhere Inside a Tests Folder
|
|
28
|
+
Every file in a folder called `tests` or `test` is a test helper at least.
|
|
29
|
+
"""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .PYTHON_BASE import PYTHON_BASE
|
|
2
|
+
from .PYTHON_CONFIG import PYTHON_CONFIG
|
|
3
|
+
from .PYTHON_STRICT_EXCLUDE import PYTHON_STRICT_EXCLUDE
|
|
4
|
+
from .PYTHON_TESTS import PYTHON_TESTS
|
|
5
|
+
from .build_python_base import build_python_base
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_DESIGN_NOTES = """
|
|
9
|
+
# python — File Rules for Python Projects
|
|
10
|
+
|
|
11
|
+
Plain imports, no `__all__` (the package root decides what is public).
|
|
12
|
+
|
|
13
|
+
## Internal Components Registry
|
|
14
|
+
|
|
15
|
+
| Component | Type | Description |
|
|
16
|
+
| :---------------------- | :------- | :------------------------------------------------------------------- |
|
|
17
|
+
| `PYTHON_BASE` | Rule | The default choice of Python files worth reading. |
|
|
18
|
+
| `PYTHON_CONFIG` | Rule | Project configuration files. |
|
|
19
|
+
| `PYTHON_STRICT_EXCLUDE` | Rule | Tests, configuration, environments and build output. |
|
|
20
|
+
| `PYTHON_TESTS` | Rule | Test files, by name or by folder. |
|
|
21
|
+
| `build_python_base` | Function | Builds the base rule; switches for `__init__.py`, `__main__.py`. |
|
|
22
|
+
"""
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from simplibs.path.rules import HasSuffix
|
|
2
|
+
from simplibs.rules import Rule
|
|
3
|
+
from simplibs.validate import validate_call
|
|
4
|
+
# Outers
|
|
5
|
+
from ...constants import PYTHON_SUFFIXES
|
|
6
|
+
from ..atomic import DOT_NAME, IN_DUNDER_DIR, INIT_NAME, MAIN_NAME
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@validate_call
|
|
10
|
+
def build_python_base(*, include_init: bool = True, include_main: bool = True) -> Rule:
|
|
11
|
+
"""Build the rule that picks the Python files worth reading.
|
|
12
|
+
|
|
13
|
+
Python files, without dot files and without anything inside a folder such
|
|
14
|
+
as `__pycache__`.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
include_init: Take `__init__.py`. Switch off to read only the modules.
|
|
18
|
+
include_main: Take `__main__.py`. Switch off to leave the entry point out.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
A rule on the path relative to the source folder.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
# 1. The core: Python files that are not hidden and not interpreter products
|
|
25
|
+
rule = HasSuffix(*PYTHON_SUFFIXES) & ~DOT_NAME & ~IN_DUNDER_DIR
|
|
26
|
+
|
|
27
|
+
# 2. The two package files, unless asked to leave them out
|
|
28
|
+
if not include_init:
|
|
29
|
+
rule = rule & ~INIT_NAME
|
|
30
|
+
if not include_main:
|
|
31
|
+
rule = rule & ~MAIN_NAME
|
|
32
|
+
|
|
33
|
+
return rule
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_DESIGN_NOTES = """
|
|
37
|
+
# build_python_base — Which Python Files Are Source
|
|
38
|
+
|
|
39
|
+
## Purpose
|
|
40
|
+
The `include` of every Python profile, with two switches for the files the old
|
|
41
|
+
code always dropped.
|
|
42
|
+
|
|
43
|
+
## What Changed From the Old `PythonBase`
|
|
44
|
+
The old rule excluded every file whose name starts with two underscores.
|
|
45
|
+
That took away `__init__.py`, which holds the public names of a package, and
|
|
46
|
+
`__main__.py`, the entry point. Now both are taken by default; the switches
|
|
47
|
+
bring back the old behaviour. Other names of that kind (`__version__.py`,
|
|
48
|
+
`__about__.py`) are ordinary modules and are taken as well.
|
|
49
|
+
|
|
50
|
+
## Folders
|
|
51
|
+
A folder whose name starts with two underscores is still left out
|
|
52
|
+
(`__pycache__`, `__pypackages__`): it holds what the interpreter made.
|
|
53
|
+
|
|
54
|
+
## Why the Name Starts With `build_`
|
|
55
|
+
The constant `PYTHON_BASE` has its own file. On Windows and macOS two files
|
|
56
|
+
whose names differ only by case (`python_base.py`, `PYTHON_BASE.py`) are the
|
|
57
|
+
same file, so the function got a name that differs by more than the case.
|
|
58
|
+
"""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from .python_comment_spans import python_comment_spans
|
|
2
|
+
from .python_outline import python_outline
|
|
3
|
+
from .remove_python_comments import remove_python_comments
|
|
4
|
+
from .remove_python_decorators import remove_python_decorators
|
|
5
|
+
from .remove_python_docstrings import remove_python_docstrings
|
|
6
|
+
from .remove_python_imports import remove_python_imports
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_DESIGN_NOTES = """
|
|
10
|
+
# logic — The Steps That Read Python
|
|
11
|
+
|
|
12
|
+
Plain imports, no `__all__` (the package root decides what is public).
|
|
13
|
+
|
|
14
|
+
## Internal Components Registry
|
|
15
|
+
|
|
16
|
+
| Component | Type | Description |
|
|
17
|
+
| :------------------------- | :------- | :--------------------------------------------------------------- |
|
|
18
|
+
| `python_comment_spans` | Function | The spans of the comments (by `tokenize`). |
|
|
19
|
+
| `python_outline` | Function | Classes and functions as signatures without bodies. |
|
|
20
|
+
| `remove_python_comments` | Function | Removes the comments. |
|
|
21
|
+
| `remove_python_decorators` | Function | Removes the decorators of classes and functions. |
|
|
22
|
+
| `remove_python_docstrings` | Function | Removes the docstrings; an emptied body gets `pass`. |
|
|
23
|
+
| `remove_python_imports` | Function | Removes the imports at any depth; an emptied body gets `pass`. |
|
|
24
|
+
| `_helpers` | Private | Offsets, the parser guard, statement cutting, finders, outline. |
|
|
25
|
+
| `_validations` | Private | The card of an unreadable source. |
|
|
26
|
+
|
|
27
|
+
## Layering & Design
|
|
28
|
+
- Python is read with `ast` and `tokenize`, never with a regex.
|
|
29
|
+
- Each step is a function of a text and returns a text; the `Action` wrappers come in `actions`.
|
|
30
|
+
- `on_error` is `"raise"` by default: a file that cannot be read is reported, not silently left unclean.
|
|
31
|
+
- The steps import only `annotations`; the ranges they find are `simplibs.text.Span`.
|
|
32
|
+
"""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SourceOffsets:
|
|
5
|
+
"""Turns the positions `ast` and `tokenize` report into offsets in the text.
|
|
6
|
+
|
|
7
|
+
`ast` counts a column in UTF-8 BYTES, `tokenize` in characters; a span has to be in
|
|
8
|
+
characters, because that is what a string is cut by.
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
offsets = SourceOffsets(text)
|
|
12
|
+
start, end = offsets.span_of(node)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__slots__ = ("_text", "_starts", "_is_ascii")
|
|
16
|
+
|
|
17
|
+
def __init__(self, text: str) -> None:
|
|
18
|
+
|
|
19
|
+
# 1. The offset where each line begins
|
|
20
|
+
starts = [0]
|
|
21
|
+
position = 0
|
|
22
|
+
for line in text.split("\n")[:-1]:
|
|
23
|
+
position += len(line) + 1
|
|
24
|
+
starts.append(position)
|
|
25
|
+
|
|
26
|
+
# 2. Keep what the conversions need
|
|
27
|
+
self._text = text
|
|
28
|
+
self._starts = starts
|
|
29
|
+
self._is_ascii = text.isascii()
|
|
30
|
+
|
|
31
|
+
def token_offset(self, row: int, column: int) -> int:
|
|
32
|
+
"""The offset of a `tokenize` position (1-based row, column in characters)."""
|
|
33
|
+
|
|
34
|
+
# 1. Characters already: only the start of the line is added
|
|
35
|
+
return self._starts[row - 1] + column
|
|
36
|
+
|
|
37
|
+
def ast_offset(self, lineno: int, column: int) -> int:
|
|
38
|
+
"""The offset of an `ast` position (1-based line, column in UTF-8 bytes)."""
|
|
39
|
+
|
|
40
|
+
# 1. Where the line begins
|
|
41
|
+
start = self._starts[lineno - 1]
|
|
42
|
+
|
|
43
|
+
# 2. Only ASCII text has one byte per character
|
|
44
|
+
if self._is_ascii or column == 0:
|
|
45
|
+
return start + column
|
|
46
|
+
|
|
47
|
+
# 3. Count the characters that fit into that many bytes
|
|
48
|
+
end = self._text.find("\n", start)
|
|
49
|
+
line = self._text[start:] if end == -1 else self._text[start:end]
|
|
50
|
+
return start + len(line.encode("utf-8")[:column].decode("utf-8"))
|
|
51
|
+
|
|
52
|
+
def span_of(self, node: ast.AST) -> tuple[int, int]:
|
|
53
|
+
"""The `(start, end)` of a node, in characters."""
|
|
54
|
+
|
|
55
|
+
# 1. Both ends of the node
|
|
56
|
+
return (
|
|
57
|
+
self.ast_offset(node.lineno, node.col_offset),
|
|
58
|
+
self.ast_offset(node.end_lineno, node.end_col_offset),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_DESIGN_NOTES = """
|
|
63
|
+
# SourceOffsets — Bytes Become Characters
|
|
64
|
+
|
|
65
|
+
## Purpose
|
|
66
|
+
`ast` reports `col_offset` in UTF-8 bytes. In a line with `é` or `€` before
|
|
67
|
+
the node, a byte column is NOT a character column, and a cut by it lands in
|
|
68
|
+
the wrong place. Every step that cuts by `ast` positions goes through this
|
|
69
|
+
class.
|
|
70
|
+
|
|
71
|
+
## Why a Class
|
|
72
|
+
The line table is built once per text and used for every node.
|
|
73
|
+
|
|
74
|
+
## ASCII Is the Common Case
|
|
75
|
+
For a text of ASCII only, a byte and a character are the same thing and the
|
|
76
|
+
conversion is an addition.
|
|
77
|
+
|
|
78
|
+
## Line Endings
|
|
79
|
+
Lines are split by `\\n` only, which is what `ast` and `tokenize` count for a
|
|
80
|
+
text that has only `\\n`; the steps refuse a text with `\\r` before they get
|
|
81
|
+
here.
|
|
82
|
+
"""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from .SourceOffsets import SourceOffsets
|
|
2
|
+
from .collect_definitions import collect_definitions
|
|
3
|
+
from .cut_statements import cut_statements
|
|
4
|
+
from .describe_source_failure import describe_source_failure
|
|
5
|
+
from .find_decorator_spans import find_decorator_spans
|
|
6
|
+
from .find_docstrings import find_docstrings
|
|
7
|
+
from .find_imports import find_imports
|
|
8
|
+
from .first_doc_line import first_doc_line
|
|
9
|
+
from .format_type_params import format_type_params
|
|
10
|
+
from .guarded_parse import guarded_parse
|
|
11
|
+
from .outline_node import outline_node
|
|
12
|
+
from .parse_python import parse_python
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_DESIGN_NOTES = """
|
|
16
|
+
# _helpers — Shared Tools of the Python Steps
|
|
17
|
+
|
|
18
|
+
Private. Plain imports, no `__all__`.
|
|
19
|
+
|
|
20
|
+
## Internal Components Registry
|
|
21
|
+
|
|
22
|
+
| Component | Type | Description |
|
|
23
|
+
| :------------------------ | :------- | :------------------------------------------------------------------- |
|
|
24
|
+
| `SourceOffsets` | Class | Turns `ast` byte columns and `tokenize` positions into offsets. |
|
|
25
|
+
| `collect_definitions` | Function | The classes and functions a body defines, looking through blocks. |
|
|
26
|
+
| `cut_statements` | Function | Removes simple statements; empty blocks get `pass`. |
|
|
27
|
+
| `describe_source_failure` | Function | An exception of a parser as a reason and a line. |
|
|
28
|
+
| `find_decorator_spans` | Function | The span of every decorator, from its `@`. |
|
|
29
|
+
| `find_docstrings` | Function | The docstring statements of a module. |
|
|
30
|
+
| `find_imports` | Function | Every import statement, at any depth. |
|
|
31
|
+
| `first_doc_line` | Function | The first line of a docstring as a trailing comment. |
|
|
32
|
+
| `format_type_params` | Function | `[T, U: int]` of a class or function (Python 3.12+). |
|
|
33
|
+
| `guarded_parse` | Function | Runs a parser; decides by `on_error` what a failure means. |
|
|
34
|
+
| `outline_node` | Function | The signature lines of one class or function. |
|
|
35
|
+
| `parse_python` | Function | `ast.parse` under the guard. |
|
|
36
|
+
"""
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
_DEFINITIONS = (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
|
|
6
|
+
_BLOCK_FIELDS = ("body", "handlers", "orelse", "finalbody", "cases")
|
|
7
|
+
_CLAUSES = (ast.ExceptHandler, ast.match_case)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def collect_definitions(body: Sequence[ast.stmt]) -> list[ast.stmt]:
|
|
11
|
+
"""Collect the classes and functions defined in a body, in source order.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
body: The statements of a module or of a class.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
The definitions at this level, including those inside `if`, `try`, `with`, `for`,
|
|
18
|
+
`while` and `match` blocks, which are looked through but not reported.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# 1. A definition counts; a block that holds definitions is looked through
|
|
22
|
+
found: list[ast.stmt] = []
|
|
23
|
+
for statement in body:
|
|
24
|
+
if isinstance(statement, _DEFINITIONS):
|
|
25
|
+
found.append(statement)
|
|
26
|
+
continue
|
|
27
|
+
for field in _BLOCK_FIELDS:
|
|
28
|
+
inner = getattr(statement, field, None)
|
|
29
|
+
if not isinstance(inner, list):
|
|
30
|
+
continue
|
|
31
|
+
for item in inner:
|
|
32
|
+
# 2. An `except` handler and a `case` hold a body of their own; anything else is a statement
|
|
33
|
+
found.extend(collect_definitions(item.body if isinstance(item, _CLAUSES) else [item]))
|
|
34
|
+
|
|
35
|
+
return found
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_DESIGN_NOTES = """
|
|
39
|
+
# collect_definitions — What a Body Defines
|
|
40
|
+
|
|
41
|
+
## Purpose
|
|
42
|
+
The outline lists classes and functions. Which ones belong to a module or a
|
|
43
|
+
class is answered here.
|
|
44
|
+
|
|
45
|
+
## Looked Through, Not Entered
|
|
46
|
+
A function defined inside another function is a detail of that function and is
|
|
47
|
+
not listed. A definition under `if sys.version_info >= ...:`, inside a `try`
|
|
48
|
+
or under a `case` of a `match` IS a member of the module, so those blocks are
|
|
49
|
+
looked through. The condition is lost; if both branches define `f`, both are
|
|
50
|
+
listed (valid Python, which allows a redefinition).
|
|
51
|
+
|
|
52
|
+
## Clauses With a Body of Their Own
|
|
53
|
+
An `except` handler and a `case` are not statements: each holds a body of its
|
|
54
|
+
own and is looked into through it. Every other item of a block is a statement
|
|
55
|
+
and is looked at as one. That is all there is, so there is no third kind to
|
|
56
|
+
skip.
|
|
57
|
+
|
|
58
|
+
## Source Order
|
|
59
|
+
Definitions come in the order they stand in the file, also across blocks.
|
|
60
|
+
"""
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from bisect import bisect_right
|
|
3
|
+
from collections.abc import Collection
|
|
4
|
+
from simplibs.text import remove_spans
|
|
5
|
+
# Inners
|
|
6
|
+
from .SourceOffsets import SourceOffsets
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_BLOCK_FIELDS = ("body", "orelse", "finalbody")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _extend_over_semicolon(text: str, start: int, end: int) -> tuple[int, int]:
|
|
13
|
+
"""Widen a statement's span over the `;` that joins it to its neighbour on the same line."""
|
|
14
|
+
|
|
15
|
+
# 1. A `;` after it: take it and the blanks behind it, so the next statement moves up
|
|
16
|
+
probe = end
|
|
17
|
+
while probe < len(text) and text[probe] in " \t":
|
|
18
|
+
probe += 1
|
|
19
|
+
if probe < len(text) and text[probe] == ";":
|
|
20
|
+
probe += 1
|
|
21
|
+
while probe < len(text) and text[probe] in " \t":
|
|
22
|
+
probe += 1
|
|
23
|
+
return start, probe
|
|
24
|
+
|
|
25
|
+
# 2. Otherwise a `;` before it: the statement is the last of its line, take the `;` with it
|
|
26
|
+
probe = start
|
|
27
|
+
while probe > 0 and text[probe - 1] in " \t":
|
|
28
|
+
probe -= 1
|
|
29
|
+
if probe > 0 and text[probe - 1] == ";":
|
|
30
|
+
probe -= 1
|
|
31
|
+
while probe > 0 and text[probe - 1] in " \t":
|
|
32
|
+
probe -= 1
|
|
33
|
+
return probe, end
|
|
34
|
+
|
|
35
|
+
# 3. Alone on its line
|
|
36
|
+
return start, end
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cut_statements(
|
|
40
|
+
text: str,
|
|
41
|
+
tree: ast.Module,
|
|
42
|
+
targets: Collection[ast.stmt],
|
|
43
|
+
offsets: SourceOffsets,
|
|
44
|
+
) -> str:
|
|
45
|
+
"""Remove simple statements from a module and keep the rest valid Python.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
text: The source the tree was parsed from.
|
|
49
|
+
tree: The parsed module.
|
|
50
|
+
targets: The statements to remove: simple ones (an import, an expression), never a compound one.
|
|
51
|
+
offsets: The offsets of `text`.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The text without the statements. A block that would be left empty gets a `pass`.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
# 1. Nothing to cut — the very same text back
|
|
58
|
+
if not targets:
|
|
59
|
+
return text
|
|
60
|
+
|
|
61
|
+
# 2. Walk every list of statements, remember what to cut and where a block would be left empty
|
|
62
|
+
spans: list[tuple[int, int]] = []
|
|
63
|
+
pass_positions: list[int] = []
|
|
64
|
+
for node in ast.walk(tree):
|
|
65
|
+
for field in _BLOCK_FIELDS:
|
|
66
|
+
statements = getattr(node, field, None)
|
|
67
|
+
if not isinstance(statements, list) or not statements:
|
|
68
|
+
continue
|
|
69
|
+
hits = [statement for statement in statements if statement in targets]
|
|
70
|
+
if not hits:
|
|
71
|
+
continue
|
|
72
|
+
if len(hits) == len(statements) and not isinstance(node, ast.Module):
|
|
73
|
+
pass_positions.append(offsets.span_of(statements[0])[0])
|
|
74
|
+
for statement in hits:
|
|
75
|
+
start, end = offsets.span_of(statement)
|
|
76
|
+
spans.append(_extend_over_semicolon(text, start, end))
|
|
77
|
+
|
|
78
|
+
# 3. Put each `pass` in front of the first statement of its block; the cut then leaves just the `pass`
|
|
79
|
+
pass_positions.sort()
|
|
80
|
+
pieces: list[str] = []
|
|
81
|
+
previous = 0
|
|
82
|
+
for position in pass_positions:
|
|
83
|
+
pieces.append(text[previous:position])
|
|
84
|
+
pieces.append("pass")
|
|
85
|
+
previous = position
|
|
86
|
+
pieces.append(text[previous:])
|
|
87
|
+
padded = "".join(pieces)
|
|
88
|
+
|
|
89
|
+
# 4. The spans move with the text: every `pass` inserted at or before an offset pushes it by four
|
|
90
|
+
shifted = [
|
|
91
|
+
(start + 4 * bisect_right(pass_positions, start), end + 4 * bisect_right(pass_positions, end))
|
|
92
|
+
for start, end in spans
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
# 5. Cut; a line that only held a removed statement disappears whole
|
|
96
|
+
return remove_spans(padded, shifted)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_DESIGN_NOTES = """
|
|
100
|
+
# cut_statements — Taking Statements Out Without Breaking the Syntax
|
|
101
|
+
|
|
102
|
+
## Purpose
|
|
103
|
+
The one primitive behind `remove_python_docstrings` and
|
|
104
|
+
`remove_python_imports`: the caller decides WHICH statements go, this decides
|
|
105
|
+
how to remove them so that what is left is still Python.
|
|
106
|
+
|
|
107
|
+
## The Three Traps
|
|
108
|
+
1. **An emptied block.** `try: import x` / `else:` / a class holding only its
|
|
109
|
+
docstring: a block needs a statement. It gets a `pass` — in a module, an
|
|
110
|
+
empty body is fine and gets none.
|
|
111
|
+
2. **A statement sharing a line.** `import a; x = 1` and `x = 1; import a`:
|
|
112
|
+
the `;` that joined them goes with the removed statement, the other one
|
|
113
|
+
stays on a line of its own. Without it, `; x = 1` would not parse.
|
|
114
|
+
3. **A statement that is not alone on its line.** `import a # note`: only the
|
|
115
|
+
statement is removed; what remains of the line is what was there besides.
|
|
116
|
+
|
|
117
|
+
## How the `pass` Gets There
|
|
118
|
+
It is inserted in front of the first statement of the block and the whole
|
|
119
|
+
block is cut afterwards, so the `pass` takes the place of the removed text,
|
|
120
|
+
indentation and all. Offsets are shifted by four for every `pass` in front
|
|
121
|
+
of them.
|
|
122
|
+
|
|
123
|
+
## Why Simple Statements Only
|
|
124
|
+
A compound statement (`def`, `if`) contains lists of statements of its own,
|
|
125
|
+
and cutting both would count some of them twice. Nothing here needs it.
|
|
126
|
+
"""
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import tokenize
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def describe_source_failure(exc: Exception) -> tuple[str, int | None]:
|
|
5
|
+
"""Say in words why a text could not be read, and where.
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
exc: What the parser raised.
|
|
9
|
+
|
|
10
|
+
Returns:
|
|
11
|
+
`(reason, line)`; the line is `None` when the exception does not carry one.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
# 1. A syntax error knows its message and its line
|
|
15
|
+
if isinstance(exc, SyntaxError):
|
|
16
|
+
return (exc.msg or "invalid syntax"), exc.lineno
|
|
17
|
+
|
|
18
|
+
# 2. The tokenizer puts the message and `(line, column)` into its arguments
|
|
19
|
+
if isinstance(exc, tokenize.TokenError):
|
|
20
|
+
reason = str(exc.args[0]) if exc.args else "tokenizing failed"
|
|
21
|
+
line = exc.args[1][0] if len(exc.args) > 1 and isinstance(exc.args[1], tuple) else None
|
|
22
|
+
return reason, line
|
|
23
|
+
|
|
24
|
+
# 3. The rest has only a text
|
|
25
|
+
return (str(exc) or type(exc).__name__), None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_DESIGN_NOTES = """
|
|
29
|
+
# describe_source_failure — From an Exception to a Sentence
|
|
30
|
+
|
|
31
|
+
## Purpose
|
|
32
|
+
`SyntaxError`, `tokenize.TokenError` and `ValueError` say the same thing in
|
|
33
|
+
three shapes. The card needs one: a reason and a line.
|
|
34
|
+
"""
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from simplibs.text import Span
|
|
3
|
+
# Inners
|
|
4
|
+
from .SourceOffsets import SourceOffsets
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _line_start(text: str, position: int) -> int:
|
|
8
|
+
"""The offset where the line holding `position` begins."""
|
|
9
|
+
return max(text.rfind("\n", 0, position), text.rfind("\r", 0, position)) + 1
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _end_of_line(text: str, position: int) -> int:
|
|
13
|
+
"""The offset of the line break that ends the line holding `position` (or the end of the text)."""
|
|
14
|
+
while position < len(text) and text[position] not in "\r\n":
|
|
15
|
+
position += 1
|
|
16
|
+
return position
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _count_opening_brackets(text: str, begin: int, stop: int) -> int:
|
|
20
|
+
"""Count the `(` in `text[begin:stop]`, leaving out comments."""
|
|
21
|
+
count = 0
|
|
22
|
+
position = begin
|
|
23
|
+
while position < stop:
|
|
24
|
+
if text[position] == "#":
|
|
25
|
+
position = _end_of_line(text, position)
|
|
26
|
+
continue
|
|
27
|
+
if text[position] == "(":
|
|
28
|
+
count += 1
|
|
29
|
+
position += 1
|
|
30
|
+
return count
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _skip_filler(text: str, position: int) -> int:
|
|
34
|
+
"""The offset of the first character from `position` on that is no blank, line break or comment."""
|
|
35
|
+
while position < len(text) and (text[position] in " \t\f\r\n" or text[position] == "#"):
|
|
36
|
+
position = _end_of_line(text, position) if text[position] == "#" else position + 1
|
|
37
|
+
return position
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def find_decorator_spans(text: str, tree: ast.Module, offsets: SourceOffsets) -> list[Span]:
|
|
41
|
+
"""Find the decorators of every class and function, each from its `@` to its end.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
text: The source the tree was parsed from.
|
|
45
|
+
tree: The parsed module.
|
|
46
|
+
offsets: The offsets of `text`.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
One span per decorator.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
# 1. Every decorator of every class and function
|
|
53
|
+
spans: list[Span] = []
|
|
54
|
+
for node in ast.walk(tree):
|
|
55
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
56
|
+
continue
|
|
57
|
+
for decorator in node.decorator_list:
|
|
58
|
+
start, end = offsets.span_of(decorator)
|
|
59
|
+
|
|
60
|
+
# 2. The `@` stands in front of the expression; an `@` with a `#` before it on its line is in a comment
|
|
61
|
+
at = text.rfind("@", 0, start)
|
|
62
|
+
while "#" in text[_line_start(text, at):at]:
|
|
63
|
+
at = text.rfind("@", 0, at)
|
|
64
|
+
|
|
65
|
+
# 3. `@(factory)`: the node does not include its own brackets, so the closing ones are taken too
|
|
66
|
+
# (the text parsed, so every opening bracket has its closing one behind the expression)
|
|
67
|
+
for _ in range(_count_opening_brackets(text, at + 1, start)):
|
|
68
|
+
end = _skip_filler(text, end) + 1
|
|
69
|
+
|
|
70
|
+
spans.append((at, end))
|
|
71
|
+
|
|
72
|
+
return spans
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_DESIGN_NOTES = """
|
|
76
|
+
# find_decorator_spans — From the `@` to the End of the Expression
|
|
77
|
+
|
|
78
|
+
## Purpose
|
|
79
|
+
`ast` gives the position of the decorator's EXPRESSION, not of the `@`. The
|
|
80
|
+
span has to start at the `@`, or what is left is a line holding a lone `@`.
|
|
81
|
+
|
|
82
|
+
## Why `rfind`, and Why a Check After It
|
|
83
|
+
The nearest `@` in front of the expression is the decorator's own: between
|
|
84
|
+
them stand only blanks, opening brackets and, inside brackets, comments. A
|
|
85
|
+
comment may hold an `@` of its own (`# mail me@home`), so one more rule is
|
|
86
|
+
needed: the real `@` is the first thing on its line, hence an `@` with a `#`
|
|
87
|
+
in front of it on its line belongs to a comment and is skipped.
|
|
88
|
+
|
|
89
|
+
## Bracketed Decorators
|
|
90
|
+
`@(lambda f: f)` is valid since Python 3.9. The expression node starts after
|
|
91
|
+
the `(` and ends before the `)`, so a cut by the node alone would leave a
|
|
92
|
+
stray bracket. The brackets in front of the expression are counted and the
|
|
93
|
+
same number of closing ones is taken after it. The text parsed, so every
|
|
94
|
+
opening bracket has its closing one behind the expression; there is nothing
|
|
95
|
+
to check, only to find.
|
|
96
|
+
|
|
97
|
+
## Comments Inside the Brackets
|
|
98
|
+
Between the `@` and the expression, and between the expression and its `)`,
|
|
99
|
+
no string can stand, so a `#` there always starts a comment. Such a comment
|
|
100
|
+
is skipped when counting the brackets and when looking for the closing one
|
|
101
|
+
(`@(dec # note)` + line break + `)` has its real `)` on the next line), and
|
|
102
|
+
it stays inside the span.
|
|
103
|
+
|
|
104
|
+
## What Stays
|
|
105
|
+
A comment AFTER the decorator (`@dec # note`) is not part of it. Comments are
|
|
106
|
+
a step of their own, normally run first.
|
|
107
|
+
"""
|