Skip to content

Commit 9ca71a3

Browse files
committed
feat: add build_recipes.py to build and upload all conan recipes
1 parent c31ddf8 commit 9ca71a3

14 files changed

Lines changed: 338 additions & 0 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,12 @@ ThirdParty/Lib
1717
ThirdParty/ConanRecipes/**/src
1818
ThirdParty/ConanRecipes/**/build
1919
ThirdParty/ConanRecipes/**/CMakeUserPresets.json
20+
!ThirdParty/ConanRecipes/build_recipes.py
2021
aqtinstall.log
2122

23+
# Claude
24+
CLAUDE.md
25+
2226
# Test Project
2327
TestProject/.idea
2428
TestProject/.vscode

ThirdParty/ConanRecipes/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,24 @@ conan export-pkg qt/conanfile.py --version="6.10.1-exp"
2525
# test stage
2626
conan test qt/test_package qt/6.10.1-exp
2727
```
28+
29+
To build every recipe at once, use the `build_recipes.py` helper. It walks each
30+
recipe directory, picks the latest version (the top-most entry in
31+
`conandata.yml`) and builds them one-by-one in dependency order. Each recipe
32+
lists the platforms it supports under a `platforms` key in its `conandata.yml`
33+
(currently `Windows-x86_64` and/or `Macos-armv8`); recipes that do not target
34+
the current host are skipped. If any recipe fails the script stops immediately
35+
and prints a summary.
36+
37+
```shell
38+
cd ThirdParty/ConanRecipes
39+
# build every recipe for the current host
40+
python build_recipes.py
41+
42+
# build everything, then upload to a remote (upload only runs if every
43+
# recipe built successfully)
44+
python build_recipes.py --upload \
45+
--remote <remote> \
46+
--remote-url <remote-url> \
47+
--remote-user <user> --remote-password <password>
48+
```
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
platforms:
2+
- Windows-x86_64
3+
- Macos-armv8
14
sources:
25
"6.0.2-exp":
36
url: "https://github.com/assimp/assimp/archive/refs/tags/v6.0.2.tar.gz"
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
#!/usr/bin/env python3
2+
"""Build (and optionally upload) every Conan recipe under ThirdParty/ConanRecipes.
3+
4+
Each recipe is built one-by-one in dependency order for the latest version (the
5+
top entry in conandata.yml), filtered by the recipe's `platforms` list. The
6+
first failure stops the run and prints a summary. Uploading is opt-in and only
7+
runs once every recipe has built successfully.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import argparse
13+
import platform
14+
import re
15+
import subprocess
16+
import sys
17+
import time
18+
from pathlib import Path
19+
20+
import yaml
21+
22+
23+
SUPPORTED_PLATFORMS = ("Windows-x86_64", "Macos-armv8")
24+
25+
26+
def current_platform() -> str:
27+
system = platform.system()
28+
machine = platform.machine().lower()
29+
if system == "Windows" and machine in ("amd64", "x86_64"):
30+
return "Windows-x86_64"
31+
if system == "Darwin" and machine in ("arm64", "aarch64"):
32+
return "Macos-armv8"
33+
sys.exit(
34+
f"error: unsupported host {system}/{platform.machine()}; "
35+
f"only {', '.join(SUPPORTED_PLATFORMS)} are supported"
36+
)
37+
38+
39+
class Recipe:
40+
def __init__(self, path: Path):
41+
self.path = path
42+
self.dir_name = path.name
43+
self.conanfile = path / "conanfile.py"
44+
self.conandata = path / "conandata.yml"
45+
46+
data = yaml.safe_load(self.conandata.read_text(encoding="utf-8")) or {}
47+
self.name = self._parse_name() or self.dir_name
48+
self.version = latest_version(data)
49+
self.platforms = data.get("platforms") or []
50+
self.requires = data.get("requires") or []
51+
52+
def _parse_name(self) -> str | None:
53+
text = self.conanfile.read_text(encoding="utf-8")
54+
match = re.search(r"""^\s*name\s*=\s*["']([^"']+)["']""", text, re.MULTILINE)
55+
return match.group(1) if match else None
56+
57+
def supports(self, host: str) -> bool:
58+
return host in self.platforms
59+
60+
@property
61+
def reference(self) -> str:
62+
return f"{self.name}/{self.version}"
63+
64+
65+
def latest_version(data: dict) -> str | None:
66+
sources = data.get("sources")
67+
if isinstance(sources, dict) and sources:
68+
return next(iter(sources))
69+
versions = data.get("versions")
70+
if isinstance(versions, list) and versions:
71+
return versions[0]
72+
return None
73+
74+
75+
def discover_recipes(root: Path) -> list[Recipe]:
76+
return [
77+
Recipe(child)
78+
for child in sorted(p for p in root.iterdir() if p.is_dir())
79+
if (child / "conanfile.py").is_file() and (child / "conandata.yml").is_file()
80+
]
81+
82+
83+
def order_by_dependencies(recipes: list[Recipe]) -> list[Recipe]:
84+
by_name = {r.name: r for r in recipes}
85+
ordered: list[Recipe] = []
86+
visited: set[str] = set()
87+
visiting: set[str] = set()
88+
89+
def visit(recipe: Recipe):
90+
if recipe.name in visited:
91+
return
92+
if recipe.name in visiting:
93+
raise SystemExit(f"error: dependency cycle involving '{recipe.name}'")
94+
visiting.add(recipe.name)
95+
for dep in sorted(recipe.requires):
96+
if dep in by_name:
97+
visit(by_name[dep])
98+
visiting.discard(recipe.name)
99+
visited.add(recipe.name)
100+
ordered.append(recipe)
101+
102+
for recipe in recipes:
103+
visit(recipe)
104+
return ordered
105+
106+
107+
def run(cmd: list[str], cwd: Path | None = None) -> int:
108+
print(f"\n$ {' '.join(cmd)}", flush=True)
109+
return subprocess.run(cmd, cwd=str(cwd) if cwd else None).returncode
110+
111+
112+
def parse_args() -> argparse.Namespace:
113+
parser = argparse.ArgumentParser(
114+
description="Build and optionally upload all Conan recipes.",
115+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
116+
)
117+
parser.add_argument(
118+
"--recipes-root",
119+
type=Path,
120+
default=Path(__file__).resolve().parent,
121+
help="directory containing the recipe sub-directories",
122+
)
123+
parser.add_argument("--conan", default="conan", help="path to the conan executable")
124+
parser.add_argument(
125+
"--build", default="missing", help="conan --build policy (passed as --build=<value>)"
126+
)
127+
parser.add_argument(
128+
"--profile", action="append", default=[], help="conan profile (repeatable)"
129+
)
130+
parser.add_argument(
131+
"--conan-arg",
132+
action="append",
133+
default=[],
134+
dest="conan_args",
135+
help="extra raw argument forwarded to 'conan create' (repeatable)",
136+
)
137+
parser.add_argument(
138+
"--only", action="append", default=[], help="only build these recipe names (repeatable)"
139+
)
140+
parser.add_argument(
141+
"--skip", action="append", default=[], help="skip these recipe names (repeatable)"
142+
)
143+
144+
upload = parser.add_argument_group("upload")
145+
upload.add_argument(
146+
"--upload",
147+
action="store_true",
148+
help="upload all packages after every recipe built successfully",
149+
)
150+
upload.add_argument("--remote", help="conan remote name to upload to")
151+
upload.add_argument(
152+
"--remote-url", help="if given, register/update the remote with this URL before login"
153+
)
154+
upload.add_argument("--remote-user", help="username for the remote")
155+
upload.add_argument("--remote-password", help="password for the remote")
156+
157+
args = parser.parse_args()
158+
if args.upload and not args.remote:
159+
parser.error("--upload requires --remote")
160+
return args
161+
162+
163+
def build_all(args: argparse.Namespace, recipes: list[Recipe], host: str):
164+
built: list[Recipe] = []
165+
skipped: list[tuple[Recipe, str]] = []
166+
167+
create_extra: list[str] = []
168+
for profile in args.profile:
169+
create_extra += ["-pr", profile]
170+
create_extra += args.conan_args
171+
172+
for index, recipe in enumerate(recipes, start=1):
173+
header = f"[{index}/{len(recipes)}] {recipe.name}"
174+
if recipe.version is None:
175+
fail(args, built, skipped, recipe,
176+
f"could not determine latest version from {recipe.conandata}")
177+
if not recipe.supports(host):
178+
reason = f"not built on {host} (platforms: {recipe.platforms or 'none'})"
179+
print(f"\n=== {header} -- SKIP: {reason} ===", flush=True)
180+
skipped.append((recipe, reason))
181+
continue
182+
183+
print(f"\n=== {header} -- building {recipe.reference} ===", flush=True)
184+
start = time.time()
185+
cmd = [
186+
args.conan, "create", f"{recipe.dir_name}/conanfile.py",
187+
"--version", recipe.version,
188+
f"--build={args.build}",
189+
] + create_extra
190+
code = run(cmd, cwd=args.recipes_root)
191+
elapsed = time.time() - start
192+
if code != 0:
193+
fail(args, built, skipped, recipe,
194+
f"'conan create' exited with code {code} after {elapsed:.0f}s")
195+
print(f"--- {recipe.reference} built in {elapsed:.0f}s ---", flush=True)
196+
built.append(recipe)
197+
198+
return built, skipped
199+
200+
201+
def fail(args, built, skipped, recipe: Recipe, message: str):
202+
print(f"\n!!! BUILD FAILED: {recipe.name} -- {message}", file=sys.stderr, flush=True)
203+
print_summary(built, skipped, failed=recipe)
204+
if args.upload:
205+
print("\nUpload skipped: not all recipes built successfully.", flush=True)
206+
sys.exit(1)
207+
208+
209+
def upload_all(args: argparse.Namespace, built: list[Recipe]):
210+
print("\n=== uploading packages ===", flush=True)
211+
212+
if args.remote_url:
213+
if run([args.conan, "remote", "add", "--force", args.remote, args.remote_url]) != 0:
214+
sys.exit("error: failed to register remote")
215+
216+
if args.remote_user is not None:
217+
login = [args.conan, "remote", "login", args.remote, args.remote_user]
218+
if args.remote_password is not None:
219+
login += ["-p", args.remote_password]
220+
if run(login) != 0:
221+
sys.exit("error: failed to log in to remote")
222+
223+
for recipe in built:
224+
print(f"\n--- uploading {recipe.reference} ---", flush=True)
225+
if run([args.conan, "upload", recipe.reference, "-r", args.remote, "--confirm"]) != 0:
226+
sys.exit(f"error: failed to upload {recipe.reference}")
227+
print(f"\nUploaded {len(built)} package(s) to '{args.remote}'.", flush=True)
228+
229+
230+
def print_summary(built, skipped, failed: Recipe | None = None):
231+
print("\n" + "=" * 60, flush=True)
232+
print("SUMMARY", flush=True)
233+
print("=" * 60, flush=True)
234+
for recipe in built:
235+
print(f" [OK] {recipe.reference}", flush=True)
236+
for recipe, reason in skipped:
237+
print(f" [SKIP] {recipe.name} ({reason})", flush=True)
238+
if failed is not None:
239+
print(f" [FAILED] {failed.reference}", flush=True)
240+
print(
241+
f"\nbuilt: {len(built)} skipped: {len(skipped)}"
242+
+ (" failed: 1" if failed is not None else ""),
243+
flush=True,
244+
)
245+
246+
247+
def main():
248+
args = parse_args()
249+
root: Path = args.recipes_root
250+
if not root.is_dir():
251+
sys.exit(f"error: recipes root not found: {root}")
252+
253+
host = current_platform()
254+
print(f"Host platform: {host}", flush=True)
255+
256+
recipes = discover_recipes(root)
257+
if args.only:
258+
recipes = [r for r in recipes if r.name in args.only or r.dir_name in args.only]
259+
if args.skip:
260+
recipes = [r for r in recipes if r.name not in args.skip and r.dir_name not in args.skip]
261+
if not recipes:
262+
sys.exit("error: no recipes to build")
263+
264+
recipes = order_by_dependencies(recipes)
265+
print("Build order: " + ", ".join(r.name for r in recipes), flush=True)
266+
267+
built, skipped = build_all(args, recipes, host)
268+
print_summary(built, skipped)
269+
270+
if args.upload:
271+
if not built:
272+
print("\nNothing was built; skipping upload.", flush=True)
273+
else:
274+
upload_all(args, built)
275+
print("\nAll done.", flush=True)
276+
277+
278+
if __name__ == "__main__":
279+
main()

ThirdParty/ConanRecipes/clipp/conandata.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
platforms:
2+
- Windows-x86_64
3+
- Macos-armv8
14
sources:
25
"1.2.3-exp":
36
url: "https://github.com/muellan/clipp/archive/refs/tags/v1.2.3.tar.gz"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
platforms:
2+
- Windows-x86_64
3+
- Macos-armv8
14
sources:
25
"1.0-exp":
36
url: "https://github.com/scottt/debugbreak/archive/refs/tags/v1.0.tar.gz"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
platforms:
2+
- Windows-x86_64
3+
- Macos-armv8
14
sources:
25
"1.8.2505.1-exp":
36
commit: "b106a961d09221b3c5bdb37be45b679257da08b8"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
platforms:
2+
- Windows-x86_64
3+
- Macos-armv8
14
sources:
25
"3.4-exp":
36
commit: "7b6aead9fb88b3623e3b3725ebb42670cbe4c579"

ThirdParty/ConanRecipes/libclang/conandata.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
platforms:
2+
- Windows-x86_64
3+
- Macos-armv8
14
sources:
25
"22.1.6-exp":
36
Windows-x86_64:
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
platforms:
2+
- Macos-armv8
13
sources:
24
"1.4.1-exp":
35
commit: "db445ff2042d9ce348c439ad8451112f354b8d2a"

0 commit comments

Comments
 (0)