diff --git a/.bazelrc b/.bazelrc index d0ae7bf8..b62438e4 100644 --- a/.bazelrc +++ b/.bazelrc @@ -1,5 +1,10 @@ # Bazel configuration for DDS project +# Skip Playwright browser tests on `bazel test //...` (network, Chromium, no-sandbox). +# Run e2e explicitly: bazel test --test_tag_filters=e2e //web:web_e2e_tests +# Run every test: bazel test --test_tag_filters= /... +test --test_tag_filters=-e2e + common --enable_platform_specific_config # Platform-specific C++ standard flags @@ -7,8 +12,8 @@ build:macos --cxxopt=-std=c++20 build:macos --host_cxxopt=-std=c++20 build:linux --cxxopt=-std=c++20 build:linux --host_cxxopt=-std=c++20 -build:windows --cxxopt=/std:c++20 -build:windows --host_cxxopt=/std:c++20 +# Keep Windows C++ standard selection in CPPVARIABLES.bzl selects. +# Avoid global /std flags here so wasm transitions on Windows don't inherit MSVC opts. # Performance optimization flags build:opt --compilation_mode=opt @@ -45,12 +50,3 @@ build:tsan --linkopt=-fsanitize=thread build:tsan --strip=never build:tsan --features=dbg build:tsan --compilation_mode=dbg - -# WebAssembly (WASM) configuration -# Usage: bazel build --config=wasm //... -# Compiles using the hermetic emsdk toolchain downloaded automatically by Bazel. -build:wasm --platforms=@emsdk//:platform_wasm -build:wasm --cpu=wasm -build:wasm --cxxopt=-std=c++20 -build:wasm --host_cxxopt=-std=c++20 -build:wasm --compilation_mode=opt diff --git a/.github/workflows/ci_wasm.yml b/.github/workflows/ci_wasm.yml index 7b0d9fcb..397285c8 100644 --- a/.github/workflows/ci_wasm.yml +++ b/.github/workflows/ci_wasm.yml @@ -20,10 +20,13 @@ jobs: with: bazelisk-version: "1.29.0" - # 3️⃣ Build WASM targets (hermetic emsdk toolchain downloaded by Bazel) + # 3️⃣ Unit tests and WASM builds (hermetic emsdk toolchain downloaded by Bazel) + - name: Test web helpers, system tests, e2e, and CalcDDtablePBN + run: bazel test --verbose_failures //web:web_tests //web:web_system_tests //examples/wasm:all + - name: Build WASM targets - run: bazel build --config=wasm --verbose_failures //examples:all_examples_wasm + run: bazel build --verbose_failures //examples/wasm:all_examples_wasm # 4️⃣ Smoke test: run solve_board under Node.js — pass if it does not crash - name: Smoke test solve_board_wasm - run: node bazel-bin/examples/solve_board.js + run: node bazel-bin/examples/wasm/solve_board.js \ No newline at end of file diff --git a/BUILD.bazel b/BUILD.bazel index 02ea9a23..50327287 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -32,6 +32,7 @@ config_setting( name = "build_windows", constraint_values = [ "@platforms//os:windows", + "@platforms//cpu:x86_64", ], ) @@ -39,6 +40,7 @@ config_setting( name = "debug_build_windows", constraint_values = [ "@platforms//os:windows", + "@platforms//cpu:x86_64", ], values = {"compilation_mode": "dbg"}, ) @@ -72,11 +74,14 @@ config_setting( define_values = {"asan": "true"}, ) -# WebAssembly (WASM) build configuration -# Usage: bazel build --config=wasm //... +# Matches cc_* targets built under the wasm_cc_binary platform transition. +# Use platform constraints (not deprecated `cpu` select) so this works +# consistently across host OSes, including Windows. config_setting( name = "build_wasm", - values = {"cpu": "wasm"}, + constraint_values = [ + "@platforms//cpu:wasm32", + ], ) diff --git a/MODULE.bazel b/MODULE.bazel index cf4cdcb9..e9766b27 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -9,6 +9,8 @@ bazel_dep(name = "googletest", version = "1.17.0.bcr.2") bazel_dep(name = "pybind11_bazel", version = "3.0.1") bazel_dep(name = "rules_python", version = "2.0.1") bazel_dep(name = "toolchains_llvm", version = "1.7.0") +# WASM builds (//examples/wasm:*, //web:dds_mvp_wasm). If you bump this version, +# rebuild and run web/update_wasm.sh; see docs/wasm_build.md and patch_mvp_wasm.py. bazel_dep(name = "emsdk", version = "5.0.7") llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm") @@ -30,5 +32,13 @@ python.toolchain( ) use_repo(python, "python_3_14") +pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") +pip.parse( + hub_name = "pypi", + python_version = "3.14", + requirements_lock = "//web:requirements_lock.txt", +) +use_repo(pip, "pypi") + register_toolchains("@llvm_toolchain//:all") register_toolchains("@emsdk//:all") \ No newline at end of file diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 54b461c3..800cc995 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -549,5 +549,500 @@ } } }, - "facts": {} + "facts": { + "@@rules_python+//python/extensions:pip.bzl%pip": { + "dist_hashes": { + "https://pypi.org/simple": { + "backports-tarfile": { + "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz": "d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", + "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl": "77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34" + }, + "certifi": { + "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz": "47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", + "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl": "0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de" + }, + "cffi": { + "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", + "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl": "3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", + "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl": "5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", + "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl": "b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", + "https://files.pythonhosted.org/packages/15/12/a7a79bd0df4c3bff744b2d7e52cc1b68d5e7e427b384252c42366dc1ecbc/cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", + "https://files.pythonhosted.org/packages/1f/74/cc4096ce66f5939042ae094e2e96f53426a979864aa1f96a621ad128be27/cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", + "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl": "cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", + "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl": "7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", + "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl": "737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", + "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl": "c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", + "https://files.pythonhosted.org/packages/2b/e7/7c769804eb75e4c4b35e658dba01de1640a351a9653c3d49ca89d16ccc91/cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl": "89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", + "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl": "7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", + "https://files.pythonhosted.org/packages/32/f2/81b63e288295928739d715d00952c8c6034cb6c6a516b17d37e0c8be5600/cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl": "cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", + "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl": "b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", + "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl": "6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", + "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl": "19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", + "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl": "81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", + "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl": "de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", + "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl": "9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", + "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl": "087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", + "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl": "a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", + "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", + "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl": "8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", + "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl": "45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", + "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl": "00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", + "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", + "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl": "2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", + "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", + "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl": "d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", + "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl": "b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", + "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl": "c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", + "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl": "e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", + "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", + "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl": "da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", + "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl": "9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", + "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl": "fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", + "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl": "0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", + "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl": "4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", + "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl": "c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", + "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", + "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", + "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", + "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl": "f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", + "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl": "dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", + "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl": "9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", + "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl": "1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", + "https://files.pythonhosted.org/packages/a3/ad/5c51c1c7600bdd7ed9a24a203ec255dccdd0ebf4527f7b922a0bde2fb6ed/cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", + "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", + "https://files.pythonhosted.org/packages/aa/d9/6218d78f920dcd7507fc16a766b5ef8f3b913cc7aa938e7fc80b9978d089/cffi-2.0.0-cp39-cp39-win32.whl": "2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", + "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl": "94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", + "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl": "0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", + "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl": "66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", + "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", + "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", + "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", + "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl": "2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", + "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", + "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl": "203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", + "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl": "d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", + "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", + "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl": "d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", + "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl": "fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf", + "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", + "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl": "38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", + "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl": "256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", + "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl": "dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", + "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", + "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", + "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", + "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", + "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl": "b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", + "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", + "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl": "8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", + "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", + "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl": "6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", + "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl": "1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", + "https://files.pythonhosted.org/packages/e8/be/f6424d1dc46b1091ffcc8964fa7c0ab0cd36839dd2761b49c90481a6ba1b/cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl": "0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", + "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl": "6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", + "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz": "44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", + "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl": "74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", + "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", + "https://files.pythonhosted.org/packages/f7/e0/dda537c2309817edf60109e39265f24f24aa7f050767e22c98c53fe7f48b/cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl": "1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", + "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl": "da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", + "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe" + }, + "charset-normalizer": { + "https://files.pythonhosted.org/packages/00/bd/ef9c88464b126fa176f4ef4a317ad9b6f4d30b2cffbc43386062367c3e2c/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9", + "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl": "ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", + "https://files.pythonhosted.org/packages/04/9a/914d294daa4809c57667b77470533e65def9c0be1ef8b4c1183a99170e9d/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", + "https://files.pythonhosted.org/packages/05/35/bb59b1cd012d7196fc81c2f5879113971efc226a63812c9cf7f89fe97c40/charset_normalizer-3.4.3-cp38-cp38-win_amd64.whl": "5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40", + "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl": "42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", + "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl": "30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", + "https://files.pythonhosted.org/packages/0c/52/8b0c6c3e53f7e546a5e49b9edb876f379725914e1130297f3b423c7b71c5/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b", + "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", + "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", + "https://files.pythonhosted.org/packages/1a/79/ae516e678d6e32df2e7e740a7be51dc80b700e2697cb70054a0f1ac2c955/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b", + "https://files.pythonhosted.org/packages/20/30/5f64fe3981677fe63fa987b80e6c01042eb5ff653ff7cec1b7bd9268e54e/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl": "2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", + "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", + "https://files.pythonhosted.org/packages/22/82/63a45bfc36f73efe46731a3a71cb84e2112f7e0b049507025ce477f0f052/charset_normalizer-3.4.3-cp38-cp38-macosx_10_9_universal2.whl": "0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c", + "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl": "d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", + "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl": "1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", + "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl": "cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", + "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl": "78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", + "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", + "https://files.pythonhosted.org/packages/39/c6/99271dc37243a4f925b09090493fb96c9333d7992c6187f5cfe5312008d2/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", + "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl": "86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", + "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", + "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl": "4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", + "https://files.pythonhosted.org/packages/45/8c/dcef87cfc2b3f002a6478f38906f9040302c68aebe21468090e39cde1445/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl": "88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", + "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl": "939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", + "https://files.pythonhosted.org/packages/50/10/c117806094d2c956ba88958dab680574019abc0c02bcf57b32287afca544/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_x86_64.whl": "a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b", + "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl": "fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", + "https://files.pythonhosted.org/packages/59/c0/a74f3bd167d311365e7973990243f32c35e7a94e45103125275b9e6c479f/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4", + "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", + "https://files.pythonhosted.org/packages/61/c5/dc3ba772489c453621ffc27e8978a98fe7e41a93e787e5e5bde797f1dddb/charset_normalizer-3.4.3-cp38-cp38-win32.whl": "ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557", + "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl": "96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", + "https://files.pythonhosted.org/packages/63/86/9cbd533bd37883d467fcd1bd491b3547a3532d0fbb46de2b99feeebf185e/charset_normalizer-3.4.3-cp39-cp39-win32.whl": "16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", + "https://files.pythonhosted.org/packages/64/d1/f9d141c893ef5d4243bc75c130e95af8fd4bc355beff06e9b1e941daad6e/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_ppc64le.whl": "5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a", + "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl": "6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", + "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", + "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl": "14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", + "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl": "18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", + "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", + "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl": "d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", + "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl": "fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", + "https://files.pythonhosted.org/packages/7a/03/cbb6fac9d3e57f7e07ce062712ee80d80a5ab46614684078461917426279/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_aarch64.whl": "d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb", + "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", + "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl": "53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", + "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", + "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl": "b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", + "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", + "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz": "6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", + "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", + "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", + "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", + "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl": "ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", + "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl": "3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", + "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl": "fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", + "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl": "cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", + "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", + "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl": "6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", + "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl": "02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", + "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl": "027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", + "https://files.pythonhosted.org/packages/b0/a8/6f5bcf1bcf63cb45625f7c5cadca026121ff8a6c8a3256d8d8cd59302663/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", + "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl": "c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", + "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl": "320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", + "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl": "6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", + "https://files.pythonhosted.org/packages/c2/ca/9a0983dd5c8e9733565cf3db4df2b0a2e9a82659fd8aa2a868ac6e4a991f/charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl": "70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", + "https://files.pythonhosted.org/packages/c4/72/d3d0e9592f4e504f9dea08b8db270821c909558c353dc3b457ed2509f2fb/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl": "1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", + "https://files.pythonhosted.org/packages/c5/35/9c99739250742375167bc1b1319cd1cec2bf67438a70d84b2e1ec4c9daa3/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_s390x.whl": "b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942", + "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", + "https://files.pythonhosted.org/packages/ce/d6/7e805c8e5c46ff9729c49950acc4ee0aeb55efb8b3a56687658ad10c3216/charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl": "d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", + "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", + "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl": "fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", + "https://files.pythonhosted.org/packages/e1/ef/dd08b2cac9284fd59e70f7d97382c33a3d0a926e45b15fc21b3308324ffd/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl": "511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", + "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl": "c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", + "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", + "https://files.pythonhosted.org/packages/e4/69/132eab043356bba06eb333cc2cc60c6340857d0a2e4ca6dc2b51312886b3/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", + "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl": "e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", + "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl": "73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", + "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl": "c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", + "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", + "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl": "31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", + "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl": "bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", + "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018" + }, + "cryptography": { + "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl": "2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", + "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl": "760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", + "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl": "02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", + "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl": "8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", + "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl": "7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", + "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl": "3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", + "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl": "69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", + "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl": "aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", + "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", + "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl": "6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", + "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl": "79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", + "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl": "2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", + "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl": "ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", + "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl": "2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", + "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl": "8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", + "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl": "22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", + "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl": "6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", + "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl": "64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", + "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", + "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl": "d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", + "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl": "b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", + "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", + "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl": "380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", + "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl": "b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", + "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl": "341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", + "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl": "c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", + "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl": "90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", + "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz": "27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", + "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", + "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl": "bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", + "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl": "4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", + "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl": "8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", + "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl": "a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", + "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl": "6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", + "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl": "12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", + "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl": "7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", + "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl": "12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", + "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl": "c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", + "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl": "97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", + "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl": "67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", + "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl": "3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", + "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl": "456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", + "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl": "cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", + "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl": "9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", + "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", + "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl": "d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", + "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl": "50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", + "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", + "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl": "063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d" + }, + "docutils": { + "https://files.pythonhosted.org/packages/4a/c0/89fe6215b443b919cb98a5002e107cb5026854ed1ccb6b5833e0768419d1/docutils-0.22.2.tar.gz": "9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d", + "https://files.pythonhosted.org/packages/66/dd/f95350e853a4468ec37478414fc04ae2d61dad7a947b3015c3dcc51a09b9/docutils-0.22.2-py3-none-any.whl": "b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8" + }, + "greenlet": { + "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl": "9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", + "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl": "b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", + "https://files.pythonhosted.org/packages/0f/05/85a511e68ee109aff0aa00b4b497806091dd2d82ce209e49c6e801bd5d92/greenlet-3.5.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl": "c3d35f87c7253b715d13d679e0783d845910144f282cb939fe1ba4ac8616269c", + "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl": "d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", + "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl": "7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", + "https://files.pythonhosted.org/packages/1a/c6/50e520283a9f19388a7326b05f9e8637e566003475eacaadad04f558c68d/greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl": "ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", + "https://files.pythonhosted.org/packages/1d/21/117c8710abb7f146d804a124c07eb5964a60b90d02b72452885aecc18efa/greenlet-3.5.1-cp310-cp310-macosx_11_0_universal2.whl": "7eacb17a9d41538a2bc4912eba5ef13823c83cb69e4d141d0813debe7163187f", + "https://files.pythonhosted.org/packages/21/1c/13abd1f4860d987fa5e1170a01930d6e6cd40d328de487a3c9fdaff0ffd0/greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl": "d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", + "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl": "cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", + "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl": "7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", + "https://files.pythonhosted.org/packages/26/9a/4ba4c2bc9d9df5f41bb8943fb7bb11e440352e6b9c2e36716b6e85f8b82d/greenlet-3.5.1-cp310-cp310-manylinux_2_39_riscv64.whl": "001775efe7b8e758861294c7a27c28af87f3f3f1c20468a2bc618c45b346c061", + "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl": "51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", + "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl": "8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", + "https://files.pythonhosted.org/packages/2c/f2/8fd452fd81adb9ec79c8275c1375702ab0fd6bee4952da12eaa09b9508d8/greenlet-3.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl": "6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360", + "https://files.pythonhosted.org/packages/2e/19/60df45065b2981ff894fdd51e7c99a3a4b107412822b083d88d5d528f663/greenlet-3.5.1-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl": "00929c98ec525fd9bf075875d8c5f6a983a90906cdf78a66e6de2d8e466c2a19", + "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl": "3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", + "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl": "7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", + "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl": "3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", + "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl": "110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", + "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl": "ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", + "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl": "add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", + "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl": "50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", + "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl": "73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", + "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl": "a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", + "https://files.pythonhosted.org/packages/46/1c/43b8203cf10f4292c9e3d270e9e5f5ade79115a0a0ca5ea6f1be5f8915a7/greenlet-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl": "9d59e840387076a51016777a9328b3f2c427c6f9208a6e958bad251be50a648d", + "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl": "7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", + "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl": "e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", + "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl": "5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", + "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl": "2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", + "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl": "45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", + "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl": "a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", + "https://files.pythonhosted.org/packages/5d/14/ad1f9fc9b82384c010212464a3702bd911f95dab2f1180bc6fbcfb1f958c/greenlet-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl": "ed8cdb691169715a9a492844a83246f090182247d1a5031dc78a403f68ba1e97", + "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl": "2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", + "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl": "80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", + "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl": "c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", + "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl": "1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", + "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl": "a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", + "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl": "f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", + "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz": "5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", + "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl": "a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", + "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl": "d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", + "https://files.pythonhosted.org/packages/75/de/af6cef182862d2ccd6975440d21c9058a77c3f9b469abf94e322dfd2e0e3/greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl": "8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", + "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl": "ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", + "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl": "2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", + "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl": "a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", + "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl": "c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", + "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl": "89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", + "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl": "a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", + "https://files.pythonhosted.org/packages/89/b8/8b83d18ae07c46c019617f35afd7b47aab7f9b4fbb12fc637d681e10bdd8/greenlet-3.5.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl": "540dae7b956209af4d70a3be35927b4055f617763771e5e84a5255bea934d2f5", + "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl": "8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", + "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl": "d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", + "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl": "ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", + "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl": "ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", + "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl": "17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", + "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl": "e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", + "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl": "4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", + "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl": "3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", + "https://files.pythonhosted.org/packages/ac/6e/0344b1e99f58f71715456e46492101fd2daa408957b8186ade0a4b515da7/greenlet-3.5.1-cp310-cp310-win_amd64.whl": "b9152fca4a6466e114aaec745ae61cba739903a109754a9d4e1262f01e9259b1", + "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl": "1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", + "https://files.pythonhosted.org/packages/b9/f7/6762a56fa5f6c2295c449c6524e10ce481e381c994cc44d9d03aef0700fb/greenlet-3.5.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl": "e5cc9606aa5f4e0bde0d3bd502b44f743864c3ffa5cfa1011b1e30f5aa02366f", + "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl": "92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", + "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl": "fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", + "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl": "cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", + "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl": "6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", + "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl": "a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", + "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl": "975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", + "https://files.pythonhosted.org/packages/d9/a9/a3c2fa886c5b94863fb0e61b3bc14610b7aa94cf4f17f8741b11708305fc/greenlet-3.5.1-cp311-cp311-win_arm64.whl": "cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523", + "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl": "10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", + "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl": "e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", + "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl": "9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", + "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl": "de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", + "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl": "089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", + "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl": "a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", + "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl": "5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", + "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl": "67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", + "https://files.pythonhosted.org/packages/ec/bc/c318aa9f3ffc77320fddcee3d892be957b42e2ff947198d9450b004f3a38/greenlet-3.5.1-cp311-cp311-manylinux_2_39_riscv64.whl": "017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747", + "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl": "dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", + "https://files.pythonhosted.org/packages/f5/56/5f332b7705545eac2dc01b4e9254d24a793f2656d55d5cc6b94ee59d22ae/greenlet-3.5.1-cp311-cp311-win_amd64.whl": "88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", + "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl": "111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64" + }, + "idna": { + "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl": "946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", + "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz": "12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9" + }, + "importlib-metadata": { + "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl": "e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", + "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz": "d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000" + }, + "jaraco-classes": { + "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz": "47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", + "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl": "f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790" + }, + "jaraco-context": { + "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz": "9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", + "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl": "f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4" + }, + "jaraco-functools": { + "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl": "227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", + "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz": "cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294" + }, + "jeepney": { + "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz": "cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", + "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl": "97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683" + }, + "keyring": { + "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz": "0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", + "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl": "552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd" + }, + "markdown-it-py": { + "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz": "cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", + "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl": "87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147" + }, + "mdurl": { + "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl": "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz": "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + }, + "more-itertools": { + "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl": "52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", + "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz": "f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd" + }, + "nh3": { + "https://files.pythonhosted.org/packages/0c/e0/cf1543e798ba86d838952e8be4cb8d18e22999be2a24b112a671f1c04fd6/nh3-0.3.0-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl": "ec6cfdd2e0399cb79ba4dcffb2332b94d9696c52272ff9d48a630c5dca5e325a", + "https://files.pythonhosted.org/packages/10/71/2fb1834c10fab6d9291d62c95192ea2f4c7518bd32ad6c46aab5d095cb87/nh3-0.3.0-cp313-cp313t-musllinux_1_2_i686.whl": "0649464ac8eee018644aacbc103874ccbfac80e3035643c3acaab4287e36e7f5", + "https://files.pythonhosted.org/packages/23/1e/80a8c517655dd40bb13363fc4d9e66b2f13245763faab1a20f1df67165a7/nh3-0.3.0-cp313-cp313t-win_amd64.whl": "423201bbdf3164a9e09aa01e540adbb94c9962cc177d5b1cbb385f5e1e79216e", + "https://files.pythonhosted.org/packages/2f/d6/f1c6e091cbe8700401c736c2bc3980c46dca770a2cf6a3b48a175114058e/nh3-0.3.0-cp313-cp313t-win32.whl": "7275fdffaab10cc5801bf026e3c089d8de40a997afc9e41b981f7ac48c5aa7d5", + "https://files.pythonhosted.org/packages/33/c1/8f8ccc2492a000b6156dce68a43253fcff8b4ce70ab4216d08f90a2ac998/nh3-0.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl": "1adeb1062a1c2974bc75b8d1ecb014c5fd4daf2df646bbe2831f7c23659793f9", + "https://files.pythonhosted.org/packages/39/2c/6394301428b2017a9d5644af25f487fa557d06bc8a491769accec7524d9a/nh3-0.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "f416c35efee3e6a6c9ab7716d9e57aa0a49981be915963a82697952cba1353e1", + "https://files.pythonhosted.org/packages/4c/3c/cba7b26ccc0ef150c81646478aa32f9c9535234f54845603c838a1dc955c/nh3-0.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl": "80fe20171c6da69c7978ecba33b638e951b85fb92059259edd285ff108b82a6d", + "https://files.pythonhosted.org/packages/4e/9a/344b9f9c4bd1c2413a397f38ee6a3d5db30f1a507d4976e046226f12b297/nh3-0.3.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl": "37d3003d98dedca6cd762bf88f2e70b67f05100f6b949ffe540e189cc06887f9", + "https://files.pythonhosted.org/packages/5b/76/3165e84e5266d146d967a6cc784ff2fbf6ddd00985a55ec006b72bc39d5d/nh3-0.3.0-cp38-abi3-win_arm64.whl": "d97d3efd61404af7e5721a0e74d81cdbfc6e5f97e11e731bb6d090e30a7b62b2", + "https://files.pythonhosted.org/packages/5c/86/a96b1453c107b815f9ab8fac5412407c33cc5c7580a4daf57aabeb41b774/nh3-0.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "ce5e7185599f89b0e391e2f29cc12dc2e206167380cea49b33beda4891be2fe1", + "https://files.pythonhosted.org/packages/63/da/c5fd472b700ba37d2df630a9e0d8cc156033551ceb8b4c49cc8a5f606b68/nh3-0.3.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl": "ba0caa8aa184196daa6e574d997a33867d6d10234018012d35f86d46024a2a95", + "https://files.pythonhosted.org/packages/66/3f/cd37f76c8ca277b02a84aa20d7bd60fbac85b4e2cbdae77cb759b22de58b/nh3-0.3.0-cp38-abi3-musllinux_1_2_aarch64.whl": "634e34e6162e0408e14fb61d5e69dbaea32f59e847cfcfa41b66100a6b796f62", + "https://files.pythonhosted.org/packages/6a/1b/b15bd1ce201a1a610aeb44afd478d55ac018b4475920a3118ffd806e2483/nh3-0.3.0-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl": "e9e6a7e4d38f7e8dda9edd1433af5170c597336c1a74b4693c5cb75ab2b30f2a", + "https://files.pythonhosted.org/packages/8c/ae/324b165d904dc1672eee5f5661c0a68d4bab5b59fbb07afb6d8d19a30b45/nh3-0.3.0-cp38-abi3-win_amd64.whl": "bae63772408fd63ad836ec569a7c8f444dd32863d0c67f6e0b25ebbd606afa95", + "https://files.pythonhosted.org/packages/8f/14/079670fb2e848c4ba2476c5a7a2d1319826053f4f0368f61fca9bb4227ae/nh3-0.3.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "7852f038a054e0096dac12b8141191e02e93e0b4608c4b993ec7d4ffafea4e49", + "https://files.pythonhosted.org/packages/97/03/03f79f7e5178eb1ad5083af84faff471e866801beb980cc72943a4397368/nh3-0.3.0-cp38-abi3-musllinux_1_2_i686.whl": "c7a32a7f0d89f7d30cb8f4a84bdbd56d1eb88b78a2434534f62c71dac538c450", + "https://files.pythonhosted.org/packages/97/33/11e7273b663839626f714cb68f6eb49899da5a0d9b6bc47b41fe870259c2/nh3-0.3.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl": "389d93d59b8214d51c400fb5b07866c2a4f79e4e14b071ad66c92184fec3a392", + "https://files.pythonhosted.org/packages/9a/e0/af86d2a974c87a4ba7f19bc3b44a8eaa3da480de264138fec82fe17b340b/nh3-0.3.0-cp313-cp313t-win_arm64.whl": "16f8670201f7e8e0e05ed1a590eb84bfa51b01a69dd5caf1d3ea57733de6a52f", + "https://files.pythonhosted.org/packages/a3/e5/ac7fc565f5d8bce7f979d1afd68e8cb415020d62fa6507133281c7d49f91/nh3-0.3.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl": "af5aa8127f62bbf03d68f67a956627b1bd0469703a35b3dad28d0c1195e6c7fb", + "https://files.pythonhosted.org/packages/ad/7f/7c6b8358cf1222921747844ab0eef81129e9970b952fcb814df417159fb9/nh3-0.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "7c915060a2c8131bef6a29f78debc29ba40859b6dbe2362ef9e5fd44f11487c2", + "https://files.pythonhosted.org/packages/b4/11/340b7a551916a4b2b68c54799d710f86cf3838a4abaad8e74d35360343bb/nh3-0.3.0-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl": "a537ece1bf513e5a88d8cff8a872e12fe8d0f42ef71dd15a5e7520fecd191bbb", + "https://files.pythonhosted.org/packages/c3/a4/96cff0977357f60f06ec4368c4c7a7a26cccfe7c9fcd54f5378bf0428fd3/nh3-0.3.0.tar.gz": "d8ba24cb31525492ea71b6aac11a4adac91d828aadeff7c4586541bf5dc34d2f", + "https://files.pythonhosted.org/packages/c9/50/76936ec021fe1f3270c03278b8af5f2079038116b5d0bfe8538ffe699d69/nh3-0.3.0-cp38-abi3-win32.whl": "6d68fa277b4a3cf04e5c4b84dd0c6149ff7d56c12b3e3fab304c525b850f613d", + "https://files.pythonhosted.org/packages/ce/55/1974bcc16884a397ee699cebd3914e1f59be64ab305533347ca2d983756f/nh3-0.3.0-cp38-abi3-musllinux_1_2_x86_64.whl": "3f1b4f8a264a0c86ea01da0d0c390fe295ea0bcacc52c2103aca286f6884f518", + "https://files.pythonhosted.org/packages/ee/db/7aa11b44bae4e7474feb1201d8dee04fabe5651c7cb51409ebda94a4ed67/nh3-0.3.0-cp38-abi3-musllinux_1_2_armv7l.whl": "b0612ccf5de8a480cf08f047b08f9d3fecc12e63d2ee91769cb19d7290614c23", + "https://files.pythonhosted.org/packages/f3/ba/59e204d90727c25b253856e456ea61265ca810cda8ee802c35f3fadaab00/nh3-0.3.0-cp313-cp313t-musllinux_1_2_armv7l.whl": "e90883f9f85288f423c77b3f5a6f4486375636f25f793165112679a7b6363b35" + }, + "pkginfo": { + "https://files.pythonhosted.org/packages/24/03/e26bf3d6453b7fda5bd2b84029a426553bb373d6277ef6b5ac8863421f87/pkginfo-1.12.1.2.tar.gz": "5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b", + "https://files.pythonhosted.org/packages/fa/3d/f4f2ba829efb54b6cd2d91349c7463316a9cc55a43fc980447416c88540f/pkginfo-1.12.1.2-py3-none-any.whl": "c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343" + }, + "playwright": { + "https://files.pythonhosted.org/packages/1e/62/a20240605485ca99365a8b72ed95e0b4c5739a13fb986353f72d8d3f1d27/playwright-1.52.0-py3-none-macosx_10_13_x86_64.whl": "19b2cb9d4794062008a635a99bd135b03ebb782d460f96534a91cb583f549512", + "https://files.pythonhosted.org/packages/4e/e9/0661d343ed55860bcfb8934ce10e9597fc953358773ece507b22b0f35c57/playwright-1.52.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "4173e453c43180acc60fd77ffe1ebee8d0efbfd9986c03267007b9c3845415af", + "https://files.pythonhosted.org/packages/51/f3/cca2aa84eb28ea7d5b85d16caa92d62d18b6e83636e3d67957daca1ee4c7/playwright-1.52.0-py3-none-win_amd64.whl": "dcbf75101eba3066b7521c6519de58721ea44379eb17a0dafa94f9f1b17f59e4", + "https://files.pythonhosted.org/packages/73/c6/8e27af9798f81465b299741ef57064c6ec1a31128ed297406469907dc5a4/playwright-1.52.0-py3-none-manylinux1_x86_64.whl": "d010124d24a321e0489a8c0d38a3971a7ca7656becea7656c9376bfea7f916d4", + "https://files.pythonhosted.org/packages/7a/81/a850dbc6bc2e1bd6cc87341e59c253269602352de83d34b00ea38cf410ee/playwright-1.52.0-py3-none-win32.whl": "cd0bdf92df99db6237a99f828e80a6a50db6180ef8d5352fc9495df2c92f9971", + "https://files.pythonhosted.org/packages/a2/ff/eee8532cff4b3d768768152e8c4f30d3caa80f2969bf3143f4371d377b74/playwright-1.52.0-py3-none-macosx_11_0_universal2.whl": "7223960b7dd7ddeec1ba378c302d1d09733b8dac438f492e9854c85d3ca7144f", + "https://files.pythonhosted.org/packages/b5/4f/71a8a873e8c3c3e2d3ec03a578e546f6875be8a76214d90219f752f827cd/playwright-1.52.0-py3-none-win_arm64.whl": "9d0085b8de513de5fb50669f8e6677f0252ef95a9a1d2d23ccee9638e71e65cb", + "https://files.pythonhosted.org/packages/dc/23/57ff081663b3061a2a3f0e111713046f705da2595f2f384488a76e4db732/playwright-1.52.0-py3-none-macosx_11_0_arm64.whl": "0797c0479cbdc99607412a3c486a3a2ec9ddc77ac461259fd2878c975bcbb94a" + }, + "pycparser": { + "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl": "e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", + "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz": "78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2" + }, + "pyee": { + "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz": "0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", + "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl": "af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228" + }, + "pygments": { + "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz": "636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", + "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl": "86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" + }, + "pywin32-ctypes": { + "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz": "d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", + "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl": "8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8" + }, + "readme-renderer": { + "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz": "8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", + "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl": "2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151" + }, + "requests": { + "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz": "c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", + "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl": "3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b" + }, + "requests-toolbelt": { + "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl": "cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", + "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz": "7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6" + }, + "rfc3986": { + "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz": "97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", + "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl": "50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd" + }, + "rich": { + "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl": "536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", + "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz": "e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8" + }, + "secretstorage": { + "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz": "2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", + "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl": "f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99" + }, + "twine": { + "https://files.pythonhosted.org/packages/5d/ec/00f9d5fd040ae29867355e559a94e9a8429225a0284a3f5f091a3878bfc0/twine-5.1.1-py3-none-any.whl": "215dbe7b4b94c2c50a7315c0275d2258399280fbb7d04182c7e55e24b5f93997", + "https://files.pythonhosted.org/packages/77/68/bd982e5e949ef8334e6f7dcf76ae40922a8750aa2e347291ae1477a4782b/twine-5.1.1.tar.gz": "9aa0825139c02b3434d913545c7b847a21c835e11597f5255842d457da2322db" + }, + "typing-extensions": { + "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl": "f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", + "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz": "0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" + }, + "urllib3": { + "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl": "bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", + "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz": "1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed" + }, + "zipp": { + "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl": "071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", + "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz": "a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166" + } + } + }, + "fact_version": "v1", + "index_urls": { + "https://pypi.org/simple/": { + "backports_tarfile": "/simple/backports-tarfile/", + "certifi": "/simple/certifi/", + "cffi": "/simple/cffi/", + "charset_normalizer": "/simple/charset-normalizer/", + "cryptography": "/simple/cryptography/", + "docutils": "/simple/docutils/", + "greenlet": "/simple/greenlet/", + "idna": "/simple/idna/", + "importlib_metadata": "/simple/importlib-metadata/", + "jaraco_classes": "/simple/jaraco-classes/", + "jaraco_context": "/simple/jaraco-context/", + "jaraco_functools": "/simple/jaraco-functools/", + "jeepney": "/simple/jeepney/", + "keyring": "/simple/keyring/", + "markdown_it_py": "/simple/markdown-it-py/", + "mdurl": "/simple/mdurl/", + "more_itertools": "/simple/more-itertools/", + "nh3": "/simple/nh3/", + "pkginfo": "/simple/pkginfo/", + "playwright": "/simple/playwright/", + "pycparser": "/simple/pycparser/", + "pyee": "/simple/pyee/", + "pygments": "/simple/pygments/", + "pywin32_ctypes": "/simple/pywin32-ctypes/", + "readme_renderer": "/simple/readme-renderer/", + "requests": "/simple/requests/", + "requests_toolbelt": "/simple/requests-toolbelt/", + "rfc3986": "/simple/rfc3986/", + "rich": "/simple/rich/", + "secretstorage": "/simple/secretstorage/", + "twine": "/simple/twine/", + "typing_extensions": "/simple/typing-extensions/", + "urllib3": "/simple/urllib3/", + "zipp": "/simple/zipp/" + } + } + } + } } diff --git a/clean.sh b/clean.sh new file mode 100755 index 00000000..83bfd4c1 --- /dev/null +++ b/clean.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Run Bazel clean, then remove web/ WASM copies (not part of bazel-out). +# Usage: ./clean.sh [--expunge] [other bazel clean flags...] +set -euo pipefail +cd "$(dirname "$0")" +if command -v bazelisk >/dev/null 2>&1; then + bazelisk clean "$@" +else + bazel clean "$@" +fi +./web/clean_wasm.sh diff --git a/docs/wasm_build.md b/docs/wasm_build.md index 9cfca6db..f071beea 100644 --- a/docs/wasm_build.md +++ b/docs/wasm_build.md @@ -1,6 +1,6 @@ # WebAssembly (WASM) Build Guide -This document explains how to build the DDS library and examples for WebAssembly using Bazel. +This document explains how to build DDS examples for WebAssembly using Bazel. ## Prerequisites @@ -9,128 +9,148 @@ This document explains how to build the DDS library and examples for WebAssembly Bazel 7.x or later is required. Install using your package manager or download from: https://bazel.build/install -The Emscripten SDK (emsdk) does NOT need to be manually installed. Bazel will automatically download, configure, and cache the appropriate hermetic Emscripten toolchain for your host platform as part of the build process. +The Emscripten SDK (emsdk) does NOT need to be manually installed. Bazel downloads and caches a hermetic Emscripten toolchain when you build a `wasm_cc_binary` target. -## Building WASM Examples +### Emscripten / emsdk version -### Build All Examples +WASM builds use the Bazel Central Registry package pinned in `MODULE.bazel`: -```bash -cd /workspaces/dds -bazel build //examples:all_examples_wasm -``` +- `bazel_dep(name = "emsdk", version = "5.0.7")` (Emscripten 3.1.x toolchain) -### Build Specific Example +If you upgrade `emsdk`, rebuild WASM targets and the web MVP (`./web/update_wasm.sh`). The post-build script `web/patch_mvp_wasm.py` patches one generated `isFileURI` helper for browser/file URL safety; if Emscripten changes that line, update the regex in that script and this note. + +## Building WASM Examples + +WASM targets use `wasm_cc_binary`, which applies an Emscripten **platform transition** to the underlying `cc_binary`. You do **not** need `--config=wasm` or any other `.bazelrc` profile. + +### Build all WASM examples ```bash -bazel build //examples:solve_board_wasm +bazel build //examples/wasm:all_examples_wasm ``` -### Output Files +The alias `//examples:all_examples_wasm` points at the same filegroup. -the output files will be located in: -``` -bazel-bin/examples/ -``` +### Build a specific example -Depending on the target, you'll get: -- `target.js` - JavaScript bindings -- `target.wasm` - WebAssembly binary +```bash +bazel build //examples/wasm:solve_board_wasm +``` +### Output files -## Available WASM Targets +Outputs are under `bazel-bin/examples/wasm/`: -The following example targets are available for WASM builds: +- `solve_board.js` / `solve_board.wasm` +- `AnalysePlayBin.js` / `AnalysePlayBin.wasm` +- `calc_dd_table_pbn.js` / `calc_dd_table_pbn.wasm` -### Implemented Examples -- `solve_board` - Solves a single board (produces .js and .wasm) +## Available WASM targets -- `analyse_play_bin` - Analyze play from binary format +Rules in `examples/wasm/BUILD.bazel` wrap native examples in `examples/`: +- `solve_board_wasm` — solves a single board +- `analyse_play_bin_wasm` — analyze play from binary format +- `calc_dd_table_pbn_wasm` — double-dummy table from PBN -## WASM Build Configuration +## How it works -The WASM build is configured through: +1. **`wasm_cc_binary`** (from `@emsdk`) transitions its `cc_target` to `@emsdk//:platform_wasm` and sets `--cpu=wasm`. +2. **`//:build_wasm`** in the root `BUILD.bazel` matches `@platforms//cpu:wasm32` for `select()` in `CPPVARIABLES.bzl` and example link flags. +3. **`wasm_compat.bzl`** — shared Emscripten link flags (`WASM_LINKOPTS`) on the WASM-capable `cc_binary` targets. -1. **BUILD.bazel** - Defines the WASM config_setting: - ```python - config_setting( - name = "build_wasm", - values = {"cpu": "wasm"}, - ) - ``` +Native builds (`bazel build //...`, `bazel test //library/tests/...`, Python bindings) are unchanged and use the host LLVM toolchain. -2. **CPPVARIABLES.bzl** - Specifies WASM-specific compiler flags: - - Optimization: `-O3 -flto` - - Exceptions: `-fexceptions` - - Define: `-D__WASM__` +## Running WASM examples -3. **.bazelrc** - Contains the `wasm` profile: - ``` - build:wasm --platforms=@emsdk//:platform_wasm - build:wasm --cpu=wasm - build:wasm --cxxopt=-std=c++20 - build:wasm --host_cxxopt=-std=c++20 - build:wasm --compilation_mode=opt - ``` +### Node.js -## Running WASM Examples +```bash +node bazel-bin/examples/wasm/solve_board.js +``` -After building, you can run the examples: +### Web browser (DDS MVP) -### Node.js (requires Node.js installed) +The `web/` demo calls `CalcDDtablePBN` in the browser via `//web:dds_mvp_wasm`: ```bash -node bazel-bin/examples/solve_board.js +./web/update_wasm.sh +python3 -m http.server 8080 --directory web +# open http://localhost:8080/dds_mvp.html ``` -### Web Browser (TODO) +The MVP loads wasm from `dds_mvp_wasm_bin.js` (base64, no network fetch), so `file://` and HTTP both work. Run `./web/update_wasm.sh` to refresh `dds_mvp_wasm.{js,wasm,bin.js}` (includes a small post-process step for Emscripten `isFileURI`; see **Emscripten / emsdk version** above). -For HTML targets, open the generated HTML file in a web browser: -```bash -# Copy the output files to a web-accessible location -cp bazel-bin/examples/solve_board.* /path/to/webserver/ +`bazel clean` does not delete those copied files under `web/` (they live outside `bazel-out`). Use either: -# Then open in browser at http://localhost:8000/solve_board.html +```bash +./clean.sh # bazel clean + remove web/dds_mvp_wasm.* +./clean.sh --expunge +./web/clean_wasm.sh # web artifacts only ``` -## Compilation Flags +For other experiments, copy built `.js` / `.wasm` files from `bazel-bin/examples/wasm/` to any static file server. -The WASM build uses the following key flags: +## Compilation flags | Flag | Purpose | |------|---------| | `-O3` | Aggressive optimization | | `-flto` | Link-time optimization | | `-fexceptions` | Enable C++ exceptions | -| `-D__WASM__` | Defines `__WASM__` preprocessor constant | +| `-D__WASM__` | Preprocessor constant for WASM builds | | `-sWASM=1` | Emscripten WASM output (link flag) | | `-sALLOW_MEMORY_GROWTH=1` | Allow heap growth at runtime | -| `-sINITIAL_MEMORY=268435456` | Configure 256MB initial memory | +| `-sINITIAL_MEMORY=268435456` | 256MB initial memory | +| `-sSTACK_SIZE=8388608` | 8MB stack (default 64KB is too small for DDS search) | + +## C++ standard -## C++ Standard +WASM example binaries are built as C++20. The Emscripten toolchain (via `wasm_cc_binary`) compiles the transitioned `cc_target`; project flags for that configuration come from `CPPVARIABLES.bzl` when `//:build_wasm` matches (the Emscripten platform transition sets `wasm32`). + +Host-side Bazel actions still use the normal platform flags in `.bazelrc`: -The WASM build uses C++20 as specified in `.bazelrc`: ``` -build:wasm --cxxopt=-std=c++20 +build:macos --cxxopt=-std=c++20 +build:linux --cxxopt=-std=c++20 ``` -## Related Documentation +There is no separate `build:wasm` profile in `.bazelrc`; WASM builds are selected by targeting `//examples/wasm:*`. -- [Emscripten Documentation](https://emscripten.org/docs/) -- [Bazel Build System](https://bazel.build/docs) -- [DDS C++ API](c++_interface.md) -- [Build System Overview](BUILD_SYSTEM.md) +## Tests + +Unit and system tests (Node.js required for system tests; skipped if `node` is not on `PATH`): + +```bash +bazel test //web:web_tests //web:web_system_tests //web:web_e2e_tests +bazel test //examples/wasm:all +``` + +`bazel test //...` skips targets tagged `e2e` by default (see `.bazelrc`). Run Playwright tests explicitly, e.g. `bazel test //web:web_e2e_tests` or `bazel test --test_tag_filters=e2e //web:dds_mvp_e2e_test`. To run all tests, including the Playwright tests: `bazel test --test_tag_filters= /...` + +- **`//web:dds_mvp_wasm_system_test`** — builds `//web:dds_mvp_wasm`, runs `patch_mvp_wasm` / `gen_wasm_bin_js` / `verify_wasm_js`, then calls `dds_mvp_calc_table` via Node (`web/tests/dds_mvp_wasm_node.mjs`). +- **`//web:dds_mvp_e2e_test`** — Playwright tests for `dds_mvp.html` over `file://` and HTTP (part-score deal table, validation error). Requires Node, network (Chromium download on first run), and `tags = ["no-sandbox"]`. +- **`//examples/wasm:wasm_examples_system_test`** — runs `calc_dd_table_pbn.js` under Node and checks for `OK` on all three example hands. -## Next Steps +The MVP link flags include `-sENVIRONMENT=web,node` so the same `.js` / `.wasm` artifacts work in the browser and in Node system tests. + +## Development notes + +- The `__WASM__` preprocessor constant is defined for WASM builds (`CPPVARIABLES.bzl`). It was added to work around platform-specific code paths; revisit whether it can be narrowed or removed as WASM support matures. +- Some threading and platform-specific features are disabled or stubbed when `__WASM__` is set. +- A reusable `cc_library` WASM artifact (not only example binaries) is not yet provided; today only `wasm_cc_binary` example targets are wired up. +- The browser MVP lives under `web/`; see **Web browser (DDS MVP)** above and `//web:web_system_tests`. + +## Next steps To integrate WASM builds into CI/CD: -1. See `.github/workflows/ci_linux.yml` and `.github/workflows/ci_macos.yml` -2. Store WASM artifacts for download/release -## Development Notes +1. See `.github/workflows/ci_wasm.yml` +2. Store WASM artifacts (`*.js`, `*.wasm`) for download or release as needed + +## Related documentation -- The `__WASM__` preprocessor constant is added initially to bypass error, need to check again whether we can remove -- Some threading and platform-specific features are disabled for WASM -- The build flow for a reusable library wasm -- A good HTML example +- [Emscripten Documentation](https://emscripten.org/docs/) +- [Bazel Build System](https://bazel.build/docs) +- [DDS C++ API](c++_interface.md) +- [Build System Overview](BUILD_SYSTEM.md) diff --git a/examples/BUILD.bazel b/examples/BUILD.bazel index e577e567..8b0b67d3 100644 --- a/examples/BUILD.bazel +++ b/examples/BUILD.bazel @@ -1,60 +1,6 @@ load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library") load("//:CPPVARIABLES.bzl", "DDS_CPPOPTS", "DDS_LINKOPTS", "DDS_LOCAL_DEFINES") -load("@emsdk//emscripten_toolchain:wasm_rules.bzl", "wasm_cc_binary") - -filegroup( - name = "solve_board_wasm_inputs", - srcs = [ - "hands.cpp", - "solve_board.cpp", - "//library/src:dds_wasm_sources", - "//library/src:testable_dds_headers", - "//library/src/heuristic_sorting:wasm_sources", - "//library/src/lookup_tables:wasm_sources", - "//library/src/moves:wasm_sources", - "//library/src/solver_context:wasm_sources", - "//library/src/system:wasm_sources", - "//library/src/trans_table:wasm_sources", - "//library/src/utility:wasm_sources", - ], -) - -filegroup( - name = "analyse_play_bin_wasm_inputs", - srcs = [ - "hands.cpp", - "analyse_play_bin.cpp", - "//library/src:dds_wasm_sources", - "//library/src:testable_dds_headers", - "//library/src/heuristic_sorting:wasm_sources", - "//library/src/lookup_tables:wasm_sources", - "//library/src/moves:wasm_sources", - "//library/src/solver_context:wasm_sources", - "//library/src/system:wasm_sources", - "//library/src/trans_table:wasm_sources", - "//library/src/utility:wasm_sources", - ], -) - -wasm_cc_binary( - name = "solve_board_wasm", - cc_target = ":solve_board", - outputs = [ - "solve_board.js", - "solve_board.wasm", - ], - tags = ["manual"], -) - -wasm_cc_binary( - name = "analyse_play_bin_wasm", - cc_target = ":AnalysePlayBin", - outputs = [ - "AnalysePlayBin.js", - "AnalysePlayBin.wasm", - ], - tags = ["manual"], -) +load("//:wasm_compat.bzl", "WASM_LINKOPTS") # Examples use legacy C-style code, so we need to suppress certain warnings EXAMPLES_CPPOPTS = DDS_CPPOPTS + select({ @@ -73,14 +19,9 @@ EXAMPLES_LOCAL_DEFINES = DDS_LOCAL_DEFINES + select({ "//conditions:default": [], }) -# WASM-specific link flags for em++ output (produces .html, .js, .wasm) +# Emscripten link flags for examples built as WASM (see //examples/wasm). EXAMPLES_LINKOPTS_WASM = select({ - "//:build_wasm": [ - "-sWASM=1", - "-fexceptions", - "-sALLOW_MEMORY_GROWTH=1", - "-sINITIAL_MEMORY=268435456", - ], + "//:build_wasm": WASM_LINKOPTS, "//conditions:default": [], }) @@ -89,8 +30,10 @@ cc_library( name = "hands", srcs = ["hands.cpp"], hdrs = ["hands.hpp"], + includes = ["."], copts = EXAMPLES_CPPOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, + visibility = ["//examples:__subpackages__"], deps = [ "//library/src:dds", "//library/src/api:api_definitions", @@ -102,7 +45,7 @@ cc_binary( name = "analyse_all_plays_bin", srcs = ["analyse_all_plays_bin.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, deps = [ ":hands", @@ -115,7 +58,7 @@ cc_binary( name = "analyse_all_plays_pbn", srcs = ["analyse_all_plays_pbn.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, deps = [ ":hands", @@ -130,6 +73,7 @@ cc_binary( copts = EXAMPLES_CPPOPTS, linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, local_defines = EXAMPLES_LOCAL_DEFINES, + visibility = ["//examples/wasm:__pkg__"], deps = [ ":hands", "//library/src:dds", @@ -141,7 +85,7 @@ cc_binary( name = "analyse_play_pbn", srcs = ["analyse_play_pbn.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, deps = [ ":hands", @@ -154,7 +98,7 @@ cc_binary( name = "calc_all_tables", srcs = ["calc_all_tables.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, deps = [ ":hands", @@ -167,7 +111,7 @@ cc_binary( name = "calc_all_tables_pbn", srcs = ["calc_all_tables_pbn.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, deps = [ ":hands", @@ -180,7 +124,7 @@ cc_binary( name = "calc_dd_table", srcs = ["calc_dd_table.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, deps = [ ":hands", @@ -195,6 +139,7 @@ cc_binary( copts = EXAMPLES_CPPOPTS, linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, local_defines = EXAMPLES_LOCAL_DEFINES, + visibility = ["//examples/wasm:__pkg__"], deps = [ ":hands", "//library/src:dds", @@ -206,7 +151,7 @@ cc_binary( name = "dealer_par", srcs = ["dealer_par.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, deps = [ ":hands", @@ -219,7 +164,7 @@ cc_binary( name = "par", srcs = ["par.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, deps = [ ":hands", @@ -232,7 +177,7 @@ cc_binary( name = "solve_all_boards", srcs = ["solve_all_boards.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, deps = [ ":hands", @@ -247,6 +192,7 @@ cc_binary( copts = EXAMPLES_CPPOPTS, linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, local_defines = EXAMPLES_LOCAL_DEFINES, + visibility = ["//examples/wasm:__pkg__"], deps = [ ":hands", "//library/src:dds", @@ -258,7 +204,7 @@ cc_binary( name = "solve_board_pbn", srcs = ["solve_board_pbn.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, deps = [ ":hands", @@ -271,7 +217,7 @@ cc_binary( name = "migration_example", srcs = ["migration_example.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, deps = [ "//library/src:dds", @@ -283,7 +229,7 @@ cc_binary( name = "calc_par_context_example", srcs = ["calc_par_context_example.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, deps = [ ":hands", @@ -293,7 +239,7 @@ cc_binary( ], ) -# Convenience target to build all examples +# Convenience target to build all native examples filegroup( name = "all_examples", srcs = [ @@ -315,11 +261,8 @@ filegroup( ], ) -filegroup( +# Backward-compatible alias; prefer //examples/wasm:all_examples_wasm. +alias( name = "all_examples_wasm", - srcs = [ - ":analyse_play_bin_wasm", - ":solve_board_wasm", - ], - tags = ["manual"], + actual = "//examples/wasm:all_examples_wasm", ) diff --git a/examples/calc_dd_table_pbn.cpp b/examples/calc_dd_table_pbn.cpp index 31188dcb..7ca7fba7 100644 --- a/examples/calc_dd_table_pbn.cpp +++ b/examples/calc_dd_table_pbn.cpp @@ -27,7 +27,7 @@ auto main() -> int char line[80]; bool match; -#if defined(__linux) || defined(__APPLE__) +#if defined(__linux) || defined(__APPLE__) || defined(__WASM__) SetMaxThreads(0); #endif diff --git a/examples/wasm/BUILD.bazel b/examples/wasm/BUILD.bazel new file mode 100644 index 00000000..b9d78014 --- /dev/null +++ b/examples/wasm/BUILD.bazel @@ -0,0 +1,77 @@ +load("@rules_cc//cc:defs.bzl", "cc_test") +load("@emsdk//emscripten_toolchain:wasm_rules.bzl", "wasm_cc_binary") +load("@rules_python//python:defs.bzl", "py_test") +load("//:CPPVARIABLES.bzl", "DDS_CPPOPTS", "DDS_LOCAL_DEFINES") + +wasm_cc_binary( + name = "solve_board_wasm", + cc_target = "//examples:solve_board", + outputs = [ + "solve_board.js", + "solve_board.wasm", + ], +) + +wasm_cc_binary( + name = "analyse_play_bin_wasm", + cc_target = "//examples:AnalysePlayBin", + outputs = [ + "AnalysePlayBin.js", + "AnalysePlayBin.wasm", + ], +) + +wasm_cc_binary( + name = "calc_dd_table_pbn_wasm", + cc_target = "//examples:calc_dd_table_pbn", + outputs = [ + "calc_dd_table_pbn.js", + "calc_dd_table_pbn.wasm", + ], +) + +filegroup( + name = "all_examples_wasm", + srcs = [ + ":analyse_play_bin_wasm", + ":calc_dd_table_pbn_wasm", + ":solve_board_wasm", + ], + visibility = ["//visibility:public"], +) + +cc_test( + name = "calc_dd_table_pbn_test", + size = "small", + srcs = ["calc_dd_table_pbn_test.cpp"], + copts = DDS_CPPOPTS, + local_defines = DDS_LOCAL_DEFINES, + deps = [ + "//examples:hands", + "//library/src:dds", + "//library/src/api:api_definitions", + "@googletest//:gtest_main", + ], +) + +py_test( + name = "wasm_examples_system_test", + size = "small", + timeout = "short", + main = "tests/test_wasm_examples_system.py", + srcs = ["tests/test_wasm_examples_system.py"], + data = [":calc_dd_table_pbn_wasm"], +) + +test_suite( + name = "all", + tests = [ + ":calc_dd_table_pbn_test", + ":wasm_examples_system_test", + ], +) + +test_suite( + name = "wasm_system_tests", + tests = [":wasm_examples_system_test"], +) diff --git a/examples/wasm/calc_dd_table_pbn_test.cpp b/examples/wasm/calc_dd_table_pbn_test.cpp new file mode 100644 index 00000000..01a900c9 --- /dev/null +++ b/examples/wasm/calc_dd_table_pbn_test.cpp @@ -0,0 +1,28 @@ +/* + Regression tests for CalcDDtablePBN (WASM example target). + + Copyright 2020-2026 Adam Wildavsky + Use of this source code is governed by the MIT license. +*/ + +#include + +#include + +#include +#include "hands.hpp" + +TEST(CalcDdTablePbnWasmTest, MatchesReferenceTables) { +#if defined(__linux) || defined(__APPLE__) || defined(__WASM__) + SetMaxThreads(0); +#endif + + for (int handno = 0; handno < 3; ++handno) { + DdTableDealPBN deal{}; + std::strcpy(deal.cards, pbn_hands_[handno]); + + DdTableResults table{}; + ASSERT_EQ(CalcDDtablePBN(deal, &table), RETURN_NO_FAULT) << "hand " << handno; + EXPECT_TRUE(compare_table(&table, handno)) << "hand " << handno; + } +} diff --git a/examples/wasm/tests/test_wasm_examples_system.py b/examples/wasm/tests/test_wasm_examples_system.py new file mode 100644 index 00000000..c16fc794 --- /dev/null +++ b/examples/wasm/tests/test_wasm_examples_system.py @@ -0,0 +1,44 @@ +"""System tests: run WASM example binaries under Node.js.""" +from __future__ import annotations + +import os +import shutil +import subprocess +import unittest +from pathlib import Path + + +def _runfiles_root() -> Path: + for key in ("RUNFILES_DIR", "TEST_SRCDIR"): + if key in os.environ: + return Path(os.environ[key]) + raise RuntimeError("not running under Bazel test") + + +def rlocation(relpath: str) -> Path: + root = _runfiles_root() + for candidate in (root / relpath, root / "_main" / relpath): + if candidate.exists(): + return candidate + raise FileNotFoundError(relpath) + + +@unittest.skipUnless(shutil.which("node"), "node not found") +class WasmExamplesSystemTest(unittest.TestCase): + def test_calc_dd_table_pbn_wasm(self) -> None: + js = rlocation("examples/wasm/calc_dd_table_pbn.js") + proc = subprocess.run( + ["node", str(js)], + capture_output=True, + text=True, + check=False, + timeout=120, + ) + self.assertEqual(proc.returncode, 0, msg=proc.stderr) + for hand in (1, 2, 3): + self.assertIn(f"CalcDDtable, hand {hand}: OK", proc.stdout) + self.assertNotIn("ERROR", proc.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/library/src/BUILD.bazel b/library/src/BUILD.bazel index ca141ddd..730bc3b7 100644 --- a/library/src/BUILD.bazel +++ b/library/src/BUILD.bazel @@ -64,16 +64,6 @@ filegroup( ], ) -filegroup( - name = "dds_wasm_sources", - srcs = glob([ - "**/*.cpp", - ]), - visibility = [ - "//examples:__pkg__", - ], -) - cc_library( name = "testable_dds", srcs = ["//library/src:testable_dds_sources"], diff --git a/library/src/heuristic_sorting/BUILD.bazel b/library/src/heuristic_sorting/BUILD.bazel index e6e7ffb1..6ce9cdcd 100644 --- a/library/src/heuristic_sorting/BUILD.bazel +++ b/library/src/heuristic_sorting/BUILD.bazel @@ -44,12 +44,4 @@ cc_library( "//library/tests/heuristic_sorting:__pkg__", "//library/tests/regression/heuristic_sorting:__pkg__", ], -) - -filegroup( - name = "wasm_sources", - srcs = ["heuristic_sorting.cpp"], - visibility = [ - "//examples:__pkg__", - ], ) \ No newline at end of file diff --git a/library/src/init.cpp b/library/src/init.cpp index 5b003041..27243514 100644 --- a/library/src/init.cpp +++ b/library/src/init.cpp @@ -9,6 +9,8 @@ #include #include +#include +#include #include #include "init.hpp" diff --git a/library/src/lookup_tables/BUILD.bazel b/library/src/lookup_tables/BUILD.bazel index 2e35514c..76f9c93f 100644 --- a/library/src/lookup_tables/BUILD.bazel +++ b/library/src/lookup_tables/BUILD.bazel @@ -14,11 +14,3 @@ cc_library( linkopts = DDS_LINKOPTS, local_defines = DDS_LOCAL_DEFINES, ) - -filegroup( - name = "wasm_sources", - srcs = ["lookup_tables.cpp"], - visibility = [ - "//examples:__pkg__", - ], -) diff --git a/library/src/moves/BUILD.bazel b/library/src/moves/BUILD.bazel index 75d13fff..317f5645 100644 --- a/library/src/moves/BUILD.bazel +++ b/library/src/moves/BUILD.bazel @@ -35,12 +35,4 @@ cc_library( visibility = [ "//library/tests/moves:__pkg__", ], -) - -filegroup( - name = "wasm_sources", - srcs = glob(["*.cpp"]), - visibility = [ - "//examples:__pkg__", - ], ) \ No newline at end of file diff --git a/library/src/solve_board.cpp b/library/src/solve_board.cpp index 30a69e3a..82d76b8d 100644 --- a/library/src/solve_board.cpp +++ b/library/src/solve_board.cpp @@ -78,10 +78,21 @@ auto solve_all_boards_n( START_BLOCK_TIMER; { - std::vector threads; + // Avoid std::jthread here: Emscripten's libc++ on Windows does not + // provide it yet, while std::thread is widely available. + std::vector threads; threads.reserve(static_cast(nthreads)); - for (int i = 0; i < nthreads; ++i) - threads.emplace_back(worker); + try { + for (int i = 0; i < nthreads; ++i) + threads.emplace_back(worker); + } catch (...) { + for (auto& t : threads) + if (t.joinable()) + t.join(); + throw; + } + for (auto& t : threads) + t.join(); } END_BLOCK_TIMER; diff --git a/library/src/solver_context/BUILD.bazel b/library/src/solver_context/BUILD.bazel index d25dea9e..a566211b 100644 --- a/library/src/solver_context/BUILD.bazel +++ b/library/src/solver_context/BUILD.bazel @@ -51,11 +51,3 @@ cc_library( linkopts = DDS_LINKOPTS, local_defines = DDS_LOCAL_DEFINES + DDS_SCHEDULER_DEFINE + ["DDS_UTILITIES_STATS"], ) - -filegroup( - name = "wasm_sources", - srcs = ["solver_context.cpp"], - visibility = [ - "//examples:__pkg__", - ], -) diff --git a/library/src/system/BUILD.bazel b/library/src/system/BUILD.bazel index 8ef12ac3..fb05075a 100644 --- a/library/src/system/BUILD.bazel +++ b/library/src/system/BUILD.bazel @@ -59,11 +59,3 @@ cc_library( linkopts = DDS_LINKOPTS, local_defines = DDS_LOCAL_DEFINES + DDS_SCHEDULER_DEFINE + ["DDS_UTILITIES_STATS"], ) - -filegroup( - name = "wasm_sources", - srcs = glob(["*.cpp"]), - visibility = [ - "//examples:__pkg__", - ], -) diff --git a/library/src/system/system.cpp b/library/src/system/system.cpp index 81342817..b6965cf3 100644 --- a/library/src/system/system.cpp +++ b/library/src/system/system.cpp @@ -205,7 +205,12 @@ void System::get_hardware( kilobytes_free = 0; core_count = System::get_cores(); -#if defined(_WIN32) || defined(__CYGWIN__) +#if defined(__EMSCRIPTEN__) + // sysconf/physical memory queries are unreliable under Emscripten; use a + // conservative default so SetResources allocates a usable transposition table. + kilobytes_free = 512ULL * 1024; + core_count = 1; +#elif defined(_WIN32) || defined(__CYGWIN__) // Using GlobalMemoryStatusEx instead of GlobalMemoryStatus // was suggested by Lorne Anderson. MEMORYSTATUSEX statex; @@ -217,10 +222,7 @@ void System::get_hardware( SYSTEM_INFO sysinfo; GetSystemInfo(&sysinfo); core_count = static_cast(sysinfo.dwNumberOfProcessors); - return; -#endif - -#ifdef __APPLE__ +#elif defined(__APPLE__) // The code for Mac OS X was suggested by Matthew Kidd. // This is physical memory, rather than "free" memory as below @@ -240,10 +242,7 @@ void System::get_hardware( } core_count = sysconf(_SC_NPROCESSORS_ONLN); - return; -#endif - -#ifdef __linux__ +#elif defined(__linux__) // Use half of the physical memory long pages = sysconf (_SC_PHYS_PAGES); long pagesize = sysconf (_SC_PAGESIZE); @@ -253,7 +252,9 @@ void System::get_hardware( kilobytes_free = 1024 * 1024; // guess 1GB core_count = sysconf(_SC_NPROCESSORS_ONLN); - return; +#else + // Fallback if no platform is detected + kilobytes_free = 512ULL * 1024; #endif } diff --git a/library/src/trans_table/BUILD.bazel b/library/src/trans_table/BUILD.bazel index b3cf2f13..d9caef65 100644 --- a/library/src/trans_table/BUILD.bazel +++ b/library/src/trans_table/BUILD.bazel @@ -47,15 +47,4 @@ cc_library( visibility = [ "//library/tests/trans_table:__pkg__", ], -) - -filegroup( - name = "wasm_sources", - srcs = [ - "trans_table_l.cpp", - "trans_table_s.cpp", - ], - visibility = [ - "//examples:__pkg__", - ], ) \ No newline at end of file diff --git a/library/src/utility/BUILD.bazel b/library/src/utility/BUILD.bazel index 874ade9b..214f0e9f 100644 --- a/library/src/utility/BUILD.bazel +++ b/library/src/utility/BUILD.bazel @@ -19,12 +19,3 @@ cc_library( deps = [], ) -filegroup( - name = "wasm_sources", - srcs = ["constants.cpp"], - visibility = [ - "//examples:__pkg__", - ], -) - - diff --git a/library/tests/heuristic_sorting/BUILD.bazel b/library/tests/heuristic_sorting/BUILD.bazel index 80e0db94..3f564dd7 100644 --- a/library/tests/heuristic_sorting/BUILD.bazel +++ b/library/tests/heuristic_sorting/BUILD.bazel @@ -2,6 +2,7 @@ load("//:CPPVARIABLES.bzl", "DDS_CPPOPTS", "DDS_LINKOPTS", "DDS_LOCAL_DEFINES") load("@rules_cc//cc:defs.bzl", "cc_library") load("@rules_cc//cc:defs.bzl", "cc_test", "cc_binary") + cc_test( name = "heuristic_sorting_test", size = "small", @@ -63,4 +64,4 @@ cc_test( "//library/src/moves:moves", "@googletest//:gtest_main", ], -) +) \ No newline at end of file diff --git a/library/tests/moves/BUILD.bazel b/library/tests/moves/BUILD.bazel index 47939520..5eb94e20 100644 --- a/library/tests/moves/BUILD.bazel +++ b/library/tests/moves/BUILD.bazel @@ -1,6 +1,5 @@ load("@rules_cc//cc:defs.bzl", "cc_test") -package(default_visibility = ["//visibility:public"]) cc_test( name = "moves_test", diff --git a/library/tests/regression/heuristic_sorting/BUILD.bazel b/library/tests/regression/heuristic_sorting/BUILD.bazel index 2c7ef1c9..0671095c 100644 --- a/library/tests/regression/heuristic_sorting/BUILD.bazel +++ b/library/tests/regression/heuristic_sorting/BUILD.bazel @@ -1,5 +1,6 @@ load("@rules_cc//cc:defs.bzl", "cc_test") + # Regression tests for heuristic sorting behavior cc_test( diff --git a/library/tests/solve_board/BUILD.bazel b/library/tests/solve_board/BUILD.bazel index 222909fd..a8ec48b8 100644 --- a/library/tests/solve_board/BUILD.bazel +++ b/library/tests/solve_board/BUILD.bazel @@ -1,6 +1,8 @@ # Solve_board regression tests load("@rules_cc//cc:defs.bzl", "cc_test") +load("//:CPPVARIABLES.bzl", "DDS_CPPOPTS") + cc_test( name = "solve_board_test", @@ -12,6 +14,7 @@ cc_test( "//library/src:testable_dds", "@googletest//:gtest_main", ], + copts = DDS_CPPOPTS, ) cc_test( @@ -24,4 +27,5 @@ cc_test( "//library/src:testable_dds", "@googletest//:gtest_main", ], + copts = DDS_CPPOPTS, ) diff --git a/library/tests/trans_table/BUILD.bazel b/library/tests/trans_table/BUILD.bazel index 56a4e0cb..b7374c98 100644 --- a/library/tests/trans_table/BUILD.bazel +++ b/library/tests/trans_table/BUILD.bazel @@ -1,5 +1,6 @@ load("@rules_cc//cc:defs.bzl", "cc_library", "cc_test") + cc_library( name = "test_utilities", srcs = [ @@ -31,4 +32,4 @@ cc_test( ":test_utilities", "@googletest//:gtest_main", ], -) +) \ No newline at end of file diff --git a/library/tests/utility/BUILD.bazel b/library/tests/utility/BUILD.bazel index 5504c7c4..16101a17 100644 --- a/library/tests/utility/BUILD.bazel +++ b/library/tests/utility/BUILD.bazel @@ -1,5 +1,6 @@ load("@rules_cc//cc:defs.bzl", "cc_test") + # Utility tests: constants, lookup tables, and RNG determinism validation cc_test( diff --git a/wasm/BUILD.bazel b/wasm/BUILD.bazel new file mode 100644 index 00000000..9bb72912 --- /dev/null +++ b/wasm/BUILD.bazel @@ -0,0 +1 @@ +# WASM helpers live in //:wasm_compat.bzl (no build targets in this package). diff --git a/wasm_compat.bzl b/wasm_compat.bzl new file mode 100644 index 00000000..c3fea9b7 --- /dev/null +++ b/wasm_compat.bzl @@ -0,0 +1,11 @@ +"""Shared attributes for WebAssembly (Emscripten) builds.""" + +# Emscripten link flags for cc_binary targets wrapped by wasm_cc_binary. +WASM_LINKOPTS = [ + "-sWASM=1", + "-fexceptions", + "-sALLOW_MEMORY_GROWTH=1", + "-sINITIAL_MEMORY=268435456", + # DDS search recursion needs more than Emscripten's 64KB default stack. + "-sSTACK_SIZE=8388608", +] diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 00000000..cdefec86 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,5 @@ +# Generated by update_wasm.sh + +dds_mvp_wasm.js +dds_mvp_wasm_bin.js +dds_mvp_wasm.wasm \ No newline at end of file diff --git a/web/BUILD.bazel b/web/BUILD.bazel new file mode 100644 index 00000000..6a439057 --- /dev/null +++ b/web/BUILD.bazel @@ -0,0 +1,142 @@ +load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_test") +load("@emsdk//emscripten_toolchain:wasm_rules.bzl", "wasm_cc_binary") +load("@rules_python//python:defs.bzl", "py_library", "py_test") +load("//:CPPVARIABLES.bzl", "DDS_CPPOPTS", "DDS_LINKOPTS", "DDS_LOCAL_DEFINES") +load("//:wasm_compat.bzl", "WASM_LINKOPTS") + +# Emscripten flags for the browser MVP (modularized, no auto-run main). +WASM_MVP_LINKOPTS = WASM_LINKOPTS + [ + "-sMODULARIZE=1", + "-sEXPORT_NAME=createDdsModule", + "-sEXPORTED_FUNCTIONS=['_dds_mvp_calc_table','_malloc','_free']", + "-sEXPORTED_RUNTIME_METHODS=['ccall','getValue']", + "-sENVIRONMENT=web,node", +] + +cc_binary( + name = "dds_mvp_wasm_cc", + srcs = ["dds_mvp_wasm.cpp"], + copts = DDS_CPPOPTS, + linkopts = DDS_LINKOPTS + select({ + "//:build_wasm": WASM_MVP_LINKOPTS, + "//conditions:default": [], + }), + local_defines = DDS_LOCAL_DEFINES, + visibility = ["//web:__pkg__"], + deps = [ + "//library/src:dds", + "//library/src/api:api_definitions", + ], +) + +wasm_cc_binary( + name = "dds_mvp_wasm", + cc_target = ":dds_mvp_wasm_cc", + outputs = [ + "dds_mvp_wasm.js", + "dds_mvp_wasm.wasm", + ], +) + +cc_test( + name = "dds_mvp_wasm_test", + size = "small", + srcs = [ + "dds_mvp_wasm.cpp", + "dds_mvp_wasm_test.cpp", + ], + copts = DDS_CPPOPTS, + linkopts = DDS_LINKOPTS, + local_defines = DDS_LOCAL_DEFINES + ["DDS_MVP_WASM_NO_MAIN"], + deps = [ + "//library/src:dds", + "//library/src/api:api_definitions", + "@googletest//:gtest_main", + ], +) + +py_library( + name = "mvp_site", + srcs = ["tests/mvp_site.py"], + imports = ["tests"], + visibility = ["//web:__pkg__"], +) + +py_test( + name = "wasm_scripts_test", + size = "small", + main = "tests/test_wasm_scripts.py", + srcs = ["tests/test_wasm_scripts.py"], + data = [ + "gen_wasm_bin_js.py", + "patch_mvp_wasm.py", + "verify_wasm_js.py", + ], +) + +# Stages wasm artifacts and runs Node smoke (~1–2s; wasm build is a separate analysis action). +py_test( + name = "dds_mvp_wasm_system_test", + size = "small", + timeout = "short", + main = "tests/test_wasm_system.py", + srcs = ["tests/test_wasm_system.py"], + data = [ + ":dds_mvp_wasm", + "dds_mvp.css", + "dds_mvp.html", + "dds_mvp.js", + "gen_wasm_bin_js.py", + "patch_mvp_wasm.py", + "tests/dds_mvp_wasm_node.mjs", + "verify_wasm_js.py", + ], + deps = [":mvp_site"], +) + +# Playwright usually finishes in ~30s; first run may download Chromium (requires-network). +py_test( + name = "dds_mvp_e2e_test", + size = "small", + timeout = "short", + main = "tests/test_mvp_e2e.py", + srcs = ["tests/test_mvp_e2e.py"], + data = [ + ":dds_mvp_wasm", + "dds_mvp.css", + "dds_mvp.html", + "dds_mvp.js", + "gen_wasm_bin_js.py", + "patch_mvp_wasm.py", + ], + deps = [ + ":mvp_site", + "@pypi//playwright", + ], + tags = [ + "e2e", + "no-sandbox", + "requires-network", + ], +) + +test_suite( + name = "web_tests", + tests = [ + ":dds_mvp_wasm_test", + ":wasm_scripts_test", + ], +) + +test_suite( + name = "web_system_tests", + tests = [ + ":dds_mvp_wasm_system_test", + ":dds_mvp_e2e_test", + ], +) + +test_suite( + name = "web_e2e_tests", + tests = [":dds_mvp_e2e_test"], +) diff --git a/web/clean_wasm.sh b/web/clean_wasm.sh new file mode 100755 index 00000000..778212bc --- /dev/null +++ b/web/clean_wasm.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Remove WASM artifacts copied into web/ by update_wasm.sh. +set -euo pipefail +cd "$(dirname "$0")" +rm -f dds_mvp_wasm.js dds_mvp_wasm.wasm dds_mvp_wasm_bin.js +echo "Removed web/dds_mvp_wasm.{js,wasm,bin.js} (if present)" diff --git a/web/dds_mvp.css b/web/dds_mvp.css new file mode 100644 index 00000000..0fca2241 --- /dev/null +++ b/web/dds_mvp.css @@ -0,0 +1,74 @@ +/* Copyright 2020-2026 Adam Wildavsky + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT +*/ + +.grid-outer { + background-color: #2196F3; + padding: 10px; + max-width: 800px; +} + +.grid-container { + display: grid; + grid-template-areas: + "nw north ne" + "west center east" + "sw south se"; + grid-template-columns: 1fr 1fr 1fr; + background-color: #2196F3; +} + +.hand-north { + grid-area: north; +} + +.hand-east { + grid-area: east; +} + +.hand-south { + grid-area: south; +} + +.hand-west { + grid-area: west; +} + +.grid-filler-nw { + grid-area: nw; +} + +.grid-filler-ne { + grid-area: ne; +} + +.grid-filler-center { + grid-area: center; +} + +.grid-filler-sw { + grid-area: sw; +} + +.grid-filler-se { + grid-area: se; +} + +.grid-item { + background-color: rgba(255, 255, 255, 0.8); + border: 0px solid rgba(0, 0, 0, 0.8); + padding: 20px; + font-size: 30px; + text-align: center; +} + +.grid-filler { + padding: 20px; +} + +td { + text-align: center; +} diff --git a/web/dds_mvp.html b/web/dds_mvp.html new file mode 100644 index 00000000..db8a11c0 --- /dev/null +++ b/web/dds_mvp.html @@ -0,0 +1,127 @@ + + + + + + + + + DDS MVP + + + +

+ Enter a 52-card deal using only these pips:
+

+ + + + + + + +
+
+ +
+
+
+ ♠ 
+ ♥ 
+ ♦ 
+ ♣ 
+
+
+ ♠ 
+ ♥ 
+ ♦ 
+ ♣ 
+
+
+ ♠ 
+ ♥ 
+ ♦ 
+ ♣ 
+
+
+ ♠ 
+ ♥ 
+ ♦ 
+ ♣ 
+
+ + + + + +
+
+ +
+ + + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
♦ ♥ NT
N




E




S




W




+ + + + + + diff --git a/web/dds_mvp.js b/web/dds_mvp.js new file mode 100644 index 00000000..abdeb066 --- /dev/null +++ b/web/dds_mvp.js @@ -0,0 +1,336 @@ +// Copyright 2020-2026 Adam Wildavsky +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT + +// TODO: Add tests for the exported functions. + +// ESLint configuration +// https://eslint.org/demo +// ECMA Version: 2015 +// Environment: browser + +/* eslint-env es6 */ +/* exported fillFormWithGrandSlamTestData + fillFormWithEveryoneMakes3nTestData + fillFormWithPartScoreTestData + clearTestData + rotateClockwise + pageLoad + sendJSON + */ + +// It's also useful to pass the code through +// https://jshint.com/ and https://jslint.com/ + +"use strict"; + +const DIRECTIONS = ["north", "east", "south", "west"]; +const SUITS = ["spades", "hearts", "diamonds", "clubs"]; +const PIPS = "AKQJT98765432"; +const DENOMINATIONS = ["C", "D", "H", "S", "N"]; +const DIRECTION_LETTERS = ["N", "E", "S", "W"]; + +// DDS res_table strain index (S,H,D,C,N) to MVP table column key. +const DENOM_TO_STRAIN = { C: 3, D: 2, H: 1, S: 0, N: 4 }; +const DIR_TO_HAND = { N: 0, E: 1, S: 2, W: 3 }; + +// TODO: Clean up our HTML rendering, perhaps using custom elements. +// See https://developers.google.com/web/fundamentals/web-components/customelements +const SUIT_SYMBOLS = { + "S" : "♠", + "H" : "", + "D" : "", + "C" : "♣" +}; + +let ddsModulePromise = null; + +function loadDdsModule() { + if (typeof createDdsModule !== "function") { + return Promise.reject(new Error( + "WASM module not found. From the repo root run: ./web/update_wasm.sh" + )); + } + + if (!ddsModulePromise) { + if (typeof ddsMvpWasmBytes !== "function") { + return Promise.reject(new Error( + "WASM bytes not found. From the repo root run: ./web/update_wasm.sh" + )); + } + ddsModulePromise = createDdsModule({ + wasmBinary: ddsMvpWasmBytes() + }).catch((error) => { + // Allow retry after transient initialization failures. + ddsModulePromise = null; + throw error; + }); + } + + return ddsModulePromise; +} + +function handsToPbn(hands) { + const handOrder = ["N", "E", "S", "W"]; + const suitOrder = ["S", "H", "D", "C"]; + const handStrings = handOrder.map((direction) => { + return suitOrder.map((suit) => { + return hands[direction] + .filter((card) => card.charAt(0) === suit) + .map((card) => card.charAt(1)) + .sort((a, b) => PIPS.indexOf(a) - PIPS.indexOf(b)) + .join(""); + }).join("."); + }); + return "N:" + handStrings.join(" "); +} + +function fillFormWithTestData(nesw) { + clear_results(); + + var holdings = []; + + for (const hand of nesw) { + for (const holding of hand.split(".")) { + holdings.push(holding); + } + } + + for (const element of hand_elements()) { + element.value = holdings.shift(); + } +} + +function fillFormWithGrandSlamTestData() { + fillFormWithTestData([ + "AKQJ.AKQJ.T98.T9", + "5432.5432.32.432", + "T98.T9.AKQJ.AKQJ", + "76.876.7654.8765" + ]); +} + +function fillFormWithEveryoneMakes3nTestData() { + fillFormWithTestData([ + "QT9.A8765432.KJ.", + "KJ..A8765432.QT9", + "A8765432.QT9..KJ", + ".KJ.QT9.A8765432" + ]); +} + +function fillFormWithPartScoreTestData() { + fillFormWithTestData([ + "AQ85.AK976.5.J87", + "JT.QJ5432.Q9.KQ9", + "972..JT863.A6432", + "K643.T8.AK742.T5" + ]); +} + +function * directions_and_suits() { + // Generator + + for (const direction of DIRECTIONS) { + for (const suit of SUITS) { + yield { "direction": direction, "suit": suit }; + } + } +} + +function * hand_elements() { + // Generator + + for (const ds of directions_and_suits()) { + var element_index = ds.direction + "_" + ds.suit; + var element = document.getElementById(element_index); + yield element; + } +} + +function clearTestData() { + clear_results(); + + for (const element of hand_elements()) { + element.value = ""; + } +} + +function rotateClockwise() { + clear_results(); + + var hands = []; + + for (const element of hand_elements()) { + hands.push(element.value); + } + + // rotate west to north, and so on + for (var i = 0; i < 4; i++) { + var west = hands.pop(); + hands.unshift(west); + } + + for (const element of hand_elements()) { + element.value = hands.shift(); + } +} + +function collectHands() { + var hands = {}; + + for (const ds of directions_and_suits()) { + var direction_letter = ds.direction.charAt(0).toUpperCase(); + + hands[direction_letter] = hands[direction_letter] || []; + + var suit_letter = ds.suit.charAt(0).toUpperCase(); + var element_index = ds.direction + "_" + ds.suit; + var holding = document.getElementById(element_index).value; + + for (const card of holding) { + hands[direction_letter].push(suit_letter + card.toUpperCase()); + } + } + + return hands; +} + +function inputIsValid(hands) { + const deck = {}; + const duplicates = []; + + for (const direction of Object.keys(hands)) { + const hand = hands[direction]; + + if (hand.length != 13) { + return "Please enter 13 cards per hand."; + } + + for (const card of hand) { + const pip = card.substring(1); + + if (!PIPS.includes(pip)) { + return "Please use only these pips: " + PIPS; + } + + if (deck[card]) { + if (deck[card] == 1) { + duplicates.push(card); + } + + deck[card]++; + } else { + deck[card] = 1; + } + } + } + + if (duplicates.length) { + var error_message = "Duplicated card"; + + if (duplicates.length > 1) { + error_message += "s"; + } + + error_message += ": "; + + for (const card of duplicates) { + const suit_letter = card.substring(0, 1); + const pip = card.substring(1); + const suit_symbol = SUIT_SYMBOLS[suit_letter]; + + error_message += suit_symbol; + error_message += pip; + error_message += " "; + } + + return error_message; + } + + return ""; +} + +function pageLoad() { + document.getElementById("valid-pips").innerHTML = PIPS; +} + +function clear_results() { + var result = document.getElementById("result"); + var result_table = document.getElementById("result-table"); + + result.innerHTML = ""; + + for (var row = 1; row <= 4; row++) { + for (var column = 1; column <= 5; column++) { + var cell = result_table.rows[row].cells[column]; + cell.innerHTML = ""; + } + } +} + +async function sendJSON() { + const result = document.getElementById("result"); + const result_table = document.getElementById("result-table"); + + var hands = collectHands(); + + const error_message = inputIsValid(hands); + + if (error_message.length) { + clear_results(); + result.innerHTML = error_message; + return; + } + + clear_results(); + result.innerHTML = "Computing…"; // horizontal ellipsis + + try { + const module = await loadDdsModule(); + const pbn = handsToPbn(hands); + const outPtr = module._malloc(20 * 4); + + try { + const rc = module.ccall( + "dds_mvp_calc_table", + "number", + ["string", "number"], + [pbn, outPtr] + ); + + if (rc !== 1) { + result.innerHTML = "DDS error (code " + rc + ")."; + return; + } + + for (var row = 1; row <= 4; row++) { + for (var column = 1; column <= 5; column++) { + const cell = result_table.rows[row].cells[column]; + const denomination = DENOMINATIONS[column - 1]; + const direction = DIRECTION_LETTERS[row - 1]; + const strain = DENOM_TO_STRAIN[denomination]; + const hand = DIR_TO_HAND[direction]; + const index = strain * 4 + hand; + cell.innerHTML = module.getValue( + outPtr + index * 4, + "i32" + ); + } + } + + result.innerHTML = ""; + } finally { + module._free(outPtr); + } + } catch (err) { + clear_results(); + result.innerHTML = err instanceof Error + ? err.message + : err == null + ? "Unknown error" + : String(err); + } +} diff --git a/web/dds_mvp_wasm.cpp b/web/dds_mvp_wasm.cpp new file mode 100644 index 00000000..34634509 --- /dev/null +++ b/web/dds_mvp_wasm.cpp @@ -0,0 +1,58 @@ +/* + Browser entry point for CalcDDtablePBN. + + Copyright 2020-2026 Adam Wildavsky + Use of this source code is governed by the MIT license. +*/ + +#include + +#include + +#if defined(__EMSCRIPTEN__) +#include +#else +#define EMSCRIPTEN_KEEPALIVE +#endif + +extern "C" { + +// Fills out_table[20] with res_table[strain][hand] (strain 0..4 = S,H,D,C,N). +// Returns RETURN_NO_FAULT (1) on success, or a DDS error code. +EMSCRIPTEN_KEEPALIVE +auto dds_mvp_calc_table(const char* pbn, int* out_table) -> int +{ + if (pbn == nullptr || out_table == nullptr) { + return RETURN_UNKNOWN_FAULT; + } + + DdTableDealPBN deal{}; + const size_t pbn_len = std::strlen(pbn); + if (pbn_len >= sizeof(deal.cards)) { + return RETURN_PBN_FAULT; + } + std::memcpy(deal.cards, pbn, pbn_len + 1); + + DdTableResults table{}; + const int res = CalcDDtablePBN(deal, &table); + if (res != RETURN_NO_FAULT) { + return res; + } + + int k = 0; + for (int strain = 0; strain < DDS_STRAINS; ++strain) { + for (int hand = 0; hand < DDS_HANDS; ++hand) { + out_table[k++] = table.res_table[strain][hand]; + } + } + return RETURN_NO_FAULT; +} + +} // extern "C" + +#if !defined(__EMSCRIPTEN__) && !defined(DDS_MVP_WASM_NO_MAIN) +auto main() -> int +{ + return 0; +} +#endif diff --git a/web/dds_mvp_wasm_test.cpp b/web/dds_mvp_wasm_test.cpp new file mode 100644 index 00000000..35bc17ce --- /dev/null +++ b/web/dds_mvp_wasm_test.cpp @@ -0,0 +1,53 @@ +/* + Unit tests for the browser MVP WASM bridge (dds_mvp_calc_table). + + Copyright 2020-2026 Adam Wildavsky + Use of this source code is governed by the MIT license. +*/ + +#include +#include + +#include + +#include + +extern "C" int dds_mvp_calc_table(const char* pbn, int* out_table); + +namespace { + +constexpr int kExpectedHand0[20] = { + 5, 8, 5, 8, 6, 6, 6, 6, 5, 7, 5, 7, 7, 5, 7, 5, 6, 6, 6, 6, +}; + +constexpr char kPbnHand0[] = + "N:QJ6.K652.J85.T98 873.J97.AT764.Q4 K5.T83.KQ9.A7652 AT942.AQ4.32.KJ3"; + +} // namespace + +TEST(DdsMvpWasmTest, RejectsNullPointers) { + int out[20]{}; + EXPECT_EQ(dds_mvp_calc_table(nullptr, out), RETURN_UNKNOWN_FAULT); + EXPECT_EQ(dds_mvp_calc_table(kPbnHand0, nullptr), RETURN_UNKNOWN_FAULT); +} + +TEST(DdsMvpWasmTest, RejectsPbnTooLong) { + int out[20]{}; + const std::string too_long(80, 'A'); + EXPECT_EQ(dds_mvp_calc_table(too_long.c_str(), out), RETURN_PBN_FAULT); +} + +TEST(DdsMvpWasmTest, RejectsInvalidPbn) { + int out[20]{}; + const int res = dds_mvp_calc_table("not-a-valid-pbn", out); + EXPECT_NE(res, RETURN_NO_FAULT); + EXPECT_LT(res, RETURN_NO_FAULT); +} + +TEST(DdsMvpWasmTest, FillsFlatStrainHandTable) { + int out[20]{}; + ASSERT_EQ(dds_mvp_calc_table(kPbnHand0, out), RETURN_NO_FAULT); + for (int i = 0; i < 20; ++i) { + EXPECT_EQ(out[i], kExpectedHand0[i]) << "index " << i; + } +} diff --git a/web/gen_wasm_bin_js.py b/web/gen_wasm_bin_js.py new file mode 100755 index 00000000..b611e07c --- /dev/null +++ b/web/gen_wasm_bin_js.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Emit dds_mvp_wasm_bin.js with base64-encoded wasm for file:// and HTTP.""" +from __future__ import annotations + +import base64 +import sys +from pathlib import Path + +HEADER = """\ +// Generated by web/gen_wasm_bin_js.py — do not edit. +"use strict"; + +function ddsMvpWasmBytes() { + const bin = atob(DDS_MVP_WASM_BASE64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) { + out[i] = bin.charCodeAt(i); + } + return out; +} +""" + + +def usage() -> None: + name = Path(sys.argv[0]).name + print(f"usage: {name} WASM_FILE OUTPUT_JS", file=sys.stderr) + + +def make_bin_js(wasm_bytes: bytes) -> str: + """Return the contents of dds_mvp_wasm_bin.js for the given wasm module.""" + b64 = base64.b64encode(wasm_bytes).decode("ascii") + lines = [b64[i : i + 76] for i in range(0, len(b64), 76)] + body = 'const DDS_MVP_WASM_BASE64 =\n "' + '" +\n "'.join(lines) + '";\n\n' + return HEADER + body + + +def main() -> int: + if len(sys.argv) != 3: + usage() + return 2 + + wasm_path = Path(sys.argv[1]) + out_path = Path(sys.argv[2]) + out_path.write_text(make_bin_js(wasm_path.read_bytes()), encoding="utf-8") + print(f"wrote {out_path} ({wasm_path.stat().st_size} byte wasm)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/patch_mvp_wasm.py b/web/patch_mvp_wasm.py new file mode 100755 index 00000000..9c397678 --- /dev/null +++ b/web/patch_mvp_wasm.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Patch the single isFileURI line in Emscripten output. + +Do not use global substitution on SINGLE_FILE builds: the embedded wasm +bytes can contain arbitrary text that would corrupt the module. + +The Emscripten line we patch is generated by rules_emscripten / emsdk. When +upgrading `bazel_dep(name = "emsdk", ...)` in MODULE.bazel, rebuild +`//web:dds_mvp_wasm` and run this script; if the regex no longer matches, +update UNPATCHED_IS_FILE_URI below and document the new version in +docs/wasm_build.md. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +# Keep in sync with bazel_dep(name = "emsdk", version = "...") in MODULE.bazel. +EXPECTED_EMSDK_BAZEL_DEP_VERSION = "5.0.7" + +# Emscripten ~3.1.x (emsdk 5.0.7): var isFileURI = (filename) => filename.startsWith('file://'); +UNPATCHED_IS_FILE_URI = re.compile( + r"var\s+isFileURI\s*=\s*\(\s*(\w+)\s*\)\s*=>\s*" + r"\1\.startsWith\(\s*['\"]file://['\"]\s*\)\s*;", +) + +# Already patched by a previous run of this script. +PATCHED_IS_FILE_URI = re.compile( + r"var\s+isFileURI\s*=\s*\(\s*(\w+)\s*\)\s*=>\s*" + r"\(\s*typeof\s+\1\s*===\s*['\"]string['\"]", +) + + +def patched_line(param: str) -> str: + return ( + f"var isFileURI = ({param}) => " + f"(typeof {param} === 'string' ? {param} : ({param} && {param}.href) || '')" + f".startsWith('file://');" + ) + + +def usage() -> None: + name = Path(sys.argv[0]).name + print(f"usage: {name} EMSCRIPTEN_JS", file=sys.stderr) + + +def patch_text(text: str) -> tuple[str, int]: + """Return patched text and an exit code (0 success, 1 error).""" + if PATCHED_IS_FILE_URI.search(text): + return text, 0 + + matches = list(UNPATCHED_IS_FILE_URI.finditer(text)) + if not matches: + print( + "patch_mvp_wasm: isFileURI declaration not found " + f"(expected Emscripten output from emsdk BCR {EXPECTED_EMSDK_BAZEL_DEP_VERSION}).\n" + "After upgrading emsdk, rebuild //web:dds_mvp_wasm and update " + "UNPATCHED_IS_FILE_URI in web/patch_mvp_wasm.py if the generator changed.", + file=sys.stderr, + ) + return text, 1 + if len(matches) != 1: + print( + f"patch_mvp_wasm: expected 1 isFileURI declaration, found {len(matches)}", + file=sys.stderr, + ) + return text, 1 + + match = matches[0] + param = match.group(1) + replacement = patched_line(param) + updated = text[: match.start()] + replacement + text[match.end() :] + return updated, 0 + + +def main() -> int: + if len(sys.argv) != 2: + usage() + return 2 + + path = Path(sys.argv[1]) + text = path.read_text(encoding="utf-8") + updated, code = patch_text(text) + if code != 0: + return code + if updated != text: + path.write_text(updated, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/requirements.in b/web/requirements.in new file mode 100644 index 00000000..b10630c0 --- /dev/null +++ b/web/requirements.in @@ -0,0 +1,19 @@ +# This file is a manifest used to regenerate requirements_lock.txt. +# Regenerate the lock with the same Python version as MODULE.bazel (currently 3.14). +# requirements_lock is used to ensure a consistent set of pinned Python dependencies +# for Bazel across builds on different machines and in different environments. +# For now, that means playwright and its deps. +# playwright is used for the end-to-end tests in web/tests/test_mvp_e2e.py + +# Normal Bazel builds consume requirements_lock.txt; they do not regenerate it. +# To update requirements_lock.txt after editing this file: +# +# bazel run @python_3_14//:python3 -- -m pip install pip-tools +# bazel run @python_3_14//:python3 -- -m piptools compile --generate-hashes \ +# --output-file=web/requirements_lock.txt web/requirements.in +# +# Todo: Consider adding a 'compile_pip_requirements' rules_python target that +# would be invoked by 'bazel run … --update'. If done, though, requirements_lock.txt +# should remain in the repo so that it stays versioned. + +playwright==1.52.0 diff --git a/web/requirements_lock.txt b/web/requirements_lock.txt new file mode 100644 index 00000000..3ca40195 --- /dev/null +++ b/web/requirements_lock.txt @@ -0,0 +1,105 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# g --generate-hashes --output-file=web/requirements_lock.txt web/requirements.in +# +greenlet==3.5.1 \ + --hash=sha256:001775efe7b8e758861294c7a27c28af87f3f3f1c20468a2bc618c45b346c061 \ + --hash=sha256:00929c98ec525fd9bf075875d8c5f6a983a90906cdf78a66e6de2d8e466c2a19 \ + --hash=sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747 \ + --hash=sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1 \ + --hash=sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10 \ + --hash=sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0 \ + --hash=sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9 \ + --hash=sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64 \ + --hash=sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659 \ + --hash=sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a \ + --hash=sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541 \ + --hash=sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e \ + --hash=sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c \ + --hash=sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d \ + --hash=sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b \ + --hash=sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986 \ + --hash=sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78 \ + --hash=sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2 \ + --hash=sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a \ + --hash=sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc \ + --hash=sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b \ + --hash=sha256:540dae7b956209af4d70a3be35927b4055f617763771e5e84a5255bea934d2f5 \ + --hash=sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829 \ + --hash=sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea \ + --hash=sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436 \ + --hash=sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c \ + --hash=sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360 \ + --hash=sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f \ + --hash=sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244 \ + --hash=sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283 \ + --hash=sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54 \ + --hash=sha256:7eacb17a9d41538a2bc4912eba5ef13823c83cb69e4d141d0813debe7163187f \ + --hash=sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2 \ + --hash=sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188 \ + --hash=sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e \ + --hash=sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249 \ + --hash=sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3 \ + --hash=sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563 \ + --hash=sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de \ + --hash=sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6 \ + --hash=sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368 \ + --hash=sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26 \ + --hash=sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de \ + --hash=sha256:9d59e840387076a51016777a9328b3f2c427c6f9208a6e958bad251be50a648d \ + --hash=sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2 \ + --hash=sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e \ + --hash=sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33 \ + --hash=sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d \ + --hash=sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c \ + --hash=sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd \ + --hash=sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207 \ + --hash=sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed \ + --hash=sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b \ + --hash=sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62 \ + --hash=sha256:b9152fca4a6466e114aaec745ae61cba739903a109754a9d4e1262f01e9259b1 \ + --hash=sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0 \ + --hash=sha256:c3d35f87c7253b715d13d679e0783d845910144f282cb939fe1ba4ac8616269c \ + --hash=sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823 \ + --hash=sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab \ + --hash=sha256:cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523 \ + --hash=sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd \ + --hash=sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c \ + --hash=sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce \ + --hash=sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c \ + --hash=sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07 \ + --hash=sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135 \ + --hash=sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e \ + --hash=sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071 \ + --hash=sha256:e5cc9606aa5f4e0bde0d3bd502b44f743864c3ffa5cfa1011b1e30f5aa02366f \ + --hash=sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5 \ + --hash=sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee \ + --hash=sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e \ + --hash=sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f \ + --hash=sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad \ + --hash=sha256:ed8cdb691169715a9a492844a83246f090182247d1a5031dc78a403f68ba1e97 \ + --hash=sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc \ + --hash=sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e \ + --hash=sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2 \ + --hash=sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed + # via playwright +playwright==1.52.0 \ + --hash=sha256:0797c0479cbdc99607412a3c486a3a2ec9ddc77ac461259fd2878c975bcbb94a \ + --hash=sha256:19b2cb9d4794062008a635a99bd135b03ebb782d460f96534a91cb583f549512 \ + --hash=sha256:4173e453c43180acc60fd77ffe1ebee8d0efbfd9986c03267007b9c3845415af \ + --hash=sha256:7223960b7dd7ddeec1ba378c302d1d09733b8dac438f492e9854c85d3ca7144f \ + --hash=sha256:9d0085b8de513de5fb50669f8e6677f0252ef95a9a1d2d23ccee9638e71e65cb \ + --hash=sha256:cd0bdf92df99db6237a99f828e80a6a50db6180ef8d5352fc9495df2c92f9971 \ + --hash=sha256:d010124d24a321e0489a8c0d38a3971a7ca7656becea7656c9376bfea7f916d4 \ + --hash=sha256:dcbf75101eba3066b7521c6519de58721ea44379eb17a0dafa94f9f1b17f59e4 + # via -r web/requirements.in +pyee==13.0.1 \ + --hash=sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8 \ + --hash=sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228 + # via playwright +typing-extensions==4.15.0 \ + --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ + --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 + # via pyee diff --git a/web/tests/dds_mvp_wasm_node.mjs b/web/tests/dds_mvp_wasm_node.mjs new file mode 100644 index 00000000..dff6b0f9 --- /dev/null +++ b/web/tests/dds_mvp_wasm_node.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * System smoke test: load Emscripten MVP module and call dds_mvp_calc_table. + * + * Usage: node dds_mvp_wasm_node.mjs EMSCRIPTEN_JS WASM_FILE [PBN] + */ +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +const EXPECTED_DEFAULT = [ + 5, 8, 5, 8, 6, 6, 6, 6, 5, 7, 5, 7, 7, 5, 7, 5, 6, 6, 6, 6, +]; + +const DEFAULT_PBN = + "N:QJ6.K652.J85.T98 873.J97.AT764.Q4 K5.T83.KQ9.A7652 AT942.AQ4.32.KJ3"; + +function fail(message) { + console.error(message); + process.exit(1); +} + +async function main() { + const [jsPath, wasmPath, pbnArg] = process.argv.slice(2); + if (!jsPath || !wasmPath) { + fail("usage: node dds_mvp_wasm_node.mjs EMSCRIPTEN_JS WASM_FILE [PBN]"); + } + + const wasmBinary = fs.readFileSync(wasmPath); + const createDdsModule = require(path.resolve(jsPath)); + if (typeof createDdsModule !== "function") { + fail("createDdsModule is not a function"); + } + + const module = await createDdsModule({ wasmBinary }); + const pbn = pbnArg ?? DEFAULT_PBN; + const outPtr = module._malloc(20 * 4); + try { + const rc = module.ccall( + "dds_mvp_calc_table", + "number", + ["string", "number"], + [pbn, outPtr], + ); + if (rc !== 1) { + fail(`dds_mvp_calc_table returned ${rc}`); + } + + const out = []; + for (let i = 0; i < 20; i++) { + out.push(module.getValue(outPtr + i * 4, "i32")); + } + + for (let i = 0; i < EXPECTED_DEFAULT.length; i++) { + if (out[i] !== EXPECTED_DEFAULT[i]) { + fail(`table[${i}] = ${out[i]}, expected ${EXPECTED_DEFAULT[i]}`); + } + } + + console.log("dds_mvp_wasm_node: OK"); + } finally { + module._free(outPtr); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/web/tests/mvp_site.py b/web/tests/mvp_site.py new file mode 100644 index 00000000..1d67b98e --- /dev/null +++ b/web/tests/mvp_site.py @@ -0,0 +1,65 @@ +"""Stage a self-contained DDS MVP site directory for tests.""" +from __future__ import annotations + +import importlib.util +import os +import shutil +from pathlib import Path + +STATIC_FILES = ("dds_mvp.html", "dds_mvp.css", "dds_mvp.js") + + +def runfiles_root() -> Path: + for key in ("RUNFILES_DIR", "TEST_SRCDIR"): + if key in os.environ: + return Path(os.environ[key]) + raise RuntimeError("not running under Bazel test") + + +def rlocation(relpath: str) -> Path: + root = runfiles_root() + for candidate in (root / relpath, root / "_main" / relpath): + if candidate.exists(): + return candidate + raise FileNotFoundError(relpath) + + +def _load_module(web_root: Path, name: str): + path = web_root / f"{name}.py" + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod + + +def stage_mvp_site(dest: Path) -> Path: + """Copy HTML/JS/CSS and patched wasm artifacts into dest. Returns dest.""" + dest.mkdir(parents=True, exist_ok=True) + web_root = rlocation("web") + + for name in STATIC_FILES: + shutil.copyfile(web_root / name, dest / name) + + js_src = rlocation("web/dds_mvp_wasm.js") + wasm_src = rlocation("web/dds_mvp_wasm.wasm") + js_path = dest / "dds_mvp_wasm.js" + wasm_path = dest / "dds_mvp_wasm.wasm" + shutil.copyfile(js_src, js_path) + shutil.copyfile(wasm_src, wasm_path) + js_path.chmod(0o644) + wasm_path.chmod(0o644) + + patch_mvp_wasm = _load_module(web_root, "patch_mvp_wasm") + gen_wasm_bin_js = _load_module(web_root, "gen_wasm_bin_js") + + updated, code = patch_mvp_wasm.patch_text(js_path.read_text(encoding="utf-8")) + if code != 0: + raise RuntimeError("patch_mvp_wasm failed") + js_path.write_text(updated, encoding="utf-8") + + (dest / "dds_mvp_wasm_bin.js").write_text( + gen_wasm_bin_js.make_bin_js(wasm_path.read_bytes()), + encoding="utf-8", + ) + return dest diff --git a/web/tests/test_mvp_e2e.py b/web/tests/test_mvp_e2e.py new file mode 100644 index 00000000..aa7c6101 --- /dev/null +++ b/web/tests/test_mvp_e2e.py @@ -0,0 +1,212 @@ +"""End-to-end browser tests for web/dds_mvp.html (file:// and HTTP).""" +from __future__ import annotations + +import http.server +import os +import shutil +import subprocess +import sys +import tempfile +import threading +import unittest +from pathlib import Path + +from mvp_site import stage_mvp_site + +try: + from playwright.sync_api import sync_playwright +except ImportError: + sync_playwright = None # type: ignore[misc, assignment] + +# res_table[strain][hand], strains S,H,D,C,N — from CalcDDtablePBN on part-score test deal. +PART_SCORE_TABLE = [ + 8, 5, 7, 5, 6, 6, 6, 7, 7, 6, 7, 6, 9, 4, 8, 4, 7, 6, 6, 6, +] + +STRAIN_INDEX = {"S": 0, "H": 1, "D": 2, "C": 3, "N": 4} +HAND_INDEX = {"N": 0, "E": 1, "S": 2, "W": 3} +TABLE_ROW = {"N": 1, "E": 2, "S": 3, "W": 4} +TABLE_COL = {"C": 1, "D": 2, "H": 3, "S": 4, "N": 5} + + +def _expected_tricks(direction: str, denomination: str) -> int: + return PART_SCORE_TABLE[ + STRAIN_INDEX[denomination] * 4 + HAND_INDEX[direction] + ] + + +def _ensure_playwright_chromium(browsers_dir: Path) -> None: + env = os.environ.copy() + env["PLAYWRIGHT_BROWSERS_PATH"] = str(browsers_dir) + proc = subprocess.run( + [sys.executable, "-m", "playwright", "install", "chromium"], + env=env, + capture_output=True, + text=True, + check=False, + ) + if proc.returncode != 0: + raise unittest.SkipTest( + "playwright install chromium failed " + f"(exit {proc.returncode}): {proc.stderr or proc.stdout}" + ) + + +def _make_http_handler(directory: Path) -> type[http.server.SimpleHTTPRequestHandler]: + root = str(directory) + + class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, request, client_address, server) -> None: + super().__init__(request, client_address, server, directory=root) + + return Handler + + +class _HttpSite: + def __init__(self, directory: Path) -> None: + self._directory = directory + self._httpd: http.server.ThreadingHTTPServer | None = None + self._thread: threading.Thread | None = None + self.url = "" + + def __enter__(self) -> _HttpSite: + self._httpd = http.server.ThreadingHTTPServer( + ("127.0.0.1", 0), + _make_http_handler(self._directory), + ) + port = self._httpd.server_address[1] + self.url = f"http://127.0.0.1:{port}/dds_mvp.html" + self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True) + self._thread.start() + return self + + def __exit__(self, *args: object) -> None: + if self._httpd: + self._httpd.shutdown() + if self._thread: + self._thread.join(timeout=5) + + +@unittest.skipIf(sync_playwright is None, "playwright not installed") +class DdsMvpHtmlE2eTest(unittest.TestCase): + browsers_dir: Path + site_dir: Path + + @classmethod + def setUpClass(cls) -> None: + if not shutil.which("node"): + raise unittest.SkipTest("node not found (wasm sanity)") + cls.browsers_dir = Path(tempfile.mkdtemp(prefix="pw-browsers-")) + _ensure_playwright_chromium(cls.browsers_dir) + cls.site_dir = Path(tempfile.mkdtemp(prefix="dds-mvp-site-")) + stage_mvp_site(cls.site_dir) + + @classmethod + def tearDownClass(cls) -> None: + shutil.rmtree(cls.browsers_dir, ignore_errors=True) + shutil.rmtree(cls.site_dir, ignore_errors=True) + + def setUp(self) -> None: + os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(self.browsers_dir) + self._pw = sync_playwright().start() + self._browser = self._pw.chromium.launch(headless=True) + + def tearDown(self) -> None: + self._browser.close() + self._pw.stop() + + def _open_page(self, url: str): + page = self._browser.new_page() + errors: list[str] = [] + page.on("pageerror", lambda exc: errors.append(str(exc))) + page.goto(url, wait_until="load") + page.wait_for_function( + "() => typeof createDdsModule === 'function' && typeof ddsMvpWasmBytes === 'function'" + ) + return page, errors + + def _fill_part_score_deal(self, page) -> None: + page.get_by_role("button", name="Part-score test deal").click() + + def _run_double_dummy(self, page) -> None: + page.get_by_role("button", name="Double-dummy it!").click() + page.wait_for_function( + """() => { + const cell = document.getElementById('result-table').rows[1].cells[1]; + return cell && /^\\d+$/.test(cell.textContent.trim()); + }""", + timeout=120_000, + ) + + def _read_table_cell(self, page, direction: str, denomination: str) -> str: + row = TABLE_ROW[direction] + col = TABLE_COL[denomination] + return page.evaluate( + f"""() => {{ + return document.getElementById('result-table') + .rows[{row}].cells[{col}].textContent.trim(); + }}""" + ) + + def _assert_part_score_table(self, page) -> None: + for direction in ("N", "E", "S", "W"): + for denomination in ("C", "D", "H", "S", "N"): + expected = str(_expected_tricks(direction, denomination)) + actual = self._read_table_cell(page, direction, denomination) + self.assertEqual( + actual, + expected, + msg=f"{direction}/{denomination}", + ) + result_text = page.locator("#result").inner_text() + self.assertEqual(result_text.strip(), "") + + def test_page_load_shows_valid_pips(self) -> None: + page, errors = self._open_page(self.site_dir.joinpath("dds_mvp.html").as_uri()) + try: + pips = page.locator("#valid-pips").inner_text() + self.assertIn("A", pips) + self.assertIn("2", pips) + self.assertEqual(errors, []) + finally: + page.close() + + def test_file_url_part_score_table(self) -> None: + url = self.site_dir.joinpath("dds_mvp.html").as_uri() + page, errors = self._open_page(url) + try: + self._fill_part_score_deal(page) + self._run_double_dummy(page) + self._assert_part_score_table(page) + self.assertEqual(errors, []) + finally: + page.close() + + def test_http_part_score_table(self) -> None: + with _HttpSite(self.site_dir) as site: + page, errors = self._open_page(site.url) + try: + self._fill_part_score_deal(page) + self._run_double_dummy(page) + self._assert_part_score_table(page) + self.assertEqual(errors, []) + finally: + page.close() + + def test_validation_error_on_incomplete_deal(self) -> None: + page, errors = self._open_page(self.site_dir.joinpath("dds_mvp.html").as_uri()) + try: + page.get_by_role("button", name="Clear entries").click() + page.get_by_role("button", name="Double-dummy it!").click() + page.wait_for_function( + """() => document.getElementById('result').textContent.includes('13 cards')""" + ) + message = page.locator("#result").inner_text() + self.assertIn("13 cards", message) + self.assertEqual(errors, []) + finally: + page.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/web/tests/test_wasm_scripts.py b/web/tests/test_wasm_scripts.py new file mode 100644 index 00000000..327cfeb9 --- /dev/null +++ b/web/tests/test_wasm_scripts.py @@ -0,0 +1,153 @@ +import base64 +import importlib.util +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +WEB_ROOT = Path(__file__).resolve().parents[1] + + +def _load_module(name: str): + path = WEB_ROOT / f"{name}.py" + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise ImportError(f"Unable to load module {name} from {path}") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +patch_mvp_wasm = _load_module("patch_mvp_wasm") +gen_wasm_bin_js = _load_module("gen_wasm_bin_js") +verify_wasm_js = _load_module("verify_wasm_js") + +UNPATCHED_LINE = ( + "var isFileURI = (filename) => filename.startsWith('file://');\n" +) + + +class PatchMvpWasmTest(unittest.TestCase): + def test_patched_line_checks_type(self) -> None: + line = patch_mvp_wasm.patched_line("filename") + self.assertIn("typeof filename", line) + self.assertIn(".startsWith('file://')", line) + + def test_patch_text_replaces_unpatched(self) -> None: + updated, code = patch_mvp_wasm.patch_text(UNPATCHED_LINE) + self.assertEqual(code, 0) + self.assertNotEqual(updated, UNPATCHED_LINE) + self.assertRegex(updated, patch_mvp_wasm.PATCHED_IS_FILE_URI) + + def test_patch_text_idempotent(self) -> None: + once, _ = patch_mvp_wasm.patch_text(UNPATCHED_LINE) + again, code = patch_mvp_wasm.patch_text(once) + self.assertEqual(code, 0) + self.assertEqual(again, once) + + def test_patch_text_missing_declaration(self) -> None: + _, code = patch_mvp_wasm.patch_text("// no isFileURI here\n") + self.assertEqual(code, 1) + + def test_cli_writes_file(self) -> None: + with tempfile.NamedTemporaryFile(mode="w", suffix=".js", delete=False) as tmp: + tmp.write(UNPATCHED_LINE) + path = Path(tmp.name) + self.addCleanup(path.unlink, missing_ok=True) + proc = subprocess.run( + [sys.executable, str(WEB_ROOT / "patch_mvp_wasm.py"), str(path)], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(proc.returncode, 0) + self.assertRegex(path.read_text(encoding="utf-8"), patch_mvp_wasm.PATCHED_IS_FILE_URI) + + +class GenWasmBinJsTest(unittest.TestCase): + def test_make_bin_js_roundtrip(self) -> None: + wasm = b"\x00asm\x01\x00\x00\x00" + b"\xab" * 80 + js = gen_wasm_bin_js.make_bin_js(wasm) + self.assertIn("ddsMvpWasmBytes", js) + self.assertIn("DDS_MVP_WASM_BASE64", js) + b64 = base64.b64encode(wasm).decode("ascii") + self.assertIn(b64[:40], js.replace("\n", "").replace(" ", "")) + + def test_cli_writes_output(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + wasm_path = Path(tmpdir) / "tiny.wasm" + out_path = Path(tmpdir) / "dds_mvp_wasm_bin.js" + wasm_path.write_bytes(b"\x00asm\x01\x00\x00\x00") + proc = subprocess.run( + [ + sys.executable, + str(WEB_ROOT / "gen_wasm_bin_js.py"), + str(wasm_path), + str(out_path), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(proc.returncode, 0) + self.assertTrue(out_path.is_file()) + self.assertIn("DDS_MVP_WASM_BASE64", out_path.read_text(encoding="utf-8")) + + +class VerifyWasmJsTest(unittest.TestCase): + def test_wasm_magic_ok(self) -> None: + self.assertTrue(verify_wasm_js.wasm_magic_ok(b"\x00asm\x01\x00\x00\x00")) + self.assertFalse(verify_wasm_js.wasm_magic_ok(b"bogus")) + self.assertFalse(verify_wasm_js.wasm_magic_ok(b"\x00as")) + + def test_cli_rejects_bad_magic(self) -> None: + with tempfile.NamedTemporaryFile(suffix=".wasm", delete=False) as tmp: + tmp.write(b"not-wasm") + path = Path(tmp.name) + self.addCleanup(path.unlink, missing_ok=True) + proc = subprocess.run( + [sys.executable, str(WEB_ROOT / "verify_wasm_js.py"), str(path)], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(proc.returncode, 1) + + def test_cli_missing_node(self) -> None: + with tempfile.NamedTemporaryFile(suffix=".wasm", delete=False) as tmp: + tmp.write(b"\x00asm\x01\x00\x00\x00") + path = Path(tmp.name) + self.addCleanup(path.unlink, missing_ok=True) + env = os.environ.copy() + env["PATH"] = "" + proc = subprocess.run( + [sys.executable, str(WEB_ROOT / "verify_wasm_js.py"), str(path)], + capture_output=True, + text=True, + check=False, + env=env, + ) + self.assertEqual(proc.returncode, 127) + self.assertIn("node not found", proc.stderr) + + @unittest.skipUnless(shutil.which("node"), "node not found") + def test_cli_compiles_valid_wasm(self) -> None: + with tempfile.NamedTemporaryFile(suffix=".wasm", delete=False) as tmp: + tmp.write(b"\x00asm\x01\x00\x00\x00") + path = Path(tmp.name) + self.addCleanup(path.unlink, missing_ok=True) + proc = subprocess.run( + [sys.executable, str(WEB_ROOT / "verify_wasm_js.py"), str(path)], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(proc.returncode, 0, msg=proc.stdout + proc.stderr) + self.assertIn("compile OK", proc.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/tests/test_wasm_system.py b/web/tests/test_wasm_system.py new file mode 100644 index 00000000..ab9bb9a3 --- /dev/null +++ b/web/tests/test_wasm_system.py @@ -0,0 +1,52 @@ +"""End-to-end system tests for the web MVP WASM build and Node smoke harness.""" +from __future__ import annotations + +import shutil +import subprocess +import unittest +from pathlib import Path + +from mvp_site import stage_mvp_site + +TESTS_ROOT = Path(__file__).resolve().parent + + +def _require_node() -> str: + node = shutil.which("node") + if not node: + raise unittest.SkipTest("node not found") + return node + + +@unittest.skipUnless(shutil.which("node"), "node not found") +class DdsMvpWasmSystemTest(unittest.TestCase): + def test_update_pipeline_and_node_smoke(self) -> None: + import tempfile + + node = _require_node() + + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + stage_mvp_site(tmp) + + proc = subprocess.run( + [ + node, + str(TESTS_ROOT / "dds_mvp_wasm_node.mjs"), + str(tmp / "dds_mvp_wasm.js"), + str(tmp / "dds_mvp_wasm.wasm"), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + proc.returncode, + 0, + msg=proc.stdout + proc.stderr, + ) + self.assertIn("dds_mvp_wasm_node: OK", proc.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/update_wasm.sh b/web/update_wasm.sh new file mode 100755 index 00000000..38e5ee72 --- /dev/null +++ b/web/update_wasm.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Copy Bazel WASM artifacts next to dds_mvp.html for local static serving. +set -euo pipefail +cd "$(dirname "$0")/.." +bazel build //web:dds_mvp_wasm +bin="$(bazel info bazel-bin)/web" +cp -f "${bin}/dds_mvp_wasm.js" web/ +cp -f "${bin}/dds_mvp_wasm.wasm" web/ +chmod u+w web/dds_mvp_wasm.js +python3 web/gen_wasm_bin_js.py web/dds_mvp_wasm.wasm web/dds_mvp_wasm_bin.js +# Safe one-line fix for file:// (never global-replace inside the .js bundle). +python3 web/patch_mvp_wasm.py web/dds_mvp_wasm.js +python3 web/verify_wasm_js.py web/dds_mvp_wasm.wasm +echo "Updated web/dds_mvp_wasm.{js,wasm,bin.js}" diff --git a/web/verify_wasm_js.py b/web/verify_wasm_js.py new file mode 100755 index 00000000..585403c6 --- /dev/null +++ b/web/verify_wasm_js.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Verify MVP wasm bytes compile under Node.""" +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +NODE_COMPILE_SNIPPET = ( + "const fs=require('fs');" + "const b=fs.readFileSync(process.argv[1]);" + "WebAssembly.compile(b).then(()=>console.log('compile OK')," + "e=>{console.error(e);process.exit(1);});" +) + + +def usage() -> None: + name = Path(sys.argv[0]).name + print(f"usage: {name} WASM_FILE", file=sys.stderr) + + +def wasm_magic_ok(wasm: bytes) -> bool: + return len(wasm) >= 4 and wasm[:4] == b"\x00asm" + + +def main() -> int: + if len(sys.argv) != 2: + usage() + return 2 + + wasm_path = Path(sys.argv[1]) + wasm = wasm_path.read_bytes() + if not wasm_magic_ok(wasm): + print(f"{wasm_path}: invalid wasm magic (got {wasm[:4]!r})", file=sys.stderr) + return 1 + print(f"{wasm_path}: {len(wasm)} bytes, magic={wasm[:4]!r}") + + node = shutil.which("node") + if not node: + print("verify_wasm_js: node not found in PATH", file=sys.stderr) + return 127 + + proc = subprocess.run( + [node, "-e", NODE_COMPILE_SNIPPET, str(wasm_path)], + capture_output=True, + text=True, + ) + print(proc.stdout.strip() or proc.stderr.strip()) + return proc.returncode + + +if __name__ == "__main__": + raise SystemExit(main())