Repository: pylint-dev/astroid

Issue:
Nested partial functions lose track of previously-filled args/kwargs
### Steps to reproduce

Cause of issue [#2462](https://github.com/PyCQA/pylint/issues/2462) for `pylint`

### Current behavior

```python
node = astroid.extract_node(
    """
from functools import partial
def test(a, b, c):
    return a + b + c

test1 = partial(test, 1)
test2 = partial(test1, 2)
test2(3)
"""

call_site = astroid.arguments.CallSite.from_call(node)
called_func = next(node.func.infer())
called_args = called_func.filled_args + call_site.positional_arguments
```

The `called_args` above will contain args for `2` and `3`, but not `1`, because the second call to `partial` overwrites the first, dropping `1` from the list of `filled_args`.

### Expected behavior

All args/kwargs locked in by calls to `partial` should be present in the final function. I've written a test similar to the code example above and have implemented a fix, so I'll open a PR shortly.

### `python -c "from astroid import __pkginfo__; print(__pkginfo__.version)"` output

`2.6.6`



Discussion / hints:

Fix (patch):
diff --git a/ChangeLog b/ChangeLog
index b0a2db8880..9088e7bbc4 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -14,6 +14,11 @@ Release date: TBA
 
 * Add support for wrapt 1.13
 
+* Fixes handling of nested partial functions
+
+  Closes pyCQA/pylint#2462
+  Closes #1208
+
 
 What's New in astroid 2.8.2?
 ============================
diff --git a/astroid/objects.py b/astroid/objects.py
index 4241c170e9..ba2e41780f 100644
--- a/astroid/objects.py
+++ b/astroid/objects.py
@@ -265,10 +265,20 @@ def __init__(
         # A typical FunctionDef automatically adds its name to the parent scope,
         # but a partial should not, so defer setting parent until after init
         self.parent = parent
-        self.filled_positionals = len(call.positional_arguments[1:])
         self.filled_args = call.positional_arguments[1:]
         self.filled_keywords = call.keyword_arguments
 
+        wrapped_function = call.positional_arguments[0]
+        inferred_wrapped_function = next(wrapped_function.infer())
+        if isinstance(inferred_wrapped_function, PartialFunction):
+            self.filled_args = inferred_wrapped_function.filled_args + self.filled_args
+            self.filled_keywords = {
+                **inferred_wrapped_function.filled_keywords,
+                **self.filled_keywords,
+            }
+
+        self.filled_positionals = len(self.filled_args)
+
     def infer_call_result(self, caller=None, context=None):
         if context:
             current_passed_keywords = {


Test changes:
diff --git a/tests/unittest_brain.py b/tests/unittest_brain.py
index e3362c925a..722e163180 100644
--- a/tests/unittest_brain.py
+++ b/tests/unittest_brain.py
@@ -2986,6 +2986,34 @@ def scope():
         assert set(mod_scope) == {"test", "scope", "partial"}
         assert set(scope) == {"test2"}
 
+    def test_multiple_partial_args(self) -> None:
+        "Make sure partials remember locked-in args."
+        ast_node = astroid.extract_node(
+            """
+        from functools import partial
+        def test(a, b, c, d, e=5):
+            return a + b + c + d + e
+        test1 = partial(test, 1)
+        test2 = partial(test1, 2)
+        test3 = partial(test2, 3)
+        test3(4, e=6) #@
+        """
+        )
+        expected_args = [1, 2, 3, 4]
+        expected_keywords = {"e": 6}
+
+        call_site = astroid.arguments.CallSite.from_call(ast_node)
+        called_func = next(ast_node.func.infer())
+        called_args = called_func.filled_args + call_site.positional_arguments
+        called_keywords = {**called_func.filled_keywords, **call_site.keyword_arguments}
+        assert len(called_args) == len(expected_args)
+        assert [arg.value for arg in called_args] == expected_args
+        assert len(called_keywords) == len(expected_keywords)
+
+        for keyword, value in expected_keywords.items():
+            assert keyword in called_keywords
+            assert called_keywords[keyword].value == value
+
 
 def test_http_client_brain() -> None:
     node = astroid.extract_node(