Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Command to deploy macOS/iOS universal binaries #53

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions extensions/commands/deploy-lipo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
## Lipo commands

These are commands to deploy macOS or iOS universal binaries.
This wraps around Conan's full_deploy deployer and then runs `lipo` to
produce universal binaries.


#### [Deploy lipo](cmd_deploy_lipo.py)


**Parameters**

The same parameters as `deploy` except multiple profiles should be specified.

```
$ conan deploy-lipo . -pr x8_64 -pr armv8 -b missing -r conancenter
```

This assumes profiles named x86_64 and armv8 with corresponding architectures.
Universal binaries can only have one binary per architecture but each can have
different settings, e.g. minimum deployment OS:

## x86_64
```
[settings]
arch=x86_64
build_type=Release
compiler=apple-clang
compiler.cppstd=gnu17
compiler.libcxx=libc++
compiler.version=14
os=Macos
os.version=10.13
```

## armv8
```
[settings]
arch=armv8
build_type=Release
compiler=apple-clang
compiler.cppstd=gnu17
compiler.libcxx=libc++
compiler.version=14
os=Macos
os.version=11.0
```


#### [Example project](xcode)


```
cd ./xcode
conan deploy-lipo . -pr x86_64 -pr armv8 -b missing
conan build .
```

Verify the architectures:
```
file ./build/Release/example
```
95 changes: 95 additions & 0 deletions extensions/commands/deploy-lipo/_lipo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import os
import shutil
from subprocess import run


__all__ = ['is_macho_binary', 'lipo']

# These are for optimization only, to avoid unnecessarily reading files.
_binary_exts = ['.a', '.dylib']
_regular_exts = [
'.h', '.hpp', '.hxx', '.c', '.cc', '.cxx', '.cpp', '.m', '.mm', '.txt', '.md', '.html', '.jpg', '.png'
]


def is_macho_binary(filename):
ext = os.path.splitext(filename)[1]
if ext in _binary_exts:
return True
if ext in _regular_exts:
return False
with open(filename, "rb") as f:
header = f.read(4)
if header == b'\xcf\xfa\xed\xfe':
# cffaedfe is Mach-O binary
return True
elif header == b'\xca\xfe\xba\xbe':
# cafebabe is Mach-O fat binary
return True
elif header == b'!<arch>\n':
# ar archive
return True
return False


def copy_arch_file(src, dst, top=None, arch_folders=()):
if os.path.isfile(src):
if top and arch_folders and is_macho_binary(src):
# Try to lipo all available archs on the first path.
src_components = src.split(os.path.sep)
top_components = top.split(os.path.sep)
if src_components[:len(top_components)] == top_components:
paths = [os.path.join(a, *(src_components[len(top_components):])) for a in arch_folders]
paths = [p for p in paths if os.path.isfile(p)]
if len(paths) > 1:
run(['lipo', '-output', dst, '-create'] + paths, check=True)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to run self.conanfile.run() because there, multiple things like environment definitions can be injected. Maybe not strictly necessary at this point, but could make sense and be more aligned with other parts of the code.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course. This code started life as a wrapper outside of Conan. If this turns out to be useful, it might make sense to contribute it as conan.tools.apple.lipo in Conan itself.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, how do I get a conanfile here? For a custom command it looks like I have to load it (and it might be .txt or .py). I'm leaning towards the deployer, but that only has a dependency graph. Can I get the conanfile from that?

return
if os.path.exists(dst):
pass # don't overwrite existing files
else:
shutil.copy2(src, dst)


# Modified copytree to copy new files to an existing tree.
def graft_tree(src, dst, symlinks=False, copy_function=shutil.copy2, dirs_exist_ok=False):
names = os.listdir(src)
os.makedirs(dst, exist_ok=dirs_exist_ok)
errors = []
for name in names:
srcname = os.path.join(src, name)
dstname = os.path.join(dst, name)
try:
if symlinks and os.path.islink(srcname):
if os.path.exists(dstname):
continue
linkto = os.readlink(srcname)
os.symlink(linkto, dstname)
elif os.path.isdir(srcname):
graft_tree(srcname, dstname, symlinks, copy_function, dirs_exist_ok)
else:
copy_function(srcname, dstname)
# What about devices, sockets etc.?
# catch the Error from the recursive graft_tree so that we can
# continue with other files
except shutil.Error as err:
errors.extend(err.args[0])
except OSError as why:
errors.append((srcname, dstname, str(why)))
try:
shutil.copystat(src, dst)
except OSError as why:
# can't copy file access times on Windows
if why.winerror is None: # pylint: disable=no-member
errors.extend((src, dst, str(why)))
if errors:
raise shutil.Error(errors)

def lipo(dst_folder, arch_folders):
for folder in arch_folders:
graft_tree(folder,
dst_folder,
symlinks=True,
copy_function=lambda s, d, top=folder: copy_arch_file(s, d,
top=top,
arch_folders=arch_folders),
dirs_exist_ok=True)
60 changes: 60 additions & 0 deletions extensions/commands/deploy-lipo/cmd_deploy_lipo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import os
import shutil
from subprocess import run

from conan.api.conan_api import ConanAPI
from conan.api.output import ConanOutput
from conan.cli.command import conan_command
from conan.cli.args import common_graph_args
from conan.errors import ConanException

from _lipo import lipo as lipo_folder


_valid_archs = [
'x86',
'x86_64',
'armv7',
'armv8',
'armv8_32',
'armv8.3',
'armv7s',
'armv7k'
]

@conan_command(group="Consumer")
def deploy_lipo(conan_api: ConanAPI, parser, *args):
"""
Deploy dependencies for multiple profiles and lipo into universal binaries
"""
common_graph_args(parser)
parsed = parser.parse_args(*args)
profiles = parsed.profile_host or parsed.profile
if not profiles:
raise ConanException("Please provide profiles with -pr or -pr:h")
other_args = []
i = 0
while i < len(args[0]):
arg = args[0][i]
if arg in ['-pr', '-pr:h', '--profile', '--profile:host']:
i += 2
else:
other_args.append(arg)
i += 1
for profile in profiles:
run(['conan', 'install',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running conan recursively is not possible, please check: https://docs.conan.io/2/knowledge/guidelines.html

So this functionality must be built using the conan_api, not calling run(conan subprocess. It is possible that some higher level abstractions could be used in the API, but in any case it is better to have here a low level API usage than re-entrant behavior

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See below. I can look into the 2.0 conan_api but at least in 1 it seemed difficult to read a conanfile multiple times with different profiles.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'--deploy=full_deploy',
'-pr:h', profile
] + other_args)
output_dir = os.path.join('full_deploy', 'host')
for package in os.listdir(output_dir):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not fully clear what is the outcome of this.
If we install a dependency graph, with several dependency, the idea is that we will be creating a fat lipoed binary for every dependency in the graph?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the README needs a better explanation of the purpose. The goal is to produce Conan built libraries that can be turned into universal libraries and used outside of Conan. This should be as simple as possible, without trying to make universal packages or modifying recipes or toolchains. The '--deploy=full_deploy' option seems to be the same idea for single architecture situations.

The other complex point that was missed in the discussions under some other threads: this isn't just about supporting two or more architectures, but really multiple profiles. For example, I'd say it's pretty common in Xcode on macOS to target os.version=11.0 for armv8 (first supported OS) and something like os.version=10.13 for x86_64.

Since Conan packages are by design built around a single arch, os.version, etc. how do we do this in a single conan process? The sequence of commands should look like:

conan install --deploy=full_deploy -pr:h x86_64 . # Creates full_deploy/host/zlib/1.2.13/Release/x86_64/lib/libz.a
conan install --deploy=full_deploy -pr:h armv8 . # Creates full_deploy/host/zlib/1.2.13/Release/armv8/lib/libz.a
lipo full_deploy/host/zlib/1.2.13/Release/x86_64/lib/libz.a full_deploy/host/zlib/1.2.13/Release/armv8/lib/libz.a -create -output full_deploy/host/zlib/1.2.13/Release/lib/libz.a
# copy include files, etc to universal path as well

Now you can create an Xcode project outside of Conan with multiple architectures and references to files under 'full_deploy'. The conanfile.py is really only necessary to declare the dependencies, and maybe have build() run xcodebuild on the pre-existing Xcode project.

Any thoughts about how that fits as a custom command, or does this need to be a wrapper script outside of Conan?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The above is good when conan install --requires=zlib/1.2.13, because it has no dependencies. But I am thinking about the case for example of conan install --requires=boost/1.80, where boost depends on zlib and bzip2. So the full_deploy will deploy the 3 libraries, boost, zlib, and bzip2. What would be the expectaction/desired behavior in this case? To create a fat library for the 3 of them? like one fat boost, another fat zlib and another bzip2?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly. In the example project under 'xcode' I'm depending on "libtiff/4.5.0" and the deployment produces:
jbig
libdeflate
libjpeg
libtiff
libwebp
xz_utils
zlib
zstd

This is why posting an example somewhere with an Xcode project is going to be really helpful.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about a different approach that involves a deployer instead of the custom command weirdness (accepting all install command line arguments, and calling conan recursively). It would involve using lipo to replace a single arch at a time. The user would then run:

conan install -d lipo -pr x86_64 -b missing . # Creates 'full_deploy' as single arch universal binaries
conan install -d lipo -pr armv8 -b missing . # Runs 'lipo -replace' on 'full_deploy' to add the armv8 arch
conan build . # or xcodebuild

Does this seem like a better approach? (Let me make sure it's possible before closing this PR). It would be less efficient with a large number of archs but even on iOS it's just a few.

package_dir = os.path.join(output_dir, package)
for version in os.listdir(package_dir):
version_dir = os.path.join(package_dir, version)
for build in os.listdir(version_dir):
d = os.path.join(version_dir, build)
archs = [os.path.join(d, x) for x in os.listdir(d) if x in _valid_archs]
# We could skip if len(archs) == 1 but the dir layout would be different
lipo_folder(d, archs)
for arch in archs:
shutil.rmtree(arch)
15 changes: 15 additions & 0 deletions extensions/commands/deploy-lipo/xcode/conanfile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from conan import ConanFile
from conan.tools.apple import XcodeBuild


class UniversalRecipe(ConanFile):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems this test might not be necessary, but instead a test that does 2 conan create, then runs the lipo command.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it's not useful as a test, but I think at least the README should link to an example somewhat, which could just be a separate repo. Having an Xcode project makes this easier to try out, and makes it clear that Conan is not creating it.

# Note that we don't depend on arch
settings = "os", "compiler", "build_type"
requires = ("libtiff/4.5.0",)

def build(self):
# xcodebuild = XcodeBuild(self)
# Don't use XcodeBuild because it passes a single -arch flag
build_type = self.settings.get_safe("build_type")
project = 'example.xcodeproj'
self.run('xcodebuild -configuration {} -project {} -alltargets'.format(build_type, project))
Loading