|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Python port of .github/scripts/docs_build_examples.sh |
| 4 | +Preserves behavior and CLI options of the original bash script. |
| 5 | +
|
| 6 | +Usage: docs_build_examples.py -ai <arduino_cli_path> -au <arduino_user_path> [options] |
| 7 | +""" |
| 8 | + |
| 9 | +import argparse |
| 10 | +from argparse import RawDescriptionHelpFormatter |
| 11 | +# from esp_docs.generic_extensions.docs_embed.tool.wokwi_tool import DiagramSync |
| 12 | +import json |
| 13 | +import os |
| 14 | +import shutil |
| 15 | +import subprocess |
| 16 | +import sys |
| 17 | +from pathlib import Path |
| 18 | + |
| 19 | +SCRIPT_DIR = Path(__file__).resolve().parent |
| 20 | + |
| 21 | +# Determine SDKCONFIG_DIR like the shell script |
| 22 | +ARDUINO_ESP32_PATH = os.environ.get("ARDUINO_ESP32_PATH") |
| 23 | +GITHUB_WORKSPACE = os.environ.get("GITHUB_WORKSPACE") |
| 24 | +DOCS_DEPLOY_URL_BASE = os.environ.get("DOCS_PROD_URL_BASE") |
| 25 | +REPO_URL_PREFIX = os.environ.get("REPO_URL_PREFIX") |
| 26 | + |
| 27 | + |
| 28 | +if ARDUINO_ESP32_PATH and (Path(ARDUINO_ESP32_PATH) / "tools" / "esp32-arduino-libs").is_dir(): |
| 29 | + SDKCONFIG_DIR = Path(ARDUINO_ESP32_PATH) / "tools" / "esp32-arduino-libs" |
| 30 | +elif GITHUB_WORKSPACE and (Path(GITHUB_WORKSPACE) / "tools" / "esp32-arduino-libs").is_dir(): |
| 31 | + SDKCONFIG_DIR = Path(GITHUB_WORKSPACE) / "tools" / "esp32-arduino-libs" |
| 32 | +else: |
| 33 | + SDKCONFIG_DIR = Path("tools/esp32-arduino-libs") |
| 34 | + |
| 35 | +# Wrapper functions to call sketch_utils.sh |
| 36 | +SKETCH_UTILS = SCRIPT_DIR / "sketch_utils.sh" |
| 37 | + |
| 38 | +KEEP_FILES = [ |
| 39 | + "*.merged.bin", |
| 40 | + "ci.json", |
| 41 | + "launchpad.toml", |
| 42 | + "diagram*.json", |
| 43 | +] |
| 44 | +DOCS_BINARIES_DIR = Path("docs/_static/binaries") |
| 45 | +GENERATE_DIAGRAMS = False |
| 46 | +GENERATE_LAUNCHPAD_CONFIG = False |
| 47 | + |
| 48 | + |
| 49 | +def run_cmd(cmd, check=True, capture_output=False, text=True): |
| 50 | + try: |
| 51 | + return subprocess.run(cmd, check=check, capture_output=capture_output, text=text) |
| 52 | + except subprocess.CalledProcessError as e: |
| 53 | + # CalledProcessError is raised only when check=True and the command exits non-zero |
| 54 | + print(f"ERROR: Command failed: {' '.join(cmd)}") |
| 55 | + print(f"Exit code: {e.returncode}") |
| 56 | + if hasattr(e, 'stdout') and e.stdout: |
| 57 | + print("--- stdout ---") |
| 58 | + print(e.stdout) |
| 59 | + if hasattr(e, 'stderr') and e.stderr: |
| 60 | + print("--- stderr ---") |
| 61 | + print(e.stderr) |
| 62 | + # Exit the whole script with the same return code to mimic shell behavior |
| 63 | + sys.exit(e.returncode) |
| 64 | + except FileNotFoundError: |
| 65 | + print(f"ERROR: Command not found: {cmd[0]}") |
| 66 | + sys.exit(127) |
| 67 | + |
| 68 | + |
| 69 | +def check_requirements(sketch_dir, sdkconfig_path): |
| 70 | + # Call sketch_utils.sh check_requirements |
| 71 | + cmd = [str(SKETCH_UTILS), "check_requirements", sketch_dir, str(sdkconfig_path)] |
| 72 | + try: |
| 73 | + res = run_cmd(cmd, check=False, capture_output=True) |
| 74 | + return res.returncode == 0 |
| 75 | + except Exception: |
| 76 | + return False |
| 77 | + |
| 78 | + |
| 79 | +def install_libs(*args): |
| 80 | + cmd = [str(SKETCH_UTILS), "install_libs"] + list(args) |
| 81 | + return run_cmd(cmd, check=False) |
| 82 | + |
| 83 | + |
| 84 | +def build_sketch(args_list): |
| 85 | + cmd = [str(SKETCH_UTILS), "build"] + args_list |
| 86 | + return run_cmd(cmd, check=False) |
| 87 | + |
| 88 | + |
| 89 | +def parse_args(argv): |
| 90 | + epilog_text = ( |
| 91 | + "Example:\n" |
| 92 | + " docs_build_examples.py -ai /usr/local/bin -au ~/.arduino15 -d -l https://storage.example.com\n\n" |
| 93 | + "This script finds Arduino sketches that include a 'ci.json' with an 'upload-binary'\n" |
| 94 | + "section and builds binaries for the listed targets. The built outputs are placed\n" |
| 95 | + "under docs/_static/binaries/<sketch_path>/<target>/\n" |
| 96 | + ) |
| 97 | + |
| 98 | + p = argparse.ArgumentParser( |
| 99 | + description="Build examples that have ci.json with upload-binary targets", |
| 100 | + formatter_class=RawDescriptionHelpFormatter, |
| 101 | + epilog=epilog_text, |
| 102 | + ) |
| 103 | + p.add_argument( |
| 104 | + "-ai", |
| 105 | + dest="arduino_cli_path", |
| 106 | + help=( |
| 107 | + "Path to Arduino CLI installation (directory containing the 'arduino-cli' binary)" |
| 108 | + ), |
| 109 | + ) |
| 110 | + p.add_argument( |
| 111 | + "-au", |
| 112 | + dest="user_path", |
| 113 | + help="Arduino user path (for example: ~/.arduino15)", |
| 114 | + ) |
| 115 | + p.add_argument( |
| 116 | + "-c", |
| 117 | + dest="cleanup", |
| 118 | + action="store_true", |
| 119 | + help="Clean up docs binaries directory and exit", |
| 120 | + ) |
| 121 | + p.add_argument( |
| 122 | + "-d", |
| 123 | + dest="generate_diagrams", |
| 124 | + action="store_true", |
| 125 | + help="Generate diagrams for built examples using docs-embed", |
| 126 | + ) |
| 127 | + p.add_argument( |
| 128 | + "-l", |
| 129 | + dest="generate_launchpad_config", |
| 130 | + action="store_true", |
| 131 | + help="Generate LaunchPad config with configured storage URL", |
| 132 | + ) |
| 133 | + return p.parse_args(argv) |
| 134 | + |
| 135 | + |
| 136 | +def validate_prerequisites(args): |
| 137 | + if not args.arduino_cli_path: |
| 138 | + print("ERROR: Arduino CLI path not provided (-ai option required)") |
| 139 | + sys.exit(1) |
| 140 | + if not args.user_path: |
| 141 | + print("ERROR: Arduino user path not provided (-au option required)") |
| 142 | + sys.exit(1) |
| 143 | + arduino_cli_exe = Path(args.arduino_cli_path) / "arduino-cli" |
| 144 | + if not arduino_cli_exe.exists(): |
| 145 | + print(f"ERROR: arduino-cli not found at {arduino_cli_exe}") |
| 146 | + sys.exit(1) |
| 147 | + if not Path(args.user_path).is_dir(): |
| 148 | + print(f"ERROR: Arduino user path does not exist: {args.user_path}") |
| 149 | + sys.exit(1) |
| 150 | + |
| 151 | + |
| 152 | +def cleanup_binaries(): |
| 153 | + print(f"Cleaning up binaries directory: {DOCS_BINARIES_DIR}") |
| 154 | + if not DOCS_BINARIES_DIR.exists(): |
| 155 | + print("Binaries directory does not exist, nothing to clean") |
| 156 | + return |
| 157 | + for root, dirs, files in os.walk(DOCS_BINARIES_DIR): |
| 158 | + for fname in files: |
| 159 | + fpath = Path(root) / fname |
| 160 | + parent = Path(root).name |
| 161 | + # Always remove sketch/ci.json |
| 162 | + if parent == "sketch" and fname == "ci.json": |
| 163 | + fpath.unlink() |
| 164 | + continue |
| 165 | + keep = False |
| 166 | + for pattern in KEEP_FILES: |
| 167 | + if Path(fname).match(pattern): |
| 168 | + keep = True |
| 169 | + break |
| 170 | + if not keep: |
| 171 | + print(f"Removing: {fpath}") |
| 172 | + fpath.unlink() |
| 173 | + else: |
| 174 | + print(f"Keeping: {fpath}") |
| 175 | + # remove empty dirs |
| 176 | + for root, dirs, files in os.walk(DOCS_BINARIES_DIR, topdown=False): |
| 177 | + if not os.listdir(root): |
| 178 | + try: |
| 179 | + os.rmdir(root) |
| 180 | + except Exception: |
| 181 | + pass |
| 182 | + print("Cleanup completed") |
| 183 | + |
| 184 | + |
| 185 | +def find_examples_with_upload_binary(): |
| 186 | + res = [] |
| 187 | + for ino in Path('.').rglob('*.ino'): |
| 188 | + sketch_dir = ino.parent |
| 189 | + sketch_name = ino.stem |
| 190 | + dir_name = sketch_dir.name |
| 191 | + if dir_name != sketch_name: |
| 192 | + continue |
| 193 | + ci_json = sketch_dir / 'ci.json' |
| 194 | + if ci_json.exists(): |
| 195 | + try: |
| 196 | + data = json.loads(ci_json.read_text()) |
| 197 | + if 'upload-binary' in data and data['upload-binary']: |
| 198 | + res.append(str(ino)) |
| 199 | + except Exception: |
| 200 | + continue |
| 201 | + return res |
| 202 | + |
| 203 | + |
| 204 | +def get_upload_binary_targets(sketch_dir): |
| 205 | + ci_json = Path(sketch_dir) / 'ci.json' |
| 206 | + try: |
| 207 | + data = json.loads(ci_json.read_text()) |
| 208 | + targets = data.get('upload-binary', {}).get('targets', []) |
| 209 | + return targets |
| 210 | + except Exception: |
| 211 | + return [] |
| 212 | + |
| 213 | + |
| 214 | +def build_example_for_target(sketch_dir, target, relative_path, args): |
| 215 | + print(f"\n > Building example: {relative_path} for target: {target}") |
| 216 | + output_dir = DOCS_BINARIES_DIR / relative_path / target |
| 217 | + output_dir.mkdir(parents=True, exist_ok=True) |
| 218 | + |
| 219 | + sdkconfig = SDKCONFIG_DIR / target / 'sdkconfig' |
| 220 | + if not check_requirements(str(sketch_dir), sdkconfig): |
| 221 | + print(f"Target {target} does not meet the requirements for {Path(sketch_dir).name}. Skipping.") |
| 222 | + return True |
| 223 | + |
| 224 | + # Build the sketch using sketch_utils.sh build - pass args as in shell script |
| 225 | + build_args = [ |
| 226 | + "-ai", |
| 227 | + args.arduino_cli_path, |
| 228 | + "-au", |
| 229 | + args.user_path, |
| 230 | + "-s", |
| 231 | + str(sketch_dir), |
| 232 | + "-t", |
| 233 | + target, |
| 234 | + "-b", |
| 235 | + str(output_dir), |
| 236 | + "--first-only", |
| 237 | + ] |
| 238 | + res = build_sketch(build_args) |
| 239 | + if res.returncode == 0: |
| 240 | + print(f"Successfully built {relative_path} for {target}") |
| 241 | + ci_json = Path(sketch_dir) / 'ci.json' |
| 242 | + if ci_json.exists(): |
| 243 | + shutil.copy(ci_json, output_dir / 'ci.json') |
| 244 | + # if GENERATE_DIAGRAMS: |
| 245 | + # print(f"Generating diagram for {relative_path} ({target})...") |
| 246 | + # try: |
| 247 | + # sync = DiagramSync(output_dir) |
| 248 | + # sync.generate_diagram_from_ci(target) |
| 249 | + # except Exception as e: |
| 250 | + # print(f"WARNING: Failed to generate diagram for {relative_path} ({target}): {e}") |
| 251 | + # if GENERATE_LAUNCHPAD_CONFIG: |
| 252 | + # print(f"Generating LaunchPad config for {relative_path} ({target})...") |
| 253 | + # try: |
| 254 | + # sync = DiagramSync(output_dir) |
| 255 | + # sync.generate_launchpad_config(DOCS_DEPLOY_URL_BASE, REPO_URL_PREFIX) |
| 256 | + # except Exception as e: |
| 257 | + # print(f"WARNING: Failed to generate LaunchPad config for {relative_path} ({target}): {e}") |
| 258 | + else: |
| 259 | + print(f"ERROR: Failed to build {relative_path} for {target}") |
| 260 | + return False |
| 261 | + |
| 262 | + |
| 263 | +def build_all_examples(args): |
| 264 | + total_built = 0 |
| 265 | + total_failed = 0 |
| 266 | + |
| 267 | + if DOCS_BINARIES_DIR.exists(): |
| 268 | + shutil.rmtree(DOCS_BINARIES_DIR) |
| 269 | + print(f"Removed existing build directory: {DOCS_BINARIES_DIR}") |
| 270 | + |
| 271 | + examples = find_examples_with_upload_binary() |
| 272 | + if not examples: |
| 273 | + print("No examples found with upload-binary configuration") |
| 274 | + return 0 |
| 275 | + |
| 276 | + print('\nExamples to be built:') |
| 277 | + print('====================') |
| 278 | + for i, example in enumerate(examples, start=1): |
| 279 | + sketch_dir = Path(example).parent |
| 280 | + relative_path = str(sketch_dir).lstrip('./') |
| 281 | + targets = get_upload_binary_targets(sketch_dir) |
| 282 | + if targets: |
| 283 | + print(f"{i}. {relative_path} (targets: {' '.join(targets)})") |
| 284 | + print() |
| 285 | + |
| 286 | + for example in examples: |
| 287 | + sketch_dir = Path(example).parent |
| 288 | + relative_path = str(sketch_dir).lstrip('./') |
| 289 | + targets = get_upload_binary_targets(sketch_dir) |
| 290 | + if not targets: |
| 291 | + print(f"WARNING: No targets found for {relative_path}") |
| 292 | + continue |
| 293 | + print(f"Building {relative_path} for targets: {targets}") |
| 294 | + for target in targets: |
| 295 | + ok = build_example_for_target(sketch_dir, target, relative_path, args) |
| 296 | + if ok: |
| 297 | + total_built += 1 |
| 298 | + else: |
| 299 | + total_failed += 1 |
| 300 | + |
| 301 | + print('\nBuild summary:') |
| 302 | + print(f" Successfully built: {total_built}") |
| 303 | + print(f" Failed builds: {total_failed}") |
| 304 | + print(f" Output directory: {DOCS_BINARIES_DIR}") |
| 305 | + return total_failed |
| 306 | + |
| 307 | + |
| 308 | +def main(argv): |
| 309 | + global GENERATE_DIAGRAMS, GENERATE_LAUNCHPAD_CONFIG |
| 310 | + args = parse_args(argv) |
| 311 | + if args.cleanup: |
| 312 | + cleanup_binaries() |
| 313 | + return |
| 314 | + validate_prerequisites(args) |
| 315 | + GENERATE_DIAGRAMS = args.generate_diagrams |
| 316 | + GENERATE_LAUNCHPAD_CONFIG = args.generate_launchpad_config |
| 317 | + DOCS_BINARIES_DIR.mkdir(parents=True, exist_ok=True) |
| 318 | + result = build_all_examples(args) |
| 319 | + if result == 0: |
| 320 | + print('\nAll examples built successfully!') |
| 321 | + else: |
| 322 | + print('\nSome builds failed. Check the output above for details.') |
| 323 | + sys.exit(1) |
| 324 | + |
| 325 | + |
| 326 | +if __name__ == '__main__': |
| 327 | + main(sys.argv[1:]) |
0 commit comments