Repository: dgasmith/opt_einsum

Issue:
Is it possible to use "Interleaved Inputs" with `shapes=True`?
`oe.contract_path` has the `shapes=True` parameter, which is useful for precomputing paths:
```
>>> oe.contract_path("abc,bcd->da", (2, 3, 4), (3, 4, 5), shapes=True)
```
The interleaved format is also useful, when you are generating equations programatically:
```
>>> x, y = np.random.rand(2, 3, 4), np.random.rand(3, 4, 5)
>>> oe.contract_path(x, [1, 2, 3], y, [2, 3, 4], [4, 1])
```
However, it seems that you can't use them together:
```
>>> oe.contract_path((2, 3, 4), [1, 2, 3], (3, 4, 5), [2, 3, 4], [4, 1], shapes=True)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[53], line 1
----> 1 oe.contract_path((2, 3, 4), [1, 2, 3], (3, 4, 5), [2, 3, 4], [4, 1], shapes=True)

File /opt/homebrew/lib/python3.12/site-packages/opt_einsum/contract.py:379, in contract_path(subscripts, use_blas, optimize, memory_limit, shapes, *operands, **kwargs)
    376 size_list.append(helpers.compute_size_by_dict(out_inds, size_dict))
    378 tmp_inputs = [input_list.pop(x) for x in contract_inds]
--> 379 tmp_shapes = [input_shapes.pop(x) for x in contract_inds]
    381 if use_blas:
    382     do_blas = blas.can_blas(tmp_inputs, "".join(out_inds), idx_removed, tmp_shapes)

AttributeError: 'tuple' object has no attribute 'pop'
```

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/dgasmith/opt_einsum/pull/247?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Smith) Report
Attention: Patch coverage is `50.00000%` with `6 lines` in your changes missing coverage. Please review.
> Project coverage is 58.63%. Comparing base [(`384bb5a`)](https://app.codecov.io/gh/dgasmith/opt_einsum/commit/384bb5ae183ea8c6a2c35440caf2a4d25aaf959b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Smith) to head [(`a9ed148`)](https://app.codecov.io/gh/dgasmith/opt_einsum/commit/a9ed148845e542119c5fce2622e48a1a506af47d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Smith).

> :exclamation:  There is a different number of reports uploaded between BASE (384bb5a) and HEAD (a9ed148). Click for more details.
> 
> <details><summary>HEAD has 3 uploads less than BASE</summary>
>
>| Flag | BASE (384bb5a) | HEAD (a9ed148) |
>|------|------|------|
>||5|2|
></details>

<details><summary>Additional details and impacted files</summary>


</details>

Patch:
diff --git a/.github/workflows/Linting.yml b/.github/workflows/Linting.yml
index 9d2e90b..3d2ceae 100644
--- a/.github/workflows/Linting.yml
+++ b/.github/workflows/Linting.yml
@@ -76,4 +76,4 @@ jobs:
     - name: Lint
       run: |
         set -e
-        make format-check
+        make fmt-check
diff --git a/Makefile b/Makefile
index 1a1ff8e..3e43a0c 100644
--- a/Makefile
+++ b/Makefile
@@ -4,13 +4,18 @@
 install:
 	pip install -e .
 
-.PHONY: format
-format:
+.PHONY: fmt
+fmt:
 	ruff check opt_einsum --fix
 	ruff format opt_einsum
 
-.PHONY: format-check
-format-check:
+.PHONY: fmt-unsafe
+fmt-unsafe:
+	ruff check opt_einsum --fix --unsafe-fixes
+	ruff format opt_einsum
+
+.PHONY: fmt-check
+fmt-check:
 	ruff check opt_einsum
 	ruff format --check opt_einsum
 
diff --git a/opt_einsum/contract.py b/opt_einsum/contract.py
index 153d060..9ea929f 100644
--- a/opt_einsum/contract.py
+++ b/opt_einsum/contract.py
@@ -297,7 +297,7 @@ def contract_path(
     input_list = input_subscripts.split(",")
     input_sets = [frozenset(x) for x in input_list]
     if shapes:
-        input_shapes = operands_prepped
+        input_shapes = list(operands_prepped)
     else:
         input_shapes = [parser.get_shape(x) for x in operands_prepped]
     output_set = frozenset(output_subscript)


Test patch:
diff --git a/opt_einsum/testing.py b/opt_einsum/testing.py
index 2f18b3a..87048b1 100644
--- a/opt_einsum/testing.py
+++ b/opt_einsum/testing.py
@@ -8,17 +8,24 @@ import pytest
 from opt_einsum.parser import get_symbol
 from opt_einsum.typing import ArrayType, PathType, TensorShapeType
 
-_valid_chars = "abcdefghijklmopqABC"
-_sizes = [2, 3, 4, 5, 4, 3, 2, 6, 5, 4, 3, 2, 5, 7, 4, 3, 2, 3, 4]
+_no_collision_chars = "".join(chr(i) for i in range(7000, 7007))
+_valid_chars = "abcdefghijklmnopqABC" + _no_collision_chars
+_sizes = [2, 3, 4, 5, 4, 3, 2, 6, 5, 4, 3, 2, 5, 7, 4, 3, 2, 3, 4, 9, 10, 2, 4, 5, 3, 2, 6]
 _default_dim_dict = dict(zip(_valid_chars, _sizes))
+assert len(_valid_chars) == len(
+    _sizes
+), f"Valid characters and sizes must be the same length: {len(_valid_chars)} != {len(_sizes)}"
 
 
-def build_shapes(string: str, dimension_dict: Optional[Dict[str, int]] = None) -> Tuple[TensorShapeType, ...]:
+def build_shapes(
+    string: str, dimension_dict: Optional[Dict[str, int]] = None, replace_ellipsis: bool = False
+) -> Tuple[TensorShapeType, ...]:
     """Builds random tensor shapes for testing.
 
     Parameters:
         string: List of tensor strings to build
         dimension_dict: Dictionary of index sizes, defaults to indices size of 2-7
+        replace_ellipsis: Replace ellipsis with a string of no collision characters
 
     Returns:
         The resulting shapes.
@@ -34,6 +41,14 @@ def build_shapes(string: str, dimension_dict: Optional[Dict[str, int]] = None) -
     if dimension_dict is None:
         dimension_dict = _default_dim_dict
 
+    if "..." in string:
+        if replace_ellipsis is False:
+            raise ValueError(
+                "Ellipsis found in string but `replace_ellipsis=False`, use `replace_ellipsis=True` if this behavior is desired."
+            )
+        ellipse_replace = _no_collision_chars[:3]
+        string = string.replace("...", ellipse_replace)
+
     shapes = []
     terms = string.split("->")[0].split(",")
     for term in terms:
@@ -43,14 +58,18 @@ def build_shapes(string: str, dimension_dict: Optional[Dict[str, int]] = None) -
 
 
 def build_views(
-    string: str, dimension_dict: Optional[Dict[str, int]] = None, array_function: Optional[Any] = None
-) -> Tuple[ArrayType]:
+    string: str,
+    dimension_dict: Optional[Dict[str, int]] = None,
+    array_function: Optional[Any] = None,
+    replace_ellipsis: bool = False,
+) -> Tuple[ArrayType, ...]:
     """Builds random numpy arrays for testing.
 
     Parameters:
         string: List of tensor strings to build
         dimension_dict: Dictionary of index _sizes
         array_function: Function to build the arrays, defaults to np.random.rand
+        replace_ellipsis: Replace ellipsis with a string of no collision characters
 
     Returns:
         The resulting views.
@@ -68,7 +87,7 @@ def build_views(
         array_function = np.random.rand
 
     views = []
-    for shape in build_shapes(string, dimension_dict=dimension_dict):
+    for shape in build_shapes(string, dimension_dict=dimension_dict, replace_ellipsis=replace_ellipsis):
         if shape:
             views.append(array_function(*shape))
         else:
diff --git a/opt_einsum/tests/test_input.py b/opt_einsum/tests/test_input.py
index 24b1ead..d2babe5 100644
--- a/opt_einsum/tests/test_input.py
+++ b/opt_einsum/tests/test_input.py
@@ -2,34 +2,16 @@
 Tests the input parsing for opt_einsum. Duplicates the np.einsum input tests.
 """
 
-from typing import Any, List
+from typing import Any
 
 import pytest
 
 from opt_einsum import contract, contract_path
-from opt_einsum.typing import ArrayType
+from opt_einsum.testing import build_views
 
 np = pytest.importorskip("numpy")
 
 
-def build_views(string: str) -> List[ArrayType]:
-    """Builds random numpy arrays for testing by using a fixed size dictionary and an input string."""
-
-    chars = "abcdefghij"
-    sizes_array = np.array([2, 3, 4, 5, 4, 3, 2, 6, 5, 4])
-    sizes = dict(zip(chars, sizes_array))
-
-    views = []
-
-    string = string.replace("...", "ij")
-
-    terms = string.split("->")[0].split(",")
-    for term in terms:
-        dims = [sizes[x] for x in term]
-        views.append(np.random.rand(*dims))
-    return views
-
-
 def test_type_errors() -> None:
     # subscripts must be a string
     with pytest.raises(TypeError):
@@ -77,7 +59,7 @@ def test_type_errors() -> None:
 
     # Catch ellipsis errors
     string = "...a->...a"
-    views = build_views(string)
+    views = build_views(string, replace_ellipsis=True)
 
     # Subscript list must contain Ellipsis or (hashable && comparable) object
     with pytest.raises(TypeError):
@@ -168,6 +150,18 @@ def test_value_errors(contract_fn: Any) -> None:
         contract_fn("ij->ij", [[0, 1], [0, 1]], bad_kwarg=True)
 
 
+def test_input_formats_shapes():
+    """
+    Test that the shapes are the same for the bench and interleved input formats
+    """
+    shape1 = (2, 3, 4)
+    shape2 = (3, 4, 5)
+
+    bench = contract_path("abc,bcd->da", shape1, shape2, shapes=True)
+    interleved = contract_path(shape1, [1, 2, 3], shape2, [2, 3, 4], [4, 1], shapes=True)
+    assert bench[0] == interleved[0]
+
+
 @pytest.mark.parametrize(
     "string",
     [
@@ -181,7 +175,7 @@ def test_value_errors(contract_fn: Any) -> None:
     ],
 )
 def test_compare(string: str) -> None:
-    views = build_views(string)
+    views = build_views(string, replace_ellipsis=True)
 
     ein = contract(string, *views, optimize=False)
     opt = contract(string, *views)
@@ -193,7 +187,7 @@ def test_compare(string: str) -> None:
 
 def test_ellipse_input1() -> None:
     string = "...a->..."
-    views = build_views(string)
+    views = build_views(string, replace_ellipsis=True)
 
     ein = contract(string, *views, optimize=False)
     opt = contract(views[0], [Ellipsis, 0], [Ellipsis])
@@ -202,7 +196,7 @@ def test_ellipse_input1() -> None:
 
 def test_ellipse_input2() -> None:
     string = "...a"
-    views = build_views(string)
+    views = build_views(string, replace_ellipsis=True)
 
     ein = contract(string, *views, optimize=False)
     opt = contract(views[0], [Ellipsis, 0])
@@ -211,7 +205,7 @@ def test_ellipse_input2() -> None:
 
 def test_ellipse_input3() -> None:
     string = "...a->...a"
-    views = build_views(string)
+    views = build_views(string, replace_ellipsis=True)
 
     ein = contract(string, *views, optimize=False)
     opt = contract(views[0], [Ellipsis, 0], [Ellipsis, 0])
@@ -220,7 +214,7 @@ def test_ellipse_input3() -> None:
 
 def test_ellipse_input4() -> None:
     string = "...b,...a->..."
-    views = build_views(string)
+    views = build_views(string, replace_ellipsis=True)
 
     ein = contract(string, *views, optimize=False)
     opt = contract(views[0], [Ellipsis, 1], views[1], [Ellipsis, 0], [Ellipsis])