-
Notifications
You must be signed in to change notification settings - Fork 27
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
``` |
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) | ||
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) |
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', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See below. I can look into the 2.0 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 😉 should work the same but just for settings |
||
'--deploy=full_deploy', | ||
'-pr:h', profile | ||
] + other_args) | ||
output_dir = os.path.join('full_deploy', 'host') | ||
for package in os.listdir(output_dir): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is not fully clear what is the outcome of this. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The above is good when There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: This is why posting an example somewhere with an Xcode project is going to be really helpful. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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) |
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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)) |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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.There was a problem hiding this comment.
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?