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