From 156f90369f007928f26c6a4980b1ac0cc0691e53 Mon Sep 17 00:00:00 2001 From: Ian Stapleton Cordasco Date: Tue, 8 Aug 2017 08:54:40 -0500 Subject: [PATCH 1/4] Update our plugin registration for debugging This allows us to report whether or not a plugin is local when users provide `flake8 --bug-report` output. --- src/flake8/main/debug.py | 10 ++++++-- src/flake8/options/manager.py | 17 ++++++++++---- src/flake8/plugins/manager.py | 2 -- tests/unit/test_debug.py | 35 ++++++++++++++++++++-------- tests/unit/test_option_manager.py | 38 ++++++++++++++++--------------- tests/unit/test_plugin.py | 11 --------- 6 files changed, 65 insertions(+), 48 deletions(-) diff --git a/src/flake8/main/debug.py b/src/flake8/main/debug.py index e6ea141..ca3827e 100644 --- a/src/flake8/main/debug.py +++ b/src/flake8/main/debug.py @@ -53,8 +53,14 @@ def information(option_manager): def plugins_from(option_manager): """Generate the list of plugins installed.""" - return [{'plugin': plugin, 'version': version} - for (plugin, version) in sorted(option_manager.registered_plugins)] + return [ + { + 'plugin': plugin.name, + 'version': plugin.version, + 'is_local': plugin.local, + } + for plugin in sorted(option_manager.registered_plugins) + ] def dependencies(): diff --git a/src/flake8/options/manager.py b/src/flake8/options/manager.py index 7b23732..5b4796f 100644 --- a/src/flake8/options/manager.py +++ b/src/flake8/options/manager.py @@ -1,4 +1,5 @@ """Option handling and Option management logic.""" +import collections import logging import optparse # pylint: disable=deprecated-module @@ -153,6 +154,10 @@ class Option(object): return self._opt +PluginVersion = collections.namedtuple("PluginVersion", + ["name", "version", "local"]) + + class OptionManager(object): """Manage Options and OptionParser while adding post-processing.""" @@ -178,9 +183,9 @@ class OptionManager(object): self.extended_default_select = set() @staticmethod - def format_plugin(plugin_tuple): - """Convert a plugin tuple into a dictionary mapping name to value.""" - return dict(zip(["name", "version"], plugin_tuple)) + def format_plugin(plugin): + """Convert a PluginVersion into a dictionary mapping name to value.""" + return {attr: getattr(plugin, attr) for attr in ["name", "version"]} def add_option(self, *args, **kwargs): """Create and register a new option. @@ -306,7 +311,7 @@ class OptionManager(object): self._normalize(options) return options, xargs - def register_plugin(self, name, version): + def register_plugin(self, name, version, local=False): """Register a plugin relying on the OptionManager. :param str name: @@ -314,5 +319,7 @@ class OptionManager(object): attribute of the class or function loaded from the entry-point. :param str version: The version of the checker that we're using. + :param bool local: + Whether the plugin is local to the project/repository or not. """ - self.registered_plugins.add((name, version)) + self.registered_plugins.add(PluginVersion(name, version, local)) diff --git a/src/flake8/plugins/manager.py b/src/flake8/plugins/manager.py index 699b1aa..503dfbb 100644 --- a/src/flake8/plugins/manager.py +++ b/src/flake8/plugins/manager.py @@ -115,8 +115,6 @@ class Plugin(object): self._version = version_for(self) else: self._version = self.plugin.version - if self.local: - self._version += ' [local]' return self._version diff --git a/tests/unit/test_debug.py b/tests/unit/test_debug.py index b416378..7253bcc 100644 --- a/tests/unit/test_debug.py +++ b/tests/unit/test_debug.py @@ -4,6 +4,7 @@ import pytest import setuptools from flake8.main import debug +from flake8.options import manager def test_dependencies(): @@ -15,11 +16,18 @@ def test_dependencies(): @pytest.mark.parametrize('plugins, expected', [ ([], []), - ([('pycodestyle', '2.0.0')], [{'plugin': 'pycodestyle', - 'version': '2.0.0'}]), - ([('pycodestyle', '2.0.0'), ('mccabe', '0.5.9')], - [{'plugin': 'mccabe', 'version': '0.5.9'}, - {'plugin': 'pycodestyle', 'version': '2.0.0'}]), + ([manager.PluginVersion('pycodestyle', '2.0.0', False)], + [{'plugin': 'pycodestyle', 'version': '2.0.0', 'is_local': False}]), + ([manager.PluginVersion('pycodestyle', '2.0.0', False), + manager.PluginVersion('mccabe', '0.5.9', False)], + [{'plugin': 'mccabe', 'version': '0.5.9', 'is_local': False}, + {'plugin': 'pycodestyle', 'version': '2.0.0', 'is_local': False}]), + ([manager.PluginVersion('pycodestyle', '2.0.0', False), + manager.PluginVersion('my-local', '0.0.1', True), + manager.PluginVersion('mccabe', '0.5.9', False)], + [{'plugin': 'mccabe', 'version': '0.5.9', 'is_local': False}, + {'plugin': 'my-local', 'version': '0.0.1', 'is_local': True}, + {'plugin': 'pycodestyle', 'version': '2.0.0', 'is_local': False}]), ]) def test_plugins_from(plugins, expected): """Test that we format plugins appropriately.""" @@ -34,8 +42,10 @@ def test_information(system, pyversion, pyimpl): """Verify that we return all the information we care about.""" expected = { 'version': '3.1.0', - 'plugins': [{'plugin': 'mccabe', 'version': '0.5.9'}, - {'plugin': 'pycodestyle', 'version': '2.0.0'}], + 'plugins': [{'plugin': 'mccabe', 'version': '0.5.9', + 'is_local': False}, + {'plugin': 'pycodestyle', 'version': '2.0.0', + 'is_local': False}], 'dependencies': [{'dependency': 'setuptools', 'version': setuptools.__version__}], 'platform': { @@ -45,8 +55,10 @@ def test_information(system, pyversion, pyimpl): }, } option_manager = mock.Mock( - registered_plugins={('pycodestyle', '2.0.0'), - ('mccabe', '0.5.9')}, + registered_plugins={ + manager.PluginVersion('pycodestyle', '2.0.0', False), + manager.PluginVersion('mccabe', '0.5.9', False), + }, version='3.1.0', ) assert expected == debug.information(option_manager) @@ -75,7 +87,10 @@ def test_print_information_no_plugins(dumps, information, print_mock): @mock.patch('json.dumps', return_value='{}') def test_print_information(dumps, information, print_mock): """Verify we print and exit only when we have plugins.""" - plugins = [('pycodestyle', '2.0.0'), ('mccabe', '0.5.9')] + plugins = [ + manager.PluginVersion('pycodestyle', '2.0.0', False), + manager.PluginVersion('mccabe', '0.5.9', False), + ] option_manager = mock.Mock(registered_plugins=set(plugins)) with pytest.raises(SystemExit): debug.print_information( diff --git a/tests/unit/test_option_manager.py b/tests/unit/test_option_manager.py index 950f191..0f5c058 100644 --- a/tests/unit/test_option_manager.py +++ b/tests/unit/test_option_manager.py @@ -121,7 +121,9 @@ def test_parse_args_normalize_paths(optmanager): def test_format_plugin(): """Verify that format_plugin turns a tuple into a dictionary.""" - plugin = manager.OptionManager.format_plugin(('Testing', '0.0.0')) + plugin = manager.OptionManager.format_plugin( + manager.PluginVersion('Testing', '0.0.0', False) + ) assert plugin['name'] == 'Testing' assert plugin['version'] == '0.0.0' @@ -129,9 +131,9 @@ def test_format_plugin(): def test_generate_versions(optmanager): """Verify a comma-separated string is generated of registered plugins.""" optmanager.registered_plugins = [ - ('Testing 100', '0.0.0'), - ('Testing 101', '0.0.0'), - ('Testing 300', '0.0.0'), + manager.PluginVersion('Testing 100', '0.0.0', False), + manager.PluginVersion('Testing 101', '0.0.0', False), + manager.PluginVersion('Testing 300', '0.0.0', True), ] assert (optmanager.generate_versions() == 'Testing 100: 0.0.0, Testing 101: 0.0.0, Testing 300: 0.0.0') @@ -140,11 +142,11 @@ def test_generate_versions(optmanager): def test_plugins_are_sorted_in_generate_versions(optmanager): """Verify we sort before joining strings in generate_versions.""" optmanager.registered_plugins = [ - ('pyflakes', '1.5.0'), - ('mccabe', '0.7.0'), - ('pycodestyle', '2.2.0'), - ('flake8-docstrings', '0.6.1'), - ('flake8-bugbear', '2016.12.1'), + manager.PluginVersion('pyflakes', '1.5.0', False), + manager.PluginVersion('mccabe', '0.7.0', False), + manager.PluginVersion('pycodestyle', '2.2.0', False), + manager.PluginVersion('flake8-docstrings', '0.6.1', False), + manager.PluginVersion('flake8-bugbear', '2016.12.1', False), ] assert (optmanager.generate_versions() == 'flake8-bugbear: 2016.12.1, ' @@ -157,9 +159,9 @@ def test_plugins_are_sorted_in_generate_versions(optmanager): def test_generate_versions_with_format_string(optmanager): """Verify a comma-separated string is generated of registered plugins.""" optmanager.registered_plugins.update([ - ('Testing', '0.0.0'), - ('Testing', '0.0.0'), - ('Testing', '0.0.0'), + manager.PluginVersion('Testing', '0.0.0', False), + manager.PluginVersion('Testing', '0.0.0', False), + manager.PluginVersion('Testing', '0.0.0', False), ]) assert ( optmanager.generate_versions() == 'Testing: 0.0.0' @@ -172,9 +174,9 @@ def test_update_version_string(optmanager): assert optmanager.parser.version == TEST_VERSION optmanager.registered_plugins = [ - ('Testing 100', '0.0.0'), - ('Testing 101', '0.0.0'), - ('Testing 300', '0.0.0'), + manager.PluginVersion('Testing 100', '0.0.0', False), + manager.PluginVersion('Testing 101', '0.0.0', False), + manager.PluginVersion('Testing 300', '0.0.0', False), ] optmanager.update_version_string() @@ -190,9 +192,9 @@ def test_generate_epilog(optmanager): assert optmanager.parser.epilog is None optmanager.registered_plugins = [ - ('Testing 100', '0.0.0'), - ('Testing 101', '0.0.0'), - ('Testing 300', '0.0.0'), + manager.PluginVersion('Testing 100', '0.0.0', False), + manager.PluginVersion('Testing 101', '0.0.0', False), + manager.PluginVersion('Testing 300', '0.0.0', False), ] expected_value = ( diff --git a/tests/unit/test_plugin.py b/tests/unit/test_plugin.py index d6bc27f..84f676a 100644 --- a/tests/unit/test_plugin.py +++ b/tests/unit/test_plugin.py @@ -111,17 +111,6 @@ def test_version_proxies_to_the_plugin(): assert plugin.version == 'a.b.c' -def test_local_plugin_version(): - """Verify that local plugins have [local] appended to version.""" - entry_point = mock.Mock(spec=['require', 'resolve', 'load']) - plugin_obj = mock.Mock(spec_set=['version']) - plugin_obj.version = 'a.b.c' - plugin = manager.Plugin('T000', entry_point, local=True) - plugin._plugin = plugin_obj - - assert plugin.version == 'a.b.c [local]' - - def test_register_options(): """Verify we call add_options on the plugin only if it exists.""" # Set up our mocks and Plugin object From a2c7051c9e4d42014620099743e449415d8cb450 Mon Sep 17 00:00:00 2001 From: Ian Stapleton Cordasco Date: Tue, 8 Aug 2017 08:57:22 -0500 Subject: [PATCH 2/4] Update release notes to include local plugins --- docs/source/release-notes/3.5.0.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/source/release-notes/3.5.0.rst b/docs/source/release-notes/3.5.0.rst index c4b6f4e..f234dbe 100644 --- a/docs/source/release-notes/3.5.0.rst +++ b/docs/source/release-notes/3.5.0.rst @@ -3,13 +3,23 @@ You can view the `3.5.0 milestone`_ on GitLab for more details. +New Dependency Information +~~~~~~~~~~~~~~~~~~~~~~~~~~ + - Allow for PyFlakes 1.6.0 (See also `GitLab#359`_) - Start using new PyCodestyle checks for bare excepts and ambiguous identifier (See also `GitLab#361`_) +Features +~~~~~~~~ + - Print out information about configuring VCS hooks (See also `GitLab#335`_) +- Allow users to develop plugins "local" to a repository without using + setuptools. See our documentation on local plugins for more information. + (See also `GitLab#357`_) + .. all links .. _3.5.0 milestone: @@ -18,6 +28,8 @@ You can view the `3.5.0 milestone`_ on GitLab for more details. .. issue links .. _GitLab#335: https://gitlab.com/pycqa/flake8/issues/335 +.. _GitLab#357: + https://gitlab.com/pycqa/flake8/issues/357 .. _GitLab#359: https://gitlab.com/pycqa/flake8/issues/359 .. _GitLab#361: From 222f0a8115651eecc4f88933bb887068427e97bf Mon Sep 17 00:00:00 2001 From: Ian Stapleton Cordasco Date: Wed, 9 Aug 2017 19:35:01 -0500 Subject: [PATCH 3/4] Allow our local plugin parsing to accept commas By slightly modifying our utility function to parse comma separated lists we can parse local plugins similar to other configuration options. --- src/flake8/options/config.py | 13 ++++++++----- src/flake8/utils.py | 10 ++++++++-- tests/unit/test_get_local_plugins.py | 3 +++ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/flake8/options/config.py b/src/flake8/options/config.py index 49910ba..7e770b6 100644 --- a/src/flake8/options/config.py +++ b/src/flake8/options/config.py @@ -5,6 +5,8 @@ import logging import os.path import sys +from flake8 import utils + LOG = logging.getLogger(__name__) __all__ = ('ConfigFileFinder', 'MergedConfigParser') @@ -320,11 +322,12 @@ def get_local_plugins(config_finder, cli_config=None, isolated=False): section = '%s:local-plugins' % config_finder.program_name for plugin_type in ['extension', 'report']: if config.has_option(section, plugin_type): - getattr(local_plugins, plugin_type).extend( - c.strip() for c in config.get( - section, plugin_type - ).strip().splitlines() - ) + local_plugins_string = config.get(section, plugin_type).strip() + plugin_type_list = getattr(local_plugins, plugin_type) + plugin_type_list.extend(utils.parse_comma_separated_list( + local_plugins_string, + regexp=utils.LOCAL_PLUGIN_LIST_RE, + )) return local_plugins diff --git a/src/flake8/utils.py b/src/flake8/utils.py index 953b594..d28b810 100644 --- a/src/flake8/utils.py +++ b/src/flake8/utils.py @@ -11,14 +11,20 @@ import tokenize DIFF_HUNK_REGEXP = re.compile(r'^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@.*$') COMMA_SEPARATED_LIST_RE = re.compile(r'[,\s]') +LOCAL_PLUGIN_LIST_RE = re.compile(r'[,\t\n\r\f\v]') -def parse_comma_separated_list(value): +def parse_comma_separated_list(value, regexp=COMMA_SEPARATED_LIST_RE): # type: (Union[Sequence[str], str]) -> List[str] """Parse a comma-separated list. :param value: String or list of strings to be parsed and normalized. + :param regexp: + Compiled regular expression used to split the value when it is a + string. + :type regexp: + _sre.SRE_Pattern :returns: List of values with whitespace stripped. :rtype: @@ -28,7 +34,7 @@ def parse_comma_separated_list(value): return [] if not isinstance(value, (list, tuple)): - value = COMMA_SEPARATED_LIST_RE.split(value) + value = regexp.split(value) item_gen = (item.strip() for item in value) return [item for item in item_gen if item] diff --git a/tests/unit/test_get_local_plugins.py b/tests/unit/test_get_local_plugins.py index 942599a..b2f985e 100644 --- a/tests/unit/test_get_local_plugins.py +++ b/tests/unit/test_get_local_plugins.py @@ -18,7 +18,10 @@ def test_get_local_plugins_respects_isolated(): def test_get_local_plugins_uses_cli_config(): """Verify behaviour of get_local_plugins with a specified config.""" + config_obj = mock.Mock() config_finder = mock.MagicMock() + config_finder.cli_config.return_value = config_obj + config_obj.get.return_value = '' config.get_local_plugins(config_finder, cli_config='foo.ini') From c2218e4c9f62633935898a74b89aa450541ffb0d Mon Sep 17 00:00:00 2001 From: Ian Stapleton Cordasco Date: Sat, 12 Aug 2017 08:30:30 -0500 Subject: [PATCH 4/4] Document using local plugins --- docs/source/user/configuration.rst | 48 ++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docs/source/user/configuration.rst b/docs/source/user/configuration.rst index 5e81807..eacacef 100644 --- a/docs/source/user/configuration.rst +++ b/docs/source/user/configuration.rst @@ -222,3 +222,51 @@ They use the comments to describe the check but they could also write this as: Or they could use each comment to describe **why** they've ignored the check. |Flake8| knows how to parse these lists and will appropriately handle these situations. + + +Using Local Plugins +------------------- + +.. versionadded:: 3.5.0 + +|Flake8| allows users to write plugins that live locally in a project. These +plugins do not need to use setuptools or any of the other overhead associated +with plugins distributed on PyPI. To use these plugins, users must specify +them in their configuration file (i.e., ``.flake8``, ``setup.cfg``, or +``tox.ini``). This must be configured in a separate INI section named +``flake8:local-plugins``. + +Users may configure plugins that check source code, i.e., ``extension`` +plugins, and plugins that report errors, i.e., ``report`` plugins. + +An example configuration might look like: + +.. code-block:: ini + + [flake8:local-plugins] + extension = + MC1 = project.flake8.checkers:MyChecker1 + MC2 = project.flake8.checkers:MyChecker2 + report = + MR1 = project.flake8.reporters:MyReporter1 + MR2 = project.flake8.reporters:MyReporter2 + +|Flake8| will also, however, allow for commas to separate the plugins for +example: + +.. code-block:: ini + + [flake8:local-plugins] + extension = + MC1 = project.flake8.checkers:MyChecker1, + MC2 = project.flake8.checkers:MyChecker2 + report = + MR1 = project.flake8.reporters:MyReporter1, + MR2 = project.flake8.reporters:MyReporter2 + +These configurations will allow you to select your own custom reporter plugin +that you've designed or will utilize your new check classes. + +.. note:: + + These plugins otherwise follow the same guidelines as regular plugins.