Repository: bodo-ai/PyDough

Issue:
Ensure PyDough code is compatible with full Python syntax including function definitions/lambdas
Goal: ensure that PyDough code can be fully interleaved with all regular Python code, such as the following, and work as expected.

```py
%%pydough
def n_customers_with_inc(low, high):
  return COUNT(customers.WHERE(MONOTONIC(low, acctbal, high)))

nations(
  n_cust_in_interval_1=n_customers_with_inc(-1000, 0),
  n_cust_in_interval_2=n_customers_with_inc(0, 1000),
  n_cust_in_interval_3=n_customers_with_inc(1000, 10000),
)
```

Currently, this may misbehave due to the Python AST step not recognizing the function argument variable, thus thinking it is a PyDough unqualified variable. Other behaviors that need to be checked:
- For-loops, comprehensions, "as" bindings, and all other variable-creating syntaxes do not incorrectly identify the new variable as a PyDough variable.
- Variables defined in various scopes (local, global, nonlocal) can be accessed correctly

Discussion:
vineetg3: > Resolve #222

This PR modifies the AST visitor to support more python syntax. Here are examples of syntax that is supported now:
Here’s the updated list with meaningful headings instead of the test function names:

1. **Variable-Length Arguments with `*args` and `**kwargs`**: Handling variable-length arguments and keyword arguments in function definitions.  
   Ex:  
   ```python
   def impl(*args, **kwargs):
       terms = {}
   ```

2. **Tuple Unpacking**: Assigning multiple values from a tuple in a single statement.  
   Ex:  
   ```python
   start, end = (1992, 1994)
   ```

3. **Unpacking Nested Structures**: Unpacking complex nested structures like lists or tuples.  
   Ex:  
   ```python
   a, (b, c) = ["GERMANY", ["FRANCE", "ARGENTINA"]]
   ```

4. **Dynamic Pairing with `zip`**: Unpacking paired values dynamically within an iterable using `zip`.  
   Ex:  
   ```python
   for i, j in zip(range(5), range(1992, 1997)):
       terms[f"c{i}"] = COUNT(orders.WHERE(YEAR(order_date) == j))
   ```

5. **Context Managers with `with`**: Managing multiple resources using a `with` block and importing modules with aliases.  
   Ex:  
   ```python
   with tf.TemporaryFile() as tf_handle1, tf.TemporaryFile() as tf_handle2:
       tf_handle1.write(b"Canada")
   ```

6. **Exception Handling**: Using `try`, `except`, and `finally` blocks to handle exceptions.  
   Ex:  
   ```python
   try:
       raise Exception("Canada")
   ```

7. **Object-Oriented Programming**: Using classes to encapsulate logic and data.  
   Ex:  
   ```python
   class Customer:
       def __init__(self, countries):
           self._countries = countries
   ```

8. **Dynamic Term Generation with Loops**: Using a loop and dictionary to dynamically generate terms.  
   Ex:  
   ```python
   for i in range(3):
       terms[f"interval_{i}"] = COUNT(
           customers.WHERE(MONOTONIC(i * 1000, acctbal, (i + 1) * 1000))
       )
   ```

9. **Reusable Logic with Functions**: Defining reusable logic to generate terms using regular functions.  
   Ex:  
   ```python
   def interval_n(n):
       return COUNT(customers.WHERE(MONOTONIC(n * 1000, acctbal, (n + 1) * 1000)))
   ```

10. **Shadowing Variables in Functions**: Using function arguments that overlap with collection fields.  
    Ex:  
    ```python
    def interval_n(n, name="test"):
        return COUNT(customers.WHERE(MONOTONIC(n * 1000, acctbal, (n + 1) * 1000)))
    ```

11. **Lambda Functions for Concise Logic**: Using a lambda function for one-line anonymous logic.  
    Ex:  
    ```python
    interval_n = lambda n: COUNT(
        customers.WHERE(MONOTONIC(n * 1000, acctbal, (n + 1) * 1000))
    )
    ```

12. **Dictionary Comprehensions**: Dynamically populating dictionaries using comprehensions.  
    Ex:  
    ```python
    terms.update(
        {
            f"interval_{i}": COUNT(
                customers.WHERE(MONOTONIC(i * 1000, acctbal, (i + 1) * 1000))
            )
            for i in range(3)
        }
    )
    ```

13. **List Comprehensions**: Generating and extending lists using comprehensions.  
    Ex:  
    ```python
    terms.extend(
        [
            COUNT(customers.WHERE(MONOTONIC(i * 1000, acctbal, (i + 1) * 1000)))
            for i in range(3)
        ]
    )
    ```

14. **Set Comprehensions for Unique Terms**: Generating unique terms with a set comprehension and sorting.  
    Ex:  
    ```python
    terms.extend(
        set(
            {
                COUNT(customers.WHERE(MONOTONIC(i * 1000, acctbal, (i + 1) * 1000)))
                for i in range(3)
            }
        )
    )
    ```


knassre-bodo: > > Resolve #222
> 
> This PR modifies the AST visitor to support more python syntax. Here are examples of syntax that is supported now: Here’s the updated list with meaningful headings instead of the test function names:
> 
> 1. **Variable-Length Arguments with `*args` and `**kwargs`**: Handling variable-length arguments and keyword arguments in function definitions.
>    Ex:
>    ```python
>    def impl(*args, **kwargs):
>        terms = {}
>    ```
> 2. **Tuple Unpacking**: Assigning multiple values from a tuple in a single statement.
>    Ex:
>    ```python
>    start, end = (1992, 1994)
>    ```
> 3. **Unpacking Nested Structures**: Unpacking complex nested structures like lists or tuples.
>    Ex:
>    ```python
>    a, (b, c) = ["GERMANY", ["FRANCE", "ARGENTINA"]]
>    ```
> 4. **Dynamic Pairing with `zip`**: Unpacking paired values dynamically within an iterable using `zip`.
>    Ex:
>    ```python
>    for i, j in zip(range(5), range(1992, 1997)):
>        terms[f"c{i}"] = COUNT(orders.WHERE(YEAR(order_date) == j))
>    ```
> 5. **Context Managers with `with`**: Managing multiple resources using a `with` block and importing modules with aliases.
>    Ex:
>    ```python
>    with tf.TemporaryFile() as tf_handle1, tf.TemporaryFile() as tf_handle2:
>        tf_handle1.write(b"Canada")
>    ```
> 6. **Exception Handling**: Using `try`, `except`, and `finally` blocks to handle exceptions.
>    Ex:
>    ```python
>    try:
>        raise Exception("Canada")
>    ```
> 7. **Object-Oriented Programming**: Using classes to encapsulate logic and data.
>    Ex:
>    ```python
>    class Customer:
>        def __init__(self, countries):
>            self._countries = countries
>    ```
> 8. **Dynamic Term Generation with Loops**: Using a loop and dictionary to dynamically generate terms.
>    Ex:
>    ```python
>    for i in range(3):
>        terms[f"interval_{i}"] = COUNT(
>            customers.WHERE(MONOTONIC(i * 1000, acctbal, (i + 1) * 1000))
>        )
>    ```
> 9. **Reusable Logic with Functions**: Defining reusable logic to generate terms using regular functions.
>    Ex:
>    ```python
>    def interval_n(n):
>        return COUNT(customers.WHERE(MONOTONIC(n * 1000, acctbal, (n + 1) * 1000)))
>    ```
> 10. **Shadowing Variables in Functions**: Using function arguments that overlap with collection fields.
>     Ex:
>     ```python
>     def interval_n(n, name="test"):
>         return COUNT(customers.WHERE(MONOTONIC(n * 1000, acctbal, (n + 1) * 1000)))
>     ```
> 11. **Lambda Functions for Concise Logic**: Using a lambda function for one-line anonymous logic.
>     Ex:
>     ```python
>     interval_n = lambda n: COUNT(
>         customers.WHERE(MONOTONIC(n * 1000, acctbal, (n + 1) * 1000))
>     )
>     ```
> 12. **Dictionary Comprehensions**: Dynamically populating dictionaries using comprehensions.
>     Ex:
>     ```python
>     terms.update(
>         {
>             f"interval_{i}": COUNT(
>                 customers.WHERE(MONOTONIC(i * 1000, acctbal, (i + 1) * 1000))
>             )
>             for i in range(3)
>         }
>     )
>     ```
> 13. **List Comprehensions**: Generating and extending lists using comprehensions.
>     Ex:
>     ```python
>     terms.extend(
>         [
>             COUNT(customers.WHERE(MONOTONIC(i * 1000, acctbal, (i + 1) * 1000)))
>             for i in range(3)
>         ]
>     )
>     ```
> 14. **Set Comprehensions for Unique Terms**: Generating unique terms with a set comprehension and sorting.
>     Ex:
>     ```python
>     terms.extend(
>         set(
>             {
>                 COUNT(customers.WHERE(MONOTONIC(i * 1000, acctbal, (i + 1) * 1000)))
>                 for i in range(3)
>             }
>         )
>     )
>     ```

Btw, make sure to include this comment of yours in the merge commit. These are FANTASTIC details to include.

Patch:
diff --git a/pydough/unqualified/unqualified_transform.py b/pydough/unqualified/unqualified_transform.py
index 7be19de..5d21af4 100644
--- a/pydough/unqualified/unqualified_transform.py
+++ b/pydough/unqualified/unqualified_transform.py
@@ -16,7 +16,9 @@ class AddRootVisitor(ast.NodeTransformer):
     """
     QDAG visitor class that transforms nodes in the following ways:
     1. Whenever a variable is assigned, marks it as a known variable name
-    (in addition to any `known_name` values passed in).
+    by adding it to the current scope set (in addition to all the previous scopes).
+    New scopes are created for functions, lambda functions, comprehensions,
+    context managers, etc.
     2. Removes the `init_pydough_context` decorator from above any functions.
     3. Adds `_ROOT = UnqualifiedRoot(graph)` to the start of each function body.
     4. Prepends any unknown variable names with `_ROOT.`
@@ -24,19 +26,45 @@ class AddRootVisitor(ast.NodeTransformer):
 
     def __init__(self, graph_name: str, known_names: set[str]):
         self._graph_name = graph_name
-        self._known_names: set[str] = known_names
-        self._known_names.update({"UnqualifiedRoot", self._graph_name})
+        # Initialize the scope stack with the outermost scope, including `known_names`
+        # set which would contain global variables or module imports of the
+        # jupyter cell or function.
+        self._scope_stack: list[set[str]] = [
+            set({"UnqualifiedRoot", self._graph_name, *known_names})
+        ]
 
-    def visit_Module(self, node):
-        """Visit the root node."""
+    def current_scope(self) -> set[str]:
+        # Return the current scope
+        return self._scope_stack[-1]
+
+    def enter_scope(self) -> None:
+        # Inherit parent scope but creates new copy
+        self._scope_stack.append(set(self.current_scope()))
+
+    def exit_scope(self) -> None:
+        self._scope_stack.pop()
+
+    def visit_Module(self, node) -> ast.AST:
+        """
+        Visit the root node.
+        """
         # Create the root definition in the outermost body
         node.body = self.create_root_def() + node.body
         return self.generic_visit(node)
 
-    def visit_Assign(self, node):
+    def visit_Assign(self, node) -> ast.AST:
+        """
+        Handle unpacking assignments like `a, (b, c) = ...`
+        """
         for target in node.targets:
-            if isinstance(target, ast.Name):
-                self._known_names.add(target.id)
+            self._scope_targets(target)  # Reuse existing scope-tracking logic
+        return self.generic_visit(node)
+
+    def visit_AnnAssign(self, node) -> ast.AST:
+        """
+        Handle annotated assignments like `a: int = ...`
+        """
+        self._scope_targets(node.target)
         return self.generic_visit(node)
 
     def create_root_def(self) -> list[ast.AST]:
@@ -53,8 +81,27 @@ class AddRootVisitor(ast.NodeTransformer):
         )
         return [import_root, root_def]
 
-    def visit_FunctionDef(self, node):
-        decorator_list: list[ast.AST] = []
+    def visit_FunctionDef(self, node) -> ast.AST:
+        """
+        Tracks function parameters in the scope and removes the PyDough decorator.
+
+        Example:
+            def interval_n(n, name="test"):
+                return COUNT(customers.WHERE(
+                    MONOTONIC(n * 1000, acctbal,  (n + 1) * 1000)))
+        """
+        self.current_scope().add(node.name)
+        self.enter_scope()
+        params = []
+        params += [p.arg for p in node.args.posonlyargs]
+        params += [p.arg for p in node.args.args]
+        params += [p.arg for p in node.args.kwonlyargs]
+        if node.args.vararg:
+            params.append(node.args.vararg.arg)
+        if node.args.kwarg:
+            params.append(node.args.kwarg.arg)
+        self.current_scope().update(params)
+        decorator_list: list[ast.expr] = []
         for deco in node.decorator_list:
             if not (
                 isinstance(deco, ast.Call)
@@ -69,7 +116,7 @@ class AddRootVisitor(ast.NodeTransformer):
                 name=node.name,
                 args=node.args,
                 body=prefix + node.body,
-                decorator_list=node.decorator_list,
+                decorator_list=decorator_list,
                 type_params=node.type_params,
                 returns=node.returns,
             )
@@ -78,10 +125,11 @@ class AddRootVisitor(ast.NodeTransformer):
                 name=node.name,
                 args=node.args,
                 body=prefix + node.body,
-                decorator_list=node.decorator_list,
+                decorator_list=decorator_list,
                 returns=node.returns,
             )
         answer: ast.AST = self.generic_visit(result)
+        self.exit_scope()
         return answer
 
     def visit_expression(self, node) -> ast.expr:
@@ -94,20 +142,14 @@ class AddRootVisitor(ast.NodeTransformer):
         assert isinstance(result, ast.stmt)
         return result
 
-    def visit_For(self, node):
-        if isinstance(node.target, ast.Name):
-            self._known_names.add(node.target.id)
-        return ast.For(  # type: ignore
-            target=node.target,
-            iter=self.visit_expression(node.iter),
-            body=[self.visit_statement(elem) for elem in node.body],
-            orelse=[self.visit_statement(elem) for elem in node.orelse],
-            type_comment=node.type_comment,
-        )
+    def visit_For(self, node) -> ast.AST:
+        self._scope_targets(node.target)
+        # Visit the rest of the node as usual
+        return self.generic_visit(node)
 
-    def visit_Name(self, node):
+    def visit_Name(self, node) -> ast.AST:
         unrecognized_var: bool = False
-        if node.id not in self._known_names:
+        if not any(node.id in scope for scope in self._scope_stack):
             try:
                 eval(node.id)
             except NameError:
@@ -120,6 +162,199 @@ class AddRootVisitor(ast.NodeTransformer):
         else:
             return node
 
+    def visit_Lambda(self, node: ast.Lambda) -> ast.AST:
+        """
+        Handle lambda function parameters and scoping.
+
+        Example:
+            interval_n = lambda n: COUNT(
+                customers.WHERE(MONOTONIC(n * 1000, acctbal, (n + 1) * 1000))
+            )
+        """
+        # Enter new scope
+        self.enter_scope()
+
+        # Extract and track lambda parameters
+        params: list[str] = []
+        params += [p.arg for p in node.args.posonlyargs]
+        params += [p.arg for p in node.args.args]
+        params += [p.arg for p in node.args.kwonlyargs]
+        if node.args.vararg:
+            params.append(node.args.vararg.arg)
+        if node.args.kwarg:
+            params.append(node.args.kwarg.arg)
+        self.current_scope().update(params)
+
+        # Visit the lambda body (an expression)
+        answer: ast.AST = self.generic_visit(node)
+
+        # Exit scope
+        self.exit_scope()
+
+        return answer
+
+    def visit_DictComp(self, node: ast.DictComp) -> ast.AST:
+        """
+        Handle dictionary comprehensions.
+
+        Example:
+            {k: v for k in range(3) for v in range(3)}
+        """
+        # New scope for comprehension
+        self.enter_scope()
+
+        # Track generator targets
+        # Example: generator: "for k, v in items"; targets being "k,v"
+        # Note that there can be multiple generators
+        # (e.g. "{k: v for k in range(3) for v in range(3)}")
+        # This gives us node.generators => [for k in range(3),for v in range(3)]
+        for generator in node.generators:
+            self._scope_targets(generator.target)
+
+        # Transform key and value. We use self.visit as opposed to self.generic_visit as
+        # we would like to dispatch to a specific visitor method such as visit_Name, etc.
+        new_key: ast.expr = self.visit(node.key)
+        new_value: ast.expr = self.visit(node.value)
+        answer: ast.AST = ast.DictComp(
+            key=new_key,
+            value=new_value,
+            generators=[self.visit(gen) for gen in node.generators],
+        )
+        self.exit_scope()
+        return answer
+
+    def visit_ListComp(self, node: ast.ListComp) -> ast.AST:
+        """
+        Handle list comprehensions.
+
+        Example:
+            [ord(c) for line in file for c in line]
+        """
+        self.enter_scope()  # New scope for comprehension
+        # Track generator targets (e.g., i in "for i in items")
+        for generator in node.generators:
+            self._scope_targets(generator.target)
+        # Transform elt. The elt attribute is an ast node that corresponds
+        # to the expression before the first 'for' in a list comprehension.
+        new_elt: ast.expr = self.visit(node.elt)
+        answer: ast.expr = ast.ListComp(
+            elt=new_elt,
+            generators=[self.visit(gen) for gen in node.generators],
+        )
+        self.exit_scope()
+        return answer
+
+    def visit_SetComp(self, node: ast.SetComp) -> ast.AST:
+        """
+        Handle set comprehensions.
+
+        Example:
+            {
+                COUNT(customers.WHERE(MONOTONIC(i * 1000, acctbal, (i + 1) * 1000)))
+                for i in range(3)
+            }
+        """
+        self.enter_scope()  # New scope for comprehension
+        # Track generator targets (e.g., i in "for i in items")
+        for generator in node.generators:
+            self._scope_targets(generator.target)
+        # Transform elt. The elt attribute is an ast node that corresponds
+        # to the expression before the first for in a set comprehension.
+        new_elt: ast.expr = self.visit(node.elt)
+        answer: ast.expr = ast.SetComp(
+            elt=new_elt,
+            generators=[self.visit(gen) for gen in node.generators],
+        )
+        self.exit_scope()
+        return answer
+
+    def visit_GeneratorExp(self, node: ast.GeneratorExp) -> ast.AST:
+        """
+        Handle generator comprehensions.
+
+        Example:
+            (COUNT(customers.WHERE(MONOTONIC(i * 1000, acctbal, (i + 1) * 1000)))
+                for i in range(3))
+        """
+        self.enter_scope()  # New scope for comprehension
+        # Track generator targets (e.g., i in "for i in items")
+        for generator in node.generators:
+            self._scope_targets(generator.target)
+        # Transform elt. The elt attribute is an ast node that corresponds
+        # to the expression before the first for in a generator comprehension.
+        new_elt: ast.expr = self.visit(node.elt)
+        answer: ast.expr = ast.GeneratorExp(
+            elt=new_elt,
+            generators=[self.visit(gen) for gen in node.generators],
+        )
+        self.exit_scope()
+        return answer
+
+    def _scope_targets(self, target: ast.expr) -> None:
+        """
+        Add variables to the current scope.
+        """
+        # if target is a tuple like (k,v).
+        if isinstance(target, (ast.Tuple, ast.List)):
+            for elt in target.elts:
+                if isinstance(elt, ast.Name):
+                    self.current_scope().add(elt.id)
+                elif isinstance(elt, (ast.Tuple, ast.List)):
+                    self._scope_targets(elt)
+        # if target is single variable like k.
+        elif isinstance(target, ast.Name):
+            self.current_scope().add(target.id)
+
+    def visit_With(self, node: ast.With) -> ast.AST:
+        """
+        Handle context manager variables declared with `as`
+        """
+        # Track all variables bound by context manager(s)
+        self.enter_scope()
+        # Let's loop through the contexts. (e.g tf.TemporaryFile() as tf_handle1)
+        for item in node.items:
+            if item.optional_vars:
+                self._scope_targets(item.optional_vars)
+        answer = self.generic_visit(node)
+        self.exit_scope()
+        return answer
+
+    def visit_Import(self, node: ast.Import) -> ast.AST:
+        """
+        Track imported module aliases like `import x as y`, `import a.b.c`
+        """
+        for alias in node.names:
+            name = alias.asname or alias.name.split(".", 1)[0]
+            self.current_scope().add(name)
+        return self.generic_visit(node)
+
+    def visit_ImportFrom(self, node: ast.ImportFrom) -> ast.AST:
+        """
+        Track specific imports like `from x import y as z`
+        """
+        for alias in node.names:
+            name = alias.asname or alias.name
+            self.current_scope().add(name)
+        return self.generic_visit(node)
+
+    def visit_ExceptHandler(self, node: ast.ExceptHandler) -> ast.AST:
+        """
+        Track exception variables like `except Error as err`
+        """
+        # Add exception binding name to current scope
+        if node.name:
+            self.current_scope().add(node.name)
+        # Process body normally
+        return self.generic_visit(node)
+
+    def visit_ClassDef(self, node: ast.ClassDef) -> ast.AST:
+        """
+        Handle class definitions and their scoped content
+        """
+        # Track class name in current scope
+        self.current_scope().add(node.name)
+        return self.generic_visit(node)
+
 
 def transform_code(
     source: str, graph_dict: dict[str, GraphMetadata], known_names: set[str]
@@ -195,6 +430,7 @@ def init_pydough_context(graph: GraphMetadata):
         file_name: str = func.__code__.co_filename
         new_code = compile(new_tree, file_name, "exec")
         idx = -1
+        # Only get the CodeType corresponding to the decorated function
         for i in range(len(new_code.co_consts)):
             if new_code.co_consts[i].__class__.__name__ == "code":
                 idx = i


Test patch:
diff --git a/tests/simple_pydough_functions.py b/tests/simple_pydough_functions.py
index 7457079..fbd9c78 100644
--- a/tests/simple_pydough_functions.py
+++ b/tests/simple_pydough_functions.py
@@ -147,6 +147,20 @@ def function_defined_terms():
     )
 
 
+def function_defined_terms_with_duplicate_names():
+    # Using a regular function to generate PyDough calc terms with the function argument same as collection's fields.
+    def interval_n(n, name="test"):
+        return COUNT(customers.WHERE(MONOTONIC(n * 1000, acctbal, (n + 1) * 1000)))
+
+    return Nations(
+        name,
+        redefined_name=name,
+        interval_7=interval_n(7),
+        interval_4=interval_n(4),
+        interval_13=interval_n(13),
+    )
+
+
 def lambda_defined_terms():
     # Using a lambda function to generate PyDough calc terms
     interval_n = lambda n: COUNT(
@@ -184,7 +198,7 @@ def list_comp_terms():
             for i in range(3)
         ]
     )
-    return Nations(**terms)
+    return Nations(*terms)
 
 
 def set_comp_terms():
@@ -198,7 +212,8 @@ def set_comp_terms():
             }
         )
     )
-    return Nations(**terms)
+    sorted_terms = sorted(terms, key=lambda x: repr(x))
+    return Nations(*sorted_terms)
 
 
 def generator_comp_terms():
@@ -293,3 +308,82 @@ def exponentiation():
         low_sqrt=SQRT(low),
         low_cbrt=POWER(low, 1 / 3),
     ).TOP_K(10, by=low_square.ASC())
+
+
+def args_kwargs():
+    def impl(*args, **kwargs):
+        terms = {}
+        for i, color in enumerate(args):
+            terms[f"n_{color}"] = COUNT(parts.WHERE(CONTAINS(part_name, color)))
+        for n, size in kwargs.items():
+            terms[n] = COUNT(parts.WHERE(size == size))
+        return TPCH(**terms)
+
+    result = impl("tomato", "almond", small=10, large=40)
+    return result
+
+
+def unpacking():
+    start, end = (1992, 1994)
+    selects_orders = orders.WHERE(MONOTONIC(start, YEAR(order_date), end))
+    return selects_orders
+
+
+def nested_unpacking():
+    a, (b, c) = ["GERMANY", ["FRANCE", "ARGENTINA"]]
+    chosen_customers = customers.WHERE(ISIN(nation.name, (a, b, c)))
+    return chosen_customers
+
+
+def unpacking_in_iterable():
+    terms = {}
+    for i, j in zip(range(5), range(1992, 1997)):
+        terms[f"c{i}"] = COUNT(orders.WHERE(YEAR(order_date) == j))
+    return Nations(**terms)
+
+
+def with_import_statement():
+    import tempfile as tf
+    import logging.config
+
+    logging.config.dictConfig({"version": 1})
+    from os import path as opath
+
+    result1 = opath.join("folder", "file.txt")
+    with tf.TemporaryFile() as tf_handle1, tf.TemporaryFile() as tf_handle2:
+        tf_handle1.write(b"Canada")
+        tf_handle2.write(b"Mexico")
+        tf_handle1.seek(0)
+        tf_handle2.seek(0)
+        a = str(tf_handle1.read().decode("utf-8"))
+        b = str(tf_handle2.read().decode("utf-8"))
+        return customers.WHERE(ISIN(nation.name, (a, b)))
+
+
+def exception_handling():
+    try:
+        raise Exception("Canada")
+    except Exception as e:
+        country = str(e)
+        return customers.WHERE(ISIN(nation.name, (country, "Mexico")))
+    finally:
+        pass
+
+
+def class_handling():
+    class Customer:
+        def __init__(self, countries):
+            self._countries = countries
+
+        def query(self):
+            return customers.WHERE(ISIN(nation.name, self._countries))
+
+    return Customer(("Canada", "Mexico")).query()
+
+
+def annotated_assignment():
+    direction1: str
+    direction1 = "SOUTH "
+    specific_region: tuple[str, str] = "WEST", "AMERICA"
+    chosen_region: str = direction1 + " ".join(specific_region)
+    return Nations.WHERE(region.name == chosen_region)
diff --git a/tests/test_unqualified_node.py b/tests/test_unqualified_node.py
index 129301c..386266b 100644
--- a/tests/test_unqualified_node.py
+++ b/tests/test_unqualified_node.py
@@ -21,12 +21,22 @@ from bad_pydough_functions import (
     bad_window_7,
 )
 from simple_pydough_functions import (
+    annotated_assignment,
+    args_kwargs,
+    class_handling,
     dict_comp_terms,
+    exception_handling,
     function_defined_terms,
+    function_defined_terms_with_duplicate_names,
     generator_comp_terms,
+    lambda_defined_terms,
     list_comp_terms,
     loop_generated_terms,
+    nested_unpacking,
     set_comp_terms,
+    unpacking,
+    unpacking_in_iterable,
+    with_import_statement,
 )
 from test_utils import graph_fetcher
 from tpch_test_functions import (
@@ -418,43 +428,81 @@ def test_unqualified_to_string(
         ),
         pytest.param(
             function_defined_terms,
-            "",
+            "?.Nations(name=?.name, interval_7=COUNT(?.customers.WHERE(MONOTONIC(7000, ?.acctbal, 8000))), interval_4=COUNT(?.customers.WHERE(MONOTONIC(4000, ?.acctbal, 5000))), interval_13=COUNT(?.customers.WHERE(MONOTONIC(13000, ?.acctbal, 14000))))",
             id="function_defined_terms",
-            marks=pytest.mark.skip(
-                "TODO: (gh #222) ensure PyDough code is compatible with full Python syntax "
-            ),
+        ),
+        pytest.param(
+            function_defined_terms_with_duplicate_names,
+            "?.Nations(name=?.name, redefined_name=?.name, interval_7=COUNT(?.customers.WHERE(MONOTONIC(7000, ?.acctbal, 8000))), interval_4=COUNT(?.customers.WHERE(MONOTONIC(4000, ?.acctbal, 5000))), interval_13=COUNT(?.customers.WHERE(MONOTONIC(13000, ?.acctbal, 14000))))",
+            id="function_defined_terms_with_duplicate_names",
+            # marks=pytest.mark.skip(
+            #     "TODO: (gh #222) ensure PyDough code is compatible with full Python syntax "
+            # ),
+        ),
+        pytest.param(
+            lambda_defined_terms,
+            "?.Nations(name=?.name, interval_7=COUNT(?.customers.WHERE(MONOTONIC(7000, ?.acctbal, 8000))), interval_4=COUNT(?.customers.WHERE(MONOTONIC(4000, ?.acctbal, 5000))), interval_13=COUNT(?.customers.WHERE(MONOTONIC(13000, ?.acctbal, 14000))))",
+            id="lambda_defined_terms",
         ),
         pytest.param(
             dict_comp_terms,
-            "",
+            "?.Nations(name=?.name, interval_0=COUNT(?.customers.WHERE(MONOTONIC(0, ?.acctbal, 1000))), interval_1=COUNT(?.customers.WHERE(MONOTONIC(1000, ?.acctbal, 2000))), interval_2=COUNT(?.customers.WHERE(MONOTONIC(2000, ?.acctbal, 3000))))",
             id="dict_comp_terms",
-            marks=pytest.mark.skip(
-                "TODO: (gh #222) ensure PyDough code is compatible with full Python syntax "
-            ),
         ),
         pytest.param(
             list_comp_terms,
-            "",
+            "?.Nations(name=?.name, _expr0=COUNT(?.customers.WHERE(MONOTONIC(0, ?.acctbal, 1000))), _expr1=COUNT(?.customers.WHERE(MONOTONIC(1000, ?.acctbal, 2000))), _expr2=COUNT(?.customers.WHERE(MONOTONIC(2000, ?.acctbal, 3000))))",
             id="list_comp_terms",
-            marks=pytest.mark.skip(
-                "TODO: (gh #222) ensure PyDough code is compatible with full Python syntax "
-            ),
         ),
         pytest.param(
             set_comp_terms,
-            "",
+            "?.Nations(name=?.name, _expr0=COUNT(?.customers.WHERE(MONOTONIC(0, ?.acctbal, 1000))), _expr1=COUNT(?.customers.WHERE(MONOTONIC(1000, ?.acctbal, 2000))), _expr2=COUNT(?.customers.WHERE(MONOTONIC(2000, ?.acctbal, 3000))))",
             id="set_comp_terms",
-            marks=pytest.mark.skip(
-                "TODO: (gh #222) ensure PyDough code is compatible with full Python syntax "
-            ),
         ),
         pytest.param(
             generator_comp_terms,
-            "",
+            "?.Nations(name=?.name, interval_0=COUNT(?.customers.WHERE(MONOTONIC(0, ?.acctbal, 1000))), interval_1=COUNT(?.customers.WHERE(MONOTONIC(1000, ?.acctbal, 2000))), interval_2=COUNT(?.customers.WHERE(MONOTONIC(2000, ?.acctbal, 3000))))",
             id="generator_comp_terms",
-            marks=pytest.mark.skip(
-                "TODO: (gh #222) ensure PyDough code is compatible with full Python syntax "
-            ),
+        ),
+        pytest.param(
+            args_kwargs,
+            "?.TPCH(n_tomato=COUNT(?.parts.WHERE(CONTAINS(?.part_name, 'tomato'))), n_almond=COUNT(?.parts.WHERE(CONTAINS(?.part_name, 'almond'))), small=COUNT(?.parts.WHERE(True)), large=COUNT(?.parts.WHERE(True)))",
+            id="args_kwargs",
+        ),
+        pytest.param(
+            unpacking,
+            "?.orders.WHERE(MONOTONIC(1992, YEAR(?.order_date), 1994))",
+            id="unpacking",
+        ),
+        pytest.param(
+            nested_unpacking,
+            "?.customers.WHERE(ISIN(?.nation.name, ['GERMANY', 'FRANCE', 'ARGENTINA']))",
+            id="nested_unpacking",
+        ),
+        pytest.param(
+            unpacking_in_iterable,
+            "?.Nations(c0=COUNT(?.orders.WHERE((YEAR(?.order_date) == 1992))), c1=COUNT(?.orders.WHERE((YEAR(?.order_date) == 1993))), c2=COUNT(?.orders.WHERE((YEAR(?.order_date) == 1994))), c3=COUNT(?.orders.WHERE((YEAR(?.order_date) == 1995))), c4=COUNT(?.orders.WHERE((YEAR(?.order_date) == 1996))))",
+            id="unpacking_in_iterable",
+        ),
+        pytest.param(
+            with_import_statement,
+            "?.customers.WHERE(ISIN(?.nation.name, ['Canada', 'Mexico']))",
+            id="with_import_statement",
+        ),
+        pytest.param(
+            exception_handling,
+            "?.customers.WHERE(ISIN(?.nation.name, ['Canada', 'Mexico']))",
+            id="exception_handling",
+        ),
+        pytest.param(
+            class_handling,
+            "?.customers.WHERE(ISIN(?.nation.name, ['Canada', 'Mexico']))",
+            id="class_handling",
+        ),
+        pytest.param(
+            annotated_assignment,
+            "?.Nations.WHERE((?.region.name == 'SOUTH WEST AMERICA'))",
+            id="annotated_assignment",
         ),
     ],
 )