Repository: ipython/ipython
Pull request: Fix attribute completion for expressions with comparison operators
## Fixes #14897 

### Description

Modified `_attr_matches()` to check if an expression contains comparison operators `(=, ==, !=, etc.)` outside of parentheses. If so, it extracts the right-hand side of the operator for evaluation, providing appropriate completions.
For parenthesized expressions like `(x==y).<tab>`, the code preserves the entire expression to ensure it's evaluated as a bool.

Fixing completions fully is quite tricky. The current implementation still has flaws, especially in cases where comparison operators are involved. For example:
```python
{"==", "abc"}.<tab>
```
In this case, dictionary methods are not shown because the logic incorrectly separates the expression at the `"=="` operator.

While the current solution resolves some major issues, it also introduces new, albeit minor, ones.

Feedback on better approaches or alternative strategies is highly appreciated.


Problem statement:
Tab completion fails with comparison operators
### Description

When using tab completion in IPython, it fails to correctly identify the object type when the cursor is after a comparison operator.

For example:

`x = y = 'a'` works correctly: `x.<tab>` shows string methods
But `x == y.<tab>` incorrectly shows boolean methods instead of string methods

### Expected behavior

`x == y.<tab>` should complete with string methods for y

### System Information

IPython version: 9.3.0
Operating system: Ubuntu

Hints from issue discussion:

Commit message: remove-comment

Patch to review:
diff --git a/IPython/core/completer.py b/IPython/core/completer.py
index b450309707..4a9b457266 100644
--- a/IPython/core/completer.py
+++ b/IPython/core/completer.py
@@ -1142,6 +1142,13 @@ def _attr_matches(
             return [], ""
         expr, attr = m2.group(1, 2)
 
+        operators = ["=", "==", "!=", ">=", "<=", ">", "<"]
+        # Split by the operator and take the right side
+        if not (expr.startswith("(") and expr.endswith(")")):
+            for op in operators:
+                if op in expr:
+                    expr = expr.split(op)[-1]
+
         obj = self._evaluate_expr(expr)
 
         if obj is not_found:
diff --git a/tests/test_completer.py b/tests/test_completer.py
index 08a385ad06..dfbb46db68 100644
--- a/tests/test_completer.py
+++ b/tests/test_completer.py
@@ -597,6 +597,7 @@ def test_greedy_completions(self):
         ip = get_ipython()
         ip.ex("a=list(range(5))")
         ip.ex("d = {'a b': str}")
+        ip.ex("x=y='a'")
         _, c = ip.complete(".", line="a[0].")
         self.assertFalse(".real" in c, "Shouldn't have completed on a[0]: %s" % c)
 
@@ -654,6 +655,20 @@ def _(line, cursor_pos, expect, message, completion):
                 "Should have completed on `a.app`: %s",
                 Completion(2, 4, "append"),
             )
+            _(
+                "x.upper() == y.",
+                15,
+                ".upper",
+                "Should have completed on `x.upper() == y.`: %s",
+                Completion(15, 15, "upper"),
+            )
+            _(
+                "(x.upper() == y).",
+                17,
+                ".bit_length",
+                "Should have completed on `(x.upper() == y).`: %s",
+                Completion(17, 17, "bit_length"),
+            )
 
     def test_omit__names(self):
         # also happens to test IPCompleter as a configurable


Code review comments:
diff_hunk: @@ -1142,6 +1142,13 @@ def _attr_matches(
             return [], ""
         expr, attr = m2.group(1, 2)
 
+        operators = ["=", "==", "!=", ">=", "<=", ">", "<"]
+        # Split by the operator and take the right side
+        if not (expr.startswith("(") and expr.endswith(")")):
path: IPython/core/completer.py
text: @user1:
How would this behave for `[a == b].`?

diff_hunk: @@ -1142,6 +1142,13 @@ def _attr_matches(
             return [], ""
         expr, attr = m2.group(1, 2)
 
+        operators = ["=", "==", "!=", ">=", "<=", ">", "<"]
+        # Split by the operator and take the right side
+        if not (expr.startswith("(") and expr.endswith(")")):
+            for op in operators:
+                if op in expr:
+                    expr = expr.split(op)[-1]
path: IPython/core/completer.py
text: @user1:
It feels like we could use `ast.parse` approach in a similar way we use it in `_trim_expr`, or maybe even better use `tokenize` (we already have `_parse_tokens` utility wrapper in this file). Currently `_trim_expr` is used to trim any invalid parts:

https://github.com/ipython/ipython/blob/5a3bf345ed7c78e5fab84faa81285e5fd9dfa3da/IPython/core/completer.py#L1226-L1248


https://github.com/ipython/ipython/blob/5a3bf345ed7c78e5fab84faa81285e5fd9dfa3da/IPython/core/completer.py#L1202-L1224

@user1:
What about something like:

```python
def _strip_code_before_operator(code: str) -> str:
    operators = ["=", "==", "!=", ">=", "<=", ">", "<"]
    parens = {'(', '[', '{'}
    tokens = _parse_tokens(code)
    encountered_operator = False
    after_operator = None
    nesting_level = 0
    for t in tokens:
        if  t.type == tokenize.OP:
            if t.string in parens:
                    nesting_level += 1
            elif t.string in parens:
                    nesting_level -= 1
            elif t.string in operators and nesting_level == 0:
                encountered_operator = True
                after_operator = []
                continue
        if encountered_operator:
            after_operator.append(t.string)
    if encountered_operator:
        return ''.join(after_operator)
    return code
```

```suggestion
        # Split by the operator and take the right side
        expr = _strip_code_before_operator(op)
```

@user1:
Actually, I maybe we should not use `_parse_tokens` which ignores `tokenize.TokenError` and instead handle that error here directly so that `expr` is not changed if the code could not be tokenized, so that `_trim_expr` can handle it.