Repository: conan-io/conan

Issue:
Provide pylint plugin to avoid warnings in IDE when working with conanfile.py
## Problem
When editing `conanfile.py` with editor which has linter support (VS Code with Python extension in my case) there are a bunch of errors related to dynamic fields (like `self.copy is not callable` or `Instance of ConanFile doesn't have package_folder member`).

## Proposal
Conan has a built-in linting support for conanfile.py, but it deals with dynamic fields by post-processing json output.

It would be useful if this logic is moved to a pylint plugin which can be reused by editor/IDE or via command line options.

There are few tutorials on the Internet about how to do it.


Discussion:
Yes, this is a good idea, thanks!
Actually, extracting the linting functionality to a conan plugin system (where other custom checkers can be provided by users) is something that we want to do.

We will check this when we do that, and will try to make this plugin reusable, not only for conan, but for editors too.

Resolving patch:
diff --git a/conans/client/cmd/export_linter.py b/conans/client/cmd/export_linter.py
--- a/conans/client/cmd/export_linter.py
+++ b/conans/client/cmd/export_linter.py
@@ -50,7 +50,8 @@ def _runner(args):
 
 
 def _normal_linter(conanfile_path, hook):
-    args = ['--py3k', "--enable=all", "--reports=no", "--disable=no-absolute-import", "--persistent=no",
+    args = ["--py3k", "--enable=all", "--reports=no", "--disable=no-absolute-import", "--persistent=no", 
+            "--load-plugins=conans.pylint_plugin",
             hook, '"%s"' % conanfile_path]
     pylintrc = os.environ.get("CONAN_PYLINTRC", None)
     if pylintrc:
@@ -59,21 +60,10 @@ def _normal_linter(conanfile_path, hook):
         args.append('--rcfile="%s"' % pylintrc)
 
     output_json = _runner(args)
-    dynamic_fields = ("source_folder", "build_folder", "package_folder", "info_build",
-                      "build_requires", "info")
 
     def _accept_message(msg):
         symbol = msg.get("symbol")
-        text = msg.get("message")
 
-        if symbol == "no-member":
-            for field in dynamic_fields:
-                if field in text:
-                    return False
-        if symbol == "not-callable" and "self.copy is not callable" == text:
-            return False
-        if symbol == "not-callable" and "self.copy_deps is not callable" == text:
-            return False
         if symbol in ("bare-except", "broad-except"):  # No exception type(s) specified
             return False
         if symbol == "import-error" and msg.get("column") > 3:  # Import of a conan python package
diff --git a/conans/model/conan_file.py b/conans/model/conan_file.py
--- a/conans/model/conan_file.py
+++ b/conans/model/conan_file.py
@@ -147,9 +147,6 @@ def __init__(self, output, runner, settings, user=None, channel=None, local=None
         # Keys are the package names, and the values a dict with the vars
         self.deps_user_info = DepsUserInfo()
 
-        self.copy = None  # initialized at runtime
-        self.copy_deps = None  # initialized at runtime
-
         # an output stream (writeln, info, warn error)
         self.output = output
         # something that can run commands, as os.sytem
diff --git a/conans/pylint_plugin.py b/conans/pylint_plugin.py
new file mode 100644
--- /dev/null
+++ b/conans/pylint_plugin.py
@@ -0,0 +1,46 @@
+"""Pylint plugin for ConanFile"""
+
+import astroid
+from astroid import MANAGER, scoped_nodes
+
+
+def register(linter):
+    """Declare package as plugin
+    
+    This function needs to be declared so astroid treats
+    current file as a plugin.
+    """
+    pass
+
+
+def transform_conanfile(node):
+    """Transform definition of ConanFile class so dynamic fields are visible to pylint"""
+
+    str_class = scoped_nodes.builtin_lookup("str")
+    info_class = MANAGER.ast_from_module_name("conans.model.info").lookup(
+        "ConanInfo")
+    build_requires_class = MANAGER.ast_from_module_name(
+        "conans.client.graph.graph_manager").lookup("_RecipeBuildRequires")
+    file_copier_class = MANAGER.ast_from_module_name(
+        "conans.client.file_copier").lookup("FileCopier")
+    file_importer_class = MANAGER.ast_from_module_name(
+        "conans.client.importer").lookup("_FileImporter")
+
+    dynamic_fields = {
+        "source_folder": str_class,
+        "build_folder": str_class,
+        "package_folder": str_class,
+        "build_requires": build_requires_class,
+        "info_build": info_class,
+        "info": info_class,
+        "copy": file_copier_class,
+        "copy_deps": file_importer_class,
+    }
+
+    for f, t in dynamic_fields.items():
+        node.locals[f] = [t]
+
+
+MANAGER.register_transform(
+    scoped_nodes.Class, transform_conanfile,
+    lambda node: node.qname() == "conans.model.conan_file.ConanFile")


Test patch:
diff --git a/conans/test/command/export_linter_test.py b/conans/test/command/export_linter_test.py
--- a/conans/test/command/export_linter_test.py
+++ b/conans/test/command/export_linter_test.py
@@ -124,6 +124,7 @@ def source(self):
 """
         client.save({CONANFILE: conanfile_base})
         client.run("export . conan/stable")
+        self.assertNotIn("Failed pylint", client.out)
         self.assertNotIn("Linter", client.user_io.out)
 
     def test_warning_as_errors(self):
@@ -149,6 +150,7 @@ def deploy(self):
         client = TestClient()
         client.save({CONANFILE: conanfile})
         client.run("export . conan/stable")
+        self.assertNotIn("Failed pylint", client.out)
         self.assertNotIn("Linter warnings", client.out)
         self.assertNotIn("WARN: Linter. Line 8: Instance of 'BaseConan' has no 'copy_deps' member",
                          client.out)