summaryrefslogtreecommitdiff
path: root/coverage/plugin.py
blob: 5279c4d065e8cde64df01bbd6c734762deaeb365 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt

"""
.. versionadded:: 4.0

Plug-in interfaces for coverage.py.

Coverage.py supports a few different kinds of plug-ins that change its
behavior:

* File tracers implement tracing of non-Python file types.

* Configurers add custom configuration, using Python code to change the
  configuration.

* Dynamic context switchers decide when the dynamic context has changed, for
  example, to record what test function produced the coverage.

To write a coverage.py plug-in, create a module with a subclass of
:class:`~coverage.CoveragePlugin`.  You will override methods in your class to
participate in various aspects of coverage.py's processing.
Different types of plug-ins have to override different methods.

Any plug-in can optionally implement :meth:`~coverage.CoveragePlugin.sys_info`
to provide debugging information about their operation.

Your module must also contain a ``coverage_init`` function that registers an
instance of your plug-in class::

    import coverage

    class MyPlugin(coverage.CoveragePlugin):
        ...

    def coverage_init(reg, options):
        reg.add_file_tracer(MyPlugin())

You use the `reg` parameter passed to your ``coverage_init`` function to
register your plug-in object.  The registration method you call depends on
what kind of plug-in it is.

If your plug-in takes options, the `options` parameter is a dictionary of your
plug-in's options from the coverage.py configuration file.  Use them however
you want to configure your object before registering it.

Coverage.py will store its own information on your plug-in object, using
attributes whose names start with ``_coverage_``.  Don't be startled.

.. warning::
    Plug-ins are imported by coverage.py before it begins measuring code.
    If you write a plugin in your own project, it might import your product
    code before coverage.py can start measuring.  This can result in your
    own code being reported as missing.

    One solution is to put your plugins in your project tree, but not in
    your importable Python package.


.. _file_tracer_plugins:

File Tracers
============

File tracers implement measurement support for non-Python files.  File tracers
implement the :meth:`~coverage.CoveragePlugin.file_tracer` method to claim
files and the :meth:`~coverage.CoveragePlugin.file_reporter` method to report
on those files.

In your ``coverage_init`` function, use the ``add_file_tracer`` method to
register your file tracer.


.. _configurer_plugins:

Configurers
===========

.. versionadded:: 4.5

Configurers modify the configuration of coverage.py during start-up.
Configurers implement the :meth:`~coverage.CoveragePlugin.configure` method to
change the configuration.

In your ``coverage_init`` function, use the ``add_configurer`` method to
register your configurer.


.. _dynamic_context_plugins:

Dynamic Context Switchers
=========================

.. versionadded:: 5.0

Dynamic context switcher plugins implement the
:meth:`~coverage.CoveragePlugin.dynamic_context` method to dynamically compute
the context label for each measured frame.

Computed context labels are useful when you want to group measured data without
modifying the source code.

For example, you could write a plugin that checks `frame.f_code` to inspect
the currently executed method, and set the context label to a fully qualified
method name if it's an instance method of `unittest.TestCase` and the method
name starts with 'test'.  Such a plugin would provide basic coverage grouping
by test and could be used with test runners that have no built-in coveragepy
support.

In your ``coverage_init`` function, use the ``add_dynamic_context`` method to
register your dynamic context switcher.

"""

from __future__ import annotations

import functools

from types import FrameType
from typing import Any, Dict, Iterable, Optional, Set, Tuple, Union

from coverage import files
from coverage.misc import _needs_to_implement
from coverage.types import TArc, TConfigurable, TLineNo, TSourceTokenLines


class CoveragePlugin:
    """Base class for coverage.py plug-ins."""

    _coverage_plugin_name: str
    _coverage_enabled: bool

    def file_tracer(self, filename: str) -> Optional[FileTracer]: # pylint: disable=unused-argument
        """Get a :class:`FileTracer` object for a file.

        Plug-in type: file tracer.

        Every Python source file is offered to your plug-in to give it a chance
        to take responsibility for tracing the file.  If your plug-in can
        handle the file, it should return a :class:`FileTracer` object.
        Otherwise return None.

        There is no way to register your plug-in for particular files.
        Instead, this method is invoked for all  files as they are executed,
        and the plug-in decides whether it can trace the file or not.
        Be prepared for `filename` to refer to all kinds of files that have
        nothing to do with your plug-in.

        The file name will be a Python file being executed.  There are two
        broad categories of behavior for a plug-in, depending on the kind of
        files your plug-in supports:

        * Static file names: each of your original source files has been
          converted into a distinct Python file.  Your plug-in is invoked with
          the Python file name, and it maps it back to its original source
          file.

        * Dynamic file names: all of your source files are executed by the same
          Python file.  In this case, your plug-in implements
          :meth:`FileTracer.dynamic_source_filename` to provide the actual
          source file for each execution frame.

        `filename` is a string, the path to the file being considered.  This is
        the absolute real path to the file.  If you are comparing to other
        paths, be sure to take this into account.

        Returns a :class:`FileTracer` object to use to trace `filename`, or
        None if this plug-in cannot trace this file.

        """
        return None

    def file_reporter(                  # type: ignore[return]
        self,
        filename: str,                  # pylint: disable=unused-argument
    ) -> Union[FileReporter, str]:      # str should be Literal["python"]
        """Get the :class:`FileReporter` class to use for a file.

        Plug-in type: file tracer.

        This will only be invoked if `filename` returns non-None from
        :meth:`file_tracer`.  It's an error to return None from this method.

        Returns a :class:`FileReporter` object to use to report on `filename`,
        or the string `"python"` to have coverage.py treat the file as Python.

        """
        _needs_to_implement(self, "file_reporter")

    def dynamic_context(
        self,
        frame: FrameType,               # pylint: disable=unused-argument
    ) -> Optional[str]:
        """Get the dynamically computed context label for `frame`.

        Plug-in type: dynamic context.

        This method is invoked for each frame when outside of a dynamic
        context, to see if a new dynamic context should be started.  If it
        returns a string, a new context label is set for this and deeper
        frames.  The dynamic context ends when this frame returns.

        Returns a string to start a new dynamic context, or None if no new
        context should be started.

        """
        return None

    def find_executable_files(
        self,
        src_dir: str,                   # pylint: disable=unused-argument
    ) -> Iterable[str]:
        """Yield all of the executable files in `src_dir`, recursively.

        Plug-in type: file tracer.

        Executability is a plug-in-specific property, but generally means files
        which would have been considered for coverage analysis, had they been
        included automatically.

        Returns or yields a sequence of strings, the paths to files that could
        have been executed, including files that had been executed.

        """
        return []

    def configure(self, config: TConfigurable) -> None:
        """Modify the configuration of coverage.py.

        Plug-in type: configurer.

        This method is called during coverage.py start-up, to give your plug-in
        a chance to change the configuration.  The `config` parameter is an
        object with :meth:`~coverage.Coverage.get_option` and
        :meth:`~coverage.Coverage.set_option` methods.  Do not call any other
        methods on the `config` object.

        """
        pass

    def sys_info(self) -> Iterable[Tuple[str, Any]]:
        """Get a list of information useful for debugging.

        Plug-in type: any.

        This method will be invoked for ``--debug=sys``.  Your
        plug-in can return any information it wants to be displayed.

        Returns a list of pairs: `[(name, value), ...]`.

        """
        return []


class CoveragePluginBase:
    """Plugins produce specialized objects, which point back to the original plugin."""
    _coverage_plugin: CoveragePlugin


class FileTracer(CoveragePluginBase):
    """Support needed for files during the execution phase.

    File tracer plug-ins implement subclasses of FileTracer to return from
    their :meth:`~CoveragePlugin.file_tracer` method.

    You may construct this object from :meth:`CoveragePlugin.file_tracer` any
    way you like.  A natural choice would be to pass the file name given to
    `file_tracer`.

    `FileTracer` objects should only be created in the
    :meth:`CoveragePlugin.file_tracer` method.

    See :ref:`howitworks` for details of the different coverage.py phases.

    """

    def source_filename(self) -> str:       # type: ignore[return]
        """The source file name for this file.

        This may be any file name you like.  A key responsibility of a plug-in
        is to own the mapping from Python execution back to whatever source
        file name was originally the source of the code.

        See :meth:`CoveragePlugin.file_tracer` for details about static and
        dynamic file names.

        Returns the file name to credit with this execution.

        """
        _needs_to_implement(self, "source_filename")

    def has_dynamic_source_filename(self) -> bool:
        """Does this FileTracer have dynamic source file names?

        FileTracers can provide dynamically determined file names by
        implementing :meth:`dynamic_source_filename`.  Invoking that function
        is expensive. To determine whether to invoke it, coverage.py uses the
        result of this function to know if it needs to bother invoking
        :meth:`dynamic_source_filename`.

        See :meth:`CoveragePlugin.file_tracer` for details about static and
        dynamic file names.

        Returns True if :meth:`dynamic_source_filename` should be called to get
        dynamic source file names.

        """
        return False

    def dynamic_source_filename(
        self,
        filename: str,                  # pylint: disable=unused-argument
        frame: FrameType,               # pylint: disable=unused-argument
    ) -> Optional[str]:
        """Get a dynamically computed source file name.

        Some plug-ins need to compute the source file name dynamically for each
        frame.

        This function will not be invoked if
        :meth:`has_dynamic_source_filename` returns False.

        Returns the source file name for this frame, or None if this frame
        shouldn't be measured.

        """
        return None

    def line_number_range(self, frame: FrameType) -> Tuple[TLineNo, TLineNo]:
        """Get the range of source line numbers for a given a call frame.

        The call frame is examined, and the source line number in the original
        file is returned.  The return value is a pair of numbers, the starting
        line number and the ending line number, both inclusive.  For example,
        returning (5, 7) means that lines 5, 6, and 7 should be considered
        executed.

        This function might decide that the frame doesn't indicate any lines
        from the source file were executed.  Return (-1, -1) in this case to
        tell coverage.py that no lines should be recorded for this frame.

        """
        lineno = frame.f_lineno
        return lineno, lineno


@functools.total_ordering
class FileReporter(CoveragePluginBase):
    """Support needed for files during the analysis and reporting phases.

    File tracer plug-ins implement a subclass of `FileReporter`, and return
    instances from their :meth:`CoveragePlugin.file_reporter` method.

    There are many methods here, but only :meth:`lines` is required, to provide
    the set of executable lines in the file.

    See :ref:`howitworks` for details of the different coverage.py phases.

    """

    def __init__(self, filename: str) -> None:
        """Simple initialization of a `FileReporter`.

        The `filename` argument is the path to the file being reported.  This
        will be available as the `.filename` attribute on the object.  Other
        method implementations on this base class rely on this attribute.

        """
        self.filename = filename

    def __repr__(self) -> str:
        return "<{0.__class__.__name__} filename={0.filename!r}>".format(self)

    def relative_filename(self) -> str:
        """Get the relative file name for this file.

        This file path will be displayed in reports.  The default
        implementation will supply the actual project-relative file path.  You
        only need to supply this method if you have an unusual syntax for file
        paths.

        """
        return files.relative_filename(self.filename)

    def source(self) -> str:
        """Get the source for the file.

        Returns a Unicode string.

        The base implementation simply reads the `self.filename` file and
        decodes it as UTF-8.  Override this method if your file isn't readable
        as a text file, or if you need other encoding support.

        """
        with open(self.filename, encoding="utf-8") as f:
            return f.read()

    def lines(self) -> Set[TLineNo]:    # type: ignore[return]
        """Get the executable lines in this file.

        Your plug-in must determine which lines in the file were possibly
        executable.  This method returns a set of those line numbers.

        Returns a set of line numbers.

        """
        _needs_to_implement(self, "lines")

    def excluded_lines(self) -> Set[TLineNo]:
        """Get the excluded executable lines in this file.

        Your plug-in can use any method it likes to allow the user to exclude
        executable lines from consideration.

        Returns a set of line numbers.

        The base implementation returns the empty set.

        """
        return set()

    def translate_lines(self, lines: Iterable[TLineNo]) -> Set[TLineNo]:
        """Translate recorded lines into reported lines.

        Some file formats will want to report lines slightly differently than
        they are recorded.  For example, Python records the last line of a
        multi-line statement, but reports are nicer if they mention the first
        line.

        Your plug-in can optionally define this method to perform these kinds
        of adjustment.

        `lines` is a sequence of integers, the recorded line numbers.

        Returns a set of integers, the adjusted line numbers.

        The base implementation returns the numbers unchanged.

        """
        return set(lines)

    def arcs(self) -> Set[TArc]:
        """Get the executable arcs in this file.

        To support branch coverage, your plug-in needs to be able to indicate
        possible execution paths, as a set of line number pairs.  Each pair is
        a `(prev, next)` pair indicating that execution can transition from the
        `prev` line number to the `next` line number.

        Returns a set of pairs of line numbers.  The default implementation
        returns an empty set.

        """
        return set()

    def no_branch_lines(self) -> Set[TLineNo]:
        """Get the lines excused from branch coverage in this file.

        Your plug-in can use any method it likes to allow the user to exclude
        lines from consideration of branch coverage.

        Returns a set of line numbers.

        The base implementation returns the empty set.

        """
        return set()

    def translate_arcs(self, arcs: Iterable[TArc]) -> Set[TArc]:
        """Translate recorded arcs into reported arcs.

        Similar to :meth:`translate_lines`, but for arcs.  `arcs` is a set of
        line number pairs.

        Returns a set of line number pairs.

        The default implementation returns `arcs` unchanged.

        """
        return set(arcs)

    def exit_counts(self) -> Dict[TLineNo, int]:
        """Get a count of exits from that each line.

        To determine which lines are branches, coverage.py looks for lines that
        have more than one exit.  This function creates a dict mapping each
        executable line number to a count of how many exits it has.

        To be honest, this feels wrong, and should be refactored.  Let me know
        if you attempt to implement this method in your plug-in...

        """
        return {}

    def missing_arc_description(
        self,
        start: TLineNo,
        end: TLineNo,
        executed_arcs: Optional[Iterable[TArc]] = None,     # pylint: disable=unused-argument
    ) -> str:
        """Provide an English sentence describing a missing arc.

        The `start` and `end` arguments are the line numbers of the missing
        arc. Negative numbers indicate entering or exiting code objects.

        The `executed_arcs` argument is a set of line number pairs, the arcs
        that were executed in this file.

        By default, this simply returns the string "Line {start} didn't jump
        to {end}".

        """
        return f"Line {start} didn't jump to line {end}"

    def source_token_lines(self) -> TSourceTokenLines:
        """Generate a series of tokenized lines, one for each line in `source`.

        These tokens are used for syntax-colored reports.

        Each line is a list of pairs, each pair is a token::

            [('key', 'def'), ('ws', ' '), ('nam', 'hello'), ('op', '('), ... ]

        Each pair has a token class, and the token text.  The token classes
        are:

        * ``'com'``: a comment
        * ``'key'``: a keyword
        * ``'nam'``: a name, or identifier
        * ``'num'``: a number
        * ``'op'``: an operator
        * ``'str'``: a string literal
        * ``'ws'``: some white space
        * ``'txt'``: some other kind of text

        If you concatenate all the token texts, and then join them with
        newlines, you should have your original source back.

        The default implementation simply returns each line tagged as
        ``'txt'``.

        """
        for line in self.source().splitlines():
            yield [('txt', line)]

    def __eq__(self, other: Any) -> bool:
        return isinstance(other, FileReporter) and self.filename == other.filename

    def __lt__(self, other: Any) -> bool:
        return isinstance(other, FileReporter) and self.filename < other.filename

    # This object doesn't need to be hashed.
    __hash__ = None         # type: ignore[assignment]