This is an automated email from the ASF dual-hosted git repository. tqchen pushed a commit to branch jenkins-wheel-test-artifacts in repository https://gitbox.apache.org/repos/asf/tvm.git
commit 43204d1820a6cbcd6113ce12929f50b12ac5d733 Merge: 0c852cf5e5 d5089384b5 Author: Tianqi Chen <[email protected]> AuthorDate: Thu Oct 15 02:54:02 2026 +0000 Merge native Jenkins cleanup and artifact interfaces into wheel pipelines .gitignore | 4 - CMakeLists.txt | 59 +- apps/cpp_rpc/CMakeLists.txt | 12 - ci/README.md | 3 +- ci/jenkins/README.md | 79 +- ci/jenkins/{data.py => artifacts.py} | 14 +- ci/jenkins/generated/arm_jenkinsfile.groovy | 4 +- ci/jenkins/generated/common.groovy | 284 ++-- ci/jenkins/generated/cpu_jenkinsfile.groovy | 4 +- ci/jenkins/generated/gpu_jenkinsfile.groovy | 8 +- ci/jenkins/generated/wasm_jenkinsfile.groovy | 2 + ci/jenkins/scripts/s3.py | 83 +- ci/scripts/github/github_tvmbot.py | 799 ----------- ci/scripts/jenkins/cmd_utils.py | 107 -- ci/scripts/jenkins/determine_docker_images.py | 153 --- ci/scripts/jenkins/git_change_docker.sh | 55 - ci/scripts/jenkins/git_change_docs.sh | 39 - ci/scripts/jenkins/git_skip_ci.py | 61 - ci/scripts/jenkins/git_skip_ci_globs.py | 76 -- ci/scripts/jenkins/git_utils.py | 212 --- ci/scripts/jenkins/http_utils.py | 34 - ci/scripts/jenkins/retry.sh | 39 - ci/scripts/jenkins/s3.py | 193 --- ci/scripts/jenkins/should_rebuild_docker.py | 152 --- ci/scripts/jenkins/should_run_slow_tests.py | 85 -- ci/scripts/package/README.md | 31 - ci/scripts/package/build-environment.yaml | 42 - .../package/manylinux_build_libtvm_runtime_cuda.sh | 64 - .../package/windows_build_libtvm_runtime_cuda.bat | 99 -- cmake/config.cmake | 17 +- cmake/modules/Hexagon.cmake | 345 ----- cmake/modules/HexagonSDK.cmake | 205 --- cmake/modules/LLVM.cmake | 4 + cmake/modules/RuntimeExtra.cmake | 12 +- docker/with_the_same_user | 4 - docs/reference/api/python/s_tir/tensor_intrin.rst | 6 - docs/reference/api/python/script/script.rst | 2 +- docs/tirx/api/cuda.rst | 4 + docs/tirx/api/execution.rst | 37 +- docs/tirx/api/ptx.rst | 4 +- docs/tirx/api/script.rst | 57 + docs/tirx/api/tile_dispatch.rst | 24 +- docs/tirx/api/tirx.rst | 2 +- docs/tirx/arch/lowering_pipeline.rst | 2 +- docs/tirx/arch/tile_dispatch.rst | 8 +- include/tvm/s_tir/meta_schedule/mutator.h | 2 - include/tvm/s_tir/meta_schedule/postproc.h | 2 - include/tvm/s_tir/meta_schedule/schedule_rule.h | 30 - include/tvm/script/ir_builder/base.h | 14 +- include/tvm/tirx/layout.h | 10 +- include/tvm/tirx/script/ir_builder/frame.h | 2 +- include/tvm/tirx/script/ir_builder/ir.h | 4 +- include/tvm/tirx/stmt.h | 2 +- include/tvm/tirx/tile/context.h | 18 +- include/tvm/tirx/tile/exec_scope.h | 57 - include/tvm/tirx/tile/thread_scope.h | 82 ++ include/tvm/tirx/transform.h | 6 - python/tvm/backend/cuda/__init__.py | 7 +- python/tvm/backend/cuda/op.py | 2 +- python/tvm/backend/cuda/script.py | 4 +- .../{trn/exec_scope.py => cuda/thread_scope.py} | 18 +- python/tvm/backend/hexagon/__init__.py | 16 - python/tvm/backend/trn/__init__.py | 11 +- .../tvm/backend/trn/thread_scope.py | 12 +- python/tvm/backend/trn/tile/binary/default.py | 2 +- python/tvm/backend/trn/tile/copy/default.py | 2 +- python/tvm/backend/trn/tile/gemm/default.py | 2 +- python/tvm/backend/trn/tile/reduction/utils.py | 2 +- python/tvm/backend/trn/tile/select/default.py | 2 +- python/tvm/backend/trn/tile/unary/default.py | 2 +- .../tvm/backend/trn/tile/unary/with_bias_scale.py | 2 +- .../backend/trn/transform/private_tensor_alloc.py | 6 +- .../frontend/torch/base_fx_graph_translator.py | 22 +- python/tvm/s_tir/meta_schedule/mutator/mutator.py | 4 +- .../tvm/s_tir/meta_schedule/postproc/postproc.py | 5 +- .../s_tir/meta_schedule/schedule_rule/__init__.py | 1 - .../schedule_rule/multi_level_tiling.py | 58 +- .../meta_schedule/schedule_rule/schedule_rule.py | 5 +- .../space_generator/space_generator.py | 12 +- .../meta_schedule/testing/space_generation.py | 4 +- python/tvm/s_tir/pipeline.py | 1 - python/tvm/s_tir/tensor_intrin/__init__.py | 1 - python/tvm/s_tir/tensor_intrin/cuda.py | 1193 ----------------- python/tvm/s_tir/tensor_intrin/rocm.py | 107 +- python/tvm/s_tir/testing/tir.py | 119 +- python/tvm/script/ir_builder/base.py | 15 + python/tvm/script/ir_builder/tracing.py | 163 +++ python/tvm/support/libinfo.py | 1 - python/tvm/testing/env.py | 10 +- python/tvm/testing/utils.py | 2 +- python/tvm/tirx/__init__.py | 2 +- python/tvm/tirx/compilation_pipeline.py | 1 - python/tvm/tirx/exec_scope.py | 45 - python/tvm/tirx/layout.py | 8 +- python/tvm/tirx/script/ir_builder/ir.py | 4 +- python/tvm/tirx/thread_scope.py | 64 + python/tvm/tirx/tile/dispatch.py | 10 +- python/tvm/tirx/tile/instruction.py | 35 +- python/tvm/tirx/transform/transform.py | 11 - src/backend/cuda/codegen/codegen_cuda.cc | 131 +- src/backend/cuda/codegen/ptx.cc | 494 +------ src/backend/cuda/codegen/ptx.h | 76 -- src/backend/cuda/codegen/target_kind.cc | 12 + src/backend/cuda/layout/axis.cc | 87 +- src/backend/cuda/op/legacy.cc | 61 + src/backend/cuda/op/target_builtin.cc | 25 - src/backend/cuda/tile/exec_analysis.cc | 10 +- src/backend/cuda/tile/exec_analysis.h | 4 +- src/backend/cuda/tile/exec_context.h | 2 +- .../cuda/tile/{exec_scope.cc => thread_scope.cc} | 2 +- .../cuda/tile/{exec_scope.h => thread_scope.h} | 6 +- .../hexagon/codegen/hexagon_fallback_module.cc | 23 +- .../hexagon/codegen/hexagon_fallback_module.h | 4 +- .../hexagon/codegen/llvm/codegen_hexagon.cc | 11 +- src/backend/hexagon/{ => codegen}/memory_scope.h | 6 +- src/backend/hexagon/codegen/target_kind.cc | 2 +- src/backend/hexagon/runtime/README.md | 74 -- src/backend/hexagon/runtime/hexagon_buffer.cc | 301 ----- src/backend/hexagon/runtime/hexagon_buffer.h | 204 --- .../hexagon/runtime/hexagon_buffer_manager.h | 93 -- src/backend/hexagon/runtime/hexagon_common.cc | 101 -- src/backend/hexagon/runtime/hexagon_common.h | 52 - src/backend/hexagon/runtime/hexagon_device_api.cc | 349 ----- src/backend/hexagon/runtime/hexagon_device_api.h | 270 ---- src/backend/hexagon/runtime/hexagon_htp.cc | 81 -- src/backend/hexagon/runtime/hexagon_htp.h | 62 - src/backend/hexagon/runtime/hexagon_hvx.cc | 61 - src/backend/hexagon/runtime/hexagon_hvx.h | 68 - src/backend/hexagon/runtime/hexagon_module.cc | 156 --- .../hexagon/runtime/hexagon_power_manager.cc | 108 -- .../hexagon/runtime/hexagon_power_manager.h | 63 - .../hexagon/runtime/hexagon_thread_manager.cc | 377 ------ .../hexagon/runtime/hexagon_thread_manager.h | 250 ---- src/backend/hexagon/runtime/hexagon_user_dma.cc | 140 -- src/backend/hexagon/runtime/hexagon_user_dma.h | 110 -- .../hexagon/runtime/hexagon_user_dma_descriptors.h | 319 ----- .../runtime/hexagon_user_dma_instructions.h | 79 -- .../hexagon/runtime/hexagon_user_dma_registers.h | 278 ---- src/backend/hexagon/runtime/hexagon_vtcm_pool.cc | 173 --- src/backend/hexagon/runtime/hexagon_vtcm_pool.h | 118 -- src/backend/hexagon/runtime/ops/conv2d.h | 332 ----- src/backend/hexagon/runtime/ops/conv2d_fp16_hvx.cc | 499 ------- .../hexagon/runtime/ops/conv2d_quant_hvx.cc | 330 ----- src/backend/hexagon/runtime/ops/conv_utils.cc | 221 ---- src/backend/hexagon/runtime/qhl/qhl_wrapper.cc | 93 -- src/backend/hexagon/runtime/ring_buffer.h | 191 --- src/backend/hexagon/runtime/rpc/android/session.cc | 136 -- .../hexagon/runtime/rpc/hexagon/rpc_server.cc | 355 ----- src/backend/hexagon/runtime/rpc/hexagon_rpc.idl | 29 - .../runtime/rpc/simulator/hexagon_sim_proto.h | 75 -- .../hexagon/runtime/rpc/simulator/rpc_server.cc | 358 ----- .../hexagon/runtime/rpc/simulator/session.cc | 1395 -------------------- src/backend/opencl/codegen/codegen_opencl.cc | 2 + src/backend/trn/codegen/target_kind.cc | 7 + src/runtime/threading_backend.cc | 111 +- src/runtime/vm/hexagon/builtin.cc | 89 -- src/s_tir/meta_schedule/mutator/mutator.cc | 3 - src/s_tir/meta_schedule/postproc/postproc.cc | 15 - .../multi_level_tiling_tensor_core.cc | 984 -------------- .../meta_schedule/schedule_rule/schedule_rule.cc | 43 - .../space_generator/space_generator.cc | 22 - src/s_tir/transform/inject_permuted_layout.cc | 116 +- src/s_tir/transform/inject_software_pipeline.cc | 40 - src/s_tir/transform/transform_mma_tensor_layout.cc | 23 +- src/script/ir_builder/base.cc | 12 +- src/tirx/ir/layout/axis_registry.cc | 8 +- src/tirx/ir/layout/tile_canonicalize.cc | 4 +- src/tirx/ir/layout/tile_core.cc | 45 +- src/tirx/script/ir_builder/frame.cc | 2 +- src/tirx/script/ir_builder/ir.cc | 2 +- src/tirx/script/printer/block.cc | 15 +- src/tirx/script/printer/expr.cc | 4 +- src/tirx/tile/context.cc | 16 +- src/tirx/tile/exec_scope.cc | 68 - src/tirx/tile/lower.cc | 12 +- src/tirx/tile/op.cc | 12 +- src/tirx/tile/thread_scope.cc | 80 ++ src/tirx/transform/lower_warp_memory.cc | 814 ------------ tests/cpp/tirx/thread_scope_test.cc | 108 ++ .../python/backend/hexagon/codegen/test_hexagon.py | 29 +- tests/python/ci/jenkins_loader_test.groovy | 84 ++ tests/python/ci/test_ci.py | 57 +- tests/python/ci/test_jenkins_helpers.py | 189 ++- .../python/ci/test_jenkins_loader.py | 27 +- tests/python/ci/test_wheel_artifacts.py | 2 +- tests/python/relax/test_frontend_from_fx.py | 36 + .../test_meta_schedule_mma_tensorize.py | 343 ----- ...est_meta_schedule_postproc_rewrite_tensorize.py | 2 +- ...ule_space_cuda_async_multiple_initialization.py | 2 +- .../test_meta_schedule_space_generator.py | 21 + .../test_meta_schedule_trace_apply.py | 3 +- .../s_tir/schedule/test_tir_schedule_analysis.py | 31 +- ..._tir_schedule_tensorize_ldmatrix_mma_numeric.py | 464 ------- .../test_tir_schedule_tensorize_mfma_numeric.py | 108 +- .../test_s_tir_transform_inject_permuted_layout.py | 360 +---- ...est_s_tir_transform_inject_software_pipeline.py | 169 --- .../test_s_tir_transform_mma_tensor_layout.py | 23 + tests/python/script/test_builder_void_emission.py | 7 +- tests/python/testing/test_env.py | 30 +- tests/python/tirx/base/test_tirx_ptx_ldmatrix.py | 101 -- tests/python/tirx/base/test_tirx_ptx_mma.py | 1264 ------------------ tests/python/tirx/codegen/test_codegen_cuda.py | 54 + .../tirx/script/test_tirx_script_basic_usage.py | 18 +- tests/python/tirx/script/test_tirx_script_gpu.py | 2 +- .../python/tirx/script/test_tirx_script_printer.py | 3 +- .../python/tirx/script/test_tirx_script_tracing.py | 312 +++++ tests/python/tirx/test_exec_scope.py | 43 - tests/python/tirx/test_layout.py | 24 +- tests/python/tirx/test_op.py | 2 +- tests/python/tirx/test_thread_scope.py | 143 ++ tests/python/tirx/test_tile_namespace.py | 24 +- tests/python/tirx/test_tirx_axis_enum.py | 4 +- .../python/tirx/tile/cuda/test_add_sub_mul_div.py | 48 +- tests/python/tirx/tile/cuda/test_cp_async_bulk.py | 6 +- .../tirx/tile/cuda/test_cp_async_bulk_tensor.py | 4 +- tests/python/tirx/tile/cuda/test_mov.py | 18 +- tests/python/tirx/tile/test_dispatcher.py | 27 +- tests/python/tirx/tile/trn/test_binary_trn.py | 6 +- tests/python/tirx/tile/trn/test_compose_op_trn.py | 6 +- tests/python/tirx/tile/trn/test_copy_trn.py | 6 +- tests/python/tirx/tile/trn/test_gemm_trn.py | 6 +- tests/python/tirx/tile/trn/test_reduction_trn.py | 6 +- tests/python/tirx/tile/trn/test_select_trn.py | 6 +- tests/python/tirx/tile/trn/test_unary_trn.py | 6 +- tests/scripts/task_show_node_info.sh | 44 - 225 files changed, 2589 insertions(+), 18847 deletions(-) diff --cc ci/jenkins/README.md index f4a6a97e18,e33f38da4c..cbe33aeb33 --- a/ci/jenkins/README.md +++ b/ci/jenkins/README.md @@@ -92,50 -111,3 +111,54 @@@ No job path or controller reconfigurati The community's infrastructure configuration and documentation live in [tlc-pack/ci](https://github.com/tlc-pack/ci). Coordinate external callers when changing any retained script interface. + +## Wheel build and test artifacts + +Normal builds use `uv build --wheel --no-build-isolation --offline` with the +existing platform CMake configuration. `uv build` builds only TVM; it does not +resolve runtime dependencies. CPU, ARM and Wasm host builds request `all;cpptest` +and run C++ tests in that same native tree before uploading anything. Python +consumers install the exact TVM wheel with `--no-deps --offline`, clear checkout +path overrides, and verify the package and native-library paths. FFI comes from - the checked-out submodule. GPU Java tests also receive their existing native runtime libraries; - the separate runtime-only compile check and Emscripten runtime build remain. ++the checked-out submodule. GPU Java tests also receive their existing native ++runtime libraries; the separate runtime-only compile check and Emscripten runtime ++build remain. + +Immediately before building, the same container and Python install the declared +`pyproject.toml` build requirements (scikit-build-core, setuptools-scm and +mlc-z3-static) and their build dependencies. A +failed bootstrap stops the build. Build requirements install into a writable local +directory, which is added to `PYTHONPATH` only for the build command. Both commands +select the original image Python, preserving its existing dependencies without +modifying the image environment or exposing the build overlay to test consumers. +`uv build` then runs offline without isolation. + +The selected images must already provide compatible runtime and test dependencies, - apart from core FFI and its ORCJIT addon. Consumers install the checkout's pinned FFI source into - the writable test environment with `--no-deps`, using its declared isolated build ++apart from core FFI and its ORCJIT addon. Consumers install the checkout's pinned ++FFI source into the writable test environment with `--no-deps`, using its declared ++isolated build +requirements, plus the explicit `apache-tvm-ffi-orcjit==0.1.2 --no-deps` prerequisite +used by legacy Python tests. They then install the transferred TVM wheel with +`--no-deps --offline`. +The checkout remains available for tests and resources through `TVM_HOME`, including - Wasm assets under `web/dist`, without adding its Python package ahead of the wheel. Provenance checks verify both TVM/native libraries and - source-built FFI load from the test environment; other missing runtime dependencies ++Wasm assets under `web/dist`, without adding its Python package ahead of the ++wheel. Provenance checks verify both TVM/native libraries and source-built FFI ++load from the test environment; other missing runtime dependencies +remain errors rather than permission to fetch replacements. + +`CMAKE_BUILD_PARALLEL_LEVEL` is passed through Docker as an environment variable. +An explicit value wins; otherwise builds use at least one CPU and divide the +node CPU count by `CI_NUM_EXECUTORS`. Both the wheel backend and specialized +native commands use that bound without an additional parallel flag. + - ### Old pipeline compatibility ++### Current-main pipeline compatibility + +Jenkins may select the old trusted-main Jenkinsfile while checking out a newer +PR revision. Retain `tests/scripts/task_build.py`, the original raw-artifact +bundles, and the source-mode test interfaces until trusted main has migrated. - The native pipelines and local wheel commands explicitly pass - `TVM_TEST_USE_WHEEL=1`; only that mode installs wheel artifacts without fetching runtime - dependencies. Calls without the flag retain the old source setup and dependency - installation behavior. A missing wheel in wheel mode is an error, not a fallback ++The wheel entrypoints opt in after preparation, leaving the shared helper's ++default unchanged for older entrypoints. They and local wheel commands explicitly ++pass `TVM_TEST_USE_WHEEL=1`; that mode installs transferred TVM wheels with the ++explicit FFI setup above. Calls without the flag retain the old source setup and ++dependency installation behavior. A missing wheel in wheel mode is an error, not a fallback +to source mode. diff --cc ci/jenkins/artifacts.py index 8b92cc808d,d777ba7fb7..b222d0dd4d --- a/ci/jenkins/artifacts.py +++ b/ci/jenkins/artifacts.py @@@ -30,11 -33,7 +33,10 @@@ artifact prefix import sys - files_to_stash = { + ARTIFACTS = { + # New pipelines install the exact TVM wheel and retain only JVM native files. + "wheels": ["build/wheels"], + "jvm_runtime": ["build/lib/libtvm_runtime.so", "build/lib/libtvm_ffi.so"], - # Keep the original bundles while trusted main still runs the old pipeline. # Executables and build files needed to run c++ tests "cpptest": ["build/cpptest", "build/build.ninja", "build/CMakeFiles/rules.ninja"], # runtime files diff --cc ci/jenkins/generated/arm_jenkinsfile.groovy index 562faf6c93,2ac6165a7e..b376794a3c --- a/ci/jenkins/generated/arm_jenkinsfile.groovy +++ b/ci/jenkins/generated/arm_jenkinsfile.groovy @@@ -19,11 -19,12 +19,13 @@@ ci = evaluate(readTrusted('ci/jenkins/generated/common.groovy')) ci.start() ++docker_run += ' --env TVM_TEST_USE_WHEEL=1' ++ ci.build('ARM-GRAVITON3', 'BUILD: arm', 'tvm/build-arm', images.ci_arm, 'arm') { sh( script: "${docker_run} ${images.ci_arm} ./tests/scripts/task_config_build_arm.sh build", label: 'Create ARM CMake config', ) - ci.cmake_build(images.ci_arm, 'build') - ci.make_cpp_tests(images.ci_arm, 'build') - ci.upload('arm', '--artifacts tvm_lib --artifacts cpptest') + ci.wheel_build(images.ci_arm, true) - ci.upload('arm', '--bundle wheels') ++ ci.upload('arm', '--artifacts wheels') } diff --cc ci/jenkins/generated/common.groovy index 68b7be3f92,1682ca1147..954e89c71e --- a/ci/jenkins/generated/common.groovy +++ b/ci/jenkins/generated/common.groovy @@@ -283,64 -253,118 +253,160 @@@ def prepare(node_type) } } } - def python_unittest(image) { - sh ( - script: "${docker_run} ${image} ./tests/scripts/task_python_unittest.sh", - label: 'Run Python unit tests', + + def retry_latest(ex, body) { + echo 'Exception during SPOT run ' + ex.toString() + if (!is_last_build()) { + echo 'Exit since it is not last build' + throw ex + } + echo 'Retry on-demand given it is last build' + currentBuild.result = 'SUCCESS' + // Do not catch failure of the on-demand retry. + body() + } + + def run_on_node(label, name, folder, image, variables, body) { + if (skip_ci) { + Utils.markStageSkippedForConditional(name) + return + } + echo 'Begin running on node_type ' + label + node(label) { + ws(per_exec_ws(folder)) { + init_git() + docker_init(image) + timeout(time: max_time, unit: 'MINUTES') { + withEnv(variables) { body() } + } + } + } + echo 'End running on node_type ' + label + } + + // Public entry points. + + def docker_init(image) { + // Clear out all Docker images that aren't going to be used + sh( + script: """ + set -eux + docker image ls --all + IMAGES=\$(docker image ls --all --format '{{.Repository}}:{{.Tag}} {{.ID}}') + + echo -e "Found images:\\n\$IMAGES" + echo "\$IMAGES" | { grep -vE '${image}' || test \$? = 1; } | { xargs docker rmi || test \$? = 123; } + + docker image ls --all + """, + label: 'Clean old Docker images', ) + + if (image.contains("amazonaws.com")) { + // If this string is in the image name it's from ECR and needs to be pulled + // with the right credentials + ecr_pull(image) + } else { + sh( + script: """ + set -eux + . ${jenkins_scripts_root}/retry.sh + retry 5 docker pull ${image} + """, + label: 'Pull docker image', + ) + } + } + + // Preparation retries Exceptions unconditionally; Errors and cancellation of the + // previous build are deliberately outside this fallback. + def start() { + cancel_previous_build() + try { + prepare('CPU-SMALL-SPOT') + } catch (Exception ex) { + prepare('CPU-SMALL') + } } +// Share only the compiler/cache environment; build commands stay at their callers. +def build_environment() { + return ''' + set -eux + if command -v sccache >/dev/null; then + export CC=/opt/sccache/cc CXX=/opt/sccache/c++ + if [ -n "${AWS_ACCESS_KEY_ID:-}" ]; then + export SCCACHE_BUCKET=tvm-sccache-prod SCCACHE_REGION=us-west-2 + fi + sccache --start-server || true + sccache --show-stats + trap "sccache --show-stats" EXIT + fi + ''' +} + +// Preserve explicit caller limits, otherwise divide this node among its executors. +def build_docker() { + return '''set -eu + parallel=${CMAKE_BUILD_PARALLEL_LEVEL:-$(( $(getconf _NPROCESSORS_ONLN) / ${CI_NUM_EXECUTORS:-1} ))} + if [ "$parallel" -lt 1 ]; then parallel=1; fi + ''' + "${docker_run} --env CMAKE_BUILD_PARALLEL_LEVEL=\"\$parallel\" " +} + +def wheel_build(image, cpp_tests = false) { + // Overlay build requirements without modifying the image Python environment. + // The build itself stays offline; runtime dependencies belong to the image. + def commands = build_environment() + ''' + rm -rf build/wheels build/wheel-build-deps + uv pip install --python python3 --target build/wheel-build-deps "scikit-build-core>=0.11" "setuptools-scm>=8" "mlc-z3-static>=4.16.0" + PYTHONPATH="$PWD/build/wheel-build-deps${PYTHONPATH:+:$PYTHONPATH}" \ + uv build --wheel --no-build-isolation --offline --python python3 --out-dir build/wheels . \ + -Cbuild-dir=build -Ccmake.build-type=RelWithDebInfo -Cbuild.verbose="${VERBOSE:-true}" + '''.stripIndent().trim() + if (cpp_tests) { + commands += " -Ccmake.define.USE_GTEST=ON -Cbuild.targets=all\\;cpptest" + commands += "\n./tests/scripts/task_cpp_unittest.sh build" + } + sh(script: build_docker() + "${image} bash -c '${commands}'", + label: 'Build TVM wheel' + (cpp_tests ? ' and run C++ tests' : '')) +} + - // Preparation retries Exceptions unconditionally; Errors and cancellation of the - // previous build are deliberately outside this fallback. - def start() { - cancel_previous_build() - try { - prepare('CPU-SMALL-SPOT') - } catch (Exception ex) { - prepare('CPU-SMALL') - } + // Legacy compatibility functions. Keep names, signatures and behavior until + // existing pipeline callers have migrated after merge. + + def python_unittest(image) { + sh ( + script: "${docker_run} ${image} ./tests/scripts/task_python_unittest.sh", + label: 'Run Python unit tests', + ) + } + + def make_cpp_tests(image, build_dir) { + sh ( + script: """ + set -eux + ${docker_run} ${image} python3 ./tests/scripts/task_build.py \ + --sccache-bucket tvm-sccache-prod \ + --sccache-region us-west-2 \ + --cmake-target cpptest \ + --build-dir ${build_dir} + """, + label: 'Make C++ tests', + ) + } + + def cmake_build(image, path) { + sh ( + script: "${docker_run} --env CI_NUM_EXECUTORS ${image} ./tests/scripts/task_build.py --sccache-bucket tvm-sccache-prod --sccache-region us-west-2 --build-dir ${path}", + label: 'Run CMake build', + ) + } + + def cpp_unittest(image) { + sh ( + script: "${docker_run} --env CI_NUM_EXECUTORS ${image} ./tests/scripts/task_cpp_unittest.sh", + label: 'Run C++ tests', + ) } // A failed build command is final. Infrastructure failures retry only while diff --cc ci/jenkins/generated/cpu_jenkinsfile.groovy index b064c89455,881cd2cedc..72ed6e4e6f --- a/ci/jenkins/generated/cpu_jenkinsfile.groovy +++ b/ci/jenkins/generated/cpu_jenkinsfile.groovy @@@ -19,13 -19,14 +19,15 @@@ ci = evaluate(readTrusted('ci/jenkins/generated/common.groovy')) ci.start() ++docker_run += ' --env TVM_TEST_USE_WHEEL=1' ++ ci.build('CPU', 'BUILD: CPU', 'tvm/build-cpu', images.ci_cpu, 'cpu') { sh( script: "${docker_run} ${images.ci_cpu} ./tests/scripts/task_config_build_cpu.sh build", label: 'Create CPU CMake config', ) - ci.cmake_build(images.ci_cpu, 'build') - ci.make_cpp_tests(images.ci_cpu, 'build') - ci.upload('cpu', '--artifacts tvm_lib --artifacts cpptest') + ci.wheel_build(images.ci_cpu, true) - ci.upload('cpu', '--bundle wheels') ++ ci.upload('cpu', '--artifacts wheels') } stage('Test') { diff --cc ci/jenkins/generated/gpu_jenkinsfile.groovy index df4992f7d2,98c04b332e..b5afbe0b42 --- a/ci/jenkins/generated/gpu_jenkinsfile.groovy +++ b/ci/jenkins/generated/gpu_jenkinsfile.groovy @@@ -19,10 -19,10 +19,12 @@@ ci = evaluate(readTrusted('ci/jenkins/generated/common.groovy')) ci.start() ++docker_run += ' --env TVM_TEST_USE_WHEEL=1' ++ ci.build('CPU', 'BUILD: GPU', 'tvm/build-gpu', images.ci_gpu, 'gpu') { sh "${docker_run} --no-gpu ${images.ci_gpu} ./tests/scripts/task_config_build_gpu.sh build" - ci.cmake_build("${images.ci_gpu} --no-gpu", 'build') - ci.upload('gpu', '--artifacts tvm_lib --artifacts tvm_lib_gpu_extra') + ci.wheel_build("--no-gpu ${images.ci_gpu}") - ci.upload('gpu', '--bundle wheels --bundle jvm_runtime') ++ ci.upload('gpu', '--artifacts wheels --artifacts jvm_runtime') // Check the compiler-only GPU configuration after publishing the test build. sh 'rm -rf build' diff --cc ci/jenkins/generated/wasm_jenkinsfile.groovy index 289ac8dba8,ddf932529d..f514772dda --- a/ci/jenkins/generated/wasm_jenkinsfile.groovy +++ b/ci/jenkins/generated/wasm_jenkinsfile.groovy @@@ -19,6 -19,6 +19,8 @@@ ci = evaluate(readTrusted('ci/jenkins/generated/common.groovy')) ci.start() ++docker_run += ' --env TVM_TEST_USE_WHEEL=1' ++ ci.build('CPU', 'BUILD: WASM', 'tvm/build-wasm', images.ci_wasm, 'wasm') { sh( script: "${docker_run} ${images.ci_wasm} ./tests/scripts/task_config_build_wasm.sh build", diff --cc tests/python/ci/test_jenkins_helpers.py index cfc0c69ced,d6653f8198..39428aad66 --- a/tests/python/ci/test_jenkins_helpers.py +++ b/tests/python/ci/test_jenkins_helpers.py @@@ -137,3 -135,178 +135,184 @@@ with pathlib.Path(os.environ["COMMAND_L assert build[0][4].startswith("-j") and int(build[0][4][2:]) >= 1 assert len(build[0]) == 5 assert build[1:] == configure[1:] + + + @pytest.fixture + def artifact_cli(tmp_path): + script_root = tmp_path / "ci/jenkins/scripts" + script_root.mkdir(parents=True) + for name in ("s3.py", "retry.sh"): + shutil.copy(JENKINS_SCRIPT_ROOT / name, script_root / name) + registry = { + "wheels": ["build/dist/*.whl", "build/dist/a.whl", "./build/dist/a.whl"], + "extra": ["build/exact.so", "build/dir", "build/missing.so", "build/no-match/*.so"], + } + (script_root.parent / "artifacts.py").write_text("ARTIFACTS = " + repr(registry)) + for name in ( + "build/dist/b space.whl", + "build/dist/a.whl", + "build/exact.so", + "build/dir/nested", + ): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("artifact") + binary_dir = tmp_path / "bin" + binary_dir.mkdir() + executable( + binary_dir / "aws", + """ + import json, os, pathlib, sys + with pathlib.Path(os.environ["AWS_LOG"]).open("a") as out: + out.write(json.dumps(sys.argv[1:]) + "\\n") + if sys.argv[-1] == ".": + pathlib.Path("downloaded file").write_text("downloaded") + print("download: s3://bucket/prefix/downloaded file to downloaded file") + """, + ) + log = tmp_path / "commands" + env = dict(os.environ, PATH=f"{binary_dir}:{os.environ['PATH']}", AWS_LOG=str(log)) + + def run(*options, action="upload"): + log.unlink(missing_ok=True) + result = subprocess.run( + [ + sys.executable, + script_root / "s3.py", + "--action", + action, + "--bucket", + "bucket", + "--prefix", + "/prefix/", + *options, + ], + cwd=tmp_path, + env=env, + text=True, + capture_output=True, + ) + commands = ( + [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] + ) + return result, commands + + return run + + + def test_artifact_upload_aliases_and_globs(artifact_cli): + expected = [ + ["s3", "cp", "--no-progress", "--recursive", "build/dir", "s3://bucket/prefix/build/dir"], + ["s3", "cp", "--no-progress", "build/dist/a.whl", "s3://bucket/prefix/build/dist/a.whl"], + [ + "s3", + "cp", + "--no-progress", + "build/dist/b space.whl", + "s3://bucket/prefix/build/dist/b space.whl", + ], + ["s3", "cp", "--no-progress", "build/exact.so", "s3://bucket/prefix/build/exact.so"], + ] + for flags in ( + ["--artifacts", "wheels", "extra"], + ["--artifacts", "extra", "--artifacts", "wheels"], + ["--bundle", "wheels", "--bundle", "extra"], + ["--bundle", "extra", "wheels"], + ): + result, commands = artifact_cli(*flags) + assert result.returncode == 0, result.stderr + assert commands == expected + assert "No artifacts matched pattern: build/no-match/*.so" in result.stderr + assert "The path doesn't exist: build/missing.so" in result.stderr + + + def test_artifact_paths_alias_and_unmatched_pattern(artifact_cli): + for flag in ("--paths", "--items"): + result, commands = artifact_cli(flag, "build/dist/*.whl", "./build/dist/a.whl") + assert result.returncode == 0, result.stderr + assert [command[-2] for command in commands] == [ + "build/dist/a.whl", + "build/dist/b space.whl", + ] + result, commands = artifact_cli(flag, "absent/*.whl") + assert result.returncode == 0 + assert commands == [] + assert "No artifacts matched pattern: absent/*.whl" in result.stderr + + + @pytest.mark.parametrize("artifacts_flag", ["--artifacts", "--bundle"]) + @pytest.mark.parametrize("paths_flag", ["--paths", "--items"]) + def test_artifact_flags_are_mutually_exclusive(artifact_cli, artifacts_flag, paths_flag): + result, commands = artifact_cli(artifacts_flag, "wheels", paths_flag, "build/exact.so") + assert result.returncode == 2 + assert "not allowed with argument" in result.stderr + assert commands == [] + + + def test_artifact_download_copies_whole_prefix(artifact_cli, tmp_path): + result, commands = artifact_cli(action="download") + assert result.returncode == 0, result.stderr + assert commands == [["s3", "cp", "--no-progress", "--recursive", "s3://bucket/prefix", "."]] + downloaded = tmp_path / "downloaded file" + assert downloaded.read_text() == "downloaded" + assert downloaded.stat().st_mode & 0o111 + + + @pytest.mark.parametrize("absolute", [False, True]) + def test_legacy_artifact_literal_paths(artifact_cli, tmp_path, absolute): + outside = tmp_path.parent / "outside.so" + outside.write_text("artifact") + path = str(outside) if absolute else "../outside.so" + result, commands = artifact_cli("--items", path) + assert result.returncode == 0, result.stderr + assert commands == [["s3", "cp", "--no-progress", path, "s3://bucket/prefix/" + path]] + + + def test_recursive_artifact_overlap(artifact_cli): + result, commands = artifact_cli("--paths", "build/dir", "build/dir/*", "build/dir/nested") + assert result.returncode == 0, result.stderr + assert commands == [ + ["s3", "cp", "--no-progress", "--recursive", "build/dir", "s3://bucket/prefix/build/dir"] + ] + + + def test_artifact_empty_directory_and_unknown_name(artifact_cli, tmp_path): + (tmp_path / "empty").mkdir() + result, commands = artifact_cli("--paths", "empty") + assert result.returncode != 0 + assert "Cannot upload empty folder" in result.stderr + assert commands == [] + result, commands = artifact_cli("--artifacts", "unknown") + assert result.returncode != 0 + assert "Unknown artifact" in result.stderr + assert commands == [] + + + def test_current_pipeline_artifact_options(artifact_cli, tmp_path): + # c14bc80 passes repeated --bundle names; the new Jenkinsfiles use --artifacts. + registry = load_s3().load_artifacts() + (tmp_path / "ci/jenkins/artifacts.py").write_text("ARTIFACTS = " + repr(registry)) + for entries in registry.values(): + for item in entries: + path = tmp_path / item + path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("artifact") ++ if item == "build/wheels": ++ path.mkdir(exist_ok=True) ++ (path / "apache_tvm.whl").write_text("wheel") ++ else: ++ path.write_text("artifact") + for names in ( + ["tvm_lib", "cpptest"], + ["tvm_lib", "tvm_lib_gpu_extra", "cpptest"], + ["tvm_runtime"], ++ ["wheels"], ++ ["wheels", "jvm_runtime"], + ): + old = [option for name in names for option in ("--bundle", name)] + old_result, old_commands = artifact_cli(*old) + new_result, new_commands = artifact_cli("--artifacts", *names) + assert old_result.returncode == new_result.returncode == 0 + assert old_commands == new_commands + assert [command[-2] for command in new_commands] == sorted( + {item for name in names for item in registry[name]} + ) diff --cc tests/python/ci/test_wheel_artifacts.py index fa707f15d9,0000000000..a8adeef068 mode 100644,000000..100644 --- a/tests/python/ci/test_wheel_artifacts.py +++ b/tests/python/ci/test_wheel_artifacts.py @@@ -1,309 -1,0 +1,309 @@@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Wheel artifact transport and local CI command contracts, without a TVM import.""" + +import ast +import importlib.util +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] + + +def load_local_ci(): + spec = importlib.util.spec_from_file_location("local_ci", ROOT / "tests/scripts/ci.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + [email protected]("platform", ["cpu", "gpu", "arm", "wasm"]) +def test_local_wheel_build(monkeypatch, platform): + ci = load_local_ci() + calls = [] + monkeypatch.setattr(ci, "docker", lambda **kwargs: calls.append(kwargs)) + command, _, _ = ci.generate_command(platform, {}, "wheel build") + command(cpp=True) + scripts = calls[0]["scripts"] + wheel = next(script for script in scripts if "-Cbuild-dir=" in script) + assert "-Ccmake.build-type=RelWithDebInfo" in wheel + assert "all;cpptest" in wheel + assert not any("./3rdparty/tvm-ffi" in script for script in scripts) + assert "--no-build-isolation --offline" in wheel + assert any("task_python_install_wheels.sh" in script for script in scripts) + calls.clear() + command(skip_build=True, tests=["tests/python/example.py"]) + assert not any("uv build" in script for script in calls[0]["scripts"]) + + [email protected]("succeed", [True, False]) +def test_wheel_directory_upload_retries_each_transfer(tmp_path, succeed): + # Exercise the existing S3 CLI/retry boundary with a directory bundle. + for relative in [ + "ci/jenkins/scripts/s3.py", + "ci/jenkins/scripts/retry.sh", - "ci/jenkins/data.py", ++ "ci/jenkins/artifacts.py", + ]: + target = tmp_path / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(ROOT / relative, target) + wheels = tmp_path / "build/wheels" + wheels.mkdir(parents=True) + (wheels / "apache_tvm.whl").write_text("tvm") + binaries = tmp_path / "bin" + binaries.mkdir() + aws = binaries / "aws" + aws.write_text( + f"#!{sys.executable}\n" + "import json, pathlib, sys\n" + "path = pathlib.Path('attempts.json')\n" + "calls = json.loads(path.read_text()) if path.exists() else []\n" + "calls.append(sys.argv[1:])\n" + "path.write_text(json.dumps(calls))\n" + f"sys.exit(0 if {succeed!r} and len(calls) == 3 else 1)\n" + ) + aws.chmod(0o755) + sleep = binaries / "sleep" + sleep.write_text('#!/bin/sh\necho "$1" >> delays.txt\n') + sleep.chmod(0o755) + result = subprocess.run( + [ + sys.executable, + "ci/jenkins/scripts/s3.py", + "--action", + "upload", + "--bucket", + "test", + "--prefix", + "cpu", + "--bundle", + "wheels", + ], + cwd=tmp_path, + env={**os.environ, "PATH": f"{binaries}:{os.environ['PATH']}"}, + capture_output=True, + text=True, + ) + assert (result.returncode == 0) == succeed, result.stdout + result.stderr + attempts = json.loads((tmp_path / "attempts.json").read_text()) + assert len(attempts) == 3 + assert all( + call[-3:] == ["--recursive", "build/wheels", "s3://test/cpu/build/wheels"] + for call in attempts + ) + delays = [int(line) for line in (tmp_path / "delays.txt").read_text().splitlines()] + assert len(delays) == 2 and all(30 <= delay <= 200 for delay in delays) + + [email protected]("fail", [False, True]) +def test_local_interactive_keeps_cache_stats(tmp_path, monkeypatch, fail): + ci = load_local_ci() + monkeypatch.setattr(ci, "check_docker", lambda: None) + monkeypatch.setattr(ci, "cmd", lambda *args, **kwargs: None) + monkeypatch.setattr(ci, "SCRIPT_DIR", tmp_path / "scripts") + monkeypatch.setattr(ci, "REPO_ROOT", tmp_path) + monkeypatch.delenv("USE_SCCACHE", raising=False) + monkeypatch.setenv("DEBUG", "1") + binaries = tmp_path / "bin" + binaries.mkdir() + for name in ["sccache", "bash"]: + binary = binaries / name + binary.write_text(f'#!/bin/sh\necho "{name} $*" >> "$CALL_LOG"\n') + binary.chmod(0o755) + ci.docker("interactive", "ci_cpu", ["false" if fail else "true"], {}, True) + result = subprocess.run( + [shutil.which("bash"), str(ci.SCRIPT_DIR / "interactive.sh")], + env={ + **os.environ, + "PATH": f"{binaries}:{os.environ['PATH']}", + "CALL_LOG": str(tmp_path / "calls"), + }, + capture_output=True, + text=True, + ) + assert (result.returncode != 0) == fail + calls = (tmp_path / "calls").read_text().splitlines() + assert calls == [ + "sccache --start-server", + "sccache --show-stats", + "sccache --show-stats", + "bash ", + ] + + [email protected]("wheel_docs", [False, True]) +def test_docs_import_path(tmp_path, monkeypatch, wheel_docs): + # Run the real Sphinx path bootstrap without requiring Sphinx extensions. + # A stale --target=python FFI package must not precede Jenkins' wheel install. + monkeypatch.chdir(ROOT / "docs") + source_python = str(ROOT / "python") + monkeypatch.setattr(sys, "path", [path for path in sys.path if path != source_python]) + monkeypatch.setenv("TVM_DOCS_USE_WHEEL", "1" if wheel_docs else "0") + config = ROOT / "docs/conf.py" + bootstrap = config.read_text().split("# -- General configuration")[0] + exec(compile(bootstrap, str(config), "exec"), {"__file__": str(config)}) + assert (source_python in sys.path) != wheel_docs + + [email protected]( + "host_limit,caller_limit,expected", [(None, None, "2"), ("7", None, "7"), ("7", "9", "9")] +) +def test_local_parallel_limit_crosses_docker_boundary( + tmp_path, monkeypatch, host_limit, caller_limit, expected +): + ci = load_local_ci() + calls = [] + monkeypatch.setattr(ci, "check_docker", lambda: None) + monkeypatch.setattr(ci, "cmd", lambda command: calls.append(command)) + monkeypatch.setattr(ci, "SCRIPT_DIR", tmp_path / "scripts") + monkeypatch.setattr(ci, "REPO_ROOT", tmp_path) + monkeypatch.setattr(ci, "NPROC", 8) + monkeypatch.setenv("CI_NUM_EXECUTORS", "3") + if host_limit is None: + monkeypatch.delenv("CMAKE_BUILD_PARALLEL_LEVEL", raising=False) + else: + monkeypatch.setenv("CMAKE_BUILD_PARALLEL_LEVEL", host_limit) + env = {} if caller_limit is None else {"CMAKE_BUILD_PARALLEL_LEVEL": caller_limit} + ci.docker("build", "ci_cpu", ["true"], env, False) + args = calls[0] + index = args.index(f"CMAKE_BUILD_PARALLEL_LEVEL={expected}") + assert args[index - 1] == "--env" + + [email protected]( + "script", ["task_python_unittest.sh", "task_python_docs.sh", "task_web_wasm.sh"] +) [email protected]("wheel_mode", [False, True]) +def test_shared_scripts_preserve_legacy_and_wheel_modes(tmp_path, script, wheel_mode): + scripts = tmp_path / "tests/scripts" + scripts.mkdir(parents=True) + shutil.copy(ROOT / "tests/scripts" / script, scripts / script) + # Substitute only the install boundary; execute the real caller's mode routing. + (scripts / "task_python_install_wheels.sh").write_text('echo WHEEL_SETUP >> "$CALL_LOG"\n') + for directory in ["docs", "web", "bin"]: + (tmp_path / directory).mkdir() + for tool in ["uv", "python3", "make"]: + binary = tmp_path / "bin" / tool + binary.write_text( + f'#!/bin/sh\necho "{tool}:$*" >> "$CALL_LOG"\n' + + ("exit 77\n" if tool == "make" else "") + ) + binary.chmod(0o755) + result = subprocess.run( + [shutil.which("bash"), str(scripts / script)], + cwd=tmp_path, + env={ + **os.environ, + "PATH": f"{tmp_path / 'bin'}:{os.environ['PATH']}", + "CALL_LOG": str(tmp_path / "calls"), + "TVM_TEST_USE_WHEEL": "1" if wheel_mode else "0", + "IS_LOCAL": "0", + }, + capture_output=True, + text=True, + ) + assert result.returncode == (0 if script == "task_python_unittest.sh" else 77), result.stderr + calls = (tmp_path / "calls").read_text().splitlines() + assert ("WHEEL_SETUP" in calls) == wheel_mode + dependency_calls = [call for call in calls if call.startswith("uv:")] + if wheel_mode: + assert not dependency_calls + else: + assert "uv:pip install -v --target=python ./3rdparty/tvm-ffi/" in dependency_calls + + +def test_wheel_mode_does_not_fall_back_when_install_fails(tmp_path): + scripts = tmp_path / "tests/scripts" + scripts.mkdir(parents=True) + shutil.copy(ROOT / "tests/scripts/task_python_unittest.sh", scripts) + (scripts / "task_python_install_wheels.sh").write_text("return 37\n") + result = subprocess.run( + [shutil.which("bash"), str(scripts / "task_python_unittest.sh")], + cwd=tmp_path, + env={**os.environ, "TVM_TEST_USE_WHEEL": "1"}, + capture_output=True, + text=True, + ) + assert result.returncode == 37 + assert "uv pip install" not in result.stderr + + [email protected]("bootstrap_fails", [False, True]) +def test_build_bootstrap_precedes_offline_build(tmp_path, monkeypatch, bootstrap_fails): + import shlex + + ci = load_local_ci() + calls = [] + monkeypatch.setattr(ci, "docker", lambda **kwargs: calls.append(kwargs)) + command, _, _ = ci.generate_command("cpu", {}, "wheel build") + command() + commands = [ + script + for script in calls[0]["scripts"] + if "uv pip install" in script or "uv build" in script + ] + bootstrap, build = commands + arguments = shlex.split(bootstrap) + # The declared build requirements are a flat string list; keep this test usable on Python 3.10. + requires = re.search(r"(?ms)^requires = (\[.*?\])", (ROOT / "pyproject.toml").read_text()) + expected = ast.literal_eval(requires[1]) + assert arguments[:5] == ["uv", "pip", "install", "--python", "python3"] + assert arguments[5] == "--target" + assert arguments[7:] == expected + assert "--no-build-isolation --offline --python python3" in build + assert build.startswith(f'PYTHONPATH="$PWD/{arguments[6]}${{PYTHONPATH:+:$PYTHONPATH}}" ') + binary = tmp_path / "uv" + binary.write_text( + f"#!{sys.executable}\n" + "import json, os, pathlib, sys\n" + "with pathlib.Path('calls').open('a') as out: out.write(json.dumps(sys.argv[1:]) + '\\n')\n" + "with pathlib.Path('paths').open('a') as out: out.write(os.environ['PYTHONPATH'] + '\\n')\n" + f"sys.exit(17 if {bootstrap_fails!r} and sys.argv[1] == 'pip' else 0)\n" + ) + binary.chmod(0o755) + result = subprocess.run( + [ + shutil.which("bash"), + "-ec", + "\n".join([*commands, 'printf "%s" "$PYTHONPATH" > after-path']), + ], + cwd=tmp_path, + env={ + **os.environ, + "PATH": f"{tmp_path}:{os.environ['PATH']}", + "PYTHONPATH": "image-marker", + }, + capture_output=True, + text=True, + ) + assert result.returncode == (17 if bootstrap_fails else 0) + recorded = [json.loads(line) for line in (tmp_path / "calls").read_text().splitlines()] + assert [call[0] for call in recorded] == (["pip"] if bootstrap_fails else ["pip", "build"]) + paths = (tmp_path / "paths").read_text().splitlines() + assert paths[0] == "image-marker" + if not bootstrap_fails: + assert paths[1] == f"{tmp_path}/{arguments[6]}:image-marker" + assert (tmp_path / "after-path").read_text() == "image-marker" + else: + assert not (tmp_path / "after-path").exists()
