Repository: pylint-dev/astroid

Issue:
String formatting is inferred as empty string
Originally reported by: **Florian Bruhin (BitBucket: [The-Compiler](http://bitbucket.org/The-Compiler), GitHub: @The-Compiler?)**

---

With this code:

``` py
def foo(arg):
    pass

foo('foo{}bar'.format(42))
foo('const')
```

The first call is inferred as an empty string.

I can reproduce this with pylint/astroid from tip and this pylint plugin:

``` py
import astroid
from pylint import interfaces, checkers
from pylint.checkers import utils


class TestChecker(checkers.BaseChecker):

    __implements__ = interfaces.IAstroidChecker
    name = 'foo'
    msgs = {
        'E0000': ('Got called with empty string!', 'empty-string', None),
    }
    priority = -1

    @utils.check_messages('bad-config-call')
    def visit_callfunc(self, node):
        if not hasattr(node, 'func'):
            return
        infer = utils.safe_infer(node.func)
        if infer is None or infer.name != 'foo':
            return
        try:
            arg = utils.get_argument_from_call(node, position=0)
        except utils.NoSuchArgumentError:
            return
        arg = utils.safe_infer(arg)
        if not isinstance(arg, astroid.Const):
            return
        if arg.value == '':
            self.add_message('empty-string', node=node)


def register(linter):
    linter.register_checker(TestChecker(linter))
```

output:

```
$ PYTHONPATH=$PWD ./venv/bin/pylint foo.py --load-plugins=plugin
No config file found, using default configuration
************* Module foo
[...]
E:  4, 3: Got called with empty string! (empty-string)
[...]
```

Assigning to PCManticore as discussed in IRC.

---
- Bitbucket: https://bitbucket.org/logilab/astroid/issue/104



Discussion / hints:

Fix (patch):
diff --git a/ChangeLog b/ChangeLog
index 7db3e6c682..4cc346a478 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -22,6 +22,10 @@ Release date: TBA
 
   Closes PyCQA/pylint#3518
 
+* Calls to ``str.format`` are now correctly inferred.
+
+  Closes #104
+
 * Adds missing enums from ``ssl`` module.
 
   Closes PyCQA/pylint#3691
diff --git a/astroid/brain/brain_builtin_inference.py b/astroid/brain/brain_builtin_inference.py
index 711444c5df..68445e731c 100644
--- a/astroid/brain/brain_builtin_inference.py
+++ b/astroid/brain/brain_builtin_inference.py
@@ -911,6 +911,48 @@ def _infer_copy_method(
     raise UseInferenceDefault()
 
 
+def _is_str_format_call(node: nodes.Call) -> bool:
+    """Catch calls to str.format()."""
+    return (
+        isinstance(node.func, nodes.Attribute)
+        and node.func.attrname == "format"
+        and isinstance(node.func.expr, nodes.Const)
+        and isinstance(node.func.expr.value, str)
+    )
+
+
+def _infer_str_format_call(
+    node: nodes.Call, context: InferenceContext | None = None
+) -> Iterator[nodes.Const | type[util.Uninferable]]:
+    """Return a Const node based on the template and passed arguments."""
+    call = arguments.CallSite.from_call(node, context=context)
+    format_template: str = node.func.expr.value
+
+    # Get the positional arguments passed
+    inferred_positional = [
+        helpers.safe_infer(i, context) for i in call.positional_arguments
+    ]
+    if not all(isinstance(i, nodes.Const) for i in inferred_positional):
+        return iter([util.Uninferable])
+    pos_values: list[str] = [i.value for i in inferred_positional]
+
+    # Get the keyword arguments passed
+    inferred_keyword = {
+        k: helpers.safe_infer(v, context) for k, v in call.keyword_arguments.items()
+    }
+    if not all(isinstance(i, nodes.Const) for i in inferred_keyword.values()):
+        return iter([util.Uninferable])
+    keyword_values: dict[str, str] = {k: v.value for k, v in inferred_keyword.items()}
+
+    try:
+        formatted_string = format_template.format(*pos_values, **keyword_values)
+    except IndexError:
+        # If there is an IndexError there are too few arguments to interpolate
+        return iter([util.Uninferable])
+
+    return iter([nodes.const_factory(formatted_string)])
+
+
 # Builtins inference
 register_builtin_transform(infer_bool, "bool")
 register_builtin_transform(infer_super, "super")
@@ -946,3 +988,7 @@ def _infer_copy_method(
     lambda node: isinstance(node.func, nodes.Attribute)
     and node.func.attrname == "copy",
 )
+
+AstroidManager().register_transform(
+    nodes.Call, inference_tip(_infer_str_format_call), _is_str_format_call
+)


Test changes:
diff --git a/tests/unittest_brain_builtin.py b/tests/unittest_brain_builtin.py
index 0d7493034a..a659c4fdf2 100644
--- a/tests/unittest_brain_builtin.py
+++ b/tests/unittest_brain_builtin.py
@@ -6,12 +6,15 @@
 
 import unittest
 
-from astroid import extract_node, objects
+import pytest
+
+from astroid import nodes, objects, util
+from astroid.builder import _extract_single_node
 
 
 class BuiltinsTest(unittest.TestCase):
     def test_infer_property(self):
-        class_with_property = extract_node(
+        class_with_property = _extract_single_node(
             """
         class Something:
             def getter():
@@ -22,3 +25,85 @@ def getter():
         inferred_property = list(class_with_property.value.infer())[0]
         self.assertTrue(isinstance(inferred_property, objects.Property))
         self.assertTrue(hasattr(inferred_property, "args"))
+
+
+class TestStringNodes:
+    @pytest.mark.parametrize(
+        "format_string",
+        [
+            pytest.param(
+                """"My name is {}, I'm {}".format("Daniel", 12)""", id="empty-indexes"
+            ),
+            pytest.param(
+                """"My name is {0}, I'm {1}".format("Daniel", 12)""",
+                id="numbered-indexes",
+            ),
+            pytest.param(
+                """"My name is {fname}, I'm {age}".format(fname = "Daniel", age = 12)""",
+                id="named-indexes",
+            ),
+            pytest.param(
+                """
+        name = "Daniel"
+        age = 12
+        "My name is {0}, I'm {1}".format(name, age)
+        """,
+                id="numbered-indexes-from-positional",
+            ),
+            pytest.param(
+                """
+        name = "Daniel"
+        age = 12
+        "My name is {fname}, I'm {age}".format(fname = name, age = age)
+        """,
+                id="named-indexes-from-keyword",
+            ),
+            pytest.param(
+                """
+        name = "Daniel"
+        age = 12
+        "My name is {0}, I'm {age}".format(name, age = age)
+        """,
+                id="mixed-indexes-from-mixed",
+            ),
+        ],
+    )
+    def test_string_format(self, format_string: str) -> None:
+        node: nodes.Call = _extract_single_node(format_string)
+        inferred = next(node.infer())
+        assert isinstance(inferred, nodes.Const)
+        assert inferred.value == "My name is Daniel, I'm 12"
+
+    @pytest.mark.parametrize(
+        "format_string",
+        [
+            """
+            from missing import Unknown
+            name = Unknown
+            age = 12
+            "My name is {fname}, I'm {age}".format(fname = name, age = age)
+            """,
+            """
+            from missing import Unknown
+            age = 12
+            "My name is {fname}, I'm {age}".format(fname = Unknown, age = age)
+            """,
+            """
+            from missing import Unknown
+            "My name is {}, I'm {}".format(Unknown, 12)
+            """,
+            """"I am {}".format()""",
+        ],
+    )
+    def test_string_format_uninferable(self, format_string: str) -> None:
+        node: nodes.Call = _extract_single_node(format_string)
+        inferred = next(node.infer())
+        assert inferred is util.Uninferable
+
+    def test_string_format_with_specs(self) -> None:
+        node: nodes.Call = _extract_single_node(
+            """"My name is {}, I'm {:.2f}".format("Daniel", 12)"""
+        )
+        inferred = next(node.infer())
+        assert isinstance(inferred, nodes.Const)
+        assert inferred.value == "My name is Daniel, I'm 12.00"
diff --git a/tests/unittest_inference.py b/tests/unittest_inference.py
index 6ae4f40ae3..cb808df7bb 100644
--- a/tests/unittest_inference.py
+++ b/tests/unittest_inference.py
@@ -2127,7 +2127,6 @@ def test_str_methods(self) -> None:
         ' '.decode() #@
         ' '.join('abcd') #@
         ' '.replace('a', 'b') #@
-        ' '.format('a') #@
         ' '.capitalize() #@
         ' '.title() #@
         ' '.lower() #@
@@ -2143,20 +2142,22 @@ def test_str_methods(self) -> None:
         ' '.index() #@
         ' '.find() #@
         ' '.count() #@
+
+        ' '.format('a') #@
         """
         ast = extract_node(code, __name__)
         self.assertInferConst(ast[0], "")
-        for i in range(1, 15):
+        for i in range(1, 14):
             self.assertInferConst(ast[i], "")
-        for i in range(15, 18):
+        for i in range(14, 17):
             self.assertInferConst(ast[i], 0)
+        self.assertInferConst(ast[17], " ")
 
     def test_unicode_methods(self) -> None:
         code = """
         u' '.decode() #@
         u' '.join('abcd') #@
         u' '.replace('a', 'b') #@
-        u' '.format('a') #@
         u' '.capitalize() #@
         u' '.title() #@
         u' '.lower() #@
@@ -2172,13 +2173,16 @@ def test_unicode_methods(self) -> None:
         u' '.index() #@
         u' '.find() #@
         u' '.count() #@
+
+        u' '.format('a') #@
         """
         ast = extract_node(code, __name__)
         self.assertInferConst(ast[0], "")
-        for i in range(1, 15):
+        for i in range(1, 14):
             self.assertInferConst(ast[i], "")
-        for i in range(15, 18):
+        for i in range(14, 17):
             self.assertInferConst(ast[i], 0)
+        self.assertInferConst(ast[17], " ")
 
     def test_scope_lookup_same_attributes(self) -> None:
         code = """
diff --git a/tests/unittest_scoped_nodes.py b/tests/unittest_scoped_nodes.py
index 0236fcab27..45307c8bdc 100644
--- a/tests/unittest_scoped_nodes.py
+++ b/tests/unittest_scoped_nodes.py
@@ -1699,10 +1699,12 @@ def __init__(self):
                 "FinalClass",
                 "ClassB",
                 "MixinB",
-                "",
+                # We don't recognize what 'cls' is at time of .format() call, only
+                # what it is at the end.
+                # "strMixin",
                 "ClassA",
                 "MixinA",
-                "",
+                "intMixin",
                 "Base",
                 "object",
             ],