New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add hook: recipe linting #141
Merged
Merged
Changes from 17 commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
69a211a
hook for recipe linting
jgsogo fe889db
cannot import from hooks
jgsogo e192ddb
depending on the version, pylint has different layout
jgsogo 0bb3467
change in Run signature
jgsogo 8c4d3e3
no extra verbosity
jgsogo 435ad96
use epylint.py_run as recommended
jgsogo d569bad
warnings for py2
jgsogo b4e8186
not an error in py2
jgsogo 8afc214
in py2, print is not a function
jgsogo bec19b5
add skipunless to the python_requires test
jgsogo 830f59f
remove typo
jgsogo a1471d3
add requirements (conandev has removed them)
jgsogo 74ec917
will woork
jgsogo 3ed53c6
use a list for the args (windows fails with JSON)
jgsogo a0e305d
alternate implementation (let's see if it works in Windows/appveyor)
jgsogo 0c5b671
remove ANSI escape sequences
jgsogo feeb324
update README with the recipe linter
jgsogo 6c1eb1f
using forward slashes may make the code cleaner
jgsogo 64342f9
no need for shell in Win/Lin
jgsogo 03a4a19
yes, needed
jgsogo 6f5f1b6
better comment
jgsogo File filter
Filter by extension
Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
# coding=utf-8 | ||
|
||
import json | ||
import os | ||
import platform | ||
import subprocess | ||
import sys | ||
import re | ||
|
||
from conans.errors import ConanException | ||
from conans.tools import logger | ||
|
||
try: | ||
import astroid # Conan 'pylint_plugin.py' uses astroid | ||
from pylint import epylint as lint | ||
except ImportError as e: | ||
sys.stderr.write("Install pylint to use 'recipe_linter' hook: 'pip install pylint astroid'") | ||
sys.exit(1) | ||
|
||
|
||
CONAN_HOOK_PYLINT_RCFILE = "CONAN_PYLINTRC" | ||
CONAN_HOOK_PYLINT_WERR = "CONAN_PYLINT_WERR" | ||
CONAN_HOOK_PYLINT_RECIPE_PLUGINS = "CONAN_PYLINT_RECIPE_PLUGINS" | ||
|
||
|
||
def pre_export(output, conanfile_path, *args, **kwargs): | ||
output.info("Lint recipe '{}'".format(conanfile_path)) | ||
conanfile_dirname = os.path.dirname(conanfile_path) | ||
|
||
lint_args = ['--output-format=json', # JSON output fails in Windows (parsing) | ||
'--py3k', | ||
'--enable=all', | ||
'--reports=no', | ||
'--disable=no-absolute-import', | ||
'--persistent=no', | ||
# These were disabled in linter that was inside Conan | ||
# '--disable=W0702', # No exception type(s) specified (bare-except) | ||
# '--disable=W0703', # Catching too general exception Exception (broad-except) | ||
'--init-hook="import sys;sys.path.extend([\'{}\',])"'.format(conanfile_dirname.replace('\\', '\\\\')) | ||
] | ||
|
||
pylint_plugins = os.getenv(CONAN_HOOK_PYLINT_RECIPE_PLUGINS, 'conans.pylint_plugin') | ||
if pylint_plugins: | ||
lint_args += ['--load-plugins={}'.format(pylint_plugins)] | ||
|
||
rc_file = os.getenv(CONAN_HOOK_PYLINT_RCFILE) | ||
if rc_file: | ||
lint_args += ['--rcfile', rc_file] | ||
|
||
try: | ||
command = ['pylint'] + lint_args + ['"{}"'.format(conanfile_path).replace('\\', '\\\\')] | ||
jgsogo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
command = " ".join(command) | ||
shell = bool(platform.system() != "Windows") | ||
danimtb marked this conversation as resolved.
Show resolved
Hide resolved
|
||
p = subprocess.Popen(command, shell=shell, bufsize=10, | ||
stdout=subprocess.PIPE, stderr=subprocess.PIPE) | ||
pylint_stdout, pylint_stderr = p.communicate() | ||
# Remove ANSI escape sequences | ||
jgsogo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
ansi_escape = re.compile(r'\x1B\[[0-?]*[ -/]*[@-~]') | ||
jgsogo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
pylint_stdout = ansi_escape.sub('', pylint_stdout.decode('utf-8')) | ||
except Exception as exc: | ||
output.error("Unexpected error running linter: {}".format(exc)) | ||
else: | ||
try: | ||
messages = json.loads(pylint_stdout) | ||
except Exception as exc: | ||
output.error("Error parsing JSON output: {}".format(exc)) | ||
logger.error( | ||
"Error parsing linter output for recipe '{}': {}".format(conanfile_path, exc)) | ||
logger.error(" - linter arguments: {}".format(lint_args)) | ||
logger.error(" - output: {}".format(pylint_stdout.getvalue())) | ||
logger.error(" - stderr: {}".format(pylint_stderr.getvalue())) | ||
else: | ||
errors = 0 | ||
for msg in messages: | ||
line = "{path}:{line}:{column}: {message-id}: {message} ({symbol})".format(**msg) | ||
output.info(line) | ||
errors += int(msg["type"] == "error") | ||
|
||
output.info("Linter detected '{}' errors".format(errors)) | ||
if os.getenv(CONAN_HOOK_PYLINT_WERR) and errors: | ||
raise ConanException("Package recipe has linter errors. Please fix them.") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,3 +2,5 @@ pytest>=3.6 | |
parameterized | ||
responses | ||
pluggy==0.11.0 | ||
pylint | ||
astroid |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,210 @@ | ||
# coding=utf-8 | ||
|
||
import os | ||
import textwrap | ||
import unittest | ||
|
||
import six | ||
from packaging import version | ||
from parameterized import parameterized | ||
|
||
from conans import __version__ as conan_version | ||
from conans import tools | ||
from conans.client.command import ERROR_GENERAL, SUCCESS | ||
from conans.tools import environment_append | ||
from tests.utils.test_cases.conan_client import ConanClientTestCase | ||
|
||
|
||
class RecipeLinterTests(ConanClientTestCase): | ||
conanfile = textwrap.dedent(r""" | ||
from conans import ConanFile, tools | ||
|
||
class TestConan(ConanFile): | ||
name = "name" | ||
version = "version" | ||
|
||
def build(self): | ||
print("Hello world") | ||
for k, v in {}.iteritems(): | ||
pass | ||
tools.msvc_build_command(self.settings, "path") | ||
""") | ||
|
||
def _get_environ(self, **kwargs): | ||
kwargs = super(RecipeLinterTests, self)._get_environ(**kwargs) | ||
kwargs.update({'CONAN_HOOKS': os.path.join(os.path.dirname( | ||
__file__), '..', '..', 'hooks', 'recipe_linter')}) | ||
return kwargs | ||
|
||
@parameterized.expand([(False, ), (True, )]) | ||
def test_basic(self, pylint_werr): | ||
tools.save('conanfile.py', content=self.conanfile) | ||
pylint_werr_value = "1" if pylint_werr else None | ||
with environment_append({"CONAN_PYLINT_WERR": pylint_werr_value}): | ||
return_code = ERROR_GENERAL if pylint_werr else SUCCESS | ||
output = self.conan(['export', '.', 'name/version@'], expected_return_code=return_code) | ||
|
||
if pylint_werr: | ||
self.assertIn("pre_export(): Package recipe has linter errors." | ||
" Please fix them.", output) | ||
|
||
if six.PY2: | ||
self.assertIn("pre_export(): conanfile.py:9:8:" | ||
" E1601: print statement used (print-statement)", output) | ||
else: | ||
self.assertIn("pre_export(): conanfile.py:10:20:" | ||
" E1101: Instance of 'dict' has no 'iteritems' member (no-member)", | ||
output) | ||
|
||
self.assertIn("pre_export(): conanfile.py:10:20:" | ||
" W1620: Calling a dict.iter*() method (dict-iter-method)", output) | ||
self.assertIn("pre_export(): conanfile.py:10:12:" | ||
" W0612: Unused variable 'k' (unused-variable)", output) | ||
self.assertIn("pre_export(): conanfile.py:10:15:" | ||
" W0612: Unused variable 'v' (unused-variable)", output) | ||
|
||
def test_path_with_spaces(self): | ||
conanfile = textwrap.dedent(r""" | ||
from conans import ConanFile | ||
|
||
class Recipe(ConanFile): | ||
def build(self): | ||
pass | ||
""") | ||
tools.save(os.path.join("path spaces", "conanfile.py"), content=conanfile) | ||
output = self.conan(['export', 'path spaces/conanfile.py', 'name/version@']) | ||
recipe_path = os.path.join(os.getcwd(), "path spaces", "conanfile.py") | ||
self.assertIn("pre_export(): Lint recipe '{}'".format(recipe_path), output) | ||
self.assertIn("pre_export(): Linter detected '0' errors", output) | ||
|
||
def test_custom_rcfile(self): | ||
tools.save('conanfile.py', content=self.conanfile) | ||
tools.save('pylintrc', content="[FORMAT]\nindent-string=' '") | ||
|
||
with environment_append({"CONAN_PYLINTRC": os.path.join(os.getcwd(), "pylintrc")}): | ||
output = self.conan(['export', '.', 'name/version@']) | ||
self.assertIn("pre_export(): conanfile.py:5:0: " | ||
"W0311: Bad indentation. Found 4 spaces, expected 2 (bad-indentation)", output) | ||
|
||
def test_custom_plugin(self): | ||
conanfile = textwrap.dedent(r""" | ||
from conans import ConanFile | ||
|
||
class Recipe(ConanFile): | ||
def build(self): | ||
self.output.info(self.source_folder) | ||
""") | ||
tools.save('conanfile.py', content=conanfile) | ||
with environment_append({"CONAN_PYLINT_WERR": "1"}): | ||
# With the default 'python_plugin' it doesn't raise | ||
with environment_append({"CONAN_PYLINT_RECIPE_PLUGINS": None}): | ||
output = self.conan(['export', '.', 'consumer/version@']) | ||
self.assertIn("pre_export(): Lint recipe", output) # Hook run without errors | ||
self.assertIn("pre_export(): Linter detected '0' errors", output) | ||
|
||
# With a custom one, it should fail | ||
tools.save("plugin_empty.py", content="def register(_):\n\tpass") | ||
with environment_append({"CONAN_PYLINT_RECIPE_PLUGINS": "plugin_empty"}): | ||
output = self.conan(['export', '.', 'consumer/other@'], expected_return_code=ERROR_GENERAL) | ||
self.assertIn("pre_export(): Package recipe has linter errors." | ||
" Please fix them.", output) | ||
|
||
def test_dynamic_fields(self): | ||
conanfile = textwrap.dedent(""" | ||
from conans import ConanFile | ||
|
||
class TestConan(ConanFile): | ||
name = "consumer" | ||
version = "version" | ||
|
||
def build(self): | ||
self.output.info(self.source_folder) | ||
self.output.info(self.package_folder) | ||
self.output.info(self.build_folder) | ||
self.output.info(self.install_folder) | ||
|
||
def package(self): | ||
self.copy("*") | ||
|
||
def package_id(self): | ||
self.info.header_only() | ||
|
||
def build_id(self): | ||
self.output.info(str(self.info_build)) | ||
|
||
def build_requirements(self): | ||
self.build_requires("name/version") | ||
|
||
def requirements(self): | ||
self.requires("name/version") | ||
|
||
def deploy(self): | ||
self.copy_deps("*.dll") | ||
""") | ||
tools.save('consumer.py', content=conanfile) | ||
with environment_append({"CONAN_PYLINT_WERR": "1"}): | ||
output = self.conan(['export', 'consumer.py', 'consumer/version@']) | ||
self.assertIn("pre_export(): Lint recipe", output) # Hook run without errors | ||
self.assertIn("pre_export(): Linter detected '0' errors", output) | ||
self.assertNotIn("(no-member)", output) | ||
|
||
def test_catch_them_all(self): | ||
conanfile = textwrap.dedent(""" | ||
from conans import ConanFile | ||
class BaseConan(ConanFile): | ||
|
||
def source(self): | ||
try: | ||
raise Exception("Pikaaaaa!!") | ||
except: | ||
pass | ||
try: | ||
raise Exception("Pikaaaaa!!") | ||
except Exception: | ||
pass | ||
""") | ||
|
||
tools.save('conanfile.py', content=conanfile) | ||
with environment_append({"CONAN_PYLINT_WERR": "1"}): | ||
output = self.conan(['export', '.', 'consumer/version@']) | ||
self.assertIn("pre_export(): Lint recipe", output) # Hook run without errors | ||
self.assertIn("pre_export(): Linter detected '0' errors", output) | ||
self.assertNotIn("no-member", output) | ||
|
||
def test_conan_data(self): | ||
conanfile = textwrap.dedent(""" | ||
from conans import ConanFile | ||
|
||
class ExampleConan(ConanFile): | ||
|
||
def build(self): | ||
_ = self.conan_data["sources"][float(self.version)] | ||
""") | ||
tools.save('conanfile.py', content=conanfile) | ||
with environment_append({"CONAN_PYLINT_WERR": "1"}): | ||
output = self.conan(['export', '.', 'consumer/version@']) | ||
self.assertIn("pre_export(): Lint recipe", output) # Hook run without errors | ||
self.assertIn("pre_export(): Linter detected '0' errors", output) | ||
self.assertNotIn("no-member", output) | ||
|
||
@unittest.skipUnless(version.parse(conan_version) >= version.parse("1.21.0"), "Need python_version") | ||
def test_python_requires(self): | ||
""" python_requires were not added to the 'pylint_plugin' until 1.21 """ | ||
conanfile = textwrap.dedent(""" | ||
from conans import ConanFile, python_requires | ||
|
||
base = python_requires("name/version") | ||
|
||
class TestConan(ConanFile): | ||
name = "consumer" | ||
version = "version" | ||
""") | ||
tools.save('require.py', self.conanfile) | ||
self.conan(['export', 'require.py', 'name/version@']) | ||
|
||
tools.save('consumer.py', content=conanfile) | ||
with environment_append({"CONAN_PYLINT_WERR": "1"}): | ||
output = self.conan(['export', 'consumer.py', 'consumer/version@']) | ||
self.assertIn("pre_export(): Lint recipe", output) # Hook run without errors | ||
self.assertIn("pre_export(): Linter detected '0' errors", output) | ||
self.assertNotIn("(no-name-in-module)", output) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice!