Repository: matthew-brett/delocate

Issue:
This function does not support separate values per-architecture: 'i386'
**Describe the bug**
running delocate-wheel for an `arm64`-only wheel with `universal2` python installation:
```
otool: /opt/X11/lib/libSM.6.dylib
Traceback (most recent call last):
  File "/Users/savulesc/Workspace/nrn/nrn_build_venv38_-2850068/bin/delocate-wheel", line 8, in <module>
    sys.exit(main())
  File "/Users/savulesc/Workspace/nrn/nrn_build_venv38_-2850068/lib/python3.8/site-packages/delocate/cmd/delocate_wheel.py", line 128, in main
    copied = delocate_wheel(
  File "/Users/savulesc/Workspace/nrn/nrn_build_venv38_-2850068/lib/python3.8/site-packages/delocate/delocating.py", line 643, in delocate_wheel
    copied_libs = delocate_path(
  File "/Users/savulesc/Workspace/nrn/nrn_build_venv38_-2850068/lib/python3.8/site-packages/delocate/delocating.py", line 465, in delocate_path
    lib_dict = tree_libs_from_directory(
  File "/Users/savulesc/Workspace/nrn/nrn_build_venv38_-2850068/lib/python3.8/site-packages/delocate/libsana.py", line 384, in tree_libs_from_directory
    return _tree_libs_from_libraries(
  File "/Users/savulesc/Workspace/nrn/nrn_build_venv38_-2850068/lib/python3.8/site-packages/delocate/libsana.py", line 309, in _tree_libs_from_libraries
    for depending_path, install_name in get_dependencies(
  File "/Users/savulesc/Workspace/nrn/nrn_build_venv38_-2850068/lib/python3.8/site-packages/delocate/libsana.py", line 105, in get_dependencies
    for install_name in get_install_names(lib_fname):
  File "/Users/savulesc/Workspace/nrn/nrn_build_venv38_-2850068/lib/python3.8/site-packages/delocate/tools.py", line 474, in get_install_names
    names_data = _check_ignore_archs(_parse_otool_install_names(otool.stdout))
  File "/Users/savulesc/Workspace/nrn/nrn_build_venv38_-2850068/lib/python3.8/site-packages/delocate/tools.py", line 324, in _check_ignore_archs
    raise NotImplementedError(
NotImplementedError: This function does not support separate values per-architecture: {'i386': [('/opt/X11/lib/libSM.6.dylib', '7.0.0', '7.1.0'), ('/opt/X11/lib/libICE.6.dylib', '10.0.0', '10.0.0'), ('/usr/lib/libSystem.B.dylib', '1.0.0', '1252.50.4')], 'x86_64': [('/opt/X11/lib/libSM.6.dylib', '7.0.0', '7.1.0'), ('/opt/X11/lib/libICE.6.dylib', '10.0.0', '10.0.0'), ('/usr/lib/libSystem.B.dylib', '1.0.0', '1311.120.1')], 'arm64': [('/opt/X11/lib/libSM.6.dylib', '7.0.0', '7.1.0'), ('/opt/X11/lib/libICE.6.dylib', '10.0.0', '10.0.0'), ('/usr/lib/libSystem.B.dylib', '1.0.0', '1311.120.1')]}
````

**To Reproduce**
```bash
delocate-wheel -w wheelhouse -v dist/*.whl. # from `build_wheels.bash` -> see `Wheels used` section
```

**Expected behavior**
delocate-wheel works

**Wheels used**
Attaching here. 

Manual:
Install `universal2` Python 3.8.10 from Python.org ;  XQuartz from `xquartz.org` (this installs to `/opt/X11` where the issue is manifesting from) 
To create the wheel: 
```bash
git clone git@github.com:neuronsimulator/nrn.git
cd nrn
bash packaging/python/build_static_readline_osx.bash
bash packaging/python/build_wheels.bash osx 3.8
```

**Platform (please complete the following information):**
 - OS version: macOS 12.3.1
 - Delocate version: 0.10.2

**Additional context**
If I pop the `'i386'` from the dict passed to `_check_ignore_archs` it works.
[wheel.zip](https://github.com/matthew-brett/delocate/files/9029349/wheel.zip)


Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/matthew-brett/delocate/pull/230?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Matthew+Brett) Report
Attention: Patch coverage is `98.09524%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 97.18%. Comparing base [(`dfb21c8`)](https://app.codecov.io/gh/matthew-brett/delocate/commit/dfb21c8cff605ab4b0d4ee4b40cabe07748b6033?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Matthew+Brett) to head [(`840b2da`)](https://app.codecov.io/gh/matthew-brett/delocate/commit/840b2da4c135e6dc14e5da0b1e564b711f6872f6?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Matthew+Brett).

| [Files with missing lines](https://app.codecov.io/gh/matthew-brett/delocate/pull/230?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Matthew+Brett) | Patch % | Lines |
|---|---|---|
| [delocate/tools.py](https://app.codecov.io/gh/matthew-brett/delocate/pull/230?src=pr&el=tree&filepath=delocate%2Ftools.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Matthew+Brett#diff-ZGVsb2NhdGUvdG9vbHMucHk=) | 95.83% | [2 Missing :warning: ](https://app.codecov.io/gh/matthew-brett/delocate/pull/230?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Matthew+Brett) |

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


```diff
@@            Coverage Diff             @@
##           master     #230      +/-   ##
==========================================
+ Coverage   97.05%   97.18%   +0.12%     
==========================================
  Files          16       16              
  Lines        1326     1350      +24     
==========================================
+ Hits         1287     1312      +25     
+ Misses         39       38       -1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/matthew-brett/delocate/pull/230?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Matthew+Brett).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Matthew+Brett).

HexDecimal: I might change some of the internal functions. I'd like to tell the difference between a library not having data verses the file not actually being a library. I don't like these errors being silently suppressed by the current functions.
HexDecimal: The PR would've been ready immediately if I didn't theorize on updating the error handling.

This PR is ready for squash and merge.

Patch:
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index f1c8432..6ed74d3 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -45,14 +45,14 @@ jobs:
 
   tests:
     needs: [pre-commit, mypy]
-    runs-on: macos-12
+    runs-on: macos-13
     steps:
       - uses: actions/checkout@v4
         with:
           submodules: true
       - uses: maxim-lobanov/setup-xcode@v1
         with:
-          xcode-version: "14.0"
+          xcode-version: "14.2"
       - uses: actions/setup-python@v5
         with:
           python-version: "3.x"
@@ -134,12 +134,15 @@ jobs:
       fail-fast: true
       matrix:
         include:
-          - os: "macos-12"
-            xcode: "14.0"
-            python: "3.x"
           - os: "macos-13"
             xcode: "14.2"
             python: "3.x"
+          - os: "macos-14"
+            xcode: "15.4"
+            python: "3.x"
+          - os: "macos-15"
+            xcode: "16.0"
+            python: "3.x"
           - os: "ubuntu-latest"
             python: "3.9"
           - os: "ubuntu-latest"
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4f655cd..b1a81f6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -21,7 +21,7 @@ The `wheel_makers` directory holds scripts used to make test data. GitHub Action
 
 Use [pathlib](https://docs.python.org/3/library/pathlib.html) for any new code using paths.
 Refactor any touched functions to use pathlib when it does not break backwards compatibility.
-Prefer using `PurePosixPath` to handle relative library paths returned from MacOS tools such as `otool`.
+Prefer using `str` to handle paths returned from MacOS tools such as `otool`.
 
 All new functions must have [type hints](https://mypy.readthedocs.io/en/stable/getting_started.html).
 All touched functions must be refactored to use type hints, including test functions.
diff --git a/Changelog.md b/Changelog.md
index 771da88..d68d7f2 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -15,6 +15,21 @@ rules on making a good Changelog.
 - `patch_wheel` function raises `FileNotFoundError` instead of `ValueError` on
   missing patch files.
 
+### Deprecated
+
+- `get_rpaths` and `get_install_id` are deprecated due to not supporting
+  architectures.
+- `unique_by_index` is deprecated. Use more-itertools unique_everseen instead.
+
+### Fixed
+
+- Fixed `NotImplementedError` when libraries depend on differing binaries
+  per-architecture.
+  [#230](https://github.com/matthew-brett/delocate/pull/230)
+- Now checks all architectures instead of an arbitrary default.
+  This was causing inconsistent behavior across MacOS versions.
+  [#230](https://github.com/matthew-brett/delocate/pull/230)
+
 ### Removed
 
 - Dropped support for Python 3.7 and Python 3.8.
diff --git a/delocate/libsana.py b/delocate/libsana.py
index dab597e..83e5e0a 100644
--- a/delocate/libsana.py
+++ b/delocate/libsana.py
@@ -10,17 +10,21 @@ import os
 import sys
 import warnings
 from collections.abc import Iterable, Iterator
-from os.path import basename, dirname, realpath
+from os import PathLike
 from os.path import join as pjoin
+from os.path import realpath
+from pathlib import Path
 from typing import (
     Callable,
 )
 
+from typing_extensions import deprecated
+
 from .tmpdirs import TemporaryDirectory
 from .tools import (
+    _get_install_names,
+    _get_rpaths,
     get_environment_variable_paths,
-    get_install_names,
-    get_rpaths,
     zip2dir,
 )
 
@@ -40,8 +44,8 @@ def _filter_system_libs(libname: str) -> bool:
 
 
 def get_dependencies(
-    lib_fname: str,
-    executable_path: str | None = None,
+    lib_fname: str | PathLike[str],
+    executable_path: str | PathLike[str] | None = None,
     filt_func: Callable[[str], bool] = lambda filepath: True,
 ) -> Iterator[tuple[str | None, str]]:
     """Find and yield the real paths of dependencies of the library `lib_fname`.
@@ -54,9 +58,9 @@ def get_dependencies(
 
     Parameters
     ----------
-    lib_fname : str
+    lib_fname : str or PathLike
         The library to fetch dependencies from.  Must be an existing file.
-    executable_path : str, optional
+    executable_path : str or PathLike, optional
         An alternative path to use for resolving `@executable_path`.
     filt_func : callable, optional
         A callable which accepts filename as argument and returns True if we
@@ -82,50 +86,66 @@ def get_dependencies(
     DependencyNotFound
         When `lib_fname` does not exist.
     """
-    if not filt_func(lib_fname):
+    lib_fname = Path(lib_fname)
+    if not filt_func(str(lib_fname)):
         logger.debug(f"Ignoring dependencies of {lib_fname}")
         return
-    if not os.path.isfile(lib_fname):
-        if not _filter_system_libs(lib_fname):
+    if not lib_fname.is_file():
+        if not _filter_system_libs(str(lib_fname)):
             logger.debug(
                 "Ignoring missing library %s because it is a system library.",
                 lib_fname,
             )
             return
         raise DependencyNotFound(lib_fname)
-    rpaths = get_rpaths(lib_fname) + get_environment_variable_paths()
-    for install_name in get_install_names(lib_fname):
-        try:
-            if install_name.startswith("@"):
-                dependency_path = resolve_dynamic_paths(
-                    install_name,
-                    rpaths,
-                    loader_path=dirname(lib_fname),
-                    executable_path=executable_path,
-                )
-            else:
-                dependency_path = search_environment_for_lib(install_name)
-            if not os.path.isfile(dependency_path):
-                if not _filter_system_libs(dependency_path):
-                    logger.debug(
-                        "Skipped missing dependency %s"
-                        " because it is a system library.",
-                        dependency_path,
+
+    environment_paths = get_environment_variable_paths()
+    rpaths = {
+        arch: [*paths, *environment_paths]
+        for arch, paths in _get_rpaths(lib_fname).items()
+    }
+
+    install_name_seen = set()
+    for arch, install_names in _get_install_names(lib_fname).items():
+        for install_name in install_names:
+            if install_name in install_name_seen:
+                # The same dependency listed by multiple architectures should
+                # only be counted once.
+                continue
+            install_name_seen.add(install_name)
+            try:
+                if install_name.startswith("@"):
+                    dependency_path = resolve_dynamic_paths(
+                        install_name,
+                        rpaths[arch],
+                        loader_path=lib_fname.parent,
+                        executable_path=executable_path,
                     )
                 else:
-                    raise DependencyNotFound(dependency_path)
-            if dependency_path != install_name:
-                logger.debug(
-                    "%s resolved to: %s", install_name, dependency_path
+                    dependency_path = search_environment_for_lib(install_name)
+                if not Path(dependency_path).is_file():
+                    if not _filter_system_libs(dependency_path):
+                        logger.debug(
+                            "Skipped missing dependency %s"
+                            " because it is a system library.",
+                            dependency_path,
+                        )
+                    else:
+                        raise DependencyNotFound(dependency_path)
+                if dependency_path != install_name:
+                    logger.debug(
+                        "%s resolved to: %s", install_name, dependency_path
+                    )
+                yield dependency_path, str(install_name)
+            except DependencyNotFound:
+                message = (
+                    f"\n{install_name} not found:\n  Needed by: {lib_fname}"
                 )
-            yield dependency_path, install_name
-        except DependencyNotFound:
-            message = f"\n{install_name} not found:\n  Needed by: {lib_fname}"
-            if install_name.startswith("@rpath"):
-                message += "\n  Search path:\n    " + "\n    ".join(rpaths)
-            logger.error(message)
-            # At this point install_name is known to be a bad path.
-            yield None, install_name
+                if Path(install_name).parts[0] == "@rpath":
+                    message += "\n  Search path:\n    " + "\n    ".join(rpaths)
+                logger.error(message)
+                # At this point install_name is known to be a bad path.
+                yield None, str(install_name)
 
 
 def walk_library(
@@ -459,10 +479,10 @@ _default_paths_to_search = ("/usr/local/lib", "/usr/lib")
 
 
 def resolve_dynamic_paths(
-    lib_path: str,
-    rpaths: Iterable[str],
-    loader_path: str,
-    executable_path: str | None = None,
+    lib_path: str | PathLike[str],
+    rpaths: Iterable[str | PathLike[str]],
+    loader_path: str | PathLike[str],
+    executable_path: str | PathLike[str] | None = None,
 ) -> str:
     """Return `lib_path` with any special runtime linking names resolved.
 
@@ -475,15 +495,15 @@ def resolve_dynamic_paths(
 
     Parameters
     ----------
-    lib_path : str
+    lib_path : str or PathLike
         The path to a library file, which may or may not be a relative path
         starting with `@rpath`, `@loader_path`, or `@executable_path`.
-    rpaths : sequence of str
+    rpaths : sequence of str or PathLike
         A sequence of search paths, usually gotten from a call to `get_rpaths`.
-    loader_path : str
+    loader_path : str or PathLike
         The path to be used for `@loader_path`.
         This should be the directory of the library which is loading `lib_path`.
-    executable_path : None or str, optional
+    executable_path : None or str or PathLike, optional
         The path to be used for `@executable_path`.
         If None is given then the path of the Python executable will be used.
 
@@ -498,39 +518,48 @@ def resolve_dynamic_paths(
         When `lib_path` has `@rpath` in it but no library can be found on any
         of the provided `rpaths`.
     """
+    lib_path = Path(lib_path)
+
     if executable_path is None:
-        executable_path = dirname(sys.executable)
+        executable_path = Path(sys.executable).parent
 
-    if not lib_path.startswith(
-        ("@rpath/", "@loader_path/", "@executable_path/")
+    if lib_path.parts[0] not in (
+        ("@rpath", "@loader_path", "@executable_path")
     ):
-        return realpath(lib_path)
+        return str(Path(lib_path).resolve())
 
-    if lib_path.startswith("@loader_path/"):
+    paths_to_search = []
+    if lib_path.parts[0] == "@loader_path":
         paths_to_search = [loader_path]
-    elif lib_path.startswith("@executable_path/"):
+    elif lib_path.parts[0] == "@executable_path":
         paths_to_search = [executable_path]
-    elif lib_path.startswith("@rpath/"):
+    elif lib_path.parts[0] == "@rpath":
         paths_to_search = list(rpaths)
 
     # these paths are searched by the macos loader in order if the
     # library is not in the previous paths.
     paths_to_search.extend(_default_paths_to_search)
 
-    rel_path = lib_path.split("/", 1)[1]
+    rel_path = Path(*lib_path.parts[1:])
     for prefix_path in paths_to_search:
         try:
-            abs_path = resolve_dynamic_paths(
-                pjoin(prefix_path, rel_path), (), loader_path, executable_path
-            )
+            abs_path = Path(
+                resolve_dynamic_paths(
+                    Path(prefix_path, rel_path),
+                    (),
+                    loader_path,
+                    executable_path,
+                )
+            ).resolve()
         except DependencyNotFound:
             continue
-        if os.path.exists(abs_path):
-            return realpath(abs_path)
+        if abs_path.exists():
+            return str(abs_path)
 
     raise DependencyNotFound(lib_path)
 
 
+@deprecated("This function was replaced by resolve_dynamic_paths")
 def resolve_rpath(lib_path: str, rpaths: Iterable[str]) -> str:
     """Return `lib_path` with its `@rpath` resolved.
 
@@ -579,7 +608,7 @@ def resolve_rpath(lib_path: str, rpaths: Iterable[str]) -> str:
     return lib_path
 
 
-def search_environment_for_lib(lib_path: str) -> str:
+def search_environment_for_lib(lib_path: str | PathLike[str]) -> str:
     """Search common environment variables for `lib_path`.
 
     We'll use a single approach here:
@@ -601,7 +630,7 @@ def search_environment_for_lib(lib_path: str) -> str:
 
     Parameters
     ----------
-    lib_path : str
+    lib_path : str or PathLike
         Name of the library to search for
 
     Returns
@@ -610,27 +639,21 @@ def search_environment_for_lib(lib_path: str) -> str:
         Real path of the first found location, if it can be found, or
         ``realpath(lib_path)`` if it cannot.
     """
-    lib_basename = basename(lib_path)
-    potential_library_locations = []
-
-    # 1. Search on DYLD_LIBRARY_PATH
-    potential_library_locations += _paths_from_var(
-        "DYLD_LIBRARY_PATH", lib_basename
-    )
-
-    # 2. Search for realpath(lib_path)
-    potential_library_locations.append(realpath(lib_path))
-
-    # 3. Search on DYLD_FALLBACK_LIBRARY_PATH
-    potential_library_locations += _paths_from_var(
-        "DYLD_FALLBACK_LIBRARY_PATH", lib_basename
-    )
+    lib_path = Path(lib_path)
+    potential_library_locations: list[Path] = [
+        # 1. Search on DYLD_LIBRARY_PATH
+        *_paths_from_var("DYLD_LIBRARY_PATH", lib_path.name),
+        # 2. Search for realpath(lib_path)
+        lib_path.resolve(),
+        # 3. Search on DYLD_FALLBACK_LIBRARY_PATH
+        *_paths_from_var("DYLD_FALLBACK_LIBRARY_PATH", lib_path.name),
+    ]
 
     for location in potential_library_locations:
-        if os.path.exists(location):
+        if location.exists():
             # See GH#133 for why we return the realpath here if it can be found
-            return realpath(location)
-    return realpath(lib_path)
+            return str(location.resolve())
+    return str(lib_path.resolve())
 
 
 def get_prefix_stripper(strip_prefix: str) -> Callable[[str], str]:
@@ -754,8 +777,10 @@ def wheel_libs(
     return stripped_lib_dict(lib_dict, realpath(tmpdir) + os.path.sep)
 
 
-def _paths_from_var(varname: str, lib_basename: str) -> list[str]:
+def _paths_from_var(
+    varname: str, lib_basename: str | PathLike[str]
+) -> Iterator[Path]:
     var = os.environ.get(varname)
     if var is None:
-        return []
-    return [pjoin(path, lib_basename) for path in var.split(os.pathsep)]
+        return
+    yield from (Path(path, lib_basename) for path in var.split(os.pathsep))
diff --git a/delocate/tools.py b/delocate/tools.py
index f1bc16e..943bcc2 100644
--- a/delocate/tools.py
+++ b/delocate/tools.py
@@ -2,6 +2,7 @@
 
 from __future__ import annotations
 
+import itertools
 import logging
 import os
 import re
@@ -10,7 +11,7 @@ import subprocess
 import time
 import warnings
 import zipfile
-from collections.abc import Iterable, Sequence
+from collections.abc import Iterable, Iterator, Sequence
 from datetime import datetime
 from os import PathLike
 from os.path import exists, isdir
@@ -21,6 +22,8 @@ from typing import (
     TypeVar,
 )
 
+from typing_extensions import deprecated
+
 T = TypeVar("T")
 
 logger = logging.getLogger(__name__)
@@ -96,7 +99,7 @@ def back_tick(
 
 
 def _run(
-    cmd: Sequence[str], *, check: bool
+    cmd: Sequence[str | PathLike[str]], *, check: bool
 ) -> subprocess.CompletedProcess[str]:
     r"""Run ``cmd`` capturing output and handling non-zero exit codes by default.
 
@@ -174,7 +177,25 @@ def _is_macho_file(filename: str | os.PathLike[str]) -> bool:
         return False
 
 
-def unique_by_index(sequence):
+def _unique_everseen(iterable: Iterable[T], /) -> Iterator[T]:
+    """Yield unique elements, preserving order.
+
+    Simplified version of unique_everseen, see itertools recipes.
+
+    Examples
+    --------
+    >>> list(_unique_everseen('AAAABBBCCDAABBB'))
+    ['A', 'B', 'C', 'D']
+    """
+    seen: set[T] = set()
+    for element in iterable:
+        if element not in seen:
+            seen.add(element)
+            yield element
+
+
+@deprecated("Use more-itertools unique_everseen instead")
+def unique_by_index(sequence: Iterable[T]) -> list[T]:
     """Return unique elements in `sequence` in the order in which they occur.
 
     Parameters
@@ -186,12 +207,11 @@ def unique_by_index(sequence):
     uniques : list
         unique elements of sequence, ordered by the order in which the element
         occurs in `sequence`
+
+    .. deprecated:: 0.12
+        Use more-itertools unique_everseen instead.
     """
-    uniques = []
-    for element in sequence:
-        if element not in uniques:
-            uniques.append(element)
-    return uniques
+    return list(_unique_everseen(sequence))
 
 
 def chmod_perms(fname):
@@ -360,6 +380,7 @@ def _parse_otool_listing(stdout: str) -> dict[str, list[str]]:
     return out
 
 
+@deprecated("This function is no longer needed and should not be used")
 def _check_ignore_archs(input: dict[str, T]) -> T:
     """Merge architecture outputs for functions which don't support multiple.
 
@@ -474,12 +495,14 @@ BAD_OBJECT_TESTS = [
 _LINE0_RE = re.compile(r"^(?: \(architecture .*\))?:(?P<further_report>.*)")
 
 
-def _line0_says_object(stdout_stderr: str, filename: str) -> bool:
+def _line0_says_object(
+    stdout_stderr: str, filename: str | PathLike[str]
+) -> bool:
     """Return True if an output is for an object and matches filename.
 
     Parameters
     ----------
-    stdout_stderr : str
+    stdout_stderr : str or PathLike
         The combined stdout/stderr streams from ``otool``.
     filename: str
         The name of the file queried by ``otool``.
@@ -495,6 +518,7 @@ def _line0_says_object(stdout_stderr: str, filename: str) -> bool:
     InstallNameError
         On any unexpected output which would leave the return value unknown.
     """
+    filename = str(Path(filename))
     line0 = stdout_stderr.strip().split("\n", 1)[0]
     for test in BAD_OBJECT_TESTS:
         if test(line0):
@@ -516,7 +540,9 @@ def _line0_says_object(stdout_stderr: str, filename: str) -> bool:
     )
 
 
-def get_install_names(filename: str) -> tuple[str, ...]:
+def _get_install_names(
+    filename: str | PathLike[str],
+) -> dict[str, list[str]]:
     """Return install names from library named in `filename`.
 
     Returns tuple of install names.
@@ -525,7 +551,7 @@ def get_install_names(filename: str) -> tuple[str, ...]:
 
     Parameters
     ----------
-    filename : str
+    filename : str or PathLike
         filename of library
 
     Returns
@@ -535,28 +561,61 @@ def get_install_names(filename: str) -> tuple[str, ...]:
 
     Raises
     ------
-    NotImplementedError
-        If ``filename`` has different install names per-architecture.
     InstallNameError
         On any unexpected output from ``otool``.
     """
     if not _is_macho_file(filename):
-        return ()
-    otool = _run(["otool", "-L", filename], check=False)
+        return {}
+    otool = _run(["otool", "-arch", "all", "-L", filename], check=False)
     if not _line0_says_object(otool.stdout or otool.stderr, filename):
-        return ()
-    install_id = get_install_id(filename)
-    names_data = _check_ignore_archs(_parse_otool_install_names(otool.stdout))
-    names = [name for name, _, _ in names_data]
-    if install_id:  # Remove redundant install id from the install names.
-        if names[0] != install_id:
-            raise InstallNameError(
-                f"Expected {install_id!r} to be first in {names}"
-            )
-        names = names[1:]
-    return tuple(names)
+        return {}
+    install_ids = _get_install_ids(filename)
+    # Collect install names for each architecture
+    all_names: dict[str, list[str]] = {}
+    for arch, names_data in _parse_otool_install_names(otool.stdout).items():
+        names = [name for name, _, _ in names_data]
+        # Remove redundant install id from the install names.
+        if arch in install_ids:
+            if names[0] != install_ids[arch]:
+                raise InstallNameError(
+                    f"Expected {install_ids[arch]!r} to be first in {names}"
+                )
+            names = names[1:]
+        all_names[arch] = names
+
+    return all_names
+
+
+def get_install_names(filename: str | PathLike[str]) -> tuple[str, ...]:
+    """Return install names from library named in `filename`.
+
+    Returns tuple of install names.
+
+    tuple will be empty if no install names, or if this is not an object file.
+
+    Parameters
+    ----------
+    filename : str or PathLike
+        filename of library
+
+    Returns
+    -------
+    install_names : tuple
+        tuple of install names for library `filename`
+
+    Raises
+    ------
+    InstallNameError
+        On any unexpected output from ``otool``.
+    """
+    return tuple(
+        _unique_everseen(
+            itertools.chain(*_get_install_names(filename).values())
+        )
+    )
 
 
+@deprecated("This function was replaced by _get_install_ids")
 def get_install_id(filename: str) -> str | None:
     """Return install id from library named in `filename`.
 
@@ -576,6 +635,10 @@ def get_install_id(filename: str) -> str | None:
     ------
     NotImplementedError
         If ``filename`` has different install ids per-architecture.
+
+    .. deprecated:: 0.12
+        This function has been replaced by the private function
+        `_get_install_ids`.
     """
     install_ids = _get_install_ids(filename)
     if not install_ids:
@@ -583,7 +646,7 @@ def get_install_id(filename: str) -> str | None:
     return _check_ignore_archs(install_ids)
 
 
-def _get_install_ids(filename: str) -> dict[str, str]:
+def _get_install_ids(filename: str | PathLike[str]) -> dict[str, str]:
     """Return the install ids of a library.
 
     Parameters
@@ -606,7 +669,7 @@ def _get_install_ids(filename: str) -> dict[str, str]:
     """
     if not _is_macho_file(filename):
         return {}
-    otool = _run(["otool", "-D", filename], check=False)
+    otool = _run(["otool", "-arch", "all", "-D", filename], check=False)
     if not _line0_says_object(otool.stdout or otool.stderr, filename):
         return {}
     out = {}
@@ -652,12 +715,14 @@ def set_install_name(
 
 
 @ensure_writable
-def set_install_id(filename: str, install_id: str, ad_hoc_sign: bool = True):
+def set_install_id(
+    filename: str | PathLike[str], install_id: str, ad_hoc_sign: bool = True
+):
     """Set install id for library named in `filename`.
 
     Parameters
     ----------
-    filename : str
+    filename : str or PathLike
         filename of library
     install_id : str
         install id for library `filename`
@@ -668,7 +733,7 @@ def set_install_id(filename: str, install_id: str, ad_hoc_sign: bool = True):
     ------
     RuntimeError if `filename` has not install id
     """
-    if get_install_id(filename) is None:
+    if not _get_install_ids(filename):
         raise InstallNameError(f"{filename} has no install id")
     _run(["install_name_tool", "-id", install_id, filename], check=True)
     if ad_hoc_sign:
@@ -736,16 +801,49 @@ def _parse_otool_rpaths(stdout: str) -> dict[str, list[str]]:
     return rpaths
 
 
-def get_rpaths(filename: str) -> tuple[str, ...]:
-    """Return a tuple of rpaths from the library `filename`.
+def _get_rpaths(
+    filename: str | PathLike[str],
+) -> dict[str, list[str]]:
+    """Return the rpaths from the library `filename` organized by architecture.
 
-    If `filename` is not a library then the returned tuple will be empty.
+    If `filename` is not a library then the returned dict will be empty.
     Duplicate rpaths will be returned if there are duplicate rpaths in the
     Mach-O binary.
 
     Parameters
     ----------
-    filename : str
+    filename
+        filename of library
+
+    Returns
+    -------
+    rpaths
+        A dict of rpaths, the key is the architecture and the values are a
+        sequence of directories.
+
+    Raises
+    ------
+    InstallNameError
+        On any unexpected output from ``otool``.
+    """
+    if not _is_macho_file(filename):
+        return {}
+    otool = _run(["otool", "-arch", "all", "-l", filename], check=False)
+    if not _line0_says_object(otool.stdout or otool.stderr, filename):
+        return {}
+
+    return _parse_otool_rpaths(otool.stdout)
+
+
+@deprecated("This function has been replaced by _get_rpaths")
+def get_rpaths(filename: str | PathLike[str]) -> tuple[str, ...]:
+    """Return a tuple of rpaths from the library `filename`.
+
+    If `filename` is not a library then the returned tuple will be empty.
+
+    Parameters
+    ----------
+    filename : str or PathLike
         filename of library
 
     Returns
@@ -759,17 +857,17 @@ def get_rpaths(filename: str) -> tuple[str, ...]:
         If ``filename`` has different rpaths per-architecture.
     InstallNameError
         On any unexpected output from ``otool``.
+
+    .. deprecated:: 0.12
+        This function has been replaced by the private function `_get_rpaths`.
     """
-    if not _is_macho_file(filename):
-        return ()
-    otool = _run(["otool", "-l", filename], check=False)
-    if not _line0_says_object(otool.stdout or otool.stderr, filename):
-        return ()
-    rpaths = _check_ignore_archs(_parse_otool_rpaths(otool.stdout))
-    return tuple(rpaths)
+    # Simply return all rpaths ignoring architectures
+    return tuple(
+        _unique_everseen(itertools.chain(*_get_rpaths(filename).values()))
+    )
 
 
-def get_environment_variable_paths():
+def get_environment_variable_paths() -> tuple[str, ...]:
     """Return a tuple of entries in `DYLD_LIBRARY_PATH` and `DYLD_FALLBACK_LIBRARY_PATH`.
 
     This will allow us to search those locations for dependencies of libraries
@@ -1116,7 +1214,7 @@ def lipo_fuse(
 
 
 @ensure_writable
-def replace_signature(filename: str, identity: str) -> None:
+def replace_signature(filename: str | PathLike[str], identity: str) -> None:
     """Replace the signature of a binary file using `identity`.
 
     See the codesign documentation for more info.
diff --git a/delocate/wheeltools.py b/delocate/wheeltools.py
index ec56741..49cc98c 100644
--- a/delocate/wheeltools.py
+++ b/delocate/wheeltools.py
@@ -24,7 +24,7 @@ from packaging.utils import parse_wheel_filename
 from delocate.pkginfo import read_pkg_info, write_pkg_info
 
 from .tmpdirs import InTemporaryDirectory
-from .tools import dir2zip, open_rw, unique_by_index, zip2dir
+from .tools import _unique_everseen, dir2zip, open_rw, zip2dir
 
 
 class WheelToolsError(Exception):
@@ -246,7 +246,7 @@ def add_platforms(
         # Python version, C-API version combinations
         pyc_apis = ["-".join(tag.split("-")[:2]) for tag in in_info_tags]
         # unique Python version, C-API version combinations
-        pyc_apis = unique_by_index(pyc_apis)
+        pyc_apis = list(_unique_everseen(pyc_apis))
         # Add new platform tags for each Python version, C-API combination
         required_tags = ["-".join(tup) for tup in product(pyc_apis, platforms)]
         needs_write = False
diff --git a/pyproject.toml b/pyproject.toml
index 714b26d..de3e2ce 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,7 +15,7 @@ dependencies = [
     "bindepend; sys_platform == 'win32'",
     "machomachomangler; sys_platform == 'win32'",
     "packaging>=20.9",
-    "typing_extensions",
+    "typing_extensions>=4.12.2",
     "macholib",
 ]
 classifiers = [


Test patch:
diff --git a/delocate/tests/test_install_names.py b/delocate/tests/test_install_names.py
index 748f224..cca08c9 100644
--- a/delocate/tests/test_install_names.py
+++ b/delocate/tests/test_install_names.py
@@ -226,6 +226,8 @@ class ToolArchMock(NamedTuple):
             commands={
                 (
                     "otool",
+                    "-arch",
+                    "all",
                     "-L",
                     "example.so",
                 ): """\
@@ -236,6 +238,8 @@ example.so:
 """,  # noqa: E501
                 (
                     "otool",
+                    "-arch",
+                    "all",
                     "-D",
                     "example.so",
                 ): """\
@@ -244,6 +248,8 @@ example.so:
 """,
                 (
                     "otool",
+                    "-arch",
+                    "all",
                     "-l",
                     "example.so",
                 ): """\
@@ -263,6 +269,8 @@ cmdsize 0
             commands={
                 (  # Install names match.
                     "otool",
+                    "-arch",
+                    "all",
                     "-L",
                     "example.so",
                 ): """\
@@ -277,6 +285,8 @@ example.so (architecture arm64):
 """,  # noqa: E501
                 (  # Install IDs match.
                     "otool",
+                    "-arch",
+                    "all",
                     "-D",
                     "example.so",
                 ): """\
@@ -287,6 +297,8 @@ example.so (architecture arm64):
 """,
                 (  # Rpaths match.
                     "otool",
+                    "-arch",
+                    "all",
                     "-l",
                     "example.so",
                 ): """\
@@ -310,6 +322,8 @@ cmdsize 0
             commands={  # Multi arch - not matching install names, rpaths
                 (  # Install names do not match (compatibility version).
                     "otool",
+                    "-arch",
+                    "all",
                     "-L",
                     "example.so",
                 ): """\
@@ -324,6 +338,8 @@ example.so (architecture arm64):
 """,  # noqa: E501
                 (  # Install IDs match.
                     "otool",
+                    "-arch",
+                    "all",
                     "-D",
                     "example.so",
                 ): """\
@@ -334,6 +350,8 @@ example.so (architecture arm64):
 """,
                 (  # Rpaths do not match.
                     "otool",
+                    "-arch",
+                    "all",
                     "-l",
                     "example.so",
                 ): """\
@@ -347,13 +365,18 @@ cmdsize 0
    path path/arm64 (offset 0)
 """,
             },
-            expected_install_names=NotImplementedError,
-            expected_rpaths=NotImplementedError,
+            expected_install_names=(
+                "/usr/lib/libc++.1.dylib",
+                "/usr/lib/libSystem.B.dylib",
+            ),
+            expected_rpaths=("path/x86_64", "path/arm64"),
         ),
         ToolArchMock(
             commands={  # Multi arch - not matching install IDS
                 (  # Install names match.
                     "otool",
+                    "-arch",
+                    "all",
                     "-L",
                     "example.so",
                 ): """\
@@ -368,6 +391,8 @@ example.so (architecture arm64):
 """,  # noqa: E501
                 (  # Different install IDs for different archs.
                     "otool",
+                    "-arch",
+                    "all",
                     "-D",
                     "example.so",
                 ): """\
@@ -378,6 +403,8 @@ example.so (architecture arm64):
 """,
                 (  # RPaths match.
                     "otool",
+                    "-arch",
+                    "all",
                     "-l",
                     "example.so",
                 ): """\
@@ -391,7 +418,7 @@ cmdsize 0
    path path/x86_64 (offset 0)
 """,
             },
-            expected_install_names=NotImplementedError,
+            expected_install_names=InstallNameError,  # Bad install id
             expected_rpaths=("path/x86_64",),
         ),
     ],