diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 000000000000..b1be6ba1e68c --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,142 @@ +name: Deploy docs and tutorials website + +on: + release: + types: [ published ] + workflow_dispatch: + inputs: + target_repo: + description: >- + Repo to deploy to. Override with a personal fork (e.g. + your-username/halide.github.com) to rehearse a real push + without touching the live site. + default: halide/halide.github.com + required: false + dry_run: + description: >- + Build and diff only -- never push anywhere, even to target_repo + above. Uploads the generated docs/ and tutorials/ as a workflow + artifact instead, for manual inspection. This is the safe default + for testing changes to this workflow or the generator itself. + type: boolean + default: true + +permissions: + contents: read + +env: + TARGET_REPO: ${{ github.event.inputs.target_repo || 'halide/halide.github.com' }} + # Only ever a real push for `release` events -- any workflow_dispatch run + # defaults to a dry run unless explicitly opted out. + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run || 'false' }} + +jobs: + deploy: + name: Build and deploy to halide.github.com + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v7 + + - uses: astral-sh/setup-uv@v7 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libpng-dev libjpeg-dev doxygen gdb + + - name: Sync CI environment + run: | + uv sync --group ci-llvm-22 --group tutorial-website --no-install-project + echo "${GITHUB_WORKSPACE}/.venv/bin" >> "$GITHUB_PATH" + echo "VIRTUAL_ENV=${GITHUB_WORKSPACE}/.venv" >> "$GITHUB_ENV" + + - name: Configure LLVM + run: echo "Halide_LLVM_ROOT=$(halide-llvm --prefix)" >> "$GITHUB_ENV" + + - uses: actions/create-github-app-token@v3 + id: app-token + with: + app-id: ${{ secrets.LLVM_UPDATER_ID }} + private-key: ${{ secrets.LLVM_UPDATER_PRIVATE_KEY }} + + - name: Get GitHub App user ID + id: get-user-id + run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + + - name: Checkout ${{ env.TARGET_REPO }} + uses: actions/checkout@v7 + with: + repository: ${{ env.TARGET_REPO }} + ref: master + token: ${{ steps.app-token.outputs.token }} + path: halide.github.com + + - name: Configure CMake + run: >- + cmake --preset ci-linux-x86-64 + -DWITH_DOCS=ON + -DWITH_TUTORIAL_WEBSITE=ON + -DHalide_TUTORIAL_WEBSITE_DEBUGGER=GDB + -DHalide_INSTALL_DOXYGENDIR=docs + -DHalide_INSTALL_TUTORIALDIR=tutorials + -DWITH_TESTS=NO + -DWITH_PYTHON_BINDINGS=NO + -DWITH_UTILS=NO + + - name: Build docs and tutorials website + run: cmake --build build --target doc --target tutorial_website + + - name: Replace docs/ and tutorials/ + id: replace + run: | + git config --global user.name "${{ steps.app-token.outputs.app-slug }}[bot]" + git config --global user.email "${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com" + + # Halide_INSTALL_DOXYGENDIR/Halide_INSTALL_TUTORIALDIR above point + # the Halide_Doxygen/Halide_Tutorial components directly at + # halide.github.com's own docs/ and tutorials/ layout, so no + # staging directory or path-guessing is needed -- clear stale + # content first (so removed/renamed lessons don't linger), then + # install straight into the checkout. + rm -rf halide.github.com/docs halide.github.com/tutorials + cmake --install build --prefix "$GITHUB_WORKSPACE/halide.github.com" --component Halide_Doxygen + cmake --install build --prefix "$GITHUB_WORKSPACE/halide.github.com" --component Halide_Tutorial + + cd halide.github.com + git add -A + + if git diff --cached --quiet; then + echo "No changes to deploy." + echo "has-changes=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "has-changes=true" >> "$GITHUB_OUTPUT" + git diff --cached --stat + + - name: Upload docs/tutorials as an artifact (dry run) + if: steps.replace.outputs.has-changes == 'true' && env.DRY_RUN == 'true' + uses: actions/upload-artifact@v7 + with: + name: docs-tutorials-site + path: | + halide.github.com/docs + halide.github.com/tutorials + retention-days: 7 + + - name: Commit and push + if: steps.replace.outputs.has-changes == 'true' && env.DRY_RUN != 'true' + working-directory: halide.github.com + run: | + if [ -n "${{ github.event.release.tag_name }}" ]; then + MESSAGE="Deploy docs and tutorials for Halide ${{ github.event.release.tag_name }}" + else + MESSAGE="Deploy docs and tutorials (manual run from halide/Halide@${{ github.sha }})" + fi + + git commit -m "$MESSAGE" + # actions/checkout leaves a detached HEAD, so push explicitly by + # refspec rather than relying on a tracked branch. + git push origin HEAD:master diff --git a/CMakeLists.txt b/CMakeLists.txt index 94ea3557a751..10a2a0500078 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -237,6 +237,12 @@ Halide_feature( ) Halide_feature(WITH_TESTS "Halide's unit test suite" TOP_LEVEL) Halide_feature(WITH_TUTORIALS "Halide's tutorial code" TOP_LEVEL) +Halide_feature( + WITH_TUTORIAL_WEBSITE + "Generate the Halide tutorials website (requires gdb or lldb, and Python's pygments)" + OFF + DEPENDS WITH_TUTORIALS +) Halide_feature( WITH_UTILS "Optional utility programs for Halide, including HalideTraceViz" TOP_LEVEL ) diff --git a/packaging/CMakeLists.txt b/packaging/CMakeLists.txt index 3049fa4116f1..f1a962e39e0d 100644 --- a/packaging/CMakeLists.txt +++ b/packaging/CMakeLists.txt @@ -23,6 +23,14 @@ set(Halide_INSTALL_TOOLSDIR "${CMAKE_INSTALL_DATADIR}/tools" CACHE STRING "Path to Halide build-time tools and sources" ) +set(Halide_INSTALL_TUTORIALDIR "${CMAKE_INSTALL_DATADIR}/tutorial" + CACHE STRING "Path to Halide's generated tutorial website" +) + +set(Halide_INSTALL_DOXYGENDIR "${CMAKE_INSTALL_DOCDIR}/html" + CACHE STRING "Path to Halide's generated Doxygen documentation" +) + ## # RPATH patching helper ## @@ -187,6 +195,14 @@ if (WITH_TUTORIALS) ) endif () +if (WITH_TUTORIAL_WEBSITE) + install( + DIRECTORY ${Halide_BINARY_DIR}/tutorial/website/site/ + DESTINATION ${Halide_INSTALL_TUTORIALDIR} + COMPONENT Halide_Tutorial + ) +endif () + ## # CMake scripts ## @@ -335,7 +351,11 @@ _Halide_install_pkgdeps( ## if (WITH_DOCS) - install(DIRECTORY ${Halide_BINARY_DIR}/doc/html TYPE DOC COMPONENT Halide_Documentation) + install( + DIRECTORY ${Halide_BINARY_DIR}/doc/html/ + DESTINATION ${Halide_INSTALL_DOXYGENDIR} + COMPONENT Halide_Doxygen + ) endif () ## @@ -405,3 +425,15 @@ cpack_add_component( DISPLAY_NAME "Halide documentation" DESCRIPTION "Documentation for Halide" ) + +cpack_add_component( + Halide_Doxygen + DISPLAY_NAME "Halide Doxygen documentation" + DESCRIPTION "Generated Doxygen API documentation (WITH_DOCS)" +) + +cpack_add_component( + Halide_Tutorial + DISPLAY_NAME "Halide tutorial website" + DESCRIPTION "Generated HTML tutorial website (WITH_TUTORIAL_WEBSITE)" +) diff --git a/pyproject.toml b/pyproject.toml index 987e651a4829..c660bde97e3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,12 @@ tools = [ "ruff>=0.12", "tbump>=6.11", ] +tutorial-website = [ + # for tutorial/website -- generates the tutorials website via gdb/lldb + "jinja2>=3.1", + "markupsafe>=3.0", + "pygments>=2.17", +] ci-base = [ { include-group = "dev" }, diff --git a/tutorial/CMakeLists.txt b/tutorial/CMakeLists.txt index 779c43d50882..673c3675ebec 100644 --- a/tutorial/CMakeLists.txt +++ b/tutorial/CMakeLists.txt @@ -32,6 +32,27 @@ function(add_tutorial source_file) if (args_WITH_IMAGE_IO) target_link_libraries(${name} PRIVATE Halide::ImageIO) endif () + + _Halide_register_tutorial_website_target("${source_file}" "${name}") +endfunction() + +# Registers a lesson source file's corresponding executable target so that +# tutorial/website can locate its built binary (by way of a generate-time +# manifest, since executable output paths aren't known until then), and +# builds it unoptimized with debug info, regardless of build type, so that +# single-line stepping/breakpoints are reliable (matches +# test/debugger/CMakeLists.txt's debugger_fixture, which needs the same thing +# for the same reason: at -O2/-O3, reordered/merged line-table entries make +# `next`/one-shot breakpoints land on the wrong statement). +function(_Halide_register_tutorial_website_target source_file target) + if (NOT WITH_TUTORIAL_WEBSITE) + return() + endif () + target_compile_options( + ${target} + PRIVATE $<$:-g;-O0;-fno-omit-frame-pointer> + ) + set_property(GLOBAL APPEND PROPERTY HALIDE_TUTORIAL_SOURCE_TARGETS "${source_file}=${target}") endfunction() # Declare tutorials @@ -107,6 +128,10 @@ if ("NVPTX" IN_LIST Halide_LLVM_COMPONENTS) PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" ) + _Halide_register_tutorial_website_target( + lesson_10_aot_compilation_run.cpp lesson_10_aot_compilation_run + ) + add_test(NAME tutorial_lesson_10_aot_compilation_run COMMAND lesson_10_aot_compilation_run) set_tests_properties( tutorial_lesson_10_aot_compilation_run @@ -125,6 +150,7 @@ add_tutorial(lesson_14_types.cpp) # Lesson 15 add_executable(lesson_15_generate lesson_15_generators.cpp) target_link_libraries(lesson_15_generate PRIVATE Halide::Generator) +_Halide_register_tutorial_website_target(lesson_15_generators.cpp lesson_15_generate) ## Hack to build the libraries @@ -217,6 +243,7 @@ if (Halide_TARGET MATCHES "wasm") else () add_executable(lesson_16_rgb_generate lesson_16_rgb_generate.cpp) target_link_libraries(lesson_16_rgb_generate PRIVATE Halide::Generator) + _Halide_register_tutorial_website_target(lesson_16_rgb_generate.cpp lesson_16_rgb_generate) add_halide_library( brighten_planar @@ -254,6 +281,7 @@ else () brighten_planar brighten_interleaved brighten_either brighten_specialized Halide::ImageIO Halide::Tools ) + _Halide_register_tutorial_website_target(lesson_16_rgb_run.cpp lesson_16_rgb_run) add_test(NAME tutorial_lesson_16_rgb_run COMMAND lesson_16_rgb_run) set_tests_properties(tutorial_lesson_16_rgb_run PROPERTIES LABELS tutorial) @@ -269,6 +297,9 @@ add_tutorial(lesson_20_cloning_funcs.cpp) if (TARGET Halide::Mullapudi2016) add_executable(lesson_21_auto_scheduler_generate lesson_21_auto_scheduler_generate.cpp) target_link_libraries(lesson_21_auto_scheduler_generate PRIVATE Halide::Generator) + _Halide_register_tutorial_website_target( + lesson_21_auto_scheduler_generate.cpp lesson_21_auto_scheduler_generate + ) add_halide_library( auto_schedule_false @@ -293,6 +324,9 @@ if (TARGET Halide::Mullapudi2016) lesson_21_auto_scheduler_run PRIVATE auto_schedule_false auto_schedule_true Halide::Tools ) + _Halide_register_tutorial_website_target( + lesson_21_auto_scheduler_run.cpp lesson_21_auto_scheduler_run + ) add_test(NAME tutorial_lesson_21_auto_scheduler_run COMMAND lesson_21_auto_scheduler_run) set_tests_properties( @@ -307,3 +341,7 @@ endif () add_tutorial(lesson_22_jit_performance.cpp) add_tutorial(lesson_23_serialization.cpp WITH_IMAGE_IO) add_tutorial(lesson_24_async.cpp GROUPS multithreaded) + +if (WITH_TUTORIAL_WEBSITE) + add_subdirectory(website) +endif () diff --git a/tutorial/website/CMakeLists.txt b/tutorial/website/CMakeLists.txt new file mode 100644 index 000000000000..c47bd769e32d --- /dev/null +++ b/tutorial/website/CMakeLists.txt @@ -0,0 +1,210 @@ +find_package(Python3 COMPONENTS Interpreter REQUIRED) + +find_program(GDB_EXECUTABLE gdb) +find_program(LLDB_EXECUTABLE lldb) +if (NOT GDB_EXECUTABLE AND NOT LLDB_EXECUTABLE) + message(FATAL_ERROR "WITH_TUTORIAL_WEBSITE requires gdb or lldb to be installed") +endif () + +set(Halide_TUTORIAL_WEBSITE_DEBUGGER "AUTO" + CACHE STRING + "Which debugger tutorial/website should use to capture output snippets: AUTO, GDB, or LLDB" +) +set_property(CACHE Halide_TUTORIAL_WEBSITE_DEBUGGER PROPERTY STRINGS AUTO GDB LLDB) + +execute_process( + COMMAND "${Python3_EXECUTABLE}" -c "import jinja2, markupsafe, pygments" + RESULT_VARIABLE _tutorial_website_python_deps_missing + OUTPUT_QUIET ERROR_QUIET +) +if (_tutorial_website_python_deps_missing) + message( + FATAL_ERROR "WITH_TUTORIAL_WEBSITE requires the Python 'jinja2', 'markupsafe', and 'pygments' packages for the " + "interpreter at ${Python3_EXECUTABLE}. Run: uv sync --group tutorial-website" + ) +endif () + +## +# Build a manifest mapping each lesson source file to its built executable, +# and (since it's the same GLOBAL property) the list of lesson source files +# that have one. +# +# Executable output paths aren't known until generate time (they depend on +# the generator and config), so this uses file(GENERATE) with +# $ rather than trying to compute paths ourselves; see +# tutorial/CMakeLists.txt's _Halide_register_tutorial_website_target for +# where HALIDE_TUTORIAL_SOURCE_TARGETS is populated. +## + +get_property(_tutorial_source_targets GLOBAL PROPERTY HALIDE_TUTORIAL_SOURCE_TARGETS) + +set(_manifest_entries "") +set(_tutorial_targets "") +# lesson_15_generators_usage.sh is the only lesson with no executable of its +# own (it's a shell script demonstrating command-line usage, not compiled +# code), so it's the one lesson source HALIDE_TUTORIAL_SOURCE_TARGETS above +# doesn't cover. +set(_lesson_source_files "${Halide_SOURCE_DIR}/tutorial/lesson_15_generators_usage.sh") +foreach (_pair IN LISTS _tutorial_source_targets) + string(REPLACE "=" ";" _pair "${_pair}") + list(GET _pair 0 _source) + list(GET _pair 1 _target) + list(APPEND _tutorial_targets "${_target}") + list(APPEND _manifest_entries " \"${_source}\": \"$\"") + list(APPEND _lesson_source_files "${Halide_SOURCE_DIR}/tutorial/${_source}") +endforeach () +list(JOIN _manifest_entries ",\n" _manifest_body) + +set(_manifest_file "${CMAKE_CURRENT_BINARY_DIR}/manifest.json") +file(GENERATE OUTPUT "${_manifest_file}" CONTENT "{\n${_manifest_body}\n}\n") + +set(_debugger_args "") +if (GDB_EXECUTABLE) + list(APPEND _debugger_args --gdb "${GDB_EXECUTABLE}") +endif () +if (LLDB_EXECUTABLE) + list(APPEND _debugger_args --lldb "${LLDB_EXECUTABLE}") +endif () + +string(TOLOWER "${Halide_TUTORIAL_WEBSITE_DEBUGGER}" _debugger_preference) + +set(_output_dir "${CMAKE_CURRENT_BINARY_DIR}/site") +set(_generator_script "${CMAKE_CURRENT_SOURCE_DIR}/generate_tutorial_website.py") + +# Every rendering process (sitemap/assets/each lesson) imports the same +# package, so a change to any of its source files must invalidate every +# output that could depend on it, not just whichever lesson it looks +# specific to. This repo's CMake style checks disallow file(GLOB ...) (it +# interacts poorly with incremental builds), so -- like the lesson list +# above -- this is listed explicitly; unlike the lesson list, there's no +# existing registration mechanism to derive it from, since these aren't +# CMake targets. +set(_tutorial_website_pkg_files + "${_generator_script}" + "${CMAKE_CURRENT_SOURCE_DIR}/tutorial_website/__init__.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tutorial_website/capture.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tutorial_website/cli.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tutorial_website/highlight.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tutorial_website/lesson.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tutorial_website/manifest.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tutorial_website/render.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tutorial_website/sitemap.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tutorial_website/assets/style.css" + "${CMAKE_CURRENT_SOURCE_DIR}/tutorial_website/templates/base.html.j2" + "${CMAKE_CURRENT_SOURCE_DIR}/tutorial_website/templates/blocks.html.j2" + "${CMAKE_CURRENT_SOURCE_DIR}/tutorial_website/templates/code_lesson.html.j2" + "${CMAKE_CURRENT_SOURCE_DIR}/tutorial_website/templates/index.html.j2" +) + +## +# One custom command per lesson (fanned out below) lets ninja build lesson +# pages in parallel and skip ones whose own inputs haven't changed, instead +# of the previous single always-rerun target that recomputed the whole site +# (all lessons' captured output, following gdb/lldb around each binary) on +# every build regardless of what changed. +## + +## +# sitemap.json: cheap {number, slug, title} metadata for every lesson, shared +# by every page's navigation sidebar. Kept separate from each lesson's own +# render so that rendering lesson N doesn't require rescanning every *other* +# lesson's source file for its title -- but every lesson page still needs to +# be rebuilt if a title changes (or a lesson is added or removed), hence +# ${_lesson_source_files} below. +## + +set(_sitemap_file "${CMAKE_CURRENT_BINARY_DIR}/sitemap.json") +add_custom_command( + OUTPUT "${_sitemap_file}" + COMMAND + "${Python3_EXECUTABLE}" "${_generator_script}" sitemap + --tutorial-dir "${Halide_SOURCE_DIR}/tutorial" + --output "${_sitemap_file}" + DEPENDS ${_lesson_source_files} ${_tutorial_website_pkg_files} + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + COMMENT "Building tutorial website sitemap" + VERBATIM +) + +## +# Shared assets (style.css, figures/) plus index.html. Also its own rule, +# separate from any lesson: unlike lesson pages, these aren't safe to +# produce from several concurrent per-lesson render processes, since +# concurrent writes/copies into the same shared output paths would race. +# +# tutorial/figures isn't tracked as a DEPENDS here (also to avoid +# file(GLOB ...)): the previous always-rerun target never tracked it either, +# so this isn't a regression, and figures change rarely enough that a full +# reconfigure when one does is an acceptable way to pick it up. +## + +add_custom_command( + OUTPUT + "${_output_dir}/index.html" + "${_output_dir}/style.css" + COMMAND + "${Python3_EXECUTABLE}" "${_generator_script}" assets + --tutorial-dir "${Halide_SOURCE_DIR}/tutorial" + --sitemap "${_sitemap_file}" + --output-dir "${_output_dir}" + DEPENDS "${_sitemap_file}" ${_tutorial_website_pkg_files} + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + COMMENT "Generating Halide tutorials website assets" + VERBATIM +) + +set(_lesson_htmls "") +foreach (_source_file IN LISTS _lesson_source_files) + cmake_path(GET _source_file STEM _slug) + cmake_path(GET _source_file FILENAME _source_name) + + set(_lesson_depends + "${_source_file}" "${_sitemap_file}" "${_manifest_file}" ${_tutorial_website_pkg_files} + ) + + # Not every lesson has a compiled binary (e.g. lesson_15_generators_usage.sh + # is illustrative shell only, with no registered target) -- only add a + # target dependency where _Halide_register_tutorial_website_target + # actually registered one for this source file. + # + # Note: because these targets live in tutorial/CMakeLists.txt -- a + # different directory than this one -- CMake's Ninja generator adds an + # order-only dependency on *every* target in that directory (all ~27 + # lesson binaries), not just this one, to preserve legacy cross-directory + # ordering guarantees. That's unavoidable short of moving this logic into + # tutorial/CMakeLists.txt itself, and isn't a regression: the previous + # single monolithic target depended on every lesson binary directly + # anyway. What this loop still buys is real: once all binaries exist, + # each lesson's (often multi-second) capture-and-render step becomes its + # own ninja job instead of one long serial pass over every lesson. + foreach (_pair IN LISTS _tutorial_source_targets) + string(REPLACE "=" ";" _pair "${_pair}") + list(GET _pair 0 _pair_source) + if (_pair_source STREQUAL _source_name) + list(GET _pair 1 _pair_target) + list(APPEND _lesson_depends "${_pair_target}") + endif () + endforeach () + + set(_lesson_html "${_output_dir}/${_slug}.html") + add_custom_command( + OUTPUT "${_lesson_html}" + COMMAND + "${Python3_EXECUTABLE}" "${_generator_script}" lesson + --slug "${_slug}" + --manifest "${_manifest_file}" + --tutorial-dir "${Halide_SOURCE_DIR}/tutorial" + --sitemap "${_sitemap_file}" + --run-cwd "${CMAKE_CURRENT_BINARY_DIR}/.." + --output-dir "${_output_dir}" + --prefer "${_debugger_preference}" + ${_debugger_args} + DEPENDS ${_lesson_depends} + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + COMMENT "Generating tutorial page: ${_slug}.html" + VERBATIM + ) + list(APPEND _lesson_htmls "${_lesson_html}") +endforeach () + +add_custom_target(tutorial_website ALL DEPENDS "${_output_dir}/index.html" ${_lesson_htmls}) diff --git a/tutorial/website/generate_tutorial_website.py b/tutorial/website/generate_tutorial_website.py new file mode 100755 index 000000000000..e62612646bb8 --- /dev/null +++ b/tutorial/website/generate_tutorial_website.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Entry point for generating the Halide tutorials website. + +See tutorial_website/cli.py for the actual implementation; this thin wrapper +just makes sure the package is importable when run directly (e.g. from +tutorial/website/CMakeLists.txt) without needing to install it. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from tutorial_website.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tutorial/website/tutorial_website/__init__.py b/tutorial/website/tutorial_website/__init__.py new file mode 100644 index 000000000000..934f676838d4 --- /dev/null +++ b/tutorial/website/tutorial_website/__init__.py @@ -0,0 +1,8 @@ +"""Generates the Halide tutorials website from tutorial/lesson_*.cpp. + +Captures the output of interesting statements (Func::realize(), Pipeline:: +print_loop_nest(), etc.) by driving either GDB or LLDB in batch mode against +the already-built lesson binaries, then renders each lesson as a syntax- +highlighted HTML page with the captured output inlined as collapsible +
blocks. +""" diff --git a/tutorial/website/tutorial_website/assets/style.css b/tutorial/website/tutorial_website/assets/style.css new file mode 100644 index 000000000000..633613c33fd7 --- /dev/null +++ b/tutorial/website/tutorial_website/assets/style.css @@ -0,0 +1,156 @@ +/* Minimal, dependency-free styling for the Halide tutorials website. + Built around Pygments' "monokai" style (see highlight.py) so the page + chrome, code blocks, output blocks, and figures all read as one theme + instead of a page-editor-generated pile of mismatched panels. */ + +:root { + --bg: #272822; /* matches monokai's own background exactly */ + --bg-panel: #2d2e28; + --fg: #f8f8f2; + --muted: #9d9d8f; + --border: #49483e; /* monokai's highlight/selection color, doubling as a border */ + --accent: #a6e22e; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + display: flex; + min-height: 100vh; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + line-height: 1.5; + background: var(--bg); + color: var(--fg); +} + +.sidebar { + flex: 0 0 260px; + padding: 1rem; + border-right: 1px solid var(--border); + overflow-y: auto; + background: var(--bg-panel); +} + +.sidebar ul { + list-style: none; + margin: 0; + padding: 0; +} + +.sidebar li a { + display: block; + padding: 0.35rem 0.6rem; + border-radius: 4px; + text-decoration: none; + color: var(--muted); +} + +.sidebar li a:hover { + background: var(--border); + color: var(--fg); +} + +.sidebar li.current a { + background: var(--border); + color: var(--accent); + font-weight: 600; +} + +.sidebar .group-title { + display: block; + padding: 0.35rem 0.6rem; + color: var(--muted); +} + +.sidebar .lesson-group ul { + margin-left: 0.75rem; + border-left: 1px solid var(--border); +} + +main { + flex: 1; + min-width: 0; + padding: 1rem 2rem 3rem; + max-width: 60rem; +} + +/* Code blocks, output blocks, and figures all share the same framing + (border, radius, spacing) so they read as one continuous flow. */ +.highlight, +details.output, +.figure { + border: 1px solid var(--border); + border-radius: 6px; + margin: 0.75rem 0; +} + +.highlight pre { + margin: 0; + overflow-x: auto; + padding: 0.75rem 1rem; +} + +details.output { + background: var(--bg-panel); +} + +details.output summary { + cursor: pointer; + color: var(--muted); + padding: 0.5rem 1rem; +} + +details.output summary:hover { + color: var(--fg); +} + +details.output pre { + margin: 0; + overflow-x: auto; + padding: 0.75rem 1rem; + border-top: 1px solid var(--border); + color: var(--fg); +} + +.figure { + background: var(--bg-panel); + padding: 1rem; + display: flex; + justify-content: center; +} + +.figure img, +.figure video { + max-width: 70%; + display: block; +} + +.scroll-top { + position: fixed; + right: 1.5rem; + bottom: 1.5rem; + width: 2.5rem; + height: 2.5rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--bg-panel); + border: 1px solid var(--border); + color: var(--fg); + text-decoration: none; + font-size: 1.25rem; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); +} + +.scroll-top:hover { + background: var(--border); + color: var(--accent); +} diff --git a/tutorial/website/tutorial_website/capture.py b/tutorial/website/tutorial_website/capture.py new file mode 100644 index 000000000000..d4eb96367c60 --- /dev/null +++ b/tutorial/website/tutorial_website/capture.py @@ -0,0 +1,240 @@ +"""Captures the stdout/stderr a lesson binary produces at specific source +lines, by driving GDB or LLDB in batch mode. + +The technique (validated against both debuggers): + +1. Set a one-shot breakpoint at every interesting line up front (both + debuggers resolve breakpoints against not-yet-running local executables + fine, so there's no need to separately "stop at main" first). +2. Launch the process with its OWN stdout/stderr redirected straight to two + temp files -- not the debugger's stdout, which is discarded. This keeps + the debugger's own command echo/breakpoint-hit messages completely + separate from the program's real output. +3. At each stop: flush any output the target has already buffered, write an + unbuffered BEGIN marker (raw write(2) to fds 1 and 2, not fprintf(stdout, + ...)/fprintf(stderr, ...) -- LLDB's expression evaluator doesn't resolve + the `stdout`/`stderr` macros without a #include, whereas write() needs no + symbol beyond the fd number), step over exactly that one line, flush + again so its output lands before the END marker, write the END marker, + then continue to the next breakpoint. +4. Slice the two capture files between each line's BEGIN/END marker text. + +This mirrors the interleaving trick the original gdb-only shell script used +(inferior-side fprintf calls bracketing each captured line), translated to +work identically under LLDB. +""" + +from __future__ import annotations + +import platform +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path + +_BEGIN = "BEGIN_SNIPPET_{}_" +_END = "END_SNIPPET_{}_" + +_TIMEOUT_SECS = 300 + + +@dataclass(frozen=True) +class DebuggerBackend: + name: str # "gdb" or "lldb" + executable: Path + + +def find_backends(gdb: Path | None, lldb: Path | None) -> dict[str, Path]: + found = {} + if gdb: + found["gdb"] = gdb + if lldb: + found["lldb"] = lldb + return found + + +def pick_backend(available: dict[str, Path], preference: str) -> DebuggerBackend: + preference = (preference or "auto").lower() + if preference in ("gdb", "lldb"): + if preference not in available: + raise SystemExit( + f"--prefer {preference} requested, but {preference} was not found" + ) + return DebuggerBackend(preference, available[preference]) + + # auto: prefer the platform-native debugger, since Homebrew gdb on macOS + # generally isn't codesigned with debugging entitlements. + order = ["lldb", "gdb"] if platform.system() == "Darwin" else ["gdb", "lldb"] + for name in order: + if name in available: + return DebuggerBackend(name, available[name]) + raise SystemExit("no gdb or lldb executable available") + + +def _marker_writes(is_gdb: bool, marker: str) -> list[str]: + text = marker + r"\n" + n = len(marker) + 1 + call = "call" if is_gdb else "expression --" + return [ + f'{call} (long)write(1, "{text}", {n})', + f'{call} (long)write(2, "{text}", {n})', + ] + + +def _snippet_commands(lines: list[int], is_gdb: bool) -> list[str]: + flush = "call (int)fflush(0)" if is_gdb else "expression -- (int)fflush(0)" + cmds = [] + for line in lines: + cmds.append(flush) + cmds += _marker_writes(is_gdb, _BEGIN.format(line)) + cmds.append("next") + cmds.append(flush) + cmds += _marker_writes(is_gdb, _END.format(line)) + cmds.append("continue") + return cmds + + +def _build_args( + backend: DebuggerBackend, + binary: Path, + source_name: str, + lines: list[int], + stdout_path: Path, + stderr_path: Path, +) -> list[str]: + is_gdb = backend.name == "gdb" + if is_gdb: + args = [ + str(backend.executable), + "-nx", + "-batch", + "-ex", + f"set args 2> {stderr_path} > {stdout_path}", + "-ex", + "set height 0", + "-ex", + "set width 0", + ] + for line in lines: + args += ["-ex", f"tbreak {source_name}:{line}"] + args += ["-ex", "run"] + for cmd in _snippet_commands(lines, is_gdb=True): + args += ["-ex", cmd] + args += ["-ex", "quit", str(binary)] + return args + + # Every lesson binary dynamically links the same handful of huge shared + # libraries (notably libLLVM), so re-parsing their symbol tables from + # scratch in every one of the ~20 lldb invocations this script makes + # dominates the whole website build. LLDB's index cache persists parsed + # symbol tables across invocations, keyed by module UUID, avoiding that. + # The max-byte-size setting defaults to 0 (cache disabled for writes) + # even with enable-lldb-index-cache true, so it must be raised explicitly. + # Both settings must be enabled before the target (and its dependent + # images) are created -- which is why the binary is loaded via an + # explicit "target create" command here instead of the usual trailing + # positional arg: that positional is processed before any "-o" command + # runs, so enabling the cache via "-o" wouldn't take effect in time to + # cover this launch. + args = [ + str(backend.executable), + "--batch", + "-o", + "settings set symbols.enable-lldb-index-cache true", + "-o", + "settings set symbols.lldb-index-cache-max-byte-size 1073741824", + "-o", + f"target create {binary}", + ] + for line in lines: + args += [ + "-o", + f"breakpoint set --file {source_name} --line {line} --one-shot true", + ] + args += ["-o", f"process launch -o {stdout_path} -e {stderr_path} --"] + for cmd in _snippet_commands(lines, is_gdb=False): + args += ["-o", cmd] + args += ["-o", "quit"] + return args + + +def _slice_markers(text: str, lines: list[int]) -> dict[int, str]: + all_lines = text.splitlines() + result = {} + for line in lines: + begin, end = _BEGIN.format(line), _END.format(line) + start_idx = next( + (i for i, ln in enumerate(all_lines) if ln.strip() == begin), None + ) + if start_idx is None: + continue + collected = [] + for ln in all_lines[start_idx + 1 :]: + if ln.strip() == end: + break + collected.append(ln) + chunk = "\n".join(collected).strip("\n") + if chunk: + result[line] = chunk + return result + + +def capture_snippets( + backend: DebuggerBackend, + binary: Path, + cwd: Path, + source_name: str, + lines: list[int], +) -> dict[int, str]: + if not lines: + return {} + + with tempfile.TemporaryDirectory() as tmp: + stdout_path = Path(tmp) / "stdout.txt" + stderr_path = Path(tmp) / "stderr.txt" + args = _build_args( + backend, binary, source_name, lines, stdout_path, stderr_path + ) + subprocess.run( + args, + cwd=cwd, + capture_output=True, + text=True, + timeout=_TIMEOUT_SECS, + check=False, + ) + stdout_text = stdout_path.read_text() if stdout_path.exists() else "" + stderr_text = stderr_path.read_text() if stderr_path.exists() else "" + + stdout_chunks = _slice_markers(stdout_text, lines) + stderr_chunks = _slice_markers(stderr_text, lines) + + result = {} + for line in lines: + parts = [p for p in (stdout_chunks.get(line), stderr_chunks.get(line)) if p] + if parts: + result[line] = "\n".join(parts) + return result + + +def capture_env_output(binary: Path, cwd: Path, env_overrides: dict[str, str]) -> str: + """Runs the whole binary with extra environment variables set and returns + stderr -- used for lesson 3's HL_DEBUG_CODEGEN=1 walkthrough, which has + no single interesting line to break at.""" + import os + + env = {**os.environ, **env_overrides} + proc = subprocess.run( + [str(binary)], + cwd=cwd, + env=env, + capture_output=True, + text=True, + timeout=_TIMEOUT_SECS, + ) + return proc.stderr + + +def debugger_available(name: str) -> bool: + return shutil.which(name) is not None diff --git a/tutorial/website/tutorial_website/cli.py b/tutorial/website/tutorial_website/cli.py new file mode 100644 index 000000000000..e14cd668ef12 --- /dev/null +++ b/tutorial/website/tutorial_website/cli.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from . import capture +from .lesson import discover_lessons +from .manifest import load_manifest +from .render import render_assets, render_index, render_lesson_page +from .sitemap import build_sitemap, load_sitemap, write_sitemap + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Generate the Halide tutorials website" + ) + subparsers = parser.add_subparsers(dest="mode", required=True) + + sitemap_parser = subparsers.add_parser( + "sitemap", help="write sitemap.json (cheap per-lesson metadata for navigation)" + ) + sitemap_parser.add_argument("--tutorial-dir", type=Path, required=True) + sitemap_parser.add_argument("--output", type=Path, required=True) + + assets_parser = subparsers.add_parser( + "assets", help="materialize shared assets (css/figures) and index.html" + ) + assets_parser.add_argument("--tutorial-dir", type=Path, required=True) + assets_parser.add_argument("--sitemap", type=Path, required=True) + assets_parser.add_argument("--output-dir", type=Path, required=True) + + lesson_parser = subparsers.add_parser("lesson", help="render one lesson's page") + lesson_parser.add_argument("--slug", required=True) + lesson_parser.add_argument( + "--manifest", type=Path, required=True, help="source->binary JSON manifest" + ) + lesson_parser.add_argument( + "--tutorial-dir", type=Path, required=True, help="tutorial/ directory" + ) + lesson_parser.add_argument("--sitemap", type=Path, required=True) + lesson_parser.add_argument( + "--run-cwd", + type=Path, + required=True, + help="directory to run lesson binaries from", + ) + lesson_parser.add_argument( + "--output-dir", type=Path, required=True, help="output site directory" + ) + lesson_parser.add_argument( + "--prefer", default="auto", choices=["auto", "gdb", "lldb"] + ) + lesson_parser.add_argument("--gdb", type=Path, default=None) + lesson_parser.add_argument("--lldb", type=Path, default=None) + + args = parser.parse_args(argv) + if args.mode == "sitemap": + return _main_sitemap(args) + if args.mode == "assets": + return _main_assets(args) + return _main_lesson(args) + + +def _main_sitemap(args: argparse.Namespace) -> int: + entries = build_sitemap(args.tutorial_dir) + if not entries: + print( + f"error: no lesson_*.cpp/lesson_*.sh files found under {args.tutorial_dir}", + file=sys.stderr, + ) + return 1 + write_sitemap(entries, args.output) + return 0 + + +def _main_assets(args: argparse.Namespace) -> int: + sitemap = load_sitemap(args.sitemap) + render_assets(args.tutorial_dir / "figures", args.output_dir) + render_index(sitemap, args.output_dir) + return 0 + + +def _main_lesson(args: argparse.Namespace) -> int: + manifest = load_manifest(args.manifest) + lessons = discover_lessons(args.tutorial_dir, manifest) + lesson = next((c for c in lessons if c.slug == args.slug), None) + if lesson is None: + print(f"error: no lesson found with slug {args.slug!r}", file=sys.stderr) + return 1 + + sitemap = load_sitemap(args.sitemap) + + available = capture.find_backends(args.gdb, args.lldb) + backend = capture.pick_backend(available, args.prefer) if available else None + if backend is None and lesson.interesting_lines: + print( + f"warning: no gdb/lldb backend available; rendering {lesson.slug} " + "without captured output", + file=sys.stderr, + ) + + snippets: dict[int, str] = {} + if lesson.binary_path is not None and lesson.binary_path.exists(): + if lesson.interesting_lines and backend is not None: + snippets = capture.capture_snippets( + backend, + lesson.binary_path, + args.run_cwd, + lesson.source_path.name, + lesson.interesting_lines, + ) + + if lesson.env_capture_line is not None: + output = capture.capture_env_output( + lesson.binary_path, args.run_cwd, lesson.env_capture_vars + ) + if output.strip(): + snippets[lesson.env_capture_line] = output.strip() + + render_lesson_page(lesson, sitemap, snippets, args.output_dir) + print(f"Rendered {lesson.slug}.html") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tutorial/website/tutorial_website/highlight.py b/tutorial/website/tutorial_website/highlight.py new file mode 100644 index 000000000000..48ed34a1a2f5 --- /dev/null +++ b/tutorial/website/tutorial_website/highlight.py @@ -0,0 +1,43 @@ +"""Syntax highlighting via Pygments. + +Replaces the old pipeline's dependency on the external `highlight` CLI and +its `Halide.theme`/`-s Halide` language file, neither of which exists +anywhere any more. +""" + +from __future__ import annotations + +from pygments import highlight as _pygments_highlight +from pygments.formatters import HtmlFormatter +from pygments.lexers import BashLexer, CppLexer + +_LEXERS = { + "cpp": CppLexer(stripnl=False, stripall=False, ensurenl=True), + "bash": BashLexer(stripnl=False, stripall=False, ensurenl=True), +} +# The default Pygments style only sets a background color, leaving +# unstyled/plain tokens (identifiers, punctuation, ...) to inherit whatever +# `color` the page happens to set -- fine for a light page, invisible on a +# dark one. Monokai sets both, and its palette anchors the whole page's +# color scheme (see assets/style.css). +_FORMATTER = HtmlFormatter(nowrap=False, cssclass="highlight", style="monokai") + + +def highlight_source(text: str, lexer: str = "cpp") -> str: + """Highlights a chunk of source, returning a self-contained + `
...
` block. + + Callers pass one contiguous chunk of lesson source per call (rather than + the whole file at once) so that output/figure blocks can be interleaved + between chunks; each chunk is lexed independently, so a lexer state that + spans a chunk boundary (e.g. a still-open /* block comment */) won't + carry over -- a rare cosmetic-only edge case given lesson sources + overwhelmingly use // line comments. + """ + if not text.endswith("\n"): + text += "\n" + return _pygments_highlight(text, _LEXERS[lexer], _FORMATTER) + + +def stylesheet() -> str: + return _FORMATTER.get_style_defs(".highlight") diff --git a/tutorial/website/tutorial_website/lesson.py b/tutorial/website/tutorial_website/lesson.py new file mode 100644 index 000000000000..649086cd98d6 --- /dev/null +++ b/tutorial/website/tutorial_website/lesson.py @@ -0,0 +1,139 @@ +"""Discovers lesson_*.cpp/lesson_*.sh files and the conventions their prose +already uses: + +- Every lesson has a `// Halide tutorial lesson N: Title` (or `#`-commented, + for the one shell-script lesson) line near the top -- not necessarily the + very first line, since shell scripts lead with a shebang. +- Lines that call `.realize(`, `tick(`, `print_loop_nest()`, or print + "Printing a complex Expr" are worth capturing output for. +- Comments referencing `figures/lesson_XX_....{gif,mp4,jpg,png}` mark a + pre-rendered static asset to inline. + +None of this requires any changes to the lesson sources themselves. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path + +TITLE_RE = re.compile(r"^(?://|#)\s*Halide tutorial lesson\s+\d+:\s*(.+?)\s*$") +NUMBER_RE = re.compile(r"^lesson_(\d+)_") +FIGURE_RE = re.compile(r"figures/lesson_\w+\.(?:gif|mp4|jpg|jpeg|png)") +BLANK_LINE_RE = re.compile(r"^\s*$") +INTERESTING_LINE_RE = re.compile( + r"tick\(|\.realize\(|print_loop_nest\(\)|Printing a complex Expr" +) +COMMENT_ONLY_RE = re.compile(r"^\s*(?://|#)") + +# Maps a source file's suffix to the Pygments lexer name highlight.py should +# use for it. +LEXERS = {".cpp": "cpp", ".sh": "bash"} + +# Lessons that are best understood by re-running the whole binary with a +# debug env var set, rather than by capturing a single statement's output. +# Keyed by lesson number (not by grepping for the env var name generically, +# since more than one lesson's prose happens to mention HL_DEBUG_CODEGEN). +ENV_CAPTURE_LESSONS: dict[int, tuple[str, dict[str, str]]] = { + 3: ("HL_DEBUG_CODEGEN", {"HL_DEBUG_CODEGEN": "1"}), +} + + +@dataclass +class Lesson: + number: int + slug: str + title: str + lexer: str + source_path: Path + lines: list[str] + display_lines: list[str] + interesting_lines: list[int] + figure_lines: dict[int, list[str]] + env_capture_line: int | None = None + env_capture_vars: dict[str, str] = field(default_factory=dict) + binary_path: Path | None = None + + +def _next_blank_line(lines: list[str], from_line: int) -> int: + """Returns the 1-indexed line number of the first blank line at or after + `from_line + 1`, or the file's last line if the comment block runs to + EOF without one.""" + for i in range(from_line, len(lines)): + if BLANK_LINE_RE.match(lines[i]): + return i + 1 + return len(lines) + + +def discover_lessons(tutorial_dir: Path, manifest: dict[str, Path]) -> list[Lesson]: + sources = sorted( + [*tutorial_dir.glob("lesson_*.cpp"), *tutorial_dir.glob("lesson_*.sh")] + ) + lessons = [] + for source_path in sources: + number_match = NUMBER_RE.match(source_path.name) + if not number_match: + continue + number = int(number_match.group(1)) + + lines = source_path.read_text().splitlines() + title = source_path.stem + # The title comment isn't necessarily the first line -- shell-script + # lessons lead with a shebang -- so scan for it near the top instead + # of assuming lines[0]. + for line in lines[:5]: + title_match = TITLE_RE.match(line) + if title_match: + title = title_match.group(1) + break + + env_marker = ENV_CAPTURE_LESSONS.get(number) + env_line = None + env_vars: dict[str, str] = {} + interesting_lines = [] + display_lines = list(lines) + figure_lines: dict[int, list[str]] = {} + + for i, line in enumerate(lines, start=1): + figure_match = FIGURE_RE.search(line) + if figure_match: + # The prose reads naturally with the raw path replaced by + # "below", and the figure itself is placed after the whole + # comment block it belongs to (i.e. at the next blank line), + # not immediately after the line that happens to mention it. + display_lines[i - 1] = FIGURE_RE.sub("below", line) + insertion_line = _next_blank_line(lines, i) + figure_lines.setdefault(insertion_line, []).append( + figure_match.group(0) + ) + + if env_marker and env_line is None and env_marker[0] in line: + # Same reasoning as figures: place it after the whole comment + # block, not mid-paragraph where the env var happens to be + # mentioned. + env_line = _next_blank_line(lines, i) + env_vars = env_marker[1] + + if COMMENT_ONLY_RE.match(line): + continue + if INTERESTING_LINE_RE.search(line): + interesting_lines.append(i) + + lessons.append( + Lesson( + number=number, + slug=source_path.stem, + title=title, + lexer=LEXERS.get(source_path.suffix, "text"), + source_path=source_path, + lines=lines, + display_lines=display_lines, + interesting_lines=interesting_lines, + figure_lines=figure_lines, + env_capture_line=env_line, + env_capture_vars=env_vars, + binary_path=manifest.get(source_path.name), + ) + ) + return lessons diff --git a/tutorial/website/tutorial_website/manifest.py b/tutorial/website/tutorial_website/manifest.py new file mode 100644 index 000000000000..c3c6b9d9d514 --- /dev/null +++ b/tutorial/website/tutorial_website/manifest.py @@ -0,0 +1,17 @@ +"""Loads the source-file -> built-executable manifest that CMake generates. + +Executable output paths depend on the generator and config and aren't known +until CMake's generate step, so tutorial/website/CMakeLists.txt writes them +out via file(GENERATE) with $ rather than us trying to +compute them ourselves. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def load_manifest(path: Path) -> dict[str, Path]: + data = json.loads(path.read_text()) + return {name: Path(binary) for name, binary in data.items() if binary} diff --git a/tutorial/website/tutorial_website/render.py b/tutorial/website/tutorial_website/render.py new file mode 100644 index 000000000000..8444c2ec397e --- /dev/null +++ b/tutorial/website/tutorial_website/render.py @@ -0,0 +1,218 @@ +"""Assembles the final static site: one HTML page per lesson (syntax- +highlighted code with captured output/figures inlined) plus an index page +with a shared nav sidebar. No JS, no Bootstrap -- collapsible output blocks +are native
/. +""" + +from __future__ import annotations + +import itertools +import shutil +from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader, select_autoescape +from markupsafe import Markup + +from . import highlight +from .lesson import Lesson +from .sitemap import SitemapEntry + +_ASSETS_DIR = Path(__file__).parent / "assets" +_TEMPLATES_DIR = Path(__file__).parent / "templates" + + +@dataclass +class ContentBlock: + kind: str + source: str | None = None + lexer: str | None = None + text: str | None = None + src: str | None = None + alt: str | None = None + + +@dataclass +class Insertion: + output: ContentBlock | None = None + figures: list[ContentBlock] = field(default_factory=list) + + +@dataclass +class NavigationItem: + label: str + href: str | None = None + children: list[NavigationItem] = field(default_factory=list) + + +@dataclass +class Page: + title: str + heading: str + current_href: str | None + blocks: list[ContentBlock] = field(default_factory=list) + + +def _build_insertions( + lesson: Lesson, snippets: dict[int, str], output_dir: Path +) -> dict[int, Insertion]: + insertions: defaultdict[int, Insertion] = defaultdict(Insertion) + for line, text in snippets.items(): + insertions[line].output = ContentBlock(kind="output", text=text) + for line, figure_names in lesson.figure_lines.items(): + for figure_name in figure_names: + if figure := _render_figure(figure_name, output_dir): + insertions[line].figures.append(figure) + return insertions + + +def _render_figure(figure_ref: str, output_dir: Path) -> ContentBlock | None: + # Figures are materialized into output_dir/figures once, up front, by + # render_assets -- not here, since this runs as one of many concurrent + # per-lesson render processes and can't safely copy into a shared + # directory itself (concurrent "does it exist yet" checks would race). + name = Path(figure_ref).name + if not (output_dir / "figures" / name).exists(): + return None + rel = f"figures/{name}" + kind = "video" if name.lower().endswith(".mp4") else "image" + return ContentBlock(kind=kind, src=rel, alt=name) + + +def _trim_blank_lines(lines: list[str]) -> list[str]: + start, end = 0, len(lines) + while start < end and not lines[start].strip(): + start += 1 + while end > start and not lines[end - 1].strip(): + end -= 1 + return lines[start:end] + + +def _highlight_chunk(lines: list[str], lexer: str) -> ContentBlock | None: + trimmed = _trim_blank_lines(lines) + if not trimmed: + return None + return ContentBlock(kind="code", source="\n".join(trimmed), lexer=lexer) + + +def _render_blocks( + lesson: Lesson, insertions: dict[int, Insertion] +) -> list[ContentBlock]: + blocks: list[ContentBlock | None] = [] + start = 0 # 0-indexed offset into lesson.display_lines + for line_no, insertion in sorted(insertions.items()): + blocks.append( + _highlight_chunk(lesson.display_lines[start:line_no], lesson.lexer) + ) + blocks.append(insertion.output) + blocks.extend(insertion.figures) + start = line_no + blocks.append(_highlight_chunk(lesson.display_lines[start:], lesson.lexer)) + return [block for block in blocks if block is not None] + + +def _navigation_items(sitemap: list[SitemapEntry]) -> list[NavigationItem]: + return [ + _navigation_item(number, list(group)) + for number, group in itertools.groupby(sitemap, key=lambda entry: entry.number) + ] + + +def _navigation_item(number: int, entries: list[SitemapEntry]) -> NavigationItem: + if len(entries) == 1: + entry = entries[0] + return _lesson_item(entry, f"{number}. {entry.title}") + + # Multi-part lessons (10, 15, 16, 21) share a nav header and use each + # title's subtitle as their indented link label. + base_title, _ = _title_parts(entries[0].title) + return NavigationItem( + label=f"{number}. {base_title}", + children=[ + _lesson_item(entry, _title_parts(entry.title)[1] or entry.title) + for entry in entries + ], + ) + + +def _lesson_item(entry: SitemapEntry, label: str) -> NavigationItem: + return NavigationItem(label, f"{entry.slug}.html") + + +def _title_parts(title: str) -> tuple[str, str | None]: + base, separator, subtitle = title.partition(":") + subtitle = subtitle.lstrip() + if not separator or not subtitle: + return title, None + return base, subtitle[:1].upper() + subtitle[1:] + + +def _template_environment() -> Environment: + environment = Environment( + loader=FileSystemLoader(_TEMPLATES_DIR), + autoescape=select_autoescape(["html", "j2"]), + trim_blocks=True, + lstrip_blocks=True, + ) + environment.filters["highlight"] = _highlight_filter + return environment + + +def _highlight_filter(source: str, lexer: str) -> Markup: + """Convert source to the trusted markup emitted by Pygments.""" + return Markup(highlight.highlight_source(source, lexer)) + + +def render_assets(figures_dir: Path, output_dir: Path) -> None: + """Materializes everything shared across pages that isn't specific to any + one lesson: the stylesheet and the figures directory. Run once, up front, + from a single process -- unlike lesson pages, these aren't safe to + produce from several concurrent per-lesson render processes (concurrent + writes/copies into the same shared paths would race).""" + output_dir.mkdir(parents=True, exist_ok=True) + base_css = (_ASSETS_DIR / "style.css").read_text() + (output_dir / "style.css").write_text( + base_css + "\n" + highlight.stylesheet() + "\n" + ) + + dest_figures = output_dir / "figures" + if dest_figures.exists(): + shutil.rmtree(dest_figures) + if figures_dir.exists(): + shutil.copytree(figures_dir, dest_figures) + + +def render_index(sitemap: list[SitemapEntry], output_dir: Path) -> None: + output_dir.mkdir(parents=True, exist_ok=True) + templates = _template_environment() + index_template = templates.get_template("index.html.j2") + index_page = index_template.render( + navigation=_navigation_items(sitemap), + page=Page(title="Introduction", heading="Introduction", current_href=None), + root="", + ) + (output_dir / "index.html").write_text(index_page) + + +def render_lesson_page( + lesson: Lesson, + sitemap: list[SitemapEntry], + snippets: dict[int, str], + output_dir: Path, +) -> None: + output_dir.mkdir(parents=True, exist_ok=True) + templates = _template_environment() + lesson_template = templates.get_template("code_lesson.html.j2") + insertions = _build_insertions(lesson, snippets, output_dir) + page = lesson_template.render( + navigation=_navigation_items(sitemap), + page=Page( + title=f"Lesson {lesson.number}: {lesson.title}", + heading=f"Lesson {lesson.number}: {lesson.title}", + current_href=f"{lesson.slug}.html", + blocks=_render_blocks(lesson, insertions), + ), + root="", + ) + (output_dir / f"{lesson.slug}.html").write_text(page) diff --git a/tutorial/website/tutorial_website/sitemap.py b/tutorial/website/tutorial_website/sitemap.py new file mode 100644 index 000000000000..b495fe2ffe5d --- /dev/null +++ b/tutorial/website/tutorial_website/sitemap.py @@ -0,0 +1,52 @@ +"""The cheap subset of lesson metadata (number, slug, title) needed to render +the shared navigation sidebar on every page. + +Every lesson page's nav includes links to *all* lessons, but rendering one +lesson's page shouldn't require rescanning every other lesson's source file +just to learn its title -- so this is precomputed once, up front, and shared +(as sitemap.json) with each per-lesson render invocation. +""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from pathlib import Path + +from .lesson import discover_lessons + + +@dataclass(frozen=True) +class SitemapEntry: + number: int + slug: str + title: str + + +def build_sitemap(tutorial_dir: Path) -> list[SitemapEntry]: + # No binary manifest is needed: only number/slug/title are used for + # navigation, none of which depend on a lesson's built binary. + lessons = discover_lessons(tutorial_dir, manifest={}) + return [ + SitemapEntry(number=lesson.number, slug=lesson.slug, title=lesson.title) + for lesson in lessons + ] + + +def write_sitemap(entries: list[SitemapEntry], path: Path) -> None: + content = json.dumps([asdict(entry) for entry in entries], indent=2) + "\n" + # CMake marks this custom command "restat", which lets ninja skip + # rebuilding every downstream per-lesson page when this output didn't + # actually change -- but only if this file's mtime doesn't change either, + # so the write must be skipped entirely when the content is identical. + # Editing a lesson's code without touching its number/slug/title (by far + # the common case) should invalidate only that one lesson's page, not + # every other lesson's. + if path.exists() and path.read_text() == content: + return + path.write_text(content) + + +def load_sitemap(path: Path) -> list[SitemapEntry]: + data = json.loads(path.read_text()) + return [SitemapEntry(**entry) for entry in data] diff --git a/tutorial/website/tutorial_website/templates/base.html.j2 b/tutorial/website/tutorial_website/templates/base.html.j2 new file mode 100644 index 000000000000..6c45ac4d886e --- /dev/null +++ b/tutorial/website/tutorial_website/templates/base.html.j2 @@ -0,0 +1,32 @@ + + + + +{{ page.title }} - Halide tutorials + + + + +
+{% block content %}{% endblock %} +
+ + + diff --git a/tutorial/website/tutorial_website/templates/blocks.html.j2 b/tutorial/website/tutorial_website/templates/blocks.html.j2 new file mode 100644 index 000000000000..048fb9792b20 --- /dev/null +++ b/tutorial/website/tutorial_website/templates/blocks.html.j2 @@ -0,0 +1,14 @@ +{% macro render(block) %} +{% if block.kind == "code" %} +{{ block.source | highlight(block.lexer) }} +{% elif block.kind == "output" %} +
+Show output +
{{ block.text }}
+
+{% elif block.kind == "image" %} +
{{ block.alt }}
+{% elif block.kind == "video" %} +
+{% endif %} +{% endmacro %} diff --git a/tutorial/website/tutorial_website/templates/code_lesson.html.j2 b/tutorial/website/tutorial_website/templates/code_lesson.html.j2 new file mode 100644 index 000000000000..52e2cca124e5 --- /dev/null +++ b/tutorial/website/tutorial_website/templates/code_lesson.html.j2 @@ -0,0 +1,9 @@ +{% extends "base.html.j2" %} +{% from "blocks.html.j2" import render as render_block %} + +{% block content %} +

{{ page.heading }}

+{% for block in page.blocks %} +{{ render_block(block) }} +{% endfor %} +{% endblock %} diff --git a/tutorial/website/tutorial_website/templates/index.html.j2 b/tutorial/website/tutorial_website/templates/index.html.j2 new file mode 100644 index 000000000000..6727fec1660d --- /dev/null +++ b/tutorial/website/tutorial_website/templates/index.html.j2 @@ -0,0 +1,6 @@ +{% extends "base.html.j2" %} + +{% block content %} +

{{ page.heading }}

+

These are the Halide tutorials. Start with lesson 1 in the sidebar.

+{% endblock %} diff --git a/uv.lock b/uv.lock index 5c8987245b55..7d7fc978e1cd 100644 --- a/uv.lock +++ b/uv.lock @@ -202,6 +202,11 @@ tools = [ { name = "ruff" }, { name = "tbump" }, ] +tutorial-website = [ + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "pygments" }, +] [package.metadata] requires-dist = [ @@ -285,6 +290,11 @@ tools = [ { name = "ruff", specifier = ">=0.12" }, { name = "tbump", specifier = ">=6.11" }, ] +tutorial-website = [ + { name = "jinja2", specifier = ">=3.1" }, + { name = "markupsafe", specifier = ">=3.0" }, + { name = "pygments", specifier = ">=2.17" }, +] [[package]] name = "halide-llvm" @@ -434,6 +444,103 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "ml-dtypes" version = "0.5.4"