From db260dc3feb931947309e88fa93063a0a37b6eda Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Sat, 6 Jan 2018 17:06:59 -0500 Subject: A new kind of plug-in: configurers. #563 --- CHANGES.rst | 9 ++- coverage/control.py | 21 ++++-- coverage/ctracer/tracer.c | 4 +- coverage/plugin.py | 168 ++++++++++++++++++++++++++++++--------------- coverage/plugin_support.py | 10 +++ doc/api_plugin.rst | 9 ++- doc/plugins.rst | 52 +++++++------- tests/plugin_config.py | 22 ++++++ tests/test_plugins.py | 19 ++++- 9 files changed, 216 insertions(+), 98 deletions(-) create mode 100644 tests/plugin_config.py diff --git a/CHANGES.rst b/CHANGES.rst index b14f1ed..9885167 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,10 +5,15 @@ Change history for Coverage.py ============================== -Unreleases +Unreleased ---------- -None yet. +- A new kind of plugin is supported: configurators are invoked at start-up to + allow more complex configuration than the .coveragerc file can easily do. + See :ref:`api_plugin` for details. This solves the complex configuration + problem described in `issue 563`_. + +.. _issue 563: https://bitbucket.org/ned/coveragepy/issues/563/platform-specific-configuration .. _changes_442: diff --git a/coverage/control.py b/coverage/control.py index fdcb544..f1db1ef 100644 --- a/coverage/control.py +++ b/coverage/control.py @@ -11,6 +11,7 @@ import os import platform import re import sys +import time import traceback from coverage import env @@ -216,14 +217,22 @@ class Coverage(object): self._debug_file = sys.stderr self.debug = DebugControl(self.config.debug, self._debug_file) - # Load plugins - self.plugins = Plugins.load_plugins(self.config.plugins, self.config, self.debug) - # _exclude_re is a dict that maps exclusion list names to compiled regexes. self._exclude_re = {} set_relative_directory() + # Load plugins + self.plugins = Plugins.load_plugins(self.config.plugins, self.config, self.debug) + + # Run configuring plugins. + for plugin in self.plugins.configurers: + # We need an object with set_option and get_option. Either self or + # self.config will do. Choosing randomly stops people from doing + # other things with those objects, against the public API. Yes, + # this is a bit childish. :) + plugin.configure([self, self.config][int(time.time()) % 2]) + # The source argument can be directories or package names. self.source = [] self.source_pkgs = [] @@ -507,7 +516,7 @@ class Coverage(object): break except Exception: self._warn( - "Disabling plugin %r due to an exception:" % ( + "Disabling plug-in %r due to an exception:" % ( plugin._coverage_plugin_name ) ) @@ -639,8 +648,8 @@ class Coverage(object): option name. For example, the ``branch`` option in the ``[run]`` section of the config file would be indicated with ``"run:branch"``. - `value` is the new value for the option. This should be a Python - value where appropriate. For example, use True for booleans, not the + `value` is the new value for the option. This should be an + appropriate Python value. For example, use True for booleans, not the string ``"True"``. As an example, calling:: diff --git a/coverage/ctracer/tracer.c b/coverage/ctracer/tracer.c index 0ade741..6dcdc57 100644 --- a/coverage/ctracer/tracer.c +++ b/coverage/ctracer/tracer.c @@ -596,7 +596,7 @@ CTracer_disable_plugin(CTracer *self, PyObject * disposition) goto error; } msg = MyText_FromFormat( - "Disabling plugin '%s' due to previous exception", + "Disabling plug-in '%s' due to previous exception", MyText_AsString(plugin_name) ); if (msg == NULL) { @@ -622,7 +622,7 @@ error: /* This function doesn't return a status, so if an error happens, print it, * but don't interrupt the flow. */ /* PySys_WriteStderr is nicer, but is not in the public API. */ - fprintf(stderr, "Error occurred while disabling plugin:\n"); + fprintf(stderr, "Error occurred while disabling plug-in:\n"); PyErr_Print(); ok: diff --git a/coverage/plugin.py b/coverage/plugin.py index 3e0e483..b11aa56 100644 --- a/coverage/plugin.py +++ b/coverage/plugin.py @@ -1,70 +1,105 @@ # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 # For details: https://bitbucket.org/ned/coveragepy/src/default/NOTICE.txt -"""Plugin interfaces for coverage.py""" +""" +Plug-in interfaces for coverage.py. -from coverage import files -from coverage.misc import contract, _needs_to_implement +Coverage.py supports a few different kinds of plug-ins that change its +behavior: +* File tracers implement tracing of non-Python file types. -class CoveragePlugin(object): - """Base class for coverage.py plugins. +* Configurers add custom configuration, using Python code to change the + configuration. - To write a coverage.py plugin, create a module with a subclass of - :class:`CoveragePlugin`. You will override methods in your class to - participate in various aspects of coverage.py's processing. +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. - Currently the only plugin type is a file tracer, for implementing - measurement support for non-Python files. File tracer plugins implement - the :meth:`file_tracer` method to claim files and the :meth:`file_reporter` - method to report on those files. +Any plug-in can optionally implement :meth:`~coverage.CoveragePlugin.sys_info` +to provide debugging information about their operation. - Any plugin can optionally implement :meth:`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:: - Coverage.py will store its own information on your plugin object, using - attributes whose names start with ``_coverage_``. Don't be startled. + import coverage - To register your plugin, define a function called `coverage_init` in your - module:: + class MyPlugin(coverage.CoveragePlugin): + ... - def coverage_init(reg, options): - reg.add_file_tracer(MyPlugin()) + def coverage_init(reg, options): + reg.add_file_tracer(MyPlugin()) - You use the `reg` parameter passed to your `coverage_init` function to - register your plugin object. It has one method, `add_file_tracer`, which - takes a newly created instance of your plugin. +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 plugin takes options, the `options` parameter is a dictionary of - your plugin's options from the coverage.py configuration file. Use them - however you want to configure your object before registering it. +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. + + +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. + + +Configurers +=========== + +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. + +""" + +from coverage import files +from coverage.misc import contract, _needs_to_implement - """ + +class CoveragePlugin(object): + """Base class for coverage.py plug-ins.""" def file_tracer(self, filename): # pylint: disable=unused-argument """Get a :class:`FileTracer` object for a file. - Every Python source file is offered to the plugin to give it a chance - to take responsibility for tracing the file. If your plugin can handle - the file, then return a :class:`FileTracer` object. Otherwise return - None. + Plug-in type: file tracer. - There is no way to register your plugin for particular files. Instead, - this method is invoked for all files, and the plugin 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 plugin. + 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, then 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, 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 plugin, depending on the kind of - files your plugin supports: + 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 plugin is invoked with + 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 plugin implements + Python file. In this case, your plug-in implements :meth:`FileTracer.dynamic_source_filename` to provide the actual source file for each execution frame. @@ -73,7 +108,7 @@ class CoveragePlugin(object): paths, be sure to take this into account. Returns a :class:`FileTracer` object to use to trace `filename`, or - None if this plugin cannot trace this file. + None if this plug-in cannot trace this file. """ return None @@ -81,6 +116,8 @@ class CoveragePlugin(object): def file_reporter(self, filename): # pylint: disable=unused-argument """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. @@ -92,7 +129,9 @@ class CoveragePlugin(object): def find_executable_files(self, src_dir): # pylint: disable=unused-argument """Yield all of the executable files in `src_dir`, recursively. - Executability is a plugin-specific property, but generally means files + 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. @@ -102,11 +141,27 @@ class CoveragePlugin(object): """ return [] + def configure(self, config): + """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 change 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): """Get a list of information useful for debugging. + Plug-in type: any. + This method will be invoked for ``--debug=sys``. Your - plugin can return any information it wants to be displayed. + plug-in can return any information it wants to be displayed. Returns a list of pairs: `[(name, value), ...]`. @@ -117,6 +172,9 @@ class CoveragePlugin(object): class FileTracer(object): """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`. @@ -131,7 +189,7 @@ class FileTracer(object): def source_filename(self): """The source file name for this file. - This may be any file name you like. A key responsibility of a plugin + 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. @@ -164,7 +222,7 @@ class FileTracer(object): def dynamic_source_filename(self, filename, frame): # pylint: disable=unused-argument """Get a dynamically computed source file name. - Some plugins need to compute the source file name dynamically for each + Some plug-ins need to compute the source file name dynamically for each frame. This function will not be invoked if @@ -197,14 +255,14 @@ class FileTracer(object): class FileReporter(object): """Support needed for files during the analysis and reporting phases. - See :ref:`howitworks` for details of the different coverage.py phases. - - `FileReporter` objects should only be created in the - :meth:`CoveragePlugin.file_reporter` method. + 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): @@ -248,7 +306,7 @@ class FileReporter(object): def lines(self): """Get the executable lines in this file. - Your plugin must determine which lines in the file were possibly + 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. @@ -259,7 +317,7 @@ class FileReporter(object): def excluded_lines(self): """Get the excluded executable lines in this file. - Your plugin can use any method it likes to allow the user to exclude + 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. @@ -277,8 +335,8 @@ class FileReporter(object): multi-line statement, but reports are nicer if they mention the first line. - Your plugin can optionally define this method to perform these kinds of - adjustment. + Your plug-in can optionally define this method to perform these kinds + of adjustment. `lines` is a sequence of integers, the recorded line numbers. @@ -292,7 +350,7 @@ class FileReporter(object): def arcs(self): """Get the executable arcs in this file. - To support branch coverage, your plugin needs to be able to indicate + 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. @@ -306,7 +364,7 @@ class FileReporter(object): def no_branch_lines(self): """Get the lines excused from branch coverage in this file. - Your plugin can use any method it likes to allow the user to exclude + 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. @@ -337,7 +395,7 @@ class FileReporter(object): 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 plugin... + if you attempt to implement this method in your plug-in... """ return {} diff --git a/coverage/plugin_support.py b/coverage/plugin_support.py index 66cc710..c737a42 100644 --- a/coverage/plugin_support.py +++ b/coverage/plugin_support.py @@ -20,6 +20,7 @@ class Plugins(object): self.order = [] self.names = {} self.file_tracers = [] + self.configurers = [] self.current_module = None self.debug = None @@ -60,6 +61,15 @@ class Plugins(object): """ self._add_plugin(plugin, self.file_tracers) + def add_configurer(self, plugin): + """Add a configuring plugin. + + `plugin` is an instance of a third-party plugin class. It must + implement the :meth:`CoveragePlugin.configure` method. + + """ + self._add_plugin(plugin, self.configurers) + def add_noop(self, plugin): """Add a plugin that does nothing. diff --git a/doc/api_plugin.rst b/doc/api_plugin.rst index 3ab9cb2..80d0830 100644 --- a/doc/api_plugin.rst +++ b/doc/api_plugin.rst @@ -3,14 +3,17 @@ .. _api_plugin: -============== -Plugin classes -============== +=============== +Plug-in classes +=============== .. :history: 20150815T132400, new doc for 4.0b2 .. versionadded:: 4.0 +.. automodule:: coverage.plugin + + .. module:: coverage The CoveragePlugin class diff --git a/doc/plugins.rst b/doc/plugins.rst index 2d7fc27..fdc1adf 100644 --- a/doc/plugins.rst +++ b/doc/plugins.rst @@ -3,74 +3,72 @@ .. _plugins: -======= -Plugins -======= +======== +Plug-ins +======== .. :history: 20150124T143000, new page. .. :history: 20150802T174600, updated for 4.0b1 +.. :history: 20171228T213800, updated for configurer plugins -Coverage.py's behavior can be extended with third-party plugins. A plugin is a +Coverage.py's behavior can be extended with third-party plug-ins. A plug-in is a separately installed Python class that you register in your .coveragerc. -Plugins can be used to implement coverage measurement for non-Python files. +Plugins can alter a number of aspects of coverage.py's behavior, including +implementing coverage measurement for non-Python files. -Plugins are only supported with the :ref:`C extension `, -which must be installed for plugins to work. - -Information about using plugins is on this page. To write a plugin, see +Information about using plug-ins is on this page. To write a plug-in, see :ref:`api_plugin`. .. versionadded:: 4.0 -Using plugins -------------- +Using plug-ins +-------------- -To use a coverage.py plugin, you install it, and configure it. For this +To use a coverage.py plug-in, you install it and configure it. For this example, let's say there's a Python package called ``something`` that provides -a coverage.py plugin called ``something.plugin``. +a coverage.py plug-in called ``something.plugin``. -#. Install the plugin's package as you would any other Python package:: +#. Install the plug-in's package as you would any other Python package:: pip install something -#. Configure coverage.py to use the plugin. You do this by editing (or +#. Configure coverage.py to use the plug-in. You do this by editing (or creating) your .coveragerc file, as described in :ref:`config`. The - ``plugins`` setting indicates your plugin. It's a list of importable module - names of plugins:: + ``plugins`` setting indicates your plug-in. It's a list of importable module + names of plug-ins:: [run] plugins = something.plugin -#. If the plugin needs its own configuration, you can add those settings in - the .coveragerc file in a section named for the plugin:: +#. If the plug-in needs its own configuration, you can add those settings in + the .coveragerc file in a section named for the plug-in:: [something.plugin] option1 = True option2 = abc.foo - Check the documentation for the plugin to see if it takes any options, and - what they are. + Check the documentation for the plug-in for details on the options it takes. #. Run your tests with coverage.py as you usually would. If you get a message like "Plugin file tracers (something.plugin) aren't supported with PyTracer," then you don't have the :ref:`C extension ` - installed. + installed. The C extension is needed for certain plug-ins. -Available plugins ------------------ +Available plug-ins +------------------ -Some coverage.py plugins you might find useful: +Some coverage.py plug-ins you might find useful: -* `Django template coverage.py plugin`__: for measuring coverage in Django +* `Django template coverage.py plug-in`__: for measuring coverage in Django templates. .. __: https://pypi.python.org/pypi/django_coverage_plugin -* `Mako template coverage plugin`__: for measuring coverage in Mako templates. +* `Mako template coverage plug-in`__: for measuring coverage in Mako templates. Doesn't work yet, probably needs some changes in Mako itself. .. __: https://bitbucket.org/ned/coverage-mako-plugin diff --git a/tests/plugin_config.py b/tests/plugin_config.py new file mode 100644 index 0000000..67a790a --- /dev/null +++ b/tests/plugin_config.py @@ -0,0 +1,22 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://bitbucket.org/ned/coveragepy/src/default/NOTICE.txt + +"""A configuring plugin for test_plugins.py to import.""" + +import coverage + + +class Plugin(coverage.CoveragePlugin): + """A configuring plugin for testing.""" + def configure(self, config): + """Configure all the things!""" + opt_name = "report:exclude_lines" + exclude_lines = config.get_option(opt_name) + exclude_lines.append(r"pragma: custom") + exclude_lines.append(r"pragma: or whatever") + config.set_option(opt_name, exclude_lines) + + +def coverage_init(reg, options): # pylint: disable=unused-argument + """Called by coverage to initialize the plugins here.""" + reg.add_configurer(Plugin()) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 4dfe0bd..c4fc3ac 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -621,10 +621,10 @@ class BadFileTracerTest(FileTracerTest): # There should be a warning explaining what's happening, but only one. # The message can be in two forms: - # Disabling plugin '...' due to previous exception + # Disabling plug-in '...' due to previous exception # or: - # Disabling plugin '...' due to an exception: - msg = "Disabling plugin '%s.%s' due to " % (module_name, plugin_name) + # Disabling plug-in '...' due to an exception: + msg = "Disabling plug-in '%s.%s' due to " % (module_name, plugin_name) warnings = stderr.count(msg) self.assertEqual(warnings, 1) @@ -848,3 +848,16 @@ class BadFileTracerTest(FileTracerTest): self.run_bad_plugin( "bad_plugin", "Plugin", our_error=False, excmsg="an integer is required", ) + + +class ConfigurerPluginTest(CoverageTest): + """Test configuring plugins.""" + + def test_configurer_plugin(self): + cov = coverage.Coverage() + cov.set_option("run:plugins", ["tests.plugin_config"]) + cov.start() + cov.stop() # pragma: nested + excluded = cov.get_option("report:exclude_lines") + self.assertIn("pragma: custom", excluded) + self.assertIn("pragma: or whatever", excluded) -- cgit v1.2.1