Repository: matthew-brett/delocate

Issue:
Fat binaries of static libraries are not supported in the wheel
**Describe the bug**
The new check to ensure the minimum macOS version of all MachO generates an error when fat binaries of static libraries are included in the wheel.

Our python wheel includes JIT compilation support using LLVM. For this we need to bundle some static libraries in the wheel that are used by the JIT linker. One of these static libraries `libclang_rt.osx.a` is a fat binary containing multiple architectures (e.g. `arm64` and `arm64e`). The other "simple" static libraries are correctly ignored.

```py
Traceback (most recent call last):
  File "<frozen runpy>", line 198, in _run_module_as_main
  File "<frozen runpy>", line 88, in _run_code
  File "/Users/aictx/.pyenv/versions/3.12.6/lib/python3.12/site-packages/delocate/cmd/delocate_wheel.py", line 131, in <module>
    main()
  File "/Users/aictx/.pyenv/versions/3.12.6/lib/python3.12/site-packages/delocate/cmd/delocate_wheel.py", line 116, in main
    copied = delocate_wheel(
             ^^^^^^^^^^^^^^^
  File "/Users/aictx/.pyenv/versions/3.12.6/lib/python3.12/site-packages/delocate/delocating.py", line 1090, in delocate_wheel
    out_wheel_fixed = _check_and_update_wheel_name(
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/aictx/.pyenv/versions/3.12.6/lib/python3.12/site-packages/delocate/delocating.py", line 914, in _check_and_update_wheel_name
    new_name, problematic_files = _calculate_minimum_wheel_name(
                                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/aictx/.pyenv/versions/3.12.6/lib/python3.12/site-packages/delocate/delocating.py", line 816, in _calculate_minimum_wheel_name
    for arch, version in _get_macos_min_version(lib):
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/aictx/.pyenv/versions/3.12.6/lib/python3.12/site-packages/delocate/delocating.py", line 619, in _get_macos_min_version
    for header in MachO(dylib_path).headers:
                  ^^^^^^^^^^^^^^^^^
  File "/Users/aictx/.pyenv/versions/3.12.6/lib/python3.12/site-packages/macholib/MachO.py", line 121, in __init__
    self.load(fp)
  File "/Users/aictx/.pyenv/versions/3.12.6/lib/python3.12/site-packages/macholib/MachO.py", line 131, in load
    self.load_fat(fh)
  File "/Users/aictx/.pyenv/versions/3.12.6/lib/python3.12/site-packages/macholib/MachO.py", line 148, in load_fat
    self.load_header(fh, arch.offset, arch.size)
  File "/Users/aictx/.pyenv/versions/3.12.6/lib/python3.12/site-packages/macholib/MachO.py", line 170, in load_header
    raise ValueError("Unknown Mach-O header: 0x%08x in %r" % (header, fh))
ValueError: Unknown Mach-O header: 0x213c6172 in <_io.BufferedReader name='/private/var/folders/z_/1_rkwk513p75p8gsy53mjb8r0000gn/T/tmp77ji4upg/wheel/samna/libclang_rt.osx.a'>
```

**To Reproduce**
Steps used to reproduce the behavior, such as the delocate commands used.
* Add a static library as a fat binary containing multiple architectures.
* Run `python -m delocate.cmd.delocate_wheel $WHEEL_PATH -w $WHEELHOUSE_PATH`

**Expected behavior**
Delocating the wheel is successful, as in version 0.10.7.

Proposals:
1. Only check the files that were touched by delocate.
2. Add a parameter for files to ignore, the current filters don't influence the check.
3. Add a parameter to skip the check.

**Wheels used**
Unfortunately the wheel is private by my company.

**Platform (please complete the following information):**
 - OS version: macOS 12.1
 - Delocate version: 0.11.0

**Additional context**
My understanding of the problem is as follows. The `_calculate_minimum_wheel_name` looks at all the files in the wheel. It sees the static library and because it's a fat binary it contains the MachO magic number (0xCAFEBABF). Now it assumes that it's a dynamic library and looks for the magic number (0xFEEDFACF) for single binaries at the offsets given by the header. This magic number is missing because it's a static library, not a dynamic one.


Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/matthew-brett/delocate/pull/235?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 `94.11765%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 96.90%. Comparing base [(`9169a66`)](https://app.codecov.io/gh/matthew-brett/delocate/commit/9169a6627d0e8bfe728e3bbbbf03a2fac68707a8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Matthew+Brett) to head [(`528480a`)](https://app.codecov.io/gh/matthew-brett/delocate/commit/528480a57ebda6c3d31f659b2ccaa3ad923dbffe?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/235?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/delocating.py](https://app.codecov.io/gh/matthew-brett/delocate/pull/235?src=pr&el=tree&filepath=delocate%2Fdelocating.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Matthew+Brett#diff-ZGVsb2NhdGUvZGVsb2NhdGluZy5weQ==) | 90.90% | [1 Missing :warning: ](https://app.codecov.io/gh/matthew-brett/delocate/pull/235?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     #235      +/-   ##
==========================================
- Coverage   97.18%   96.90%   -0.29%     
==========================================
  Files          16       16              
  Lines        1350     1355       +5     
==========================================
+ Hits         1312     1313       +1     
- Misses         38       42       +4     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/matthew-brett/delocate/pull/235?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: Something to add to `make_libs.sh` to generate a fat arm64/x86_64 archive `.a` file would be nice for this. Any suggestions?
HexDecimal: > Elevating some deprecation warnings to errors.

I don't think I've promoted any deprecation warnings to errors. Any deleted warning code was moved to a `@deprecated` decorator. This allows type linters to detect calls to deprecated functions.

> The only question I had was - is ignoring the static libraries the right thing? I guess it is, in that they cannot by definition be necessary for execution.

This still checks static libraries. It's just that `macholib` does not support fat static libraries at the moment, so only single architecture static libraries will be analyzed. A version of `macholib` which does support them will automatically work as it won't throw an error anymore.
HexDecimal: There isn't a perfect solution to static libraries. Whether the static library is possibly loaded as part of the package logic or is sole used to create but not run other tools depends on the specific package.

It should be a simple change if we want to skip over static libraries during this minimum version analyses. It can even be toggled with a flag to delocate. Maybe count static libs by default but allow using a flag to ignore them.
matthew-brett: > > 
> 
> I don't think I've promoted any deprecation warnings to errors. Any deleted warning code was moved to a `@deprecated` decorator. This allows type linters to detect calls to deprecated functions.

Ah - yes - sorry - thanks for the clarification.
matthew-brett: > It should be a simple change if we want to skip over static libraries during this minimum version analyses. It can even be toggled with a flag to delocate. Maybe count static libs by default but allow using a flag to ignore them.

That sounds good - do you think the function should error when it can't detect minimum version from static libs - as it does now - unless the ignore flag is set?
HexDecimal: > That sounds good - do you think the function should error when it can't detect minimum version from static libs - as it does now - unless the ignore flag is set?

I don't have a preference on that, but the error is that `macholib` is unable to parse the file in the first place, so a failed parse doesn't mean the file was actually a fat static lib and not some other file type.

There's not much that can be done other than to fix the issue upstream: https://github.com/ronaldoussoren/macholib/issues/49

Patch:
diff --git a/Changelog.md b/Changelog.md
index 1e56fe7..0bad98f 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -33,6 +33,9 @@ rules on making a good Changelog.
   [#230](https://github.com/matthew-brett/delocate/pull/230)
 - `delocate-merge` now supports libraries with missing or unusual extensions.
   [#228](https://github.com/matthew-brett/delocate/issues/228)
+- Now supports library files ending in parentheses.
+- Fixed `Unknown Mach-O header` error when encountering a fat static library.
+  [#229](https://github.com/matthew-brett/delocate/issues/229)
 
 ### Removed
 
diff --git a/delocate/delocating.py b/delocate/delocating.py
index dd75555..e1d70dd 100644
--- a/delocate/delocating.py
+++ b/delocate/delocating.py
@@ -27,6 +27,7 @@ from macholib.mach_o import (  # type: ignore[import-untyped]
 from macholib.MachO import MachO  # type: ignore[import-untyped]
 from packaging.utils import parse_wheel_filename
 from packaging.version import Version
+from typing_extensions import deprecated
 
 from .libsana import (
     DelocationError,
@@ -39,7 +40,6 @@ from .libsana import (
 from .pkginfo import read_pkg_info, write_pkg_info
 from .tmpdirs import TemporaryDirectory
 from .tools import (
-    _is_macho_file,
     _remove_absolute_rpaths,
     dir2zip,
     find_package_dirs,
@@ -249,6 +249,7 @@ def _update_install_names(
                 set_install_name(requiring, orig_install_name, new_install_name)
 
 
+@deprecated("copy_recurse is obsolete and should no longer be called")
 def copy_recurse(
     lib_path: str,
     copy_filt_func: Callable[[str], bool] | None = None,
@@ -291,11 +292,6 @@ def copy_recurse(
         This function is obsolete.  :func:`delocate_path` handles recursive
         dependencies while also supporting `@loader_path`.
     """
-    warnings.warn(
-        "copy_recurse is obsolete and should no longer be called.",
-        DeprecationWarning,
-        stacklevel=2,
-    )
     if copied_libs is None:
         copied_libs = {}
     else:
@@ -587,12 +583,14 @@ def _make_install_name_ids_unique(
         validate_signature(lib)
 
 
-def _get_macos_min_version(dylib_path: Path) -> Iterator[tuple[str, Version]]:
+def _get_macos_min_version(
+    dylib_path: str | os.PathLike[str],
+) -> Iterator[tuple[str, Version]]:
     """Get the minimum macOS version from a dylib file.
 
     Parameters
     ----------
-    dylib_path : Path
+    dylib_path : str or PathLike
         The path to the dylib file.
 
     Yields
@@ -602,9 +600,16 @@ def _get_macos_min_version(dylib_path: Path) -> Iterator[tuple[str, Version]]:
     Version
         The minimum macOS version.
     """
-    if not _is_macho_file(dylib_path):
-        return
-    for header in MachO(dylib_path).headers:
+    try:
+        macho = MachO(dylib_path)
+    except ValueError as exc:
+        if str(exc.args[0]).startswith(
+            ("Unknown fat header magic", "Unknown Mach-O header")
+        ):
+            return  # Not a recognised Mach-O object file
+        raise  # pragma: no cover  # Unexpected error
+
+    for header in macho.headers:
         for cmd in header.commands:
             if cmd[0].cmd == LC_BUILD_VERSION:
                 version = cmd[1].minos
@@ -801,6 +806,8 @@ def _calculate_minimum_wheel_name(
     all_library_versions: dict[str, dict[Version, list[Path]]] = {}
 
     for lib in wheel_dir.glob("**/*"):
+        if lib.is_dir():
+            continue
         for arch, version in _get_macos_min_version(lib):
             all_library_versions.setdefault(arch.lower(), {}).setdefault(
                 version, []
diff --git a/delocate/libsana.py b/delocate/libsana.py
index 83e5e0a..032df0b 100644
--- a/delocate/libsana.py
+++ b/delocate/libsana.py
@@ -399,6 +399,7 @@ def _allow_all(path: str) -> bool:
     return True
 
 
+@deprecated("tree_libs doesn't support @loader_path and has been deprecated")
 def tree_libs(
     start_path: str,
     filt_func: Callable[[str], bool] | None = None,
@@ -443,11 +444,6 @@ def tree_libs(
 
         :func:`tree_libs_from_directory` should be used instead.
     """
-    warnings.warn(
-        "tree_libs doesn't support @loader_path and has been deprecated.",
-        DeprecationWarning,
-        stacklevel=2,
-    )
     if filt_func is None:
         filt_func = _allow_all
     lib_dict: dict[str, dict[str, str]] = {}
@@ -559,7 +555,10 @@ def resolve_dynamic_paths(
     raise DependencyNotFound(lib_path)
 
 
-@deprecated("This function was replaced by resolve_dynamic_paths")
+@deprecated(
+    "This function doesn't support @loader_path "
+    "and was replaced by resolve_dynamic_paths"
+)
 def resolve_rpath(lib_path: str, rpaths: Iterable[str]) -> str:
     """Return `lib_path` with its `@rpath` resolved.
 
@@ -584,12 +583,6 @@ def resolve_rpath(lib_path: str, rpaths: Iterable[str]) -> str:
         This function does not support `@loader_path`.
         Use `resolve_dynamic_paths` instead.
     """
-    warnings.warn(
-        "resolve_rpath doesn't support @loader_path and has been deprecated."
-        "  Switch to using `resolve_dynamic_paths` instead.",
-        DeprecationWarning,
-        stacklevel=2,
-    )
     if not lib_path.startswith("@rpath/"):
         return lib_path
 
diff --git a/delocate/tools.py b/delocate/tools.py
index afad217..83e7a50 100644
--- a/delocate/tools.py
+++ b/delocate/tools.py
@@ -9,7 +9,6 @@ import re
 import stat
 import subprocess
 import time
-import warnings
 import zipfile
 from collections.abc import Iterable, Iterator, Sequence
 from datetime import datetime
@@ -33,6 +32,7 @@ class InstallNameError(Exception):
     """Errors reading or modifying macOS install name identifiers."""
 
 
+@deprecated("Replace this call with subprocess.run")
 def back_tick(
     cmd: str | Sequence[str],
     ret_err: bool = False,
@@ -73,11 +73,6 @@ def back_tick(
         This function was deprecated because the return type is too dynamic.
         You should use :func:`subprocess.run` instead.
     """
-    warnings.warn(
-        "back_tick is deprecated, replace this call with subprocess.run.",
-        DeprecationWarning,
-        stacklevel=2,
-    )
     if raise_err is None:
         raise_err = False if ret_err else True
     cmd_is_seq = isinstance(cmd, (list, tuple))
@@ -566,7 +561,7 @@ def _get_install_names(
     """
     if not _is_macho_file(filename):
         return {}
-    otool = _run(["otool", "-arch", "all", "-L", filename], check=False)
+    otool = _run(["otool", "-arch", "all", "-m", "-L", filename], check=False)
     if not _line0_says_object(otool.stdout or otool.stderr, filename):
         return {}
     install_ids = _get_install_ids(filename)
@@ -669,7 +664,7 @@ def _get_install_ids(filename: str | PathLike[str]) -> dict[str, str]:
     """
     if not _is_macho_file(filename):
         return {}
-    otool = _run(["otool", "-arch", "all", "-D", filename], check=False)
+    otool = _run(["otool", "-arch", "all", "-m", "-D", filename], check=False)
     if not _line0_says_object(otool.stdout or otool.stderr, filename):
         return {}
     out = {}
@@ -828,7 +823,7 @@ def _get_rpaths(
     """
     if not _is_macho_file(filename):
         return {}
-    otool = _run(["otool", "-arch", "all", "-l", filename], check=False)
+    otool = _run(["otool", "-arch", "all", "-m", "-l", filename], check=False)
     if not _line0_says_object(otool.stdout or otool.stderr, filename):
         return {}
 
diff --git a/pyproject.toml b/pyproject.toml
index de3e2ce..c252494 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,13 +11,7 @@ maintainers = [{ name = "Matthew Brett", email = "matthew.brett@gmail.com" }]
 readme = "README.rst"
 requires-python = ">=3.9"
 license = { file = "LICENSE" }
-dependencies = [
-    "bindepend; sys_platform == 'win32'",
-    "machomachomangler; sys_platform == 'win32'",
-    "packaging>=20.9",
-    "typing_extensions>=4.12.2",
-    "macholib",
-]
+dependencies = ["packaging>=20.9", "typing_extensions>=4.12.2", "macholib"]
 classifiers = [
     "Development Status :: 5 - Production/Stable",
     "Environment :: Console",


Test patch:
diff --git a/delocate/tests/test_delocating.py b/delocate/tests/test_delocating.py
index 1a8ba1b..1487218 100644
--- a/delocate/tests/test_delocating.py
+++ b/delocate/tests/test_delocating.py
@@ -1,5 +1,7 @@
 """Tests for relocating libraries."""
 
+from __future__ import annotations
+
 import os
 import shutil
 import subprocess
@@ -8,6 +10,7 @@ from collections import namedtuple
 from collections.abc import Iterable
 from os.path import basename, dirname, realpath, relpath, splitext
 from os.path import join as pjoin
+from pathlib import Path
 from typing import Any, Callable
 
 import pytest
@@ -16,6 +19,7 @@ from packaging.version import Version
 
 from ..delocating import (
     _get_archs_and_version_from_wheel_name,
+    _get_macos_min_version,
     bads_report,
     check_archs,
     copy_recurse,
@@ -33,7 +37,18 @@ from ..tmpdirs import InTemporaryDirectory
 from ..tools import get_install_names, set_install_name
 from .env_tools import TempDirWithoutEnvVars
 from .pytest_tools import assert_equal, assert_raises
-from .test_install_names import EXT_LIBS, LIBA, LIBB, LIBC, TEST_LIB, _copy_libs
+from .test_install_names import (
+    A_OBJECT,
+    DATA_PATH,
+    EXT_LIBS,
+    ICO_FILE,
+    LIBA,
+    LIBA_STATIC,
+    LIBB,
+    LIBC,
+    TEST_LIB,
+    _copy_libs,
+)
 from .test_tools import (
     ARCH_32,
     ARCH_64,
@@ -747,3 +762,28 @@ def test_get_archs_and_version_from_wheel_name() -> None:
         _get_archs_and_version_from_wheel_name(
             "foo-1.0-py310-abi3-manylinux1.whl"
         )
+
+
+@pytest.mark.parametrize(
+    "file,expected_min_version",
+    [
+        # Dylib files
+        (LIBBOTH, {"ARM64": Version("11.0"), "x86_64": Version("10.9")}),
+        (LIBA, {"x86_64": Version("10.9")}),
+        # Shared objects
+        (
+            Path(DATA_PATH, "np-1.6.0_intel_lib__compiled_base.so"),
+            {"i386": Version("10.6"), "x86_64": Version("10.6")},
+        ),
+        # Object file
+        (A_OBJECT, {"x86_64": Version("10.9")}),
+        # Static file
+        (LIBA_STATIC, {}),
+        # Non library
+        (ICO_FILE, {}),
+    ],
+)
+def test_get_macos_min_version(
+    file: str | Path, expected_min_version: dict[str, Version]
+) -> None:
+    assert dict(_get_macos_min_version(file)) == expected_min_version
diff --git a/delocate/tests/test_install_names.py b/delocate/tests/test_install_names.py
index cca08c9..8d65812 100644
--- a/delocate/tests/test_install_names.py
+++ b/delocate/tests/test_install_names.py
@@ -9,6 +9,7 @@ import sys
 from collections.abc import Sequence
 from os.path import basename, dirname, exists
 from os.path import join as pjoin
+from pathlib import Path
 from subprocess import CompletedProcess
 from typing import (
     NamedTuple,
@@ -20,6 +21,7 @@ import pytest
 from ..tmpdirs import InTemporaryDirectory
 from ..tools import (
     InstallNameError,
+    _get_install_ids,
     add_rpath,
     get_environment_variable_paths,
     get_install_id,
@@ -104,15 +106,30 @@ def test_parse_install_name():
 
 
 @pytest.mark.xfail(sys.platform != "darwin", reason="otool")
-def test_install_id():
+def test_install_id() -> None:
     # Test basic otool library listing
-    assert_equal(get_install_id(LIBA), "liba.dylib")
-    assert_equal(get_install_id(LIBB), "libb.dylib")
-    assert_equal(get_install_id(LIBC), "libc.dylib")
-    assert_equal(get_install_id(TEST_LIB), None)
+    assert get_install_id(LIBA) == "liba.dylib"
+    assert get_install_id(LIBB) == "libb.dylib"
+    assert get_install_id(LIBC) == "libc.dylib"
+    assert get_install_id(TEST_LIB) is None
     # Non-object file returns None too
-    assert_equal(get_install_id(__file__), None)
-    assert_equal(get_install_id(ICO_FILE), None)
+    assert get_install_id(__file__) is None
+    assert get_install_id(ICO_FILE) is None
+
+
+@pytest.mark.xfail(sys.platform != "darwin", reason="otool")
+def test_install_ids(tmp_path: Path) -> None:
+    # Test basic otool library listing
+    assert _get_install_ids(LIBA) == {"": "liba.dylib"}
+    assert _get_install_ids(LIBB) == {"": "libb.dylib"}
+    assert _get_install_ids(LIBC) == {"": "libc.dylib"}
+    assert _get_install_ids(TEST_LIB) == {}
+    # Non-object file returns None too
+    assert _get_install_ids(__file__) == {}
+    assert _get_install_ids(ICO_FILE) == {}
+    # Should work ending with parentheses
+    shutil.copy(LIBA, tmp_path / "liba(test)")
+    assert _get_install_ids(tmp_path / "liba(test)") == {"": "liba.dylib"}
 
 
 @pytest.mark.xfail(sys.platform != "darwin", reason="otool")
@@ -228,6 +245,7 @@ class ToolArchMock(NamedTuple):
                     "otool",
                     "-arch",
                     "all",
+                    "-m",
                     "-L",
                     "example.so",
                 ): """\
@@ -240,6 +258,7 @@ example.so:
                     "otool",
                     "-arch",
                     "all",
+                    "-m",
                     "-D",
                     "example.so",
                 ): """\
@@ -250,6 +269,7 @@ example.so:
                     "otool",
                     "-arch",
                     "all",
+                    "-m",
                     "-l",
                     "example.so",
                 ): """\
@@ -271,6 +291,7 @@ cmdsize 0
                     "otool",
                     "-arch",
                     "all",
+                    "-m",
                     "-L",
                     "example.so",
                 ): """\
@@ -287,6 +308,7 @@ example.so (architecture arm64):
                     "otool",
                     "-arch",
                     "all",
+                    "-m",
                     "-D",
                     "example.so",
                 ): """\
@@ -299,6 +321,7 @@ example.so (architecture arm64):
                     "otool",
                     "-arch",
                     "all",
+                    "-m",
                     "-l",
                     "example.so",
                 ): """\
@@ -324,6 +347,7 @@ cmdsize 0
                     "otool",
                     "-arch",
                     "all",
+                    "-m",
                     "-L",
                     "example.so",
                 ): """\
@@ -340,6 +364,7 @@ example.so (architecture arm64):
                     "otool",
                     "-arch",
                     "all",
+                    "-m",
                     "-D",
                     "example.so",
                 ): """\
@@ -352,6 +377,7 @@ example.so (architecture arm64):
                     "otool",
                     "-arch",
                     "all",
+                    "-m",
                     "-l",
                     "example.so",
                 ): """\
@@ -377,6 +403,7 @@ cmdsize 0
                     "otool",
                     "-arch",
                     "all",
+                    "-m",
                     "-L",
                     "example.so",
                 ): """\
@@ -393,6 +420,7 @@ example.so (architecture arm64):
                     "otool",
                     "-arch",
                     "all",
+                    "-m",
                     "-D",
                     "example.so",
                 ): """\
@@ -405,6 +433,7 @@ example.so (architecture arm64):
                     "otool",
                     "-arch",
                     "all",
+                    "-m",
                     "-l",
                     "example.so",
                 ): """\
diff --git a/delocate/tests/test_tools.py b/delocate/tests/test_tools.py
index 4c6d6ad..18666e3 100644
--- a/delocate/tests/test_tools.py
+++ b/delocate/tests/test_tools.py
@@ -7,11 +7,15 @@ import subprocess
 import sys
 from os.path import dirname
 from os.path import join as pjoin
+from pathlib import Path
 
 import pytest
 
 from ..tmpdirs import InTemporaryDirectory
 from ..tools import (
+    _get_install_ids,
+    _get_install_names,
+    _get_rpaths,
     _is_macho_file,
     add_rpath,
     back_tick,
@@ -32,7 +36,7 @@ from ..tools import (
     zip2dir,
 )
 from .pytest_tools import assert_equal, assert_false, assert_raises, assert_true
-from .test_install_names import LIBSTDCXX
+from .test_install_names import LIBC, LIBSTDCXX
 
 DATA_PATH = pjoin(dirname(__file__), "data")
 LIBM1 = pjoin(DATA_PATH, "libam1.dylib")
@@ -339,3 +343,20 @@ def test_is_macho_file() -> None:
         if not os.path.isfile(path):
             continue
         assert_equal(_is_macho_file(path), filename in MACHO_FILES)
+
+
+@pytest.mark.xfail(sys.platform != "darwin", reason="Needs otool.")
+def test_archive_member(tmp_path: Path) -> None:
+    # Tools should always take a trailing parentheses as a literal file path
+    lib_path = Path(tmp_path, "libc(member)")
+    shutil.copyfile(LIBC, lib_path)
+    assert _get_install_names(lib_path) == {
+        "": [
+            "liba.dylib",
+            "libb.dylib",
+            "/usr/lib/libc++.1.dylib",
+            "/usr/lib/libSystem.B.dylib",
+        ]
+    }
+    assert _get_install_ids(lib_path) == {"": "libc.dylib"}
+    assert _get_rpaths(lib_path) == {"": []}