Skip to content

Commit

Permalink
CI: Add custom flake8 plugin (Fixes Unidata#1818)
Browse files Browse the repository at this point in the history
This gives us a custom flake8 plugin we can use to detect custom errors,
style violations that we want. Right now this checks for multiplication
by units on the right.
  • Loading branch information
dopplershift committed Apr 26, 2021
1 parent 6ff8b8e commit eb6de07
Show file tree
Hide file tree
Showing 4 changed files with 127 additions and 3 deletions.
7 changes: 4 additions & 3 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ max-line-length = 95

[flake8]
max-line-length = 95
application-import-names = metpy
application-import-names = metpy flake8_metpy
import-order-style = google
copyright-check = True
copyright-author = MetPy Developers
Expand All @@ -83,15 +83,16 @@ docstring-convention = numpy
exclude = docs build src/metpy/io/metar_parser.py
select = A B C D E F H I M Q RST S T W B902
ignore = F405 W503 RST902 SIM106
per-file-ignores = examples/*.py: D T003 T001
tutorials/*.py: D T003 T001
per-file-ignores = examples/*.py: D METPY001 T003 T001
tutorials/*.py: D METPY001 T003 T001
src/metpy/xarray.py: RST304
src/metpy/deprecation.py: C801
src/metpy/calc/*.py: RST306
src/metpy/interpolate/*.py: RST306
src/metpy/future.py: RST307
src/metpy/constants.py: RST306
docs/doc-server.py: T001
tests/*.py: METPY001

[tool:pytest]
# https://github.com/matplotlib/pytest-mpl/issues/69
Expand Down
78 changes: 78 additions & 0 deletions tools/flake8-metpy/flake8_metpy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Copyright (c) 2021 MetPy Developers.
# Distributed under the terms of the BSD 3-Clause License.
# SPDX-License-Identifier: BSD-3-Clause
"""
Custom flake8 plugin to catch MetPy-specific bad style/practice.
Currently this only looks for multiplication or division by units, since that can break
masked arrays and is slower than calling ``Quantity()``.
"""

import ast
from collections import namedtuple

try:
from importlib.metadata import version
except ImportError:
from importlib_metadata import version


Error = namedtuple('Error', 'lineno col code')


class MetPyVisitor(ast.NodeVisitor):
"""Visit nodes of the AST looking for violations."""

def __init__(self):
"""Initialize the visitor."""
self.errors = []

@staticmethod
def _is_unit(node):
"""Check whether a node should be considered to represent "units"."""
# Looking for a .units attribute, a units.myunit, or a call to units()
is_units_attr = isinstance(node, ast.Attribute) and node.attr == 'units'
is_reg_attr = (isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name) and node.value.id == 'units')
is_reg_call = (isinstance(node, ast.Call)
and isinstance(node.func, ast.Name) and node.func.id == 'units')
is_unit_alias = isinstance(node, ast.Name) and 'unit' in node.id

return is_units_attr or is_reg_attr or is_reg_call or is_unit_alias

def visit_BinOp(self, node):
"""Visit binary operations."""
# Check whether this is multiplying or dividing by units
if (isinstance(node.op, (ast.Mult, ast.Div))
and (self._is_unit(node.right) or self._is_unit(node.left))):
self.error(node.lineno, node.col_offset, 1)

super().generic_visit(node)

def error(self, lineno, col, code):
"""Add an error to our output list."""
self.errors.append(Error(lineno, col, code))


class MetPyChecker:
"""Flake8 plugin class to check MetPy style/best practice."""

name = __name__
version = version(__name__)

def __init__(self, tree):
"""Initialize the plugin."""
self.tree = tree

def run(self):
"""Run the plugin and yield errors."""
visitor = MetPyVisitor()
visitor.visit(self.tree)
for err in visitor.errors:
yield self.error(err)

def error(self, err):
"""Format errors into Flake8's required format."""
return (err.lineno, err.col,
f'METPY{err.code:03d}: Multiplying/dividing by units--use units.Quantity()',
type(self))
17 changes: 17 additions & 0 deletions tools/flake8-metpy/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Copyright (c) 2021 MetPy Developers.
# Distributed under the terms of the BSD 3-Clause License.
# SPDX-License-Identifier: BSD-3-Clause
"""Setup script for installing MetPy custom flake8 plugin."""

from setuptools import setup

setup(
name='flake8-metpy',
version='1.0',
license='BSD 3 Clause',
py_modules=['flake8_metpy'],
install_requires=['flake8 > 3.0.0'],
entry_points={
'flake8.extension': ['METPY00 = flake8_metpy:MetPyChecker'],
},
)
28 changes: 28 additions & 0 deletions tools/flake8-metpy/test_flake8_metpy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Copyright (c) 2021 MetPy Developers.
# Distributed under the terms of the BSD 3-Clause License.
# SPDX-License-Identifier: BSD-3-Clause
"""Tests for custom Flake8 plugin for MetPy."""

import ast

import pytest

from flake8_metpy import MetPyChecker


@pytest.mark.parametrize('source, errs', [
('5 * pressure.units', 1),
('pw = -1. * (np.trapz(w.magnitude, pres.magnitude) * (w.units * pres.units))', 1),
("""def foo():
return ret * moist_adiabat_temperatures.units""", 1),
('p_interp = np.sort(np.append(p_interp.m, top_pressure.m)) * pressure.units', 1),
('parameter = data[ob_type][subset].values * units(self.data.units[ob_type])', 1),
('np.nan * pressure.units', 1),
('np.array([1, 2, 3]) * units.m', 1),
('np.arange(4) * units.s', 1),
('np.ma.array([1, 2, 3]) * units.hPa', 1)
])
def test_plugin(source, errs):
"""Test that the flake8 checker works correctly."""
checker = MetPyChecker(ast.parse(source))
assert len(list(checker.run())) == errs

0 comments on commit eb6de07

Please sign in to comment.