Skip to content

Commit 54012cf

Browse files
authored
Add --group-errors option (#30)
This option groups errors by their type and content. I imagine this will be useful to identify widely used patterns that need to be addressed when adopting docstub.
1 parent b8df6e8 commit 54012cf

6 files changed

Lines changed: 270 additions & 132 deletions

File tree

src/docstub/_cli.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
import sys
33
import time
4+
from collections import Counter
45
from contextlib import contextmanager
56
from pathlib import Path
67

@@ -20,6 +21,7 @@
2021
walk_source,
2122
walk_source_and_targets,
2223
)
24+
from ._utils import ErrorReporter, GroupedErrorReporter
2325
from ._version import __version__
2426

2527
logger = logging.getLogger(__name__)
@@ -143,10 +145,16 @@ def report_execution_time():
143145
type=click.Path(exists=True, dir_okay=False),
144146
help="Set configuration file explicitly.",
145147
)
148+
@click.option(
149+
"--group-errors",
150+
is_flag=True,
151+
help="Group errors by type and content. "
152+
"Will delay showing errors until all files have been processed.",
153+
)
146154
@click.option("-v", "--verbose", count=True, help="Log more details.")
147155
@click.help_option("-h", "--help")
148156
@report_execution_time()
149-
def main(source_dir, out_dir, config_path, verbose):
157+
def main(source_dir, out_dir, config_path, group_errors, verbose):
150158
"""
151159
Parameters
152160
----------
@@ -155,25 +163,30 @@ def main(source_dir, out_dir, config_path, verbose):
155163
config_path : Path
156164
verbose : str
157165
"""
166+
167+
# Setup -------------------------------------------------------------------
168+
158169
_setup_logging(verbose=verbose)
159170

160171
source_dir = Path(source_dir)
161172
config = _load_configuration(config_path)
162173
known_imports = _build_import_map(config, source_dir)
163174

175+
reporter = GroupedErrorReporter() if group_errors else ErrorReporter()
164176
types_db = TypesDatabase(
165177
source_pkgs=[source_dir.parent.resolve()], known_imports=known_imports
166178
)
167-
# and the stub transformer
168179
stub_transformer = Py2StubTransformer(
169-
types_db=types_db, replace_doctypes=config.replace_doctypes
180+
types_db=types_db, replace_doctypes=config.replace_doctypes, reporter=reporter
170181
)
171182

172183
if not out_dir:
173184
out_dir = source_dir.parent / (source_dir.name + "-stubs")
174185
out_dir = Path(out_dir)
175186
out_dir.mkdir(parents=True, exist_ok=True)
176187

188+
# Stub generation ---------------------------------------------------------
189+
177190
for source_path, stub_path in walk_source_and_targets(source_dir, out_dir):
178191
if source_path.suffix.lower() == ".pyi":
179192
logger.debug("using existing stub file %s", source_path)
@@ -199,18 +212,25 @@ def main(source_dir, out_dir, config_path, verbose):
199212
logger.info("wrote %s", stub_path)
200213
fo.write(stub_content)
201214

215+
# Reporting --------------------------------------------------------------
216+
217+
if group_errors:
218+
reporter.print_grouped()
219+
202220
# Report basic statistics
203221
successful_queries = types_db.stats["successful_queries"]
204222
click.secho(f"{successful_queries} matched annotations", fg="green")
205223

206-
grammar_error_count = stub_transformer.transformer.stats["grammar_errors"]
207-
if grammar_error_count:
208-
click.secho(f"{grammar_error_count} grammar violations", fg="red")
224+
syntax_error_count = stub_transformer.transformer.stats["syntax_errors"]
225+
if syntax_error_count:
226+
click.secho(f"{syntax_error_count} syntax errors", fg="red")
209227

210228
unknown_doctypes = types_db.stats["unknown_doctypes"]
211229
if unknown_doctypes:
212230
click.secho(f"{len(unknown_doctypes)} unknown doctypes:", fg="red")
213-
click.echo(" " + "\n ".join(set(unknown_doctypes)))
231+
counter = Counter(unknown_doctypes)
232+
for item, count in sorted(counter.items(), key=lambda x: x[1]):
233+
click.echo(f" {item} (x{count})")
214234

215-
if unknown_doctypes or grammar_error_count:
235+
if unknown_doctypes or syntax_error_count:
216236
sys.exit(1)

src/docstub/_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def from_default(cls):
4848
return config
4949

5050
def merge(self, other):
51-
"""Merge contents with other and return a new Config instance.
51+
"""Merge contents with other and return a copy_with Config instance.
5252
5353
Parameters
5454
----------

src/docstub/_docstrings.py

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import numpydoc.docscrape as npds
1313

1414
from ._analysis import KnownImport, TypesDatabase
15-
from ._utils import ContextFormatter, DocstubError, accumulate_qualname, escape_qualname
15+
from ._utils import DocstubError, ErrorReporter, accumulate_qualname, escape_qualname
1616

1717
logger = logging.getLogger(__name__)
1818

@@ -80,7 +80,7 @@ def many_as_tuple(cls, types):
8080

8181
@classmethod
8282
def as_generator(cls, *, yield_types, receive_types=(), return_types=()):
83-
"""Create new ``Generator`` type from yield, receive and return types.
83+
"""Create copy_with ``Generator`` type from yield, receive and return types.
8484
8585
Parameters
8686
----------
@@ -257,7 +257,7 @@ def __init__(self, *, types_db=None, replace_doctypes=None, **kwargs):
257257

258258
super().__init__(**kwargs)
259259

260-
self.stats = {"grammar_errors": 0}
260+
self.stats = {"syntax_errors": 0}
261261

262262
def doctype_to_annotation(self, doctype):
263263
"""Turn a type description in a docstring into a type annotation.
@@ -289,7 +289,7 @@ def doctype_to_annotation(self, doctype):
289289
lark.exceptions.ParseError,
290290
QualnameIsKeyword,
291291
):
292-
self.stats["grammar_errors"] += 1
292+
self.stats["syntax_errors"] += 1
293293
raise
294294
finally:
295295
self._collected_imports = None
@@ -443,7 +443,7 @@ class DocstringAnnotations:
443443
----------
444444
docstring : str
445445
transformer : DoctypeTransformer
446-
ctx : ~.ContextFormatter
446+
reporter : ~.ErrorReporter
447447
448448
Examples
449449
--------
@@ -457,32 +457,32 @@ class DocstringAnnotations:
457457
>>> transformer = DoctypeTransformer()
458458
>>> annotations = DocstringAnnotations(docstring, transformer=transformer)
459459
>>> annotations.parameters.keys()
460-
invalid syntax in doctype
460+
Invalid syntax in docstring type annotation
461461
some invalid syntax
462462
^
463463
<BLANKLINE>
464-
unknown name in doctype: 'unknown.symbol'
464+
Unknown name in doctype: 'unknown.symbol'
465465
unknown.symbol
466466
^^^^^^^^^^^^^^
467467
<BLANKLINE>
468468
dict_keys(['a', 'b', 'c'])
469469
"""
470470

471-
def __init__(self, docstring, *, transformer, ctx=None):
471+
def __init__(self, docstring, *, transformer, reporter=None):
472472
"""
473473
Parameters
474474
----------
475475
docstring : str
476476
transformer : DoctypeTransformer
477-
ctx : ~.ContextFormatter, optional
477+
reporter : ~.ErrorReporter, optional
478478
"""
479479
self.docstring = docstring
480480
self.np_docstring = npds.NumpyDocString(docstring)
481481
self.transformer = transformer
482482

483-
if ctx is None:
484-
ctx = ContextFormatter(line=0)
485-
self.ctx: ContextFormatter = ctx
483+
if reporter is None:
484+
reporter = ErrorReporter(line=0)
485+
self.reporter: ErrorReporter = reporter
486486

487487
def _doctype_to_annotation(self, doctype, ds_line=0):
488488
"""Convert a type description to a Python-ready type.
@@ -501,7 +501,7 @@ def _doctype_to_annotation(self, doctype, ds_line=0):
501501
The transformed type, ready to be inserted into a stub file, with
502502
necessary imports attached.
503503
"""
504-
ctx = self.ctx.with_line(offset=ds_line)
504+
reporter = self.reporter.copy_with(line_offset=ds_line)
505505

506506
try:
507507
annotation, unknown_qualnames = self.transformer.doctype_to_annotation(
@@ -513,21 +513,23 @@ def _doctype_to_annotation(self, doctype, ds_line=0):
513513
if hasattr(error, "get_context"):
514514
details = error.get_context(doctype)
515515
details = details.replace("^", click.style("^", fg="red", bold=True))
516-
ctx.print_message("invalid syntax in doctype", details=details)
516+
reporter.message(
517+
"Invalid syntax in docstring type annotation", details=details
518+
)
517519
return FallbackAnnotation
518520

519521
except lark.visitors.VisitError as e:
520522
tb = "\n".join(traceback.format_exception(e.orig_exc))
521523
details = f"doctype: {doctype!r}\n\n{tb}"
522-
ctx.print_message("unexpected error while parsing doctype", details=details)
524+
reporter.message("unexpected error while parsing doctype", details=details)
523525
return FallbackAnnotation
524526

525527
else:
526528
for name, start_col, stop_col in unknown_qualnames:
527529
width = stop_col - start_col
528530
error_underline = click.style("^" * width, fg="red", bold=True)
529531
details = f"{doctype}\n{' ' * start_col}{error_underline}\n"
530-
ctx.print_message(f"unknown name in doctype: {name!r}", details=details)
532+
reporter.message(f"Unknown name in doctype: {name!r}", details=details)
531533
return annotation
532534

533535
@cached_property
@@ -553,7 +555,10 @@ def attributes(self):
553555
break
554556

555557
if attribute.name in annotations:
556-
logger.warning("duplicate parameter name %r, ignoring", attribute.name)
558+
self.reporter.message(
559+
"duplicate attribute name in docstring",
560+
details=self.reporter.underline(attribute.name),
561+
)
557562
continue
558563

559564
annotation = self._doctype_to_annotation(attribute.type, ds_line=ds_line)
@@ -576,7 +581,10 @@ def parameters(self):
576581

577582
duplicates = param_section.keys() & other_section.keys()
578583
for duplicate in duplicates:
579-
logger.warning("duplicate parameter name %r, ignoring", duplicate)
584+
self.reporter.message(
585+
"duplicate attribute name in docstring",
586+
details=self.reporter.underline(duplicate),
587+
)
580588

581589
# Last takes priority
582590
paramaters = other_section | param_section
@@ -653,20 +661,21 @@ def _handle_missing_whitespace(self, param):
653661
param : numpydoc.docscrape.Parameter
654662
"""
655663
if ":" in param.name and param.type == "":
656-
msg = (
657-
"Possibly missing whitespace between parameter and colon in "
658-
"docstring, make sure to include it so that the type is parsed "
659-
"properly!"
664+
msg = "Possibly missing whitespace between parameter and colon in docstring"
665+
underline = "".join("^" if c == ":" else " " for c in param.name)
666+
underline = click.style(underline, fg="red", bold=True)
667+
hint = (
668+
f"{param.name}\n{underline}"
669+
f"\nInclude whitespace so that the type is parsed properly!"
660670
)
661-
hint = f"{param.name}"
662671

663672
ds_line = 0
664673
for i, line in enumerate(self.docstring.split("\n")):
665674
if param.name in line:
666675
ds_line = i
667676
break
668-
ctx = self.ctx.with_line(offset=ds_line)
669-
ctx.print_message(msg, details=hint)
677+
reporter = self.reporter.copy_with(line_offset=ds_line)
678+
reporter.message(msg, details=hint)
670679

671680
new_name, new_type = param.name.split(":", maxsplit=1)
672681
param = npds.Parameter(name=new_name, type=new_type, desc=param.desc)
@@ -693,7 +702,10 @@ def _section_annotations(self, name):
693702

694703
if param.name in annotated_params:
695704
# TODO make error
696-
logger.warning("duplicate parameter name %r, ignoring", param.name)
705+
self.reporter.message(
706+
"duplicate parameter / attribute name in docstring",
707+
details=self.reporter.underline(param.name),
708+
)
697709
continue
698710

699711
if param.type:

src/docstub/_stubs.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from ._analysis import KnownImport
1313
from ._docstrings import DocstringAnnotations, DoctypeTransformer
14-
from ._utils import ContextFormatter, module_name_from_path
14+
from ._utils import ErrorReporter, module_name_from_path
1515

1616
logger = logging.getLogger(__name__)
1717

@@ -350,18 +350,23 @@ def print_upper(x: Incomplete) -> None: ...
350350
)
351351
_Annotation_None: ClassVar[cst.Annotation] = cst.Annotation(cst.Name("None"))
352352

353-
def __init__(self, *, types_db=None, replace_doctypes=None):
353+
def __init__(self, *, types_db=None, replace_doctypes=None, reporter=None):
354354
"""
355355
Parameters
356356
----------
357357
types_db : ~.TypesDatabase
358358
replace_doctypes : dict[str, str]
359+
reporter : ~.ErrorReporter
359360
"""
361+
if reporter is None:
362+
reporter = ErrorReporter()
363+
360364
self.types_db = types_db
361365
self.replace_doctypes = replace_doctypes
362366
self.transformer = DoctypeTransformer(
363367
types_db=types_db, replace_doctypes=replace_doctypes
364368
)
369+
self.reporter = reporter
365370
# Relevant docstring for the current context
366371
self._scope_stack = None # Entered module, class or function scopes
367372
self._pytypes_stack = None # Collected pytypes for each stack
@@ -544,11 +549,17 @@ def leave_FunctionDef(self, original_node, updated_node):
544549
position = self.get_metadata(
545550
cst.metadata.PositionProvider, original_node
546551
).start
547-
ctx = ContextFormatter(path=self.current_source, line=position.line)
552+
reporter = self.reporter.copy_with(
553+
path=self.current_source, line=position.line
554+
)
548555
replaced = _inline_node_as_code(original_node.returns.annotation)
549-
ctx.print_message(
550-
short="replacing existing inline return annotation",
551-
details=f"{replaced}\n{"^" * len(replaced)} -> {annotation_value}",
556+
details = (
557+
f"{replaced}\n"
558+
f"{reporter.underline(replaced)} -> {annotation_value}"
559+
)
560+
reporter.message(
561+
short="Replacing existing inline return annotation",
562+
details=details,
552563
)
553564

554565
annotation = cst.Annotation(cst.parse_expression(annotation_value))
@@ -735,13 +746,18 @@ def leave_AnnAssign(self, original_node, updated_node):
735746
position = self.get_metadata(
736747
cst.metadata.PositionProvider, original_node
737748
).start
738-
ctx = ContextFormatter(path=self.current_source, line=position.line)
749+
reporter = self.reporter.copy_with(
750+
path=self.current_source, line=position.line
751+
)
739752
replaced = cst.Module([]).code_for_node(
740753
updated_node.annotation.annotation
741754
)
742-
ctx.print_message(
743-
short="replacing existing inline annotation",
744-
details=f"{replaced}\n{"^" * len(replaced)} -> {pytype.value}",
755+
details = (
756+
f"{replaced}\n{reporter.underline(replaced)} -> {pytype.value}"
757+
)
758+
reporter.message(
759+
short="Replacing existing inline annotation",
760+
details=details,
745761
)
746762

747763
updated_node = updated_node.with_deep_changes(
@@ -901,12 +917,14 @@ def _annotations_from_node(self, node):
901917
position = self.get_metadata(
902918
cst.metadata.PositionProvider, docstring_node
903919
).start
904-
ctx = ContextFormatter(path=self.current_source, line=position.line)
920+
reporter = self.reporter.copy_with(
921+
path=self.current_source, line=position.line
922+
)
905923
try:
906924
annotations = DocstringAnnotations(
907925
docstring_node.evaluated_value,
908926
transformer=self.transformer,
909-
ctx=ctx,
927+
reporter=reporter,
910928
)
911929
except (SystemExit, KeyboardInterrupt):
912930
raise

0 commit comments

Comments
 (0)