micropython-stubber 1.23.1__py3-none-any.whl → 1.23.2__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 (153) hide show
  1. {micropython_stubber-1.23.1.dist-info → micropython_stubber-1.23.2.dist-info}/LICENSE +30 -30
  2. {micropython_stubber-1.23.1.dist-info → micropython_stubber-1.23.2.dist-info}/METADATA +32 -15
  3. micropython_stubber-1.23.2.dist-info/RECORD +158 -0
  4. micropython_stubber-1.23.2.dist-info/entry_points.txt +5 -0
  5. mpflash/README.md +220 -194
  6. mpflash/libusb_flash.ipynb +203 -203
  7. mpflash/mpflash/add_firmware.py +98 -98
  8. mpflash/mpflash/ask_input.py +236 -236
  9. mpflash/mpflash/basicgit.py +284 -284
  10. mpflash/mpflash/bootloader/__init__.py +2 -2
  11. mpflash/mpflash/bootloader/activate.py +60 -60
  12. mpflash/mpflash/bootloader/detect.py +82 -82
  13. mpflash/mpflash/bootloader/manual.py +101 -101
  14. mpflash/mpflash/bootloader/micropython.py +12 -12
  15. mpflash/mpflash/bootloader/touch1200.py +36 -36
  16. mpflash/mpflash/cli_download.py +129 -129
  17. mpflash/mpflash/cli_flash.py +224 -219
  18. mpflash/mpflash/cli_group.py +111 -111
  19. mpflash/mpflash/cli_list.py +87 -81
  20. mpflash/mpflash/cli_main.py +39 -39
  21. mpflash/mpflash/common.py +210 -165
  22. mpflash/mpflash/config.py +44 -44
  23. mpflash/mpflash/connected.py +96 -78
  24. mpflash/mpflash/download.py +364 -364
  25. mpflash/mpflash/downloaded.py +130 -130
  26. mpflash/mpflash/errors.py +9 -9
  27. mpflash/mpflash/flash/__init__.py +55 -55
  28. mpflash/mpflash/flash/esp.py +59 -59
  29. mpflash/mpflash/flash/stm32.py +19 -19
  30. mpflash/mpflash/flash/stm32_dfu.py +104 -104
  31. mpflash/mpflash/flash/uf2/__init__.py +88 -88
  32. mpflash/mpflash/flash/uf2/boardid.py +15 -15
  33. mpflash/mpflash/flash/uf2/linux.py +136 -130
  34. mpflash/mpflash/flash/uf2/macos.py +42 -42
  35. mpflash/mpflash/flash/uf2/uf2disk.py +12 -12
  36. mpflash/mpflash/flash/uf2/windows.py +43 -43
  37. mpflash/mpflash/flash/worklist.py +170 -170
  38. mpflash/mpflash/list.py +106 -99
  39. mpflash/mpflash/logger.py +41 -41
  40. mpflash/mpflash/mpboard_id/__init__.py +93 -93
  41. mpflash/mpflash/mpboard_id/add_boards.py +251 -251
  42. mpflash/mpflash/mpboard_id/board.py +37 -37
  43. mpflash/mpflash/mpboard_id/board_id.py +86 -86
  44. mpflash/mpflash/mpboard_id/store.py +43 -43
  45. mpflash/mpflash/mpremoteboard/__init__.py +266 -222
  46. mpflash/mpflash/mpremoteboard/mpy_fw_info.py +141 -141
  47. mpflash/mpflash/mpremoteboard/runner.py +140 -140
  48. mpflash/mpflash/vendor/click_aliases.py +91 -91
  49. mpflash/mpflash/vendor/dfu.py +165 -165
  50. mpflash/mpflash/vendor/pydfu.py +605 -605
  51. mpflash/mpflash/vendor/readme.md +2 -2
  52. mpflash/mpflash/versions.py +135 -135
  53. mpflash/poetry.lock +1599 -1599
  54. mpflash/pyproject.toml +65 -65
  55. mpflash/stm32_udev_rules.md +62 -62
  56. stubber/__init__.py +3 -3
  57. stubber/board/board_info.csv +193 -193
  58. stubber/board/boot.py +34 -34
  59. stubber/board/createstubs.py +1004 -986
  60. stubber/board/createstubs_db.py +826 -825
  61. stubber/board/createstubs_db_min.py +332 -331
  62. stubber/board/createstubs_db_mpy.mpy +0 -0
  63. stubber/board/createstubs_lvgl.py +741 -741
  64. stubber/board/createstubs_lvgl_min.py +741 -741
  65. stubber/board/createstubs_mem.py +767 -766
  66. stubber/board/createstubs_mem_min.py +307 -306
  67. stubber/board/createstubs_mem_mpy.mpy +0 -0
  68. stubber/board/createstubs_min.py +295 -294
  69. stubber/board/createstubs_mpy.mpy +0 -0
  70. stubber/board/fw_info.py +141 -141
  71. stubber/board/info.py +183 -183
  72. stubber/board/main.py +19 -19
  73. stubber/board/modulelist.txt +247 -247
  74. stubber/board/pyrightconfig.json +34 -34
  75. stubber/bulk/mcu_stubber.py +437 -454
  76. stubber/codemod/_partials/__init__.py +48 -48
  77. stubber/codemod/_partials/db_main.py +147 -147
  78. stubber/codemod/_partials/lvgl_main.py +77 -77
  79. stubber/codemod/_partials/modules_reader.py +80 -80
  80. stubber/codemod/add_comment.py +53 -53
  81. stubber/codemod/add_method.py +65 -65
  82. stubber/codemod/board.py +317 -317
  83. stubber/codemod/enrich.py +151 -145
  84. stubber/codemod/merge_docstub.py +284 -284
  85. stubber/codemod/modify_list.py +54 -54
  86. stubber/codemod/utils.py +56 -56
  87. stubber/commands/build_cmd.py +94 -94
  88. stubber/commands/cli.py +49 -55
  89. stubber/commands/clone_cmd.py +78 -78
  90. stubber/commands/config_cmd.py +29 -29
  91. stubber/commands/enrich_folder_cmd.py +71 -71
  92. stubber/commands/get_core_cmd.py +71 -71
  93. stubber/commands/get_docstubs_cmd.py +92 -89
  94. stubber/commands/get_frozen_cmd.py +117 -114
  95. stubber/commands/get_mcu_cmd.py +102 -61
  96. stubber/commands/merge_cmd.py +66 -66
  97. stubber/commands/publish_cmd.py +118 -118
  98. stubber/commands/stub_cmd.py +31 -31
  99. stubber/commands/switch_cmd.py +62 -62
  100. stubber/commands/variants_cmd.py +48 -48
  101. stubber/cst_transformer.py +178 -178
  102. stubber/data/board_info.csv +193 -193
  103. stubber/data/board_info.json +1729 -1729
  104. stubber/data/micropython_tags.csv +15 -15
  105. stubber/data/requirements-core-micropython.txt +38 -38
  106. stubber/data/requirements-core-pycopy.txt +39 -39
  107. stubber/downloader.py +37 -36
  108. stubber/freeze/common.py +72 -68
  109. stubber/freeze/freeze_folder.py +69 -69
  110. stubber/freeze/freeze_manifest_2.py +126 -113
  111. stubber/freeze/get_frozen.py +131 -127
  112. stubber/get_cpython.py +112 -101
  113. stubber/get_lobo.py +59 -59
  114. stubber/minify.py +423 -419
  115. stubber/publish/bump.py +86 -86
  116. stubber/publish/candidates.py +275 -256
  117. stubber/publish/database.py +18 -18
  118. stubber/publish/defaults.py +40 -40
  119. stubber/publish/enums.py +24 -24
  120. stubber/publish/helpers.py +29 -29
  121. stubber/publish/merge_docstubs.py +136 -130
  122. stubber/publish/missing_class_methods.py +51 -49
  123. stubber/publish/package.py +150 -146
  124. stubber/publish/pathnames.py +51 -51
  125. stubber/publish/publish.py +120 -120
  126. stubber/publish/pypi.py +42 -38
  127. stubber/publish/stubpackage.py +1055 -1027
  128. stubber/rst/__init__.py +9 -9
  129. stubber/rst/classsort.py +78 -77
  130. stubber/rst/lookup.py +533 -530
  131. stubber/rst/output_dict.py +401 -401
  132. stubber/rst/reader.py +814 -814
  133. stubber/rst/report_return.py +77 -69
  134. stubber/rst/rst_utils.py +541 -540
  135. stubber/stubber.py +38 -38
  136. stubber/stubs_from_docs.py +90 -90
  137. stubber/tools/manifestfile.py +654 -654
  138. stubber/tools/readme.md +6 -6
  139. stubber/update_fallback.py +117 -117
  140. stubber/update_module_list.py +123 -123
  141. stubber/utils/__init__.py +6 -6
  142. stubber/utils/config.py +137 -125
  143. stubber/utils/makeversionhdr.py +54 -54
  144. stubber/utils/manifest.py +90 -90
  145. stubber/utils/post.py +80 -79
  146. stubber/utils/repos.py +156 -150
  147. stubber/utils/stubmaker.py +139 -139
  148. stubber/utils/typed_config_toml.py +80 -77
  149. stubber/variants.py +106 -106
  150. micropython_stubber-1.23.1.dist-info/RECORD +0 -159
  151. micropython_stubber-1.23.1.dist-info/entry_points.txt +0 -3
  152. mpflash/basicgit.py +0 -288
  153. {micropython_stubber-1.23.1.dist-info → micropython_stubber-1.23.2.dist-info}/WHEEL +0 -0
stubber/rst/reader.py CHANGED
@@ -1,814 +1,814 @@
1
- """
2
- Read the Micropython library documentation files and use them to build stubs that can be used for static typechecking
3
- using a custom-built parser to read and process the micropython RST files
4
- - generates:
5
- - modules
6
- - docstrings
7
- - function definitions
8
- - function parameters based on documentation
9
- - docstrings
10
- - classes
11
- - docstrings
12
- - __init__ method
13
- - parameters based on documentation for class
14
- - methods
15
- - parameters based on documentation for the method
16
- - docstrings
17
-
18
- - exceptions
19
-
20
- - Tries to determine the return type by parsing the docstring.
21
- - Imperative verbs used in docstrings have a strong correlation to return -> None
22
- - recognizes documented Generators, Iterators, Callable
23
- - Coroutines are identified based tag "This is a Coroutine". Then if the return type was Foo, it will be transformed to : Coroutine[Foo]
24
- - a static Lookup list is used for a few methods/functions for which the return type cannot be determined from the docstring.
25
- - add NoReturn to a few functions that never return ( stop / deepsleep / reset )
26
- - if no type can be detected the type `Any` or `Incomplete` is used
27
-
28
- The generated stub files are formatted using `black` and checked for validity using `pyright`
29
- Note: black on python 3.7 does not like some function defs
30
- `def sizeof(struct, layout_type=NATIVE, /) -> int:`
31
-
32
- - ordering of inter-dependent classes in the same module
33
-
34
- - Literals / constants
35
- - documentation contains repeated vars with the same indentation
36
- - Module level:
37
- .. code-block::
38
-
39
- .. data:: IPPROTO_UDP
40
- IPPROTO_TCP
41
-
42
- - class level:
43
- .. code-block::
44
-
45
- .. data:: Pin.IRQ_FALLING
46
- Pin.IRQ_RISING
47
- Pin.IRQ_LOW_LEVEL
48
- Pin.IRQ_HIGH_LEVEL
49
-
50
- Selects the IRQ trigger type.
51
-
52
- - literals documented using a wildcard are added as comments only
53
-
54
- - Add GLUE imports to allow specific modules to import specific others.
55
-
56
- - Repeats of definitions in the rst file for similar functions or literals
57
- - CONSTANTS ( module and Class level )
58
- - functions
59
- - methods
60
-
61
- - Child/ Parent classes
62
- are added based on a (manual) lookup table CHILD_PARENT_CLASS
63
-
64
- """
65
-
66
- import re
67
- from pathlib import Path
68
- from typing import List, Optional, Tuple
69
-
70
- from loguru import logger as log
71
-
72
- from mpflash.versions import V_PREVIEW
73
- from stubber.rst import (
74
- CHILD_PARENT_CLASS,
75
- MODULE_GLUE,
76
- PARAM_FIXES,
77
- RST_DOC_FIXES,
78
- TYPING_IMPORT,
79
- ClassSourceDict,
80
- FunctionSourceDict,
81
- ModuleSourceDict,
82
- return_type_from_context,
83
- )
84
- from stubber.rst.lookup import Fix
85
- from stubber.utils.config import CONFIG
86
-
87
- SEPERATOR = "::"
88
-
89
-
90
- class FileReadWriter:
91
- """base class for reading rst files"""
92
-
93
- def __init__(self):
94
- self.filename = ""
95
- # input buffer
96
- self.rst_text: List[str] = []
97
- self.max_line = 0
98
- self.line_no: int = 0 # current Linenumber used during parsing.
99
- self.last_line = ""
100
-
101
- # Output buffer
102
- self.output: List[str] = []
103
-
104
- def read_file(self, filename: Path):
105
- log.trace(f"Reading : {filename}")
106
- # ignore Unicode decoding issues
107
- with open(filename, errors="ignore", encoding="utf8") as file:
108
- self.rst_text = file.readlines()
109
- # Replace incorrect definitions in .rst files with better ones
110
- for FIX in RST_DOC_FIXES:
111
- self.rst_text = [line.replace(FIX[0], FIX[1]) for line in self.rst_text]
112
- # some lines now may have \n sin them , so re-join and re-split the lines
113
- self.rst_text = "".join(self.rst_text).splitlines(keepends=True)
114
-
115
- self.filename = filename.as_posix() # use fwd slashes in origin
116
- self.max_line = len(self.rst_text) - 1
117
-
118
- def write_file(self, filename: Path) -> bool:
119
- try:
120
- log.info(f" - Writing to: {filename}")
121
- with open(filename, mode="w", encoding="utf8") as file:
122
- file.writelines(self.output)
123
- except OSError as e:
124
- log.error(e)
125
- return False
126
- return True
127
-
128
- @property
129
- def line(self) -> str:
130
- "get the current line from input, also stores this as last_line to allow for inspection and dumping the json file"
131
- if self.line_no >= 0 and self.line_no <= self.max_line:
132
- self.last_line = self.rst_text[self.line_no]
133
- else:
134
- self.last_line = ""
135
- return self.last_line
136
-
137
- @staticmethod
138
- def is_balanced(s: str) -> bool:
139
- """
140
- Check if a string has balanced parentheses
141
- """
142
- return False if s.count("(") != s.count(")") else s.count("{") == s.count("}")
143
-
144
- def extend_and_balance_line(self) -> str:
145
- """
146
- Append the current line + next line in order to try to balance the parentheses
147
- in order to do this the rst_test array is changed by the function
148
- and max_line is adjusted
149
- """
150
- append = 0
151
- newline = self.rst_text[self.line_no]
152
- while not self.is_balanced(newline) and self.line_no >= 0 and (self.line_no + append + 1) <= self.max_line:
153
- append += 1
154
- # concat the lines
155
- newline += self.rst_text[self.line_no + append]
156
- # only update line if things balanced out correctly
157
- if self.is_balanced(newline):
158
- self.rst_text[self.line_no] = newline
159
- for _ in range(append):
160
- self.rst_text.pop(self.line_no + 1)
161
- self.max_line -= 1
162
- # reprocess line
163
- return self.line
164
-
165
-
166
- class RSTReader(FileReadWriter):
167
- docstring_anchors = [
168
- ".. note::",
169
- ".. data:: Arguments:",
170
- ".. data:: Options:",
171
- ".. data:: Returns:",
172
- ".. data:: Raises:",
173
- ".. admonition::",
174
- ]
175
- # considered part of the docstrings
176
-
177
- def __init__(self):
178
- self.current_module = ""
179
- self.current_class = ""
180
- self.current_function = "" # function & method
181
- super().__init__()
182
-
183
- def read_file(self, filename: Path):
184
- super().read_file(filename)
185
- self.current_module = filename.stem # just to be sure
186
-
187
- @property
188
- def module_names(self) -> List[str]:
189
- "list of possible module names [uname , name] (longest first)"
190
- namelist: List[str] = []
191
- if self.current_module == "":
192
- return namelist
193
- # deal with module names "esp and esp.socket"
194
- if "." in self.current_module:
195
- names = [self.current_module, self.current_module.split(".")[0]]
196
- else:
197
- names = [self.current_module]
198
- # process
199
- for c_mod in names:
200
- if self.current_module[0] != "u":
201
- namelist += [f"u{c_mod}", c_mod]
202
- else:
203
- namelist += [c_mod, c_mod[1:]]
204
- return namelist
205
-
206
- @property
207
- def at_anchor(self) -> bool:
208
- "Stop at anchor '..something' ( however .. note: and ..data:: should be added)"
209
- line = self.rst_text[self.line_no].lstrip()
210
- # anchors that are considered part of the docstring
211
- # Check if the line starts with '..' but not any of the docstring_anchors.
212
- if line.startswith(".."):
213
- return not any(line.startswith(anchor) for anchor in self.docstring_anchors)
214
- return False
215
-
216
- # return _l.startswith("..") and not any(_l.startswith(a) for a in self.docstring_anchors)
217
-
218
- # @property
219
- def at_heading(self, large=False) -> bool:
220
- "stop at heading"
221
- u_line = self.rst_text[min(self.line_no + 1, self.max_line - 1)].rstrip()
222
- # Heading ---, ==, ~~~
223
- underlined = u_line.startswith("---") or u_line.startswith("===") or u_line.startswith("~~~")
224
- if underlined and self.line_no > 0:
225
- # check if previous line is a heading
226
- line = self.rst_text[self.line_no].strip()
227
- if line:
228
- # module docstrings can be a bit larger than normal
229
- if not large and len(line) == len(u_line):
230
- # heading is same length as underlined
231
- # for most docstrings that is a sensible boundary
232
- return True
233
- line = line.split()[0]
234
- # stopwords in headings
235
- return line.lower() in [
236
- "classes",
237
- "functions",
238
- "methods",
239
- "constants",
240
- "exceptions",
241
- "constructors",
242
- "class",
243
- "common",
244
- "general",
245
- # below are tuning based on module level docstrings
246
- "time",
247
- "pio",
248
- "memory",
249
- ]
250
- return False
251
-
252
- def read_docstring(self, large: bool = False) -> List[str]:
253
- """Read a textblock that will be used as a docstring, or used to process a toc tree
254
- The textblock is terminated at the following RST line structures/tags
255
- .. <anchor>
256
- -- Heading
257
- == Heading
258
- ~~ Heading
259
-
260
- The blank lines at the start and end are removed to limit the space the docstring takes up.
261
- """
262
- if self.line_no >= len(self.rst_text):
263
- raise IndexError
264
-
265
- block: List[str] = []
266
- self.line_no += 1 # advance over current line
267
- try:
268
- while (
269
- self.line_no < len(self.rst_text)
270
- and not self.at_anchor # stop at next anchor ( however .. note: and a few other anchors should be added)
271
- and not self.at_heading(large) # stop at next heading
272
- ):
273
- line = self.rst_text[self.line_no]
274
- block.append(line.rstrip())
275
- self.line_no += 1 # advance line
276
- except IndexError:
277
- pass
278
-
279
- # remove empty lines at beginning/end of block
280
- block = self.clean_docstr(block)
281
- # add clickable hyperlinks to CPython docpages
282
- block = self.add_link_to_docsstr(block)
283
- # make sure the first char of the first line is a capital
284
- if len(block) > 0 and len(block[0]) > 0:
285
- block[0] = block[0][0].upper() + block[0][1:]
286
- return block
287
-
288
- @staticmethod
289
- def clean_docstr(block: List[str]):
290
- """Clean up a docstring"""
291
- # if a Quoted Literal Block , then remove the first character of each line
292
- # https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#quoted-literal-blocks
293
- if block and len(block[0]) > 0 and block[0][0] != " ":
294
- q_char = block[0][0]
295
- if all(l.startswith(q_char) for l in block):
296
- # all lines start with the same character, so skip that character
297
- block = [l[1:] for l in block]
298
- # rstrip all lines
299
- block = [l.rstrip() for l in block]
300
- # remove empty lines at beginning/end of block
301
- while len(block) and len(block[0]) == 0:
302
- block = block[1:]
303
- while len(block) and len(block[-1]) == 0:
304
- block = block[:-1]
305
-
306
- # Clean up Synopsis
307
- if len(block) and ":synopsis:" in block[0]:
308
- block[0] = re.sub(
309
- r"\s+:synopsis:\s+(?P<syn>[\w|\s]*)",
310
- r"\g<syn>",
311
- block[0],
312
- )
313
- return block
314
-
315
- @staticmethod
316
- def add_link_to_docsstr(block: List[str]):
317
- """Add clickable hyperlinks to CPython docpages"""
318
- for i in range(len(block)):
319
- # hyperlink to Cpython doc pages
320
- # https://regex101.com/r/5RN8rj/1
321
- # Link to python 3 documentation
322
- _l = re.sub(
323
- r"(\s*\|see_cpython_module\|\s+:mod:`python:(?P<mod>[\w|\s]*)`)[.]?",
324
- r"\g<1> https://docs.python.org/3/library/\g<mod>.html .",
325
- block[i],
326
- )
327
- # RST hyperlink format is not clickable in VSCode so convert to markdown format
328
- # https://regex101.com/r/5RN8rj/1
329
- _l = re.sub(
330
- r"(.*)(?P<url><https://docs\.python\.org/.*>)(`_)",
331
- r"\g<1>`\g<url>",
332
- _l,
333
- )
334
- # Clean up note and other docstring anchors
335
- _l = _l.replace(".. note:: ", "``Note:`` ")
336
- _l = _l.replace(".. data:: ", "")
337
- _l = _l.replace(".. admonition:: ", "")
338
- _l = _l.replace("|see_cpython_module|", "CPython module:")
339
- # clean up unsupported escape sequences in rst
340
- _l = _l.replace(r"\ ", " ")
341
- _l = _l.replace(r"\*", "*")
342
- block[i] = _l
343
- return block
344
-
345
- def get_rst_hint(self):
346
- "parse the '.. <rst hint>:: ' from the current line"
347
- m = re.search(r"\.\.\s?(\w+)\s?::\s?", self.line)
348
- return m[1] if m else ""
349
-
350
- def strip_prefixes(self, name: str, strip_mod: bool = True, strip_class: bool = False):
351
- "Remove the modulename. and or the classname. from the begining of a name"
352
- prefixes = self.module_names if strip_mod else []
353
- if strip_class and self.current_class != "":
354
- prefixes += [self.current_class]
355
- for prefix in prefixes:
356
- if len(prefix) > 1 and prefix + "." in name:
357
- name = name.replace(prefix + ".", "")
358
- return name
359
-
360
-
361
- class RSTParser(RSTReader):
362
- """
363
- Parse the RST file and create a ModuleSourceDict
364
- most methods have side effects
365
- """
366
-
367
- target = ".py" # py/pyi
368
- # TODO: Move to lookup.py
369
- PARAM_RE_FIXES = [
370
- Fix(r"\[angle, time=0\]", "[angle], time=0", is_re=True), # fix: method:: Servo.angle([angle, time=0])
371
- Fix(r"\[speed, time=0\]", "[speed], time=0", is_re=True), # fix: .. method:: Servo.speed([speed, time=0])
372
- Fix(r"\[service_id, key=None, \*, \.\.\.\]", "[service_id], [key], *, ...", is_re=True), # fix: network - AbstractNIC.connect
373
- ]
374
-
375
- def __init__(self, v_tag: str) -> None:
376
- super().__init__()
377
- self.output_dict: ModuleSourceDict = ModuleSourceDict("")
378
- self.output_dict.add_import(TYPING_IMPORT)
379
- self.return_info: List[Tuple] = [] # development aid only
380
- self.source_tag = v_tag
381
- self.source_release = v_tag
382
-
383
- def leave_class(self):
384
- if self.current_class != "":
385
- self.current_class = ""
386
-
387
- def fix_parameters(self, params: str, name: str = "") -> str:
388
- """Patch / correct the documentation parameter notation to a supported format that works for linting.
389
- - params is the string containing the parameters as documented in the rst file
390
- - name is the name of the function or method or Class
391
- """
392
- params = params.strip()
393
- if not params.endswith(")"):
394
- # remove all after the closing bracket
395
- params = params[: params.rfind(")") + 1]
396
-
397
- # Remove modulename. and Classname. from class constant
398
- params = self.strip_prefixes(params, strip_mod=True, strip_class=True)
399
-
400
- ## Deal with SQUARE brackets first ( Documentation meaning := [optional])
401
-
402
- for fix in self.PARAM_RE_FIXES:
403
- params = self.apply_fix(fix, params, name)
404
-
405
- # ###########################################################################################################
406
- # does not look cool, but works really well
407
- # change [x] --> x:Optional[Any]
408
- params = params.replace("[", "")
409
- params = params.replace("]]", "") # Q&D Hack-complex nesting
410
-
411
- # Handle Optional arguments
412
- # Optional step 1: [x] --> x: Optional[Any]=None
413
- params = params.replace("]", ": Optional[Any]=None")
414
- # Optional step 2: x: Optional[Any]=None='abc' --> x: Optional[Any]='abc'
415
- params = re.sub(r": Optional\[Any\]=None\s*=", r": Optional[Any]=", params)
416
- # Optional step 3: fix ...
417
- params = re.sub(r"\.\.\.: Optional\[Any\]=None", r"...", params)
418
- # ###########################################################################################################
419
-
420
- for fix in PARAM_FIXES:
421
- if fix.module == self.current_module or not fix.module:
422
- params = self.apply_fix(fix, params, name)
423
-
424
- # # formatting
425
- # # fixme: ... not allowed in .py
426
- if self.target == ".py":
427
- params = params.replace("*, ...", "*args, **kwargs")
428
- params = params.replace("...", "*args, **kwargs")
429
-
430
- return params.strip()
431
-
432
- @staticmethod
433
- def apply_fix(fix: Fix, params: str, name: str = ""):
434
- if fix.name and fix.name != name:
435
- return params
436
- return re.sub(fix.from_, fix.to, params) if fix.is_re else params.replace(fix.from_, fix.to)
437
-
438
- def create_update_class(self, name: str, params: str, docstr: List[str]):
439
- # a bit of a hack: assume no classes in classes or functions in function
440
- self.leave_class()
441
- if full_name := self.output_dict.find(f"class {name}"):
442
- log.warning(f"TODO: UPDATE EXISTING CLASS : {name}")
443
- class_def = self.output_dict[full_name]
444
- else:
445
- parent = CHILD_PARENT_CLASS[name] if name in CHILD_PARENT_CLASS.keys() else ""
446
- if parent == "" and (name.endswith("Error") or name.endswith("Exception")):
447
- parent = "Exception"
448
- class_def = ClassSourceDict(
449
- f"class {name}({parent}):",
450
- docstr=docstr,
451
- )
452
- if params != "":
453
- method = FunctionSourceDict(
454
- name="__init__",
455
- indent=class_def.indent + 4,
456
- definition=[f"def __init__(self, {params} -> None:"],
457
- docstr=[], # todo: check if twice is needed
458
- )
459
- class_def += method
460
- # Append class to output
461
- self.output_dict += class_def
462
- self.current_class = name
463
-
464
- def parse_toc(self):
465
- "process table of content with additional rst files, and add / include them in the current module"
466
- log.trace(f"# {self.line.rstrip()}")
467
- self.line_no += 1 # skip one line
468
- toctree = self.read_docstring()
469
- # cleanup toctree
470
- toctree = [x.strip() for x in toctree if f"{self.current_module}." in x]
471
- # Now parse all files mentioned in the toc
472
- for file in toctree:
473
- #
474
- file_path = CONFIG.mpy_path / "docs" / "library" / file.strip()
475
- self.read_file(file_path)
476
- self.parse()
477
- # reset this file to done
478
- self.rst_text = []
479
- self.line_no = 1
480
-
481
- def parse_module(self):
482
- "parse a module tag and set the module's docstring"
483
- log.trace(f"# {self.line.rstrip()}")
484
- module_name = self.line.split(SEPERATOR)[-1].strip()
485
-
486
- self.current_module = module_name
487
- self.current_function = self.current_class = ""
488
- # get module docstring
489
- docstr = self.read_docstring(large=True)
490
-
491
- if len(docstr) > 0:
492
- # Add link to online documentation
493
- # https://docs.micropython.org/en/v1.17/library/array.html
494
- if "nightly" in self.source_tag:
495
- version = V_PREVIEW
496
- else:
497
- version = self.source_tag.replace("_", ".") # TODO Use clean_version(self.source_tag)
498
- docstr[0] = f"{docstr[0]}.\n\nMicroPython module: https://docs.micropython.org/en/{version}/library/{module_name}.html"
499
-
500
- self.output_dict.name = module_name
501
- self.output_dict.add_comment(f"# source version: {self.source_tag}")
502
- self.output_dict.add_comment(f"# origin module:: {self.filename}")
503
- self.output_dict.add_docstr(docstr)
504
- # Add additional imports to allow one module te refer to another
505
- if module_name in MODULE_GLUE.keys():
506
- self.output_dict.add_import(MODULE_GLUE[module_name])
507
-
508
- def parse_current_module(self):
509
- log.trace(f"# {self.line.rstrip()}")
510
- module_name = self.line.split(SEPERATOR)[-1].strip()
511
- mod_comment = f"# + module: {self.current_module}.rst"
512
- self.current_module = module_name
513
- self.current_function = self.current_class = ""
514
- log.debug(mod_comment)
515
- self.output_dict.name = module_name
516
- self.output_dict.add_comment(mod_comment)
517
- self.line_no += 1 # advance as we did not read any docstring
518
-
519
- def parse_function(self):
520
- log.trace(f"# {self.line.rstrip()}")
521
- # this_function = self.line.split(SEPERATOR)[-1].strip()
522
- # name = this_function
523
-
524
- # Get one or more names
525
- function_names = self.parse_names(oneliner=False)
526
- docstr = self.read_docstring()
527
-
528
- for this_function in function_names:
529
- # Parse return type from docstring
530
- ret_type = return_type_from_context(docstring=docstr, signature=this_function, module=self.current_module)
531
-
532
- # defaults
533
- name = params = ""
534
- try:
535
- name, params = this_function.split("(", maxsplit=1)
536
- except ValueError:
537
- log.error(this_function)
538
- self.current_function = name
539
- if name in ("classmethod", "staticmethod"):
540
- # skip the classmethod and static method functions
541
- # no use to create stubs for these
542
- return
543
-
544
- # ussl documentation uses a ssl.foobar prefix
545
- for mod in self.module_names:
546
- if name.startswith(f"{mod}."):
547
- # remove module name from the start of the function name
548
- name = name[len(f"{mod}.") :]
549
- # fixup parameters
550
- params = self.fix_parameters(params, name)
551
- # ASSUME no functions in classes,
552
- # so with ther cursor at a function this probably means that we are no longer in a class
553
- self.leave_class()
554
-
555
- fn_def = FunctionSourceDict(
556
- name=f"def {name}",
557
- definition=[f"def {name}({params} -> {ret_type}:"],
558
- docstr=docstr,
559
- )
560
- self.output_dict += fn_def
561
-
562
- def parse_class(self):
563
- log.trace(f"# {self.line.rstrip()}")
564
- this_class = self.line.split(SEPERATOR)[-1].strip() # raw
565
- if "(" in this_class:
566
- name, params = this_class.split("(", 2)
567
- else:
568
- name = this_class
569
- params = ""
570
- name = self.strip_prefixes(name)
571
- self.current_class = name
572
- self.current_function = ""
573
-
574
- log.trace(f"# class:: {name} - {this_class}")
575
- # fixup parameters
576
- params = self.fix_parameters(params, f"{name}.__init__")
577
- docstr = self.read_docstring()
578
-
579
- if any(":noindex:" in line for line in docstr):
580
- # if the class docstring contains ':noindex:' on any line then skip
581
- log.trace(f"# Skip :noindex: class {name}")
582
- else:
583
- # write a class header
584
- self.create_update_class(name, params, docstr)
585
-
586
- def parse_method(self):
587
- name = ""
588
- this_method = ""
589
- ## py:staticmethod - py:classmethod - py:decorator
590
- # ref: https://sphinx-tutorial.readthedocs.io/cheatsheet/
591
- log.trace(f"# {self.line.rstrip()}")
592
- if not self.is_balanced(self.line):
593
- self.extend_and_balance_line()
594
-
595
- ## rst_hint is used to access the method decorator ( method, staticmethod, staticmethod ... )
596
- rst_hint = self.get_rst_hint()
597
-
598
- method_names = self.parse_names(oneliner=False)
599
- # filter out overloads with 'param=value' description as these can't be output (currently)
600
- method_names = [m for m in method_names if "param=value" not in m]
601
-
602
- docstr = self.read_docstring()
603
- for this_method in method_names:
604
- try:
605
- name, params = this_method.split("(", 1) # split methodname from params
606
- except ValueError:
607
- name = this_method
608
- params = ")"
609
- is_async = "async" in name
610
- self.current_function = name
611
- # self.writeln(f"# method:: {name}")
612
- if "." in name:
613
- # todo deal with longer / deeper classes
614
- class_name = name.split(".")[0]
615
- # ESPnow.rst has a few methods that are written as `async AIOESPNow.__anext__()`
616
- if is_async:
617
- class_name = class_name.replace("async ", "").strip()
618
- # update current for out-of sequence method processing
619
- self.current_class = class_name
620
- else:
621
- # if nothing specified lets assume part of current class
622
- class_name = self.current_class
623
- name = name.split(".")[-1] # Take only the last part from Pin.toggle
624
-
625
- if full_name := self.output_dict.find(f"class {class_name}"):
626
- parent_class = self.output_dict[full_name]
627
- else:
628
- # not found, create and add new class to the output dict
629
- parent_class = ClassSourceDict(f"class {class_name}():")
630
- self.output_dict += parent_class
631
-
632
- # fixup optional [] parameters and other notations
633
- params = self.fix_parameters(params, f"{class_name}.{name}")
634
-
635
- # parse return type from docstring
636
- ret_type = return_type_from_context(docstring=docstr, signature=f"{class_name}.{name}", module=self.current_module)
637
- # methods have 4 flavours
638
- # - __init__ (self, <params>) -> None:
639
- # - classmethod (cls, <params>) -> <ret_type>:
640
- # - staticmethod ( <params>) -> <ret_type>:
641
- # - all other methods (self, <params>) -> <ret_type>:
642
- if name == "__init__":
643
- # avoid params starting with `self ,`
644
- params = self.lstrip_self(params)
645
- method = FunctionSourceDict(
646
- name=f"def {name}",
647
- indent=parent_class.indent + 4,
648
- definition=[f"def __init__(self, {params} -> None:"],
649
- docstr=docstr,
650
- )
651
- elif rst_hint == "classmethod":
652
- method = FunctionSourceDict(
653
- decorators=["@classmethod"],
654
- name=f"def {name}",
655
- indent=parent_class.indent + 4,
656
- definition=[f"def {name}(cls, {params} -> {ret_type}:"],
657
- docstr=docstr,
658
- is_async=is_async,
659
- )
660
- elif rst_hint == "staticmethod":
661
- method = FunctionSourceDict(
662
- decorators=["@staticmethod"],
663
- name=f"def {name}",
664
- indent=parent_class.indent + 4,
665
- definition=[f"def {name}({params} -> {ret_type}:"],
666
- docstr=docstr,
667
- is_async=is_async,
668
- )
669
- else: # just plain method
670
- # avoid params starting with `self ,`
671
- params = self.lstrip_self(params)
672
- method = FunctionSourceDict(
673
- name=f"def {name}",
674
- indent=parent_class.indent + 4,
675
- definition=[f"def {name}(self, {params} -> {ret_type}:"],
676
- docstr=docstr,
677
- is_async=is_async,
678
- )
679
-
680
- parent_class += method
681
-
682
- def lstrip_self(self, params):
683
- """
684
- To avoid duplicate selfs,
685
- Remove `self,` from the start of the parameters
686
- """
687
- if params.startswith("self,"):
688
- params = params[6:]
689
- elif params.startswith("self ,"):
690
- params = params[7:]
691
- return params
692
-
693
- def parse_exception(self):
694
- log.trace(f"# {self.line.rstrip()}")
695
- name = self.line.split(SEPERATOR)[1].strip()
696
- if name == "Exception":
697
- # no need to redefine Exception
698
- self.line_no += 1
699
- return
700
- # Take only the last part from module.ExceptionX
701
- if "." in name:
702
- name = name.split(".")[-1]
703
- except_1 = ClassSourceDict(name=f"class {name}(Exception) : ...", docstr=[], init="")
704
- self.output_dict += except_1
705
- # no docstream read (yet) , so need to advance to next line
706
- self.line_no += 1
707
-
708
- def parse_name(self, line: Optional[str] = None):
709
- "get the constant/function/class name from a line with an identifier"
710
- # '.. data:: espnow.MAX_DATA_LEN(=250)\n'
711
- if line:
712
- return line.split(SEPERATOR)[-1].strip()
713
- else:
714
- return self.line.split(SEPERATOR)[-1].strip()
715
-
716
- def parse_names(self, oneliner: bool = True):
717
- """get a list of constant/function/class names from and following a line with an identifier
718
- advances the linecounter
719
-
720
- oneliner : treat a line with commas as multiple names (used for constants)
721
- """
722
- names: List[str] = []
723
- names += self.parse_name().split(",") if oneliner else [self.parse_name()]
724
- m = re.search(r"..\s?\w+\s?::\s?", self.line)
725
- if not m: # pragma: no cover
726
- raise KeyError
727
- col = m.end()
728
- counter = 1
729
- while (
730
- self.line_no + counter <= self.max_line
731
- and self.rst_text[self.line_no + counter].startswith(" " * col)
732
- and not self.rst_text[self.line_no + counter][col + 1].isspace()
733
- ):
734
- log.trace("Sequence detected")
735
- names.append(self.parse_name(self.rst_text[self.line_no + counter]))
736
- counter += 1
737
- # now advance the linecounter
738
- self.line_no += counter - 1
739
- # clean up before returning
740
- names = [n.strip() for n in names if n.strip() != "etc."] # remove etc.
741
- return names
742
-
743
- def parse_data(self):
744
- """process ..data:: lines ( one or more)
745
- Note: some data islands are included in the docstring of the module/class/function as the ESPNow documentation started to use this pattern.
746
- """
747
- log.trace(f"# {self.line.rstrip()}")
748
- # Get one or more names
749
- names = self.parse_names()
750
-
751
- # get module docstring
752
- docstr = self.read_docstring()
753
-
754
- # deal with documentation wildcards
755
- for name in names:
756
- r_type = return_type_from_context(docstring=docstr, signature=name, module=self.current_module, literal=True)
757
- if r_type in ["None"]: # None does not make sense
758
- r_type = "Incomplete" # Default to Incomplete/ Unknown / int
759
- name = self.strip_prefixes(name)
760
- self.output_dict.add_constant_smart(name=name, type=r_type, docstr=docstr)
761
-
762
- def parse(self):
763
- self.line_no = 0
764
- while self.line_no < len(self.rst_text):
765
- line = self.line
766
- rst_hint = self.get_rst_hint()
767
- # self.writeln(">"+line)
768
- if rst_hint == "module":
769
- self.parse_module()
770
- elif rst_hint == "currentmodule":
771
- self.parse_current_module()
772
- elif rst_hint == "function":
773
- self.parse_function()
774
- elif rst_hint == "class":
775
- self.parse_class()
776
- elif rst_hint in ["method", "staticmethod", "classmethod"]:
777
- self.parse_method()
778
- elif rst_hint == "exception":
779
- self.parse_exception()
780
- elif rst_hint == "data":
781
- self.parse_data()
782
- elif rst_hint == "toctree":
783
- self.parse_toc()
784
- # Note: this will end the processing of this file.
785
- elif len(rst_hint) > 0:
786
- # something new / not yet parsed/understood
787
- self.line_no += 1
788
- log.trace(f"# {line.rstrip()}")
789
- else:
790
- # NOTHING TO SEE HERE , MOVE ON
791
- self.line_no += 1
792
-
793
-
794
- #################################################################################################################
795
- class RSTWriter(RSTParser):
796
- """
797
- Reads, parses and writes
798
- """
799
-
800
- def __init__(self, v_tag="v1.xx"):
801
- super().__init__(v_tag=v_tag)
802
-
803
- def write_file(self, filename: Path) -> bool:
804
- self.prepare_output()
805
- return super().write_file(filename)
806
-
807
- def prepare_output(self):
808
- "Remove trailing spaces and commas from the output."
809
- lines = str(self.output_dict).splitlines(keepends=True)
810
- self.output = lines
811
- for i in range(len(self.output)):
812
- for name in ("self", "cls"):
813
- if f"({name}, ) ->" in self.output[i]:
814
- self.output[i] = self.output[i].replace(f"({name}, ) ->", f"({name}) ->")
1
+ """
2
+ Read the Micropython library documentation files and use them to build stubs that can be used for static typechecking
3
+ using a custom-built parser to read and process the micropython RST files
4
+ - generates:
5
+ - modules
6
+ - docstrings
7
+ - function definitions
8
+ - function parameters based on documentation
9
+ - docstrings
10
+ - classes
11
+ - docstrings
12
+ - __init__ method
13
+ - parameters based on documentation for class
14
+ - methods
15
+ - parameters based on documentation for the method
16
+ - docstrings
17
+
18
+ - exceptions
19
+
20
+ - Tries to determine the return type by parsing the docstring.
21
+ - Imperative verbs used in docstrings have a strong correlation to return -> None
22
+ - recognizes documented Generators, Iterators, Callable
23
+ - Coroutines are identified based tag "This is a Coroutine". Then if the return type was Foo, it will be transformed to : Coroutine[Foo]
24
+ - a static Lookup list is used for a few methods/functions for which the return type cannot be determined from the docstring.
25
+ - add NoReturn to a few functions that never return ( stop / deepsleep / reset )
26
+ - if no type can be detected the type `Any` or `Incomplete` is used
27
+
28
+ The generated stub files are formatted using `black` and checked for validity using `pyright`
29
+ Note: black on python 3.7 does not like some function defs
30
+ `def sizeof(struct, layout_type=NATIVE, /) -> int:`
31
+
32
+ - ordering of inter-dependent classes in the same module
33
+
34
+ - Literals / constants
35
+ - documentation contains repeated vars with the same indentation
36
+ - Module level:
37
+ .. code-block::
38
+
39
+ .. data:: IPPROTO_UDP
40
+ IPPROTO_TCP
41
+
42
+ - class level:
43
+ .. code-block::
44
+
45
+ .. data:: Pin.IRQ_FALLING
46
+ Pin.IRQ_RISING
47
+ Pin.IRQ_LOW_LEVEL
48
+ Pin.IRQ_HIGH_LEVEL
49
+
50
+ Selects the IRQ trigger type.
51
+
52
+ - literals documented using a wildcard are added as comments only
53
+
54
+ - Add GLUE imports to allow specific modules to import specific others.
55
+
56
+ - Repeats of definitions in the rst file for similar functions or literals
57
+ - CONSTANTS ( module and Class level )
58
+ - functions
59
+ - methods
60
+
61
+ - Child/ Parent classes
62
+ are added based on a (manual) lookup table CHILD_PARENT_CLASS
63
+
64
+ """
65
+
66
+ import re
67
+ from pathlib import Path
68
+ from typing import List, Optional, Tuple
69
+
70
+ from mpflash.logger import log
71
+ from mpflash.versions import V_PREVIEW
72
+ from stubber.rst import (
73
+ CHILD_PARENT_CLASS,
74
+ MODULE_GLUE,
75
+ PARAM_FIXES,
76
+ RST_DOC_FIXES,
77
+ TYPING_IMPORT,
78
+ ClassSourceDict,
79
+ FunctionSourceDict,
80
+ ModuleSourceDict,
81
+ return_type_from_context,
82
+ )
83
+ from stubber.rst.lookup import Fix
84
+ from stubber.utils.config import CONFIG
85
+
86
+ SEPERATOR = "::"
87
+
88
+
89
+ class FileReadWriter:
90
+ """base class for reading rst files"""
91
+
92
+ def __init__(self):
93
+ self.filename = ""
94
+ # input buffer
95
+ self.rst_text: List[str] = []
96
+ self.max_line = 0
97
+ self.line_no: int = 0 # current Linenumber used during parsing.
98
+ self.last_line = ""
99
+
100
+ # Output buffer
101
+ self.output: List[str] = []
102
+
103
+ def read_file(self, filename: Path):
104
+ log.trace(f"Reading : {filename}")
105
+ # ignore Unicode decoding issues
106
+ with open(filename, errors="ignore", encoding="utf8") as file:
107
+ self.rst_text = file.readlines()
108
+ # Replace incorrect definitions in .rst files with better ones
109
+ for FIX in RST_DOC_FIXES:
110
+ self.rst_text = [line.replace(FIX[0], FIX[1]) for line in self.rst_text]
111
+ # some lines now may have \n sin them , so re-join and re-split the lines
112
+ self.rst_text = "".join(self.rst_text).splitlines(keepends=True)
113
+
114
+ self.filename = filename.as_posix() # use fwd slashes in origin
115
+ self.max_line = len(self.rst_text) - 1
116
+
117
+ def write_file(self, filename: Path) -> bool:
118
+ try:
119
+ log.info(f" - Writing to: {filename}")
120
+ with open(filename, mode="w", encoding="utf8") as file:
121
+ file.writelines(self.output)
122
+ except OSError as e:
123
+ log.error(e)
124
+ return False
125
+ return True
126
+
127
+ @property
128
+ def line(self) -> str:
129
+ "get the current line from input, also stores this as last_line to allow for inspection and dumping the json file"
130
+ if self.line_no >= 0 and self.line_no <= self.max_line:
131
+ self.last_line = self.rst_text[self.line_no]
132
+ else:
133
+ self.last_line = ""
134
+ return self.last_line
135
+
136
+ @staticmethod
137
+ def is_balanced(s: str) -> bool:
138
+ """
139
+ Check if a string has balanced parentheses
140
+ """
141
+ return False if s.count("(") != s.count(")") else s.count("{") == s.count("}")
142
+
143
+ def extend_and_balance_line(self) -> str:
144
+ """
145
+ Append the current line + next line in order to try to balance the parentheses
146
+ in order to do this the rst_test array is changed by the function
147
+ and max_line is adjusted
148
+ """
149
+ append = 0
150
+ newline = self.rst_text[self.line_no]
151
+ while not self.is_balanced(newline) and self.line_no >= 0 and (self.line_no + append + 1) <= self.max_line:
152
+ append += 1
153
+ # concat the lines
154
+ newline += self.rst_text[self.line_no + append]
155
+ # only update line if things balanced out correctly
156
+ if self.is_balanced(newline):
157
+ self.rst_text[self.line_no] = newline
158
+ for _ in range(append):
159
+ self.rst_text.pop(self.line_no + 1)
160
+ self.max_line -= 1
161
+ # reprocess line
162
+ return self.line
163
+
164
+
165
+ class RSTReader(FileReadWriter):
166
+ docstring_anchors = [
167
+ ".. note::",
168
+ ".. data:: Arguments:",
169
+ ".. data:: Options:",
170
+ ".. data:: Returns:",
171
+ ".. data:: Raises:",
172
+ ".. admonition::",
173
+ ]
174
+ # considered part of the docstrings
175
+
176
+ def __init__(self):
177
+ self.current_module = ""
178
+ self.current_class = ""
179
+ self.current_function = "" # function & method
180
+ super().__init__()
181
+
182
+ def read_file(self, filename: Path):
183
+ super().read_file(filename)
184
+ self.current_module = filename.stem # just to be sure
185
+
186
+ @property
187
+ def module_names(self) -> List[str]:
188
+ "list of possible module names [uname , name] (longest first)"
189
+ namelist: List[str] = []
190
+ if self.current_module == "":
191
+ return namelist
192
+ # deal with module names "esp and esp.socket"
193
+ if "." in self.current_module:
194
+ names = [self.current_module, self.current_module.split(".")[0]]
195
+ else:
196
+ names = [self.current_module]
197
+ # process
198
+ for c_mod in names:
199
+ if self.current_module[0] != "u":
200
+ namelist += [f"u{c_mod}", c_mod]
201
+ else:
202
+ namelist += [c_mod, c_mod[1:]]
203
+ return namelist
204
+
205
+ @property
206
+ def at_anchor(self) -> bool:
207
+ "Stop at anchor '..something' ( however .. note: and ..data:: should be added)"
208
+ line = self.rst_text[self.line_no].lstrip()
209
+ # anchors that are considered part of the docstring
210
+ # Check if the line starts with '..' but not any of the docstring_anchors.
211
+ if line.startswith(".."):
212
+ return not any(line.startswith(anchor) for anchor in self.docstring_anchors)
213
+ return False
214
+
215
+ # return _l.startswith("..") and not any(_l.startswith(a) for a in self.docstring_anchors)
216
+
217
+ # @property
218
+ def at_heading(self, large=False) -> bool:
219
+ "stop at heading"
220
+ u_line = self.rst_text[min(self.line_no + 1, self.max_line - 1)].rstrip()
221
+ # Heading ---, ==, ~~~
222
+ underlined = u_line.startswith("---") or u_line.startswith("===") or u_line.startswith("~~~")
223
+ if underlined and self.line_no > 0:
224
+ # check if previous line is a heading
225
+ line = self.rst_text[self.line_no].strip()
226
+ if line:
227
+ # module docstrings can be a bit larger than normal
228
+ if not large and len(line) == len(u_line):
229
+ # heading is same length as underlined
230
+ # for most docstrings that is a sensible boundary
231
+ return True
232
+ line = line.split()[0]
233
+ # stopwords in headings
234
+ return line.lower() in [
235
+ "classes",
236
+ "functions",
237
+ "methods",
238
+ "constants",
239
+ "exceptions",
240
+ "constructors",
241
+ "class",
242
+ "common",
243
+ "general",
244
+ # below are tuning based on module level docstrings
245
+ "time",
246
+ "pio",
247
+ "memory",
248
+ ]
249
+ return False
250
+
251
+ def read_docstring(self, large: bool = False) -> List[str]:
252
+ """Read a textblock that will be used as a docstring, or used to process a toc tree
253
+ The textblock is terminated at the following RST line structures/tags
254
+ .. <anchor>
255
+ -- Heading
256
+ == Heading
257
+ ~~ Heading
258
+
259
+ The blank lines at the start and end are removed to limit the space the docstring takes up.
260
+ """
261
+ if self.line_no >= len(self.rst_text):
262
+ raise IndexError
263
+
264
+ block: List[str] = []
265
+ self.line_no += 1 # advance over current line
266
+ try:
267
+ while (
268
+ self.line_no < len(self.rst_text)
269
+ and not self.at_anchor # stop at next anchor ( however .. note: and a few other anchors should be added)
270
+ and not self.at_heading(large) # stop at next heading
271
+ ):
272
+ line = self.rst_text[self.line_no]
273
+ block.append(line.rstrip())
274
+ self.line_no += 1 # advance line
275
+ except IndexError:
276
+ pass
277
+
278
+ # remove empty lines at beginning/end of block
279
+ block = self.clean_docstr(block)
280
+ # add clickable hyperlinks to CPython docpages
281
+ block = self.add_link_to_docsstr(block)
282
+ # make sure the first char of the first line is a capital
283
+ if len(block) > 0 and len(block[0]) > 0:
284
+ block[0] = block[0][0].upper() + block[0][1:]
285
+ return block
286
+
287
+ @staticmethod
288
+ def clean_docstr(block: List[str]):
289
+ """Clean up a docstring"""
290
+ # if a Quoted Literal Block , then remove the first character of each line
291
+ # https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#quoted-literal-blocks
292
+ if block and len(block[0]) > 0 and block[0][0] != " ":
293
+ q_char = block[0][0]
294
+ if all(l.startswith(q_char) for l in block):
295
+ # all lines start with the same character, so skip that character
296
+ block = [l[1:] for l in block]
297
+ # rstrip all lines
298
+ block = [l.rstrip() for l in block]
299
+ # remove empty lines at beginning/end of block
300
+ while len(block) and len(block[0]) == 0:
301
+ block = block[1:]
302
+ while len(block) and len(block[-1]) == 0:
303
+ block = block[:-1]
304
+
305
+ # Clean up Synopsis
306
+ if len(block) and ":synopsis:" in block[0]:
307
+ block[0] = re.sub(
308
+ r"\s+:synopsis:\s+(?P<syn>[\w|\s]*)",
309
+ r"\g<syn>",
310
+ block[0],
311
+ )
312
+ return block
313
+
314
+ @staticmethod
315
+ def add_link_to_docsstr(block: List[str]):
316
+ """Add clickable hyperlinks to CPython docpages"""
317
+ for i in range(len(block)):
318
+ # hyperlink to Cpython doc pages
319
+ # https://regex101.com/r/5RN8rj/1
320
+ # Link to python 3 documentation
321
+ _l = re.sub(
322
+ r"(\s*\|see_cpython_module\|\s+:mod:`python:(?P<mod>[\w|\s]*)`)[.]?",
323
+ r"\g<1> https://docs.python.org/3/library/\g<mod>.html .",
324
+ block[i],
325
+ )
326
+ # RST hyperlink format is not clickable in VSCode so convert to markdown format
327
+ # https://regex101.com/r/5RN8rj/1
328
+ _l = re.sub(
329
+ r"(.*)(?P<url><https://docs\.python\.org/.*>)(`_)",
330
+ r"\g<1>`\g<url>",
331
+ _l,
332
+ )
333
+ # Clean up note and other docstring anchors
334
+ _l = _l.replace(".. note:: ", "``Note:`` ")
335
+ _l = _l.replace(".. data:: ", "")
336
+ _l = _l.replace(".. admonition:: ", "")
337
+ _l = _l.replace("|see_cpython_module|", "CPython module:")
338
+ # clean up unsupported escape sequences in rst
339
+ _l = _l.replace(r"\ ", " ")
340
+ _l = _l.replace(r"\*", "*")
341
+ block[i] = _l
342
+ return block
343
+
344
+ def get_rst_hint(self):
345
+ "parse the '.. <rst hint>:: ' from the current line"
346
+ m = re.search(r"\.\.\s?(\w+)\s?::\s?", self.line)
347
+ return m[1] if m else ""
348
+
349
+ def strip_prefixes(self, name: str, strip_mod: bool = True, strip_class: bool = False):
350
+ "Remove the modulename. and or the classname. from the begining of a name"
351
+ prefixes = self.module_names if strip_mod else []
352
+ if strip_class and self.current_class != "":
353
+ prefixes += [self.current_class]
354
+ for prefix in prefixes:
355
+ if len(prefix) > 1 and prefix + "." in name:
356
+ name = name.replace(prefix + ".", "")
357
+ return name
358
+
359
+
360
+ class RSTParser(RSTReader):
361
+ """
362
+ Parse the RST file and create a ModuleSourceDict
363
+ most methods have side effects
364
+ """
365
+
366
+ target = ".py" # py/pyi
367
+ # TODO: Move to lookup.py
368
+ PARAM_RE_FIXES = [
369
+ Fix(r"\[angle, time=0\]", "[angle], time=0", is_re=True), # fix: method:: Servo.angle([angle, time=0])
370
+ Fix(r"\[speed, time=0\]", "[speed], time=0", is_re=True), # fix: .. method:: Servo.speed([speed, time=0])
371
+ Fix(r"\[service_id, key=None, \*, \.\.\.\]", "[service_id], [key], *, ...", is_re=True), # fix: network - AbstractNIC.connect
372
+ ]
373
+
374
+ def __init__(self, v_tag: str) -> None:
375
+ super().__init__()
376
+ self.output_dict: ModuleSourceDict = ModuleSourceDict("")
377
+ self.output_dict.add_import(TYPING_IMPORT)
378
+ self.return_info: List[Tuple] = [] # development aid only
379
+ self.source_tag = v_tag
380
+ self.source_release = v_tag
381
+
382
+ def leave_class(self):
383
+ if self.current_class != "":
384
+ self.current_class = ""
385
+
386
+ def fix_parameters(self, params: str, name: str = "") -> str:
387
+ """Patch / correct the documentation parameter notation to a supported format that works for linting.
388
+ - params is the string containing the parameters as documented in the rst file
389
+ - name is the name of the function or method or Class
390
+ """
391
+ params = params.strip()
392
+ if not params.endswith(")"):
393
+ # remove all after the closing bracket
394
+ params = params[: params.rfind(")") + 1]
395
+
396
+ # Remove modulename. and Classname. from class constant
397
+ params = self.strip_prefixes(params, strip_mod=True, strip_class=True)
398
+
399
+ ## Deal with SQUARE brackets first ( Documentation meaning := [optional])
400
+
401
+ for fix in self.PARAM_RE_FIXES:
402
+ params = self.apply_fix(fix, params, name)
403
+
404
+ # ###########################################################################################################
405
+ # does not look cool, but works really well
406
+ # change [x] --> x:Optional[Any]
407
+ params = params.replace("[", "")
408
+ params = params.replace("]]", "") # Q&D Hack-complex nesting
409
+
410
+ # Handle Optional arguments
411
+ # Optional step 1: [x] --> x: Optional[Any]=None
412
+ params = params.replace("]", ": Optional[Any]=None")
413
+ # Optional step 2: x: Optional[Any]=None='abc' --> x: Optional[Any]='abc'
414
+ params = re.sub(r": Optional\[Any\]=None\s*=", r": Optional[Any]=", params)
415
+ # Optional step 3: fix ...
416
+ params = re.sub(r"\.\.\.: Optional\[Any\]=None", r"...", params)
417
+ # ###########################################################################################################
418
+
419
+ for fix in PARAM_FIXES:
420
+ if fix.module == self.current_module or not fix.module:
421
+ params = self.apply_fix(fix, params, name)
422
+
423
+ # # formatting
424
+ # # fixme: ... not allowed in .py
425
+ if self.target == ".py":
426
+ params = params.replace("*, ...", "*args, **kwargs")
427
+ params = params.replace("...", "*args, **kwargs")
428
+
429
+ return params.strip()
430
+
431
+ @staticmethod
432
+ def apply_fix(fix: Fix, params: str, name: str = ""):
433
+ if fix.name and fix.name != name:
434
+ return params
435
+ return re.sub(fix.from_, fix.to, params) if fix.is_re else params.replace(fix.from_, fix.to)
436
+
437
+ def create_update_class(self, name: str, params: str, docstr: List[str]):
438
+ # a bit of a hack: assume no classes in classes or functions in function
439
+ self.leave_class()
440
+ if full_name := self.output_dict.find(f"class {name}"):
441
+ log.warning(f"TODO: UPDATE EXISTING CLASS : {name}")
442
+ class_def = self.output_dict[full_name]
443
+ else:
444
+ parent = CHILD_PARENT_CLASS[name] if name in CHILD_PARENT_CLASS.keys() else ""
445
+ if parent == "" and (name.endswith("Error") or name.endswith("Exception")):
446
+ parent = "Exception"
447
+ class_def = ClassSourceDict(
448
+ f"class {name}({parent}):",
449
+ docstr=docstr,
450
+ )
451
+ if params != "":
452
+ method = FunctionSourceDict(
453
+ name="__init__",
454
+ indent=class_def.indent + 4,
455
+ definition=[f"def __init__(self, {params} -> None:"],
456
+ docstr=[], # todo: check if twice is needed
457
+ )
458
+ class_def += method
459
+ # Append class to output
460
+ self.output_dict += class_def
461
+ self.current_class = name
462
+
463
+ def parse_toc(self):
464
+ "process table of content with additional rst files, and add / include them in the current module"
465
+ log.trace(f"# {self.line.rstrip()}")
466
+ self.line_no += 1 # skip one line
467
+ toctree = self.read_docstring()
468
+ # cleanup toctree
469
+ toctree = [x.strip() for x in toctree if f"{self.current_module}." in x]
470
+ # Now parse all files mentioned in the toc
471
+ for file in toctree:
472
+ #
473
+ file_path = CONFIG.mpy_path / "docs" / "library" / file.strip()
474
+ self.read_file(file_path)
475
+ self.parse()
476
+ # reset this file to done
477
+ self.rst_text = []
478
+ self.line_no = 1
479
+
480
+ def parse_module(self):
481
+ "parse a module tag and set the module's docstring"
482
+ log.trace(f"# {self.line.rstrip()}")
483
+ module_name = self.line.split(SEPERATOR)[-1].strip()
484
+
485
+ self.current_module = module_name
486
+ self.current_function = self.current_class = ""
487
+ # get module docstring
488
+ docstr = self.read_docstring(large=True)
489
+
490
+ if len(docstr) > 0:
491
+ # Add link to online documentation
492
+ # https://docs.micropython.org/en/v1.17/library/array.html
493
+ if "nightly" in self.source_tag:
494
+ version = V_PREVIEW
495
+ else:
496
+ version = self.source_tag.replace("_", ".") # TODO Use clean_version(self.source_tag)
497
+ docstr[0] = f"{docstr[0]}.\n\nMicroPython module: https://docs.micropython.org/en/{version}/library/{module_name}.html"
498
+
499
+ self.output_dict.name = module_name
500
+ self.output_dict.add_comment(f"# source version: {self.source_tag}")
501
+ self.output_dict.add_comment(f"# origin module:: {self.filename}")
502
+ self.output_dict.add_docstr(docstr)
503
+ # Add additional imports to allow one module te refer to another
504
+ if module_name in MODULE_GLUE.keys():
505
+ self.output_dict.add_import(MODULE_GLUE[module_name])
506
+
507
+ def parse_current_module(self):
508
+ log.trace(f"# {self.line.rstrip()}")
509
+ module_name = self.line.split(SEPERATOR)[-1].strip()
510
+ mod_comment = f"# + module: {self.current_module}.rst"
511
+ self.current_module = module_name
512
+ self.current_function = self.current_class = ""
513
+ log.debug(mod_comment)
514
+ self.output_dict.name = module_name
515
+ self.output_dict.add_comment(mod_comment)
516
+ self.line_no += 1 # advance as we did not read any docstring
517
+
518
+ def parse_function(self):
519
+ log.trace(f"# {self.line.rstrip()}")
520
+ # this_function = self.line.split(SEPERATOR)[-1].strip()
521
+ # name = this_function
522
+
523
+ # Get one or more names
524
+ function_names = self.parse_names(oneliner=False)
525
+ docstr = self.read_docstring()
526
+
527
+ for this_function in function_names:
528
+ # Parse return type from docstring
529
+ ret_type = return_type_from_context(docstring=docstr, signature=this_function, module=self.current_module)
530
+
531
+ # defaults
532
+ name = params = ""
533
+ try:
534
+ name, params = this_function.split("(", maxsplit=1)
535
+ except ValueError:
536
+ log.error(this_function)
537
+ self.current_function = name
538
+ if name in ("classmethod", "staticmethod"):
539
+ # skip the classmethod and static method functions
540
+ # no use to create stubs for these
541
+ return
542
+
543
+ # ussl documentation uses a ssl.foobar prefix
544
+ for mod in self.module_names:
545
+ if name.startswith(f"{mod}."):
546
+ # remove module name from the start of the function name
547
+ name = name[len(f"{mod}.") :]
548
+ # fixup parameters
549
+ params = self.fix_parameters(params, name)
550
+ # ASSUME no functions in classes,
551
+ # so with ther cursor at a function this probably means that we are no longer in a class
552
+ self.leave_class()
553
+
554
+ fn_def = FunctionSourceDict(
555
+ name=f"def {name}",
556
+ definition=[f"def {name}({params} -> {ret_type}:"],
557
+ docstr=docstr,
558
+ )
559
+ self.output_dict += fn_def
560
+
561
+ def parse_class(self):
562
+ log.trace(f"# {self.line.rstrip()}")
563
+ this_class = self.line.split(SEPERATOR)[-1].strip() # raw
564
+ if "(" in this_class:
565
+ name, params = this_class.split("(", 2)
566
+ else:
567
+ name = this_class
568
+ params = ""
569
+ name = self.strip_prefixes(name)
570
+ self.current_class = name
571
+ self.current_function = ""
572
+
573
+ log.trace(f"# class:: {name} - {this_class}")
574
+ # fixup parameters
575
+ params = self.fix_parameters(params, f"{name}.__init__")
576
+ docstr = self.read_docstring()
577
+
578
+ if any(":noindex:" in line for line in docstr):
579
+ # if the class docstring contains ':noindex:' on any line then skip
580
+ log.trace(f"# Skip :noindex: class {name}")
581
+ else:
582
+ # write a class header
583
+ self.create_update_class(name, params, docstr)
584
+
585
+ def parse_method(self):
586
+ name = ""
587
+ this_method = ""
588
+ ## py:staticmethod - py:classmethod - py:decorator
589
+ # ref: https://sphinx-tutorial.readthedocs.io/cheatsheet/
590
+ log.trace(f"# {self.line.rstrip()}")
591
+ if not self.is_balanced(self.line):
592
+ self.extend_and_balance_line()
593
+
594
+ ## rst_hint is used to access the method decorator ( method, staticmethod, staticmethod ... )
595
+ rst_hint = self.get_rst_hint()
596
+
597
+ method_names = self.parse_names(oneliner=False)
598
+ # filter out overloads with 'param=value' description as these can't be output (currently)
599
+ method_names = [m for m in method_names if "param=value" not in m]
600
+
601
+ docstr = self.read_docstring()
602
+ for this_method in method_names:
603
+ try:
604
+ name, params = this_method.split("(", 1) # split methodname from params
605
+ except ValueError:
606
+ name = this_method
607
+ params = ")"
608
+ is_async = "async" in name
609
+ self.current_function = name
610
+ # self.writeln(f"# method:: {name}")
611
+ if "." in name:
612
+ # todo deal with longer / deeper classes
613
+ class_name = name.split(".")[0]
614
+ # ESPnow.rst has a few methods that are written as `async AIOESPNow.__anext__()`
615
+ if is_async:
616
+ class_name = class_name.replace("async ", "").strip()
617
+ # update current for out-of sequence method processing
618
+ self.current_class = class_name
619
+ else:
620
+ # if nothing specified lets assume part of current class
621
+ class_name = self.current_class
622
+ name = name.split(".")[-1] # Take only the last part from Pin.toggle
623
+
624
+ if full_name := self.output_dict.find(f"class {class_name}"):
625
+ parent_class = self.output_dict[full_name]
626
+ else:
627
+ # not found, create and add new class to the output dict
628
+ parent_class = ClassSourceDict(f"class {class_name}():")
629
+ self.output_dict += parent_class
630
+
631
+ # fixup optional [] parameters and other notations
632
+ params = self.fix_parameters(params, f"{class_name}.{name}")
633
+
634
+ # parse return type from docstring
635
+ ret_type = return_type_from_context(docstring=docstr, signature=f"{class_name}.{name}", module=self.current_module)
636
+ # methods have 4 flavours
637
+ # - __init__ (self, <params>) -> None:
638
+ # - classmethod (cls, <params>) -> <ret_type>:
639
+ # - staticmethod ( <params>) -> <ret_type>:
640
+ # - all other methods (self, <params>) -> <ret_type>:
641
+ if name == "__init__":
642
+ # avoid params starting with `self ,`
643
+ params = self.lstrip_self(params)
644
+ method = FunctionSourceDict(
645
+ name=f"def {name}",
646
+ indent=parent_class.indent + 4,
647
+ definition=[f"def __init__(self, {params} -> None:"],
648
+ docstr=docstr,
649
+ )
650
+ elif rst_hint == "classmethod":
651
+ method = FunctionSourceDict(
652
+ decorators=["@classmethod"],
653
+ name=f"def {name}",
654
+ indent=parent_class.indent + 4,
655
+ definition=[f"def {name}(cls, {params} -> {ret_type}:"],
656
+ docstr=docstr,
657
+ is_async=is_async,
658
+ )
659
+ elif rst_hint == "staticmethod":
660
+ method = FunctionSourceDict(
661
+ decorators=["@staticmethod"],
662
+ name=f"def {name}",
663
+ indent=parent_class.indent + 4,
664
+ definition=[f"def {name}({params} -> {ret_type}:"],
665
+ docstr=docstr,
666
+ is_async=is_async,
667
+ )
668
+ else: # just plain method
669
+ # avoid params starting with `self ,`
670
+ params = self.lstrip_self(params)
671
+ method = FunctionSourceDict(
672
+ name=f"def {name}",
673
+ indent=parent_class.indent + 4,
674
+ definition=[f"def {name}(self, {params} -> {ret_type}:"],
675
+ docstr=docstr,
676
+ is_async=is_async,
677
+ )
678
+
679
+ parent_class += method
680
+
681
+ def lstrip_self(self, params: str):
682
+ """
683
+ To avoid duplicate selfs,
684
+ Remove `self,` from the start of the parameters
685
+ """
686
+ params = params.lstrip()
687
+
688
+ for start in ["self,", "self ,", "self ", "self"]:
689
+ if params.startswith(start):
690
+ params = params[len(start) :]
691
+ return params
692
+
693
+ def parse_exception(self):
694
+ log.trace(f"# {self.line.rstrip()}")
695
+ name = self.line.split(SEPERATOR)[1].strip()
696
+ if name == "Exception":
697
+ # no need to redefine Exception
698
+ self.line_no += 1
699
+ return
700
+ # Take only the last part from module.ExceptionX
701
+ if "." in name:
702
+ name = name.split(".")[-1]
703
+ except_1 = ClassSourceDict(name=f"class {name}(Exception) : ...", docstr=[], init="")
704
+ self.output_dict += except_1
705
+ # no docstream read (yet) , so need to advance to next line
706
+ self.line_no += 1
707
+
708
+ def parse_name(self, line: Optional[str] = None):
709
+ "get the constant/function/class name from a line with an identifier"
710
+ # '.. data:: espnow.MAX_DATA_LEN(=250)\n'
711
+ if line:
712
+ return line.split(SEPERATOR)[-1].strip()
713
+ else:
714
+ return self.line.split(SEPERATOR)[-1].strip()
715
+
716
+ def parse_names(self, oneliner: bool = True):
717
+ """get a list of constant/function/class names from and following a line with an identifier
718
+ advances the linecounter
719
+
720
+ oneliner : treat a line with commas as multiple names (used for constants)
721
+ """
722
+ names: List[str] = []
723
+ names += self.parse_name().split(",") if oneliner else [self.parse_name()]
724
+ m = re.search(r"..\s?\w+\s?::\s?", self.line)
725
+ if not m: # pragma: no cover
726
+ raise KeyError
727
+ col = m.end()
728
+ counter = 1
729
+ while (
730
+ self.line_no + counter <= self.max_line
731
+ and self.rst_text[self.line_no + counter].startswith(" " * col)
732
+ and not self.rst_text[self.line_no + counter][col + 1].isspace()
733
+ ):
734
+ log.trace("Sequence detected")
735
+ names.append(self.parse_name(self.rst_text[self.line_no + counter]))
736
+ counter += 1
737
+ # now advance the linecounter
738
+ self.line_no += counter - 1
739
+ # clean up before returning
740
+ names = [n.strip() for n in names if n.strip() != "etc."] # remove etc.
741
+ return names
742
+
743
+ def parse_data(self):
744
+ """process ..data:: lines ( one or more)
745
+ Note: some data islands are included in the docstring of the module/class/function as the ESPNow documentation started to use this pattern.
746
+ """
747
+ log.trace(f"# {self.line.rstrip()}")
748
+ # Get one or more names
749
+ names = self.parse_names()
750
+
751
+ # get module docstring
752
+ docstr = self.read_docstring()
753
+
754
+ # deal with documentation wildcards
755
+ for name in names:
756
+ r_type = return_type_from_context(docstring=docstr, signature=name, module=self.current_module, literal=True)
757
+ if r_type in ["None"]: # None does not make sense
758
+ r_type = "Incomplete" # Default to Incomplete/ Unknown / int
759
+ name = self.strip_prefixes(name)
760
+ self.output_dict.add_constant_smart(name=name, type=r_type, docstr=docstr)
761
+
762
+ def parse(self):
763
+ self.line_no = 0
764
+ while self.line_no < len(self.rst_text):
765
+ line = self.line
766
+ rst_hint = self.get_rst_hint()
767
+ # self.writeln(">"+line)
768
+ if rst_hint == "module":
769
+ self.parse_module()
770
+ elif rst_hint == "currentmodule":
771
+ self.parse_current_module()
772
+ elif rst_hint == "function":
773
+ self.parse_function()
774
+ elif rst_hint == "class":
775
+ self.parse_class()
776
+ elif rst_hint in ["method", "staticmethod", "classmethod"]:
777
+ self.parse_method()
778
+ elif rst_hint == "exception":
779
+ self.parse_exception()
780
+ elif rst_hint == "data":
781
+ self.parse_data()
782
+ elif rst_hint == "toctree":
783
+ self.parse_toc()
784
+ # Note: this will end the processing of this file.
785
+ elif len(rst_hint) > 0:
786
+ # something new / not yet parsed/understood
787
+ self.line_no += 1
788
+ log.trace(f"# {line.rstrip()}")
789
+ else:
790
+ # NOTHING TO SEE HERE , MOVE ON
791
+ self.line_no += 1
792
+
793
+
794
+ #################################################################################################################
795
+ class RSTWriter(RSTParser):
796
+ """
797
+ Reads, parses and writes
798
+ """
799
+
800
+ def __init__(self, v_tag="v1.xx"):
801
+ super().__init__(v_tag=v_tag)
802
+
803
+ def write_file(self, filename: Path) -> bool:
804
+ self.prepare_output()
805
+ return super().write_file(filename)
806
+
807
+ def prepare_output(self):
808
+ "Remove trailing spaces and commas from the output."
809
+ lines = str(self.output_dict).splitlines(keepends=True)
810
+ self.output = lines
811
+ for i in range(len(self.output)):
812
+ for name in ("self", "cls"):
813
+ if f"({name}, ) ->" in self.output[i]:
814
+ self.output[i] = self.output[i].replace(f"({name}, ) ->", f"({name}) ->")