Skip to content

Commit b7a6437

Browse files
OriolAbrillagru
andauthored
Keep inline annotations over docstring generated ones (#61)
* first pass at keeping inline annotation over docstring * Make override_docsring tests more independent Keeping parameters is already tested in `override_docstring_param` above – no need to do so again. * Remove duplication in error message The `underline` method of the `ErrorReporter` includes the underlined line already. * Simplify logic when ignoring docstring annotation Also makes sure that imports of a doctype are not unnecessarily included an inline annotation already exists. * Keep existing inline annotation for assignments even if a different docstring annotation exists. This is the same behavior as for parameters and return types. * Add more tests * Re-enable commented out tests Probably forgot to do so during debugging. * Document inline annotation as overwrite solution --------- Co-authored-by: Lars Grüter <lagru@mailbox.org>
1 parent f11cc84 commit b7a6437

File tree

5 files changed

+252
-39
lines changed

5 files changed

+252
-39
lines changed

docs/user_guide.md

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,35 @@ Two command line options can help addressing these errors gradually:
172172
173173
## Dealing with typing problems
174174
175-
Docstub may not fully or correctly implement a particular part of Python's typing system yet.
175+
For various reasons – missing features in docstub, or limitations of Python's typing system – it may not always be possible to correctly type something in a docstring.
176+
In those cases, you docstub provides a few approaches to dealing with this.
176177

177-
In some cases, you can use a comment directive to selectively disable docstub for a specific block of lines:
178+
179+
### Use inline type annotation
180+
181+
Docstub will always preserve inline type annotations, regardless of what the docstring contains.
182+
This is useful for example, if you want to express something that isn't yet supported by Python's type system.
183+
184+
E.g., consider the docstring type of `ord` parameter in [`numpy.linalg.matrix_norm`](https://numpy.org/doc/stable/reference/generated/numpy.linalg.matrix_norm.html)
185+
```rst
186+
ord : {1, -1, 2, -2, inf, -inf, ‘fro’, ‘nuc’}, optional
187+
```
188+
[Python's type system currently can't express floats as literal types](https://typing.python.org/en/latest/spec/literal.html#:~:text=Floats%3A%20e.g.%20Literal%5B3.14%5D) – such as `inf`.
189+
We don't want to make the type description here less specific to users, so instead, you could handle this with a less constrained inline type annotation like
190+
```python
191+
ord: Literal[1, -1, 2, -2, 'fro', 'nuc'] | float
192+
```
193+
Docstub will include the latter less constrained type in the stubs.
194+
This allows you to keep the information in the docstring while still having valid – if a bit less constrained – stubs.
195+
196+
197+
### Preserve code with comment directive
198+
199+
At its heart, docstub transforms Python source files into stub files.
200+
You can tell docstub to temporarily stop that transformation for a specific area with a comment directive.
201+
Wrapping lines of code with `docstub: off` and `docstub: on` comments will preserve these lines completely.
202+
203+
E.g., consider the following example:
178204
```python
179205
class Foo:
180206
# docstub: off
@@ -184,7 +210,7 @@ class Foo:
184210
c: int = None
185211
d: str = ""
186212
```
187-
will leave the parameters within the `# docstub` guards untouched in the resulting stub file:
213+
will leave the guarded parameters untouched in the resulting stub file:
188214
```python
189215
class Foo:
190216
a: int = None
@@ -193,5 +219,7 @@ class Foo:
193219
d: str
194220
```
195221
196-
If that is not possible, you can – for now – fallback to writing a correct stub file by hand.
222+
### Write a manual stub file
223+
224+
If all of the above does not solve your issue, you can fall back to writing a correct stub file by hand.
197225
Docstub will preserve this file and integrated it with other automatically generated stubs.

examples/example_pkg-stubs/_basic.pyi

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ def func_contains(
2929
def func_literals(
3030
a1: Literal[1, 3, "foo"], a2: Literal["uno", 2, "drei", "four"] = ...
3131
) -> None: ...
32+
def override_docstring_param(
33+
d1: dict[str, float], d2: dict[Literal["a", "b", "c"], int]
34+
) -> None: ...
35+
def override_docstring_return() -> list[Literal[-1, 0, 1] | float]: ...
3236
def func_use_from_elsewhere(
3337
a1: CustomException,
3438
a2: ExampleClass,
@@ -37,6 +41,9 @@ def func_use_from_elsewhere(
3741
) -> tuple[CustomException, ExampleClass.NestedClass]: ...
3842

3943
class ExampleClass:
44+
45+
b1: int
46+
4047
class NestedClass:
4148
def method_in_nested_class(self, a1: complex) -> None: ...
4249

examples/example_pkg/_basic.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
# Existing imports are preserved
77
import logging
8+
from typing import Literal
89

910
# Assign-statements are preserved
1011
logger = logging.getLogger(__name__) # Inline comments are stripped
@@ -51,6 +52,25 @@ def func_literals(a1, a2="uno"):
5152
"""
5253

5354

55+
def override_docstring_param(d1, d2: dict[Literal["a", "b", "c"], int]):
56+
"""Check type hint is kept and overrides docstring.
57+
58+
Parameters
59+
----------
60+
d1 : dict of {str : float}
61+
d2 : dict of {str : int}
62+
"""
63+
64+
65+
def override_docstring_return() -> list[Literal[-1, 0, 1] | float]:
66+
"""Check type hint is kept and overrides docstring.
67+
68+
Returns
69+
-------
70+
{"-inf", 0, 1, "inf"}
71+
"""
72+
73+
5474
def func_use_from_elsewhere(a1, a2, a3, a4):
5575
"""Check if types with full import names are matched.
5676
@@ -75,10 +95,15 @@ class ExampleClass:
7595
----------
7696
a1 : str
7797
a2 : float, default 0
98+
99+
Attributes
100+
----------
101+
b1 : Sized
78102
"""
79103

80-
class NestedClass:
104+
b1: int
81105

106+
class NestedClass:
82107
def method_in_nested_class(self, a1):
83108
"""
84109

src/docstub/_stubs.py

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -571,25 +571,31 @@ def leave_FunctionDef(self, original_node, updated_node):
571571
assert ds_annotations.returns.value
572572
annotation_value = ds_annotations.returns.value
573573

574-
if original_node.returns is not None:
574+
if original_node.returns is None:
575+
annotation = cst.Annotation(cst.parse_expression(annotation_value))
576+
node_changes["returns"] = annotation
577+
# TODO: check imports
578+
self._required_imports |= ds_annotations.returns.imports
579+
580+
else:
581+
# Notify about ignored docstring annotation
582+
# TODO: either remove message or print only in verbose mode
575583
position = self.get_metadata(
576584
cst.metadata.PositionProvider, original_node
577585
).start
578586
reporter = self.reporter.copy_with(
579587
path=self.current_source, line=position.line
580588
)
581-
replaced = _inline_node_as_code(original_node.returns.annotation)
589+
to_keep = _inline_node_as_code(original_node.returns.annotation)
582590
details = (
583-
f"{replaced}\n{reporter.underline(replaced)} -> {annotation_value}"
591+
f"{reporter.underline(to_keep)} "
592+
f"ignoring docstring: {annotation_value}"
584593
)
585594
reporter.message(
586-
short="Replacing existing inline return annotation",
595+
short="Keeping existing inline return annotation",
587596
details=details,
588597
)
589598

590-
annotation = cst.Annotation(cst.parse_expression(annotation_value))
591-
node_changes["returns"] = annotation
592-
self._required_imports |= ds_annotations.returns.imports
593599
elif original_node.returns is None:
594600
annotation = cst.Annotation(cst.parse_expression("None"))
595601
node_changes["returns"] = annotation
@@ -633,10 +639,35 @@ def leave_Param(self, original_node, updated_node):
633639
if pytype:
634640
if defaults_to_none:
635641
pytype = pytype.as_optional()
636-
annotation = cst.Annotation(cst.parse_expression(pytype.value))
637-
node_changes["annotation"] = annotation
638-
if pytype.imports:
639-
self._required_imports |= pytype.imports
642+
annotation_value = pytype.value
643+
644+
if original_node.annotation is None:
645+
annotation = cst.Annotation(cst.parse_expression(annotation_value))
646+
node_changes["annotation"] = annotation
647+
# TODO: check imports
648+
if pytype.imports:
649+
self._required_imports |= pytype.imports
650+
651+
else:
652+
# Notify about ignored docstring annotation
653+
# TODO: either remove message or print only in verbose mode
654+
position = self.get_metadata(
655+
cst.metadata.PositionProvider, original_node
656+
).start
657+
reporter = self.reporter.copy_with(
658+
path=self.current_source, line=position.line
659+
)
660+
to_keep = cst.Module([]).code_for_node(
661+
original_node.annotation.annotation
662+
)
663+
details = (
664+
f"{reporter.underline(to_keep)} "
665+
f"ignoring docstring: {annotation_value}"
666+
)
667+
reporter.message(
668+
short="Keeping existing inline parameter annotation",
669+
details=details,
670+
)
640671

641672
# Potentially use "Incomplete" except for first param in (class)methods
642673
elif not is_self_or_cls and updated_node.annotation is None:
@@ -764,31 +795,33 @@ def leave_AnnAssign(self, original_node, updated_node):
764795
if pytypes and name in pytypes.attributes:
765796
pytype = pytypes.attributes[name]
766797
expr = cst.parse_expression(pytype.value)
767-
self._required_imports |= pytype.imports
768798

769-
if updated_node.annotation is not None:
770-
# Turn original annotation into str and print with context
799+
if updated_node.annotation is None:
800+
self._required_imports |= pytype.imports
801+
updated_node = updated_node.with_deep_changes(
802+
updated_node.annotation, annotation=expr
803+
)
804+
805+
else:
806+
# Notify about ignored docstring annotation
807+
# TODO: either remove message or print only in verbose mode
771808
position = self.get_metadata(
772809
cst.metadata.PositionProvider, original_node
773810
).start
774811
reporter = self.reporter.copy_with(
775812
path=self.current_source, line=position.line
776813
)
777-
replaced = cst.Module([]).code_for_node(
814+
to_keep = cst.Module([]).code_for_node(
778815
updated_node.annotation.annotation
779816
)
780817
details = (
781-
f"{replaced}\n{reporter.underline(replaced)} -> {pytype.value}"
818+
f"{reporter.underline(to_keep)} ignoring docstring: {pytype.value}"
782819
)
783820
reporter.message(
784-
short="Replacing existing inline annotation",
821+
short="Keeping existing inline annotation for assignment",
785822
details=details,
786823
)
787824

788-
updated_node = updated_node.with_deep_changes(
789-
updated_node.annotation, annotation=expr
790-
)
791-
792825
return updated_node
793826

794827
def visit_Module(self, node):

0 commit comments

Comments
 (0)