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,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
+ """