Skip to content

Commit

Permalink
Add a module for parsing otool output
Browse files Browse the repository at this point in the history
Create a new Python module for parsing the output of (macOS) `otool`
into structured data. Use this in `installer.py`.

The new module supports extraction of load commands (`otool -l`, only
the "Load Command N" blocks) and linked libraries (`otool -L`), and
should be slightly more robust than the prior parsing code, particularly
the old load command parsing which acted on any "path" key without
regard to the type of command to which the key belonged. This will also
allow this logic to be reused for other tools that need to perform RPATH
manipulations.
  • Loading branch information
mwoehlke-kitware committed May 2, 2022
1 parent bd5d534 commit 8bc0195
Show file tree
Hide file tree
Showing 4 changed files with 190 additions and 16 deletions.
15 changes: 15 additions & 0 deletions tools/install/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ load(
"drake_py_unittest",
)

py_library(
name = "module_py",
srcs = ["__init__.py"],
visibility = [":__subpackages__"],
deps = ["//tools:module_py"],
)

py_library(
name = "otool",
srcs = ["otool.py"],
visibility = [":__subpackages__"],
deps = [":module_py"],
)

py_library(
name = "install_test_helper",
testonly = 1,
Expand All @@ -27,6 +41,7 @@ exports_files(
drake_py_binary(
name = "installer",
srcs = ["installer.py"],
deps = [":otool"],
)

# Runs `install_test_helper` unit tests.
Expand Down
1 change: 1 addition & 0 deletions tools/install/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Empty Python module `__init__`, required to make this a module.
30 changes: 14 additions & 16 deletions tools/install/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@
import shutil
import stat
import sys

from subprocess import check_output, check_call

from drake.tools.install import otool

# Stored from command-line.
color = False
prefix = None
Expand Down Expand Up @@ -213,36 +216,31 @@ def macos_fix_rpaths(basename, dst_full):
[install_name_tool, "-id", "@rpath/" + basename, dst_full]
)
# Check if file dependencies are specified with relative paths.
file_output = check_output(["otool", "-L", dst_full]).decode("utf-8")
for line in file_output.splitlines():
# keep only file path, remove version information.
relative_path = line.split(' (')[0].strip()
dep_basename = os.path.basename(relative_path)
for dep in otool.linked_libraries(dst_full):
# Look for the absolute path in the dictionary of fixup files to
# find library paths.
if dep_basename not in libraries_to_fix_rpath:
if dep.basename not in libraries_to_fix_rpath:
continue
lib_dirname = os.path.dirname(dst_full)
diff_path = os.path.relpath(libraries_to_fix_rpath[dep_basename],
diff_path = os.path.relpath(libraries_to_fix_rpath[dep.basename],
lib_dirname)
check_call(
[install_name_tool,
"-change", relative_path,
"-change", dep.path,
os.path.join('@loader_path', diff_path),
dst_full]
)
# Remove RPATH values that contain @loader_path. These are from the build
# tree and are irrelevant in the install tree. RPATH is not necessary as
# relative or absolute path to each library is already known.
file_output = check_output(["otool", "-l", dst_full]).decode("utf-8")
for line in file_output.splitlines():
split_line = line.strip().split(' ')
if len(split_line) >= 2 \
and split_line[0] == 'path' \
and split_line[1].startswith('@loader_path'):
for command in otool.load_commands(dst_full):
if command['cmd'] != 'LC_RPATH' or 'path' not in command:
continue

path = command['path']
if path.startswith('@loader_path'):
check_call(
[install_name_tool, "-delete_rpath", split_line[1], dst_full]
)
[install_name_tool, "-delete_rpath", path, dst_full])


def linux_fix_rpaths(dst_full):
Expand Down
160 changes: 160 additions & 0 deletions tools/install/otool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""
Pythonic wrappers for macOS `otool`.
"""

import os
import re
import subprocess

from collections import namedtuple

Library = namedtuple('Library', [
'basename',
'path',
'version_compat',
'version_current',
])

# Known Load command keys that contain spaces; DO NOT MODIFY at runtime.
_load_command_keys = (
'time stamp',
'current version',
'compatibility version',
)


def _join(proc, cmd='otool'):
"""
Wait for process `proc` to terminate, and raise an exception if it did not
exit successfully (i.e. gave a non-zero exit code).
"""
retcode = proc.wait(timeout=30)
if retcode:
raise CalledProcessError(retcode, cmd)


def _split_load_command(line):
"""
Splits a load command line into a key, value pair. Handles known key names
that contain spaces.
"""
for key in _load_command_keys:
if line.startswith(f'{key} '):
return [key, line[len(key):].lstrip()]

return line.split(' ', 1)


def load_commands(path):
"""
Obtains the load commands of a Mach-O binary. Returns a list of commands,
where each command is a dictionary describing the command. The key 'cmd'
is always present and describes the type of command. The command type will
determine what other keys are present.
For the most part, values (e.g. time stamps) are not translated. As a
partial exception, strings of the form 'value (offset X)' are split, and
the offset is stored as 'Y:offset', where 'Y' is the corresponding key
name. As another exception, 'cmdsize' is translated to ``int``.
"""
commands = []
command = None

proc = subprocess.Popen(
['otool', '-l', path],
stdout=subprocess.PIPE,
text=True,
)

for line in proc.stdout:
# Output looks like::
#
# Load command N
# cmd LC_FOO
# cmdsize 64
# key1 value1 (offset 16)
# key2 value2
# extended key value
# Section
# key1 value1
# key2 value2
#
# Key names may or may not be indented, and some key names contain
# spaces. Most values are aligned to a particular (but varying) column,
# but long key names may change this column.
if line.startswith('Load command'):
if command is not None and len(command):
commands.append(command)

command = {}

elif line == 'Section\n':
if command is not None and len(command):
commands.append(command)

command = None

elif command is not None:
kv = _split_load_command(line.strip())
if len(kv) == 2:
m = re.match('^(.*) [(]offset ([0-9]+)[)]$', kv[1].strip())
if m is None:
if kv[0] == 'cmdsize':
command[kv[0]] = int(kv[1].strip())
else:
command[kv[0]] = kv[1].strip()
else:
command[kv[0]] = m.group(1).strip()
command[f'{kv[0]}:offset'] = int(m.group(2))

_join(proc)

return commands


def linked_libraries(path):
"""
Obtains the set of shared libraries referenced by a Mach-O binary. Returns
a list of Library objects.
"""
libs = []

proc = subprocess.Popen(
['otool', '-L', path],
stdout=subprocess.PIPE,
text=True,
)

for line in proc.stdout:
# Output looks like (note that actual indent uses '\t')::
#
# /path/to/input.dylib
# @rpath/libfoo.0.dylib <version>
# /usr/lib/libbar.5.dylib <version>
# ...
#
# <version> looks like '(compatibility version 1.0.0, '
# 'current version 5.0.0)'.
m = re.match('^\t(.*)[(]([^)]+)[)]\\s*$', line)
if m is not None:
path = m.group(1).strip()

m = re.match('^compatibility version (.*), current version (.*)$',
m.group(2).strip())
if m is not None:
compat = m.group(1)
current = m.group(2)
else:
compat = None
current = None

libs.append(
Library(
path=path,
basename=os.path.basename(path),
version_compat=compat,
version_current=current))

_join(proc)

return libs

0 comments on commit 8bc0195

Please sign in to comment.