optics-framework-lsp 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ def hello() -> str:
2
+ return "Hello from optics-framework-lsp!"
@@ -0,0 +1,83 @@
1
+ """optics-lsp — a language server for optics-framework csv suites.
2
+
3
+ With no arguments it speaks LSP over stdio, which is how an editor starts it. `lint` instead
4
+ validates a whole suite in one shot and reports every finding:
5
+
6
+ optics-lsp lint PATH # walk a project directory; readable output
7
+ optics-lsp lint # read {"files":[{"name","content"}]} as JSON on stdin
8
+ optics-lsp lint PATH --json # the machine-readable report either way
9
+
10
+ stdin is for a caller holding uploaded files in memory with nothing on disk, which is why it
11
+ always answers JSON. A path is for a person, so it prints lines.
12
+
13
+ Both branches import lazily: `lint` must not pay for pygls, and serving must not pay for
14
+ anything the linter needs.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import sys
22
+ from pathlib import Path
23
+
24
+
25
+ def _lint(path: str | None, as_json: bool) -> int:
26
+ """Exit 0 whenever the suite was validated — findings are data, not process failure — and
27
+ 1 if the input could not be read at all."""
28
+ from .lint import as_text, report, walk
29
+
30
+ if path is None:
31
+ try:
32
+ body = json.load(sys.stdin)
33
+ files = [(entry["name"], entry["content"]) for entry in body["files"]]
34
+ except (json.JSONDecodeError, KeyError, TypeError, UnicodeDecodeError) as error:
35
+ print(
36
+ 'optics-lsp lint: expected {"files": [{"name": ..., "content": ...}]} on stdin '
37
+ f"({type(error).__name__}: {error})",
38
+ file=sys.stderr,
39
+ )
40
+ return 1
41
+ as_json = True
42
+ else:
43
+ root = Path(path).expanduser()
44
+ if not root.is_dir():
45
+ print(f"optics-lsp lint: not a directory: {root}", file=sys.stderr)
46
+ return 1
47
+ files = walk(root)
48
+
49
+ found = report(files)
50
+ if as_json:
51
+ json.dump(found, sys.stdout)
52
+ sys.stdout.write("\n")
53
+ else:
54
+ print(as_text(found))
55
+ return 0
56
+
57
+
58
+ def main() -> None:
59
+ parser = argparse.ArgumentParser(prog="optics-lsp", description=__doc__)
60
+ parser.add_argument(
61
+ "command",
62
+ nargs="?",
63
+ choices=["lint"],
64
+ help="validate a suite instead of serving over stdio",
65
+ )
66
+ parser.add_argument(
67
+ "path", nargs="?", help="project directory to walk; omitted, the suite is read on stdin"
68
+ )
69
+ parser.add_argument("--json", action="store_true", help="report as JSON rather than lines")
70
+ # Unknown flags are ignored rather than rejected: editors pass their own (`--stdio`), and
71
+ # refusing them would break a client for no gain.
72
+ args, _ = parser.parse_known_args()
73
+
74
+ if args.command == "lint":
75
+ raise SystemExit(_lint(args.path, args.json))
76
+
77
+ from .server import server
78
+
79
+ server.start_io()
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
@@ -0,0 +1,467 @@
1
+ # Completion, signature help, goto-definition and hover. The column a cursor sits in
2
+ # decides what belongs there.
3
+
4
+ from __future__ import annotations
5
+
6
+ import csv
7
+ import io
8
+ from collections.abc import Iterable, Sequence
9
+ from typing import Literal
10
+
11
+ from lsprotocol.types import (
12
+ CompletionItem,
13
+ CompletionItemKind,
14
+ Hover,
15
+ Location,
16
+ MarkupContent,
17
+ MarkupKind,
18
+ ParameterInformation,
19
+ Position,
20
+ Range,
21
+ SignatureHelp,
22
+ SignatureInformation,
23
+ TextEdit,
24
+ )
25
+
26
+ from .keyword_catalog import Catalog, Keyword, slug
27
+ from .parser.ast import AST
28
+ from .validation import (
29
+ VAR,
30
+ declarations,
31
+ declared,
32
+ element_refs,
33
+ module_conditions,
34
+ module_refs,
35
+ undefined,
36
+ )
37
+
38
+
39
+ ParamKind = Literal["module", "file", "api"]
40
+
41
+ # What a param holds, by keyword and position after `module_step`. Anything unlisted
42
+ # holds an element or variable.
43
+ PARAM_KINDS: dict[str, dict[int, ParamKind]] = {
44
+ "run loop": {0: "module"},
45
+ "execute module": {0: "module"},
46
+ "read data": {1: "file"},
47
+ "invoke api": {0: "api"},
48
+ }
49
+
50
+ # Values a param accepts, by param name. Documented in docstrings only, so the catalog
51
+ # cannot supply them: `direction` is checked as `in ("up", "down")` by the appium driver,
52
+ # and `rule` as `any(...) if rule == 'any' else all(...)`.
53
+ PARAM_VALUES = {
54
+ "direction": ["up", "down", "left", "right"],
55
+ "rule": ["any", "all"],
56
+ "element_state": ["visible", "invisible", "enabled", "disabled"],
57
+ }
58
+
59
+
60
+ # Test cases the runner lifts out of the normal order. `categorize_test_cases` matches
61
+ # the words anywhere in the name, so these are canonical spellings, not reserved words.
62
+ # No frequency is claimed here: `get_execution_queue` keys its plan by name, so the
63
+ # test-level pair lands once around the first test rather than around every one.
64
+ _LIFECYCLE = {
65
+ "Suite Setup": "suite setup, before the tests",
66
+ "Suite Teardown": "suite teardown, after the tests",
67
+ "Setup": "test-level setup",
68
+ "Teardown": "test-level teardown",
69
+ }
70
+
71
+
72
+ # The header decides what a csv is. These are the four `_identify_csv_content` accepts,
73
+ # written as the framework reads them back: `read_elements` looks up `Element_Name` with
74
+ # the case intact, while the other three readers lowercase theirs. Five params is what
75
+ # 5 of 7 real projects write.
76
+ _HEADERS = {
77
+ "test_case,test_step": "test cases",
78
+ "module_name,module_step,param_1,param_2,param_3,param_4,param_5": "modules",
79
+ "Element_Name,Element_ID": "elements",
80
+ "error_code,match_string,description,severity": "error definitions",
81
+ }
82
+
83
+
84
+ class Cursor:
85
+ """Where a position falls in a csv: its header row, column, and partial field."""
86
+
87
+ def __init__(self, text: str, position: Position) -> None:
88
+ lines = text.splitlines()
89
+ line = lines[position.line] if position.line < len(lines) else ""
90
+ prefix = line[: position.character]
91
+ self.paired = line[position.character :].startswith("}")
92
+
93
+ self.header_line, header = next(
94
+ ((i, row) for i, row in enumerate(lines) if row.strip()), (0, "")
95
+ )
96
+ self.header = header
97
+ self.headers = [h.strip().lower() for h in next(csv.reader(io.StringIO(header)), [])]
98
+ self.fields = [f.strip() for f in next(csv.reader(io.StringIO(line)), [])]
99
+
100
+ # csv, not prefix.count(","), so a quoted comma in an XPath does not shift us.
101
+ fields = next(csv.reader(io.StringIO(prefix)), [""]) or [""]
102
+ self.column = len(fields) - 1
103
+ self.partial = fields[-1]
104
+ self.line = position.line
105
+ self.start = position.character - len(self.partial)
106
+
107
+ def column_of(self, header: str) -> int | None:
108
+ return self.headers.index(header) if header in self.headers else None
109
+
110
+ def field(self, column: int) -> str:
111
+ return self.fields[column] if column < len(self.fields) else ""
112
+
113
+ def header_at(self, column: int) -> str:
114
+ return self.headers[column] if column < len(self.headers) else ""
115
+
116
+ def step_name(self, step: int) -> str:
117
+ return slug(self.field(step))
118
+
119
+ def replacement(self, text: str) -> TextEdit:
120
+ """Replace the whole field, so ${b} completes without nesting into ${${b}}."""
121
+ # An editor that auto-pairs braces leaves `${|}`, and the item brings its own.
122
+ end = self.start + len(self.partial) + (self.paired and text.endswith("}"))
123
+ return TextEdit(
124
+ range=Range(
125
+ start=Position(line=self.line, character=self.start),
126
+ end=Position(line=self.line, character=end),
127
+ ),
128
+ new_text=text,
129
+ )
130
+
131
+
132
+ def _widen_header(cursor: Cursor, step: int) -> list[TextEdit] | None:
133
+ """`csv.DictReader` drops cells the header does not name, so declare the columns."""
134
+ if cursor.line == cursor.header_line or cursor.column < len(cursor.headers):
135
+ return None
136
+
137
+ added = "".join(f",param_{i - step}" for i in range(len(cursor.headers), cursor.column + 1))
138
+ at = Position(line=cursor.header_line, character=len(cursor.header))
139
+ return [TextEdit(range=Range(start=at, end=at), new_text=added)]
140
+
141
+
142
+ def _item(cursor: Cursor, label: str, kind: CompletionItemKind, detail: str, text: str):
143
+ return CompletionItem(
144
+ label=label,
145
+ kind=kind,
146
+ detail=detail,
147
+ text_edit=cursor.replacement(text),
148
+ filter_text=text,
149
+ )
150
+
151
+
152
+ def _modules(cursor: Cursor, ast: AST, prefix: str = "") -> list[CompletionItem]:
153
+ return [
154
+ _item(cursor, name, CompletionItemKind.Module, "module", prefix + name)
155
+ for name in sorted({m.name for m in ast.modules})
156
+ ]
157
+
158
+
159
+ def _listing(
160
+ cursor: Cursor, names: Iterable[str], kind: CompletionItemKind, detail: str
161
+ ) -> list[CompletionItem]:
162
+ """Names offered as they are written."""
163
+ return [_item(cursor, name, kind, detail, name) for name in names]
164
+
165
+
166
+ def _variables(cursor: Cursor, ast: AST) -> list[CompletionItem]:
167
+ names = {e.name for e in ast.elements} | declared(ast)
168
+ return [
169
+ _item(cursor, name, CompletionItemKind.Variable, "element", f"${{{name}}}")
170
+ for name in sorted(names)
171
+ ]
172
+
173
+
174
+ def _params(
175
+ cursor: Cursor,
176
+ ast: AST,
177
+ catalog: Catalog | None,
178
+ step: int,
179
+ *,
180
+ data_files: Sequence[str],
181
+ apis: Sequence[str],
182
+ ) -> list[CompletionItem]:
183
+ """What belongs in a param column, by the keyword its row names."""
184
+ name = cursor.step_name(step)
185
+ param = cursor.column - step - 1
186
+
187
+ # `Condition` alternates condition, target. A target is always a module, while a
188
+ # condition is either a module, optionally !-inverted, or an expression.
189
+ if name == "condition":
190
+ modules = _modules(cursor, ast, "!" if cursor.partial.startswith("!") else "")
191
+ return modules if param % 2 else modules + _variables(cursor, ast)
192
+
193
+ kind = PARAM_KINDS.get(name, {}).get(param)
194
+ if kind == "module":
195
+ # A module to run, not an element to find, and written bare.
196
+ return _modules(cursor, ast)
197
+ if kind == "file":
198
+ # Resolved against the project root, so a relative path is what belongs here.
199
+ return _listing(cursor, data_files, CompletionItemKind.File, "data file")
200
+ if kind == "api":
201
+ return _listing(cursor, apis, CompletionItemKind.Value, "api")
202
+
203
+ # The catalog names the params, so a fixed-value one is found by name rather
204
+ # than by listing every keyword that happens to take a `direction`.
205
+ keyword = (catalog or {}).get(name)
206
+ names = keyword.params if keyword else []
207
+ if values := PARAM_VALUES.get(names[param] if param < len(names) else ""):
208
+ return _listing(cursor, values, CompletionItemKind.EnumMember, "value")
209
+
210
+ return _variables(cursor, ast)
211
+
212
+
213
+ def complete(
214
+ text: str,
215
+ position: Position,
216
+ ast: AST,
217
+ catalog: Catalog | None,
218
+ *,
219
+ images: Sequence[str] = (),
220
+ data_files: Sequence[str] = (),
221
+ apis: Sequence[str] = (),
222
+ ) -> list[CompletionItem]:
223
+ cursor = Cursor(text, position)
224
+
225
+ # Nothing is defined yet, so the row being typed is the header that decides the kind.
226
+ if cursor.column == 0 and len([row for row in text.splitlines() if row.strip()]) <= 1:
227
+ kind = CompletionItemKind.Struct
228
+ return [_item(cursor, h, kind, detail, h) for h, detail in _HEADERS.items()]
229
+
230
+ step = cursor.column_of("module_step")
231
+
232
+ if step is not None and cursor.column == step:
233
+ # A step names a keyword or, for nested modules, another module.
234
+ items = _modules(cursor, ast)
235
+ for name, keyword in sorted((catalog or {}).items()):
236
+ label = name.title()
237
+ items.append(
238
+ _item(
239
+ cursor,
240
+ label,
241
+ CompletionItemKind.Keyword,
242
+ ", ".join(keyword.params) or "no params",
243
+ label,
244
+ )
245
+ )
246
+ return items
247
+
248
+ if step is not None and cursor.column > step:
249
+ items = _params(cursor, ast, catalog, step, data_files=data_files, apis=apis)
250
+
251
+ # Accepting a param the header does not cover declares it in the same edit.
252
+ for item in items:
253
+ item.additional_text_edits = _widen_header(cursor, step)
254
+ return items
255
+
256
+ # Both name columns continue an existing block, so they offer what already exists.
257
+ if cursor.column in (cursor.column_of("test_step"), cursor.column_of("module_name")):
258
+ return _modules(cursor, ast)
259
+
260
+ # Defining an element is how an element-not-found gets fixed, so offer those names.
261
+ if cursor.column == cursor.column_of("element_name"):
262
+ kind = CompletionItemKind.Variable
263
+ return _listing(cursor, sorted(undefined(ast)), kind, "used, not defined")
264
+
265
+ # An id is usually an xpath or literal text, which we cannot guess, but an image
266
+ # locator is the bare filename of a template somewhere in the project. Any
267
+ # `element_id*` column holds one, as `read_elements` reads them all.
268
+ if cursor.header_at(cursor.column).startswith("element_id"):
269
+ return _listing(cursor, images, CompletionItemKind.File, "template image")
270
+
271
+ if cursor.column == cursor.column_of("test_case"):
272
+ names = sorted({t.name for t in ast.test_cases})
273
+ return _listing(cursor, names, CompletionItemKind.Value, "test case") + [
274
+ _item(cursor, name, CompletionItemKind.Event, detail, name)
275
+ for name, detail in _LIFECYCLE.items()
276
+ if name not in names
277
+ ]
278
+
279
+ return []
280
+
281
+
282
+ def _rendered(keyword: Keyword) -> list[str]:
283
+ """Params as `name='default'`, so what an omitted cell falls back to is visible."""
284
+ return [
285
+ f"{name}={keyword.defaults[name]}" if name in keyword.defaults else name
286
+ for name in keyword.params
287
+ ]
288
+
289
+
290
+ def signature(text: str, position: Position, catalog: Catalog | None) -> SignatureHelp | None:
291
+ """The keyword's params, with the column the cursor is in marked active."""
292
+ cursor = Cursor(text, position)
293
+ step = cursor.column_of("module_step")
294
+ if not catalog or step is None or cursor.column <= step:
295
+ return None
296
+
297
+ name = cursor.step_name(step)
298
+ keyword = catalog.get(name)
299
+ if keyword is None:
300
+ return None
301
+
302
+ # A parameter label must be a substring of the signature for a client to highlight
303
+ # it, so both are built from the same rendering.
304
+ params = _rendered(keyword)
305
+ return SignatureHelp(
306
+ signatures=[
307
+ SignatureInformation(
308
+ label=f"{name.title()}({', '.join(params)})",
309
+ parameters=[ParameterInformation(label=p) for p in params],
310
+ )
311
+ ],
312
+ active_signature=0,
313
+ active_parameter=min(cursor.column - step - 1, max(len(keyword.params) - 1, 0)),
314
+ )
315
+
316
+
317
+ def _at(uri: str, row: int) -> Location:
318
+ """A definition points at the start of its row, which is 1-based in the ast."""
319
+ at = Position(line=max(row - 1, 0), character=0)
320
+ return Location(uri=uri, range=Range(start=at, end=at))
321
+
322
+
323
+ def definition(
324
+ text: str, position: Position, ast: AST, catalog: Catalog | None
325
+ ) -> list[Location]:
326
+ """Where the module a step runs, or the elements a param reads, are defined."""
327
+ cursor = Cursor(text, position)
328
+ step = cursor.column_of("module_step")
329
+ if cursor.column != cursor.column_of("test_step") and (
330
+ step is None or cursor.column < step
331
+ ):
332
+ return []
333
+
334
+ field = cursor.field(cursor.column)
335
+
336
+ # A step column resolves the keyword first, so a same-named module is not what runs.
337
+ if cursor.column == step and slug(field) in (catalog or {}):
338
+ return []
339
+
340
+ # A cell holds either ${names} to read or a bare name to run. Every ${name} in the
341
+ # cell is offered: a fallback element is several rows, and so is `${a} == ${b}`.
342
+ if names := set(VAR.findall(field)):
343
+ return [_at(e.uri, e.row) for e in ast.elements if e.name in names]
344
+
345
+ # Condition writes an inverted module as `!Name`; the runner strips the same way.
346
+ wanted = field.removeprefix("!")
347
+ return [_at(m.uri, m.start_row) for m in ast.modules if m.name == wanted]
348
+
349
+
350
+ # Only these classify as Image in `determine_element_type` (.tiff discovers but never
351
+ # matches), so an id ending in one is a template filename, not an xpath or literal text.
352
+ IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".bmp")
353
+
354
+
355
+ def _symbol_at(cursor: Cursor, catalog: Catalog | None) -> tuple[str, str] | None:
356
+ """What the cursor is on, as a kind and the name to match against."""
357
+ field = cursor.field(cursor.column)
358
+ if not field:
359
+ return None
360
+
361
+ step = cursor.column_of("module_step")
362
+ if cursor.column in (cursor.column_of("module_name"), cursor.column_of("test_step")):
363
+ return "module", field
364
+ if cursor.column == cursor.column_of("element_name"):
365
+ return "element", field
366
+ if cursor.header_at(cursor.column).startswith("element_id"):
367
+ # Only an image is shared by name; an xpath is written per row.
368
+ return ("image", field) if field.lower().endswith(IMAGE_SUFFIXES) else None
369
+
370
+ if step is None or cursor.column < step:
371
+ return None
372
+ if cursor.column == step:
373
+ # A keyword beats a same-named module here, as `_execute_single_keyword` resolves.
374
+ return ("keyword" if slug(field) in (catalog or {}) else "module"), field
375
+
376
+ if names := VAR.findall(field):
377
+ return "element", names[0]
378
+
379
+ kind = PARAM_KINDS.get(cursor.step_name(step), {}).get(cursor.column - step - 1)
380
+ if kind in ("file", "api"):
381
+ return kind, field
382
+ return "module", field.removeprefix("!")
383
+
384
+
385
+ def references(
386
+ text: str,
387
+ position: Position,
388
+ ast: AST,
389
+ catalog: Catalog | None,
390
+ *,
391
+ include_declaration: bool = False,
392
+ ) -> list[Location]:
393
+ """Every place the name under the cursor is used, and optionally where it is bound."""
394
+ cursor = Cursor(text, position)
395
+ found = _symbol_at(cursor, catalog)
396
+ if found is None:
397
+ return []
398
+
399
+ kind, name = found
400
+ declared_at: list[Location] = []
401
+
402
+ if kind == "module":
403
+ # A Condition names a module to run, which validation cannot assume, and so does
404
+ # a step cell — but only when no keyword claims the name first.
405
+ seen = list(module_refs(ast)) + list(module_conditions(ast)) + [
406
+ (m.uri, step.row, step.step_name)
407
+ for m in ast.modules
408
+ for step in m.steps
409
+ if step.step_name and slug(step.step_name) not in (catalog or {})
410
+ ]
411
+ uses = [_at(uri, row) for uri, row, n in seen if n == name]
412
+ declared_at = [_at(m.uri, m.start_row) for m in ast.modules if m.name == name]
413
+ elif kind == "element":
414
+ uses = [_at(uri, row) for uri, row, n in element_refs(ast) if n == name]
415
+ declared_at = [_at(e.uri, e.row) for e in ast.elements if e.name == name] + [
416
+ _at(uri, row) for uri, row, n in declarations(ast) if n == name
417
+ ]
418
+ elif kind == "keyword":
419
+ # The framework defines it, so there is nothing here to declare.
420
+ wanted = slug(name)
421
+ uses = [
422
+ _at(module.uri, step.row)
423
+ for module in ast.modules
424
+ for step in module.steps
425
+ if slug(step.step_name) == wanted
426
+ ]
427
+ elif kind == "image":
428
+ uses = [
429
+ _at(e.uri, e.row)
430
+ for e in ast.elements
431
+ if any(locator.text == name for locator in e.locators)
432
+ ]
433
+ else:
434
+ # A file or an api, by the same table completion offers.
435
+ uses = [
436
+ _at(m.uri, step.row)
437
+ for m in ast.modules
438
+ for step in m.steps
439
+ for i, param in enumerate(step.params)
440
+ if PARAM_KINDS.get(slug(step.step_name), {}).get(i) == kind and param == name
441
+ ]
442
+
443
+ # Sorted, so a result reads top to bottom per file rather than by rule order.
444
+ uses.sort(key=lambda at: (at.uri, at.range.start.line))
445
+ return uses + declared_at if include_declaration else uses
446
+
447
+
448
+ def hover(text: str, position: Position, catalog: Catalog | None) -> Hover | None:
449
+ """A keyword's signature and the framework's own docstring for it."""
450
+ cursor = Cursor(text, position)
451
+ step = cursor.column_of("module_step")
452
+ if step is None or cursor.column != step:
453
+ return None
454
+
455
+ keyword = (catalog or {}).get(cursor.step_name(step))
456
+ if keyword is None:
457
+ return None
458
+
459
+ # Plain text, because the docstrings are reST: markdown would fold the `:param x:`
460
+ # lines into one paragraph.
461
+ label = f"{cursor.field(step)}({', '.join(_rendered(keyword))})"
462
+ return Hover(
463
+ contents=MarkupContent(
464
+ kind=MarkupKind.PlainText,
465
+ value=f"{label}\n\n{keyword.doc}" if keyword.doc else label,
466
+ )
467
+ )
@@ -0,0 +1,47 @@
1
+ # The framework's keyword signatures, from the generated table in `keywords.py`.
2
+ #
3
+ # Hardcoded rather than read from the user's install: importing `optics_framework.api.*`
4
+ # pulls numpy, cv2, pandas and skimage, so probing needs a ~342 MB install and fails outright
5
+ # when one of those is broken — leaving a project with no keyword diagnostics at all. Run
6
+ # `scripts/update_catalog.py` to refresh the table against a new optics release.
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ from .keywords import KEYWORDS, OPTICS_VERSION
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class Keyword:
17
+ required: int
18
+ variadic: bool
19
+ # Positional names in order. Python forbids a defaulted param before a plain one, so
20
+ # the first `required` of these are the mandatory ones.
21
+ params: list[str]
22
+ # The framework's own docstring, which is where the accepted values are written.
23
+ doc: str = ""
24
+ # What an omitted param falls back to, already repr'd, by param name.
25
+ defaults: dict[str, str] = field(default_factory=dict)
26
+
27
+
28
+ Catalog = dict[str, Keyword]
29
+
30
+
31
+ def slug(name: str | None) -> str:
32
+ """A step name as `_execute_single_keyword` resolves it: whitespace collapsed and
33
+ lowercased. Module names are not — those it looks up raw."""
34
+ return " ".join((name or "").split()).lower()
35
+
36
+
37
+ # Copied out of the table so a caller mutating a Keyword cannot corrupt it.
38
+ CATALOG: Catalog = {
39
+ name: Keyword(
40
+ required=signature["required"],
41
+ variadic=signature["variadic"],
42
+ params=list(signature["params"]),
43
+ doc=signature["doc"],
44
+ defaults=dict(signature["defaults"]),
45
+ )
46
+ for name, signature in KEYWORDS.items()
47
+ }