diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f81143..35b76a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,28 @@ jobs: toolchain: "1.88.0" - run: cargo check --all-targets + python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - uses: actions/setup-python@v5 + with: + # the abi3 floor and the newest CPython + python-version: | + 3.11 + 3.14 + - run: cargo fmt --check --manifest-path python/Cargo.toml + - run: cargo clippy --locked --manifest-path python/Cargo.toml --all-targets -- -D warnings + - name: Build the wheel + run: python3.14 -m pip wheel --no-deps --wheel-dir dist ./python + - name: Test the installed wheel + run: | + python3.11 scripts/release/smoke-python.py dist --tests python/tests + python3.14 scripts/release/smoke-python.py dist --tests python/tests + synthetic-correctness: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0556f2..de13229 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,8 @@ jobs: commit=$(git rev-parse "refs/tags/$TAG^{commit}") git show "$commit:Cargo.toml" > /tmp/release-Cargo.toml python3 -c 'import os,tomllib; assert tomllib.load(open("/tmp/release-Cargo.toml","rb"))["workspace"]["package"]["version"] == os.environ["TAG"][1:]' + git show "$commit:python/Cargo.toml" > /tmp/release-python-Cargo.toml + python3 -c 'import os,tomllib; assert tomllib.load(open("/tmp/release-python-Cargo.toml","rb"))["package"]["version"] == os.environ["TAG"][1:]' echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "commit=$commit" >> "$GITHUB_OUTPUT" @@ -104,6 +106,12 @@ jobs: path: dist/* if-no-files-found: error + wheels: + needs: resolve + uses: ./.github/workflows/wheels.yml + with: + source-commit: ${{ needs.resolve.outputs.commit }} + wasm: needs: resolve uses: ./.github/workflows/wasm.yml @@ -112,7 +120,7 @@ jobs: tag: ${{ needs.resolve.outputs.tag }} publish: - needs: [resolve, native, wasm] + needs: [resolve, native, wasm, wheels] runs-on: ubuntu-24.04 permissions: contents: write @@ -127,7 +135,7 @@ jobs: run: | for attempt in $(seq 1 90); do gh api "repos/$GH_REPO/commits/$SOURCE_COMMIT/check-runs?filter=latest&per_page=100" > /tmp/source-checks.json - if python3 -c 'import json,sys; checks=json.load(open("/tmp/source-checks.json"))["check_runs"]; required=[next((c for c in checks if c["name"]==name),None) for name in ("test","diagnostics","msrv","synthetic-correctness")]; failed=any(c and c["status"]=="completed" and c["conclusion"]!="success" for c in required); passed=all(c and c["status"]=="completed" and c["conclusion"]=="success" for c in required); sys.exit(2 if failed else 0 if passed else 1)'; then + if python3 -c 'import json,sys; checks=json.load(open("/tmp/source-checks.json"))["check_runs"]; required=[next((c for c in checks if c["name"]==name),None) for name in ("test","diagnostics","msrv","synthetic-correctness","python")]; failed=any(c and c["status"]=="completed" and c["conclusion"]!="success" for c in required); passed=all(c and c["status"]=="completed" and c["conclusion"]=="success" for c in required); sys.exit(2 if failed else 0 if passed else 1)'; then break else state=$? @@ -138,8 +146,13 @@ jobs: done - uses: actions/download-artifact@v4 with: + pattern: native-* path: dist merge-multiple: true + - uses: actions/download-artifact@v4 + with: + name: wasm + path: dist - name: Hash and publish all verified packages run: | test "$(gh api "repos/$GH_REPO/commits/$TAG" --jq .sha)" = "$SOURCE_COMMIT" @@ -152,3 +165,30 @@ jobs: cd ../readback sha256sum -c SHA256SUMS echo "Published $TAG from $SOURCE_COMMIT; all seven downloaded packages match SHA256SUMS." >> "$GITHUB_STEP_SUMMARY" + + pypi: + needs: [resolve, publish] + runs-on: ubuntu-24.04 + environment: + name: pypi + url: https://pypi.org/project/ournotes-sim/ + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: wheel-* + path: dist + merge-multiple: true + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Check the wheels + run: | + test "$(find dist -maxdepth 1 -name '*.whl' | wc -l)" -eq 5 + python -m pip install --upgrade twine + python -m twine check --strict dist/* + # trusted publishing: PyPI accepts this workflow's OIDC token, no API token is stored + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip-existing: true diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml new file mode 100644 index 0000000..a36b4fc --- /dev/null +++ b/.github/workflows/wheels.yml @@ -0,0 +1,64 @@ +name: Python wheels + +on: + workflow_call: + inputs: + source-commit: + description: Source commit to build and verify + required: true + type: string + pull_request: + paths: + - python/** + - scripts/release/smoke-python.py + - .github/workflows/wheels.yml + +permissions: + contents: read + +jobs: + # one abi3 wheel (CPython 3.11 and later) per platform with the extension module ournotes_sim._native + wheel: + strategy: + fail-fast: false + matrix: + include: + - {runner: ubuntu-24.04, target: x86_64, manylinux: '2_28'} + - {runner: ubuntu-24.04-arm, target: aarch64, manylinux: '2_28'} + - {runner: windows-2025, target: x64} + - {runner: macos-15-intel, target: x86_64} + - {runner: macos-15, target: aarch64} + runs-on: ${{ matrix.runner }} + timeout-minutes: 40 + defaults: + run: + shell: bash + env: + SOURCE_COMMIT: ${{ inputs.source-commit || github.sha }} + MACOSX_DEPLOYMENT_TARGET: '11.0' + steps: + - uses: actions/checkout@v7 + with: + path: automation + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.source-commit || github.sha }} + path: source + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - uses: PyO3/maturin-action@v1 + with: + working-directory: source/python + target: ${{ matrix.target }} + manylinux: ${{ matrix.manylinux || 'auto' }} + # SOURCE_COMMIT is compiled into info()["commit"] + docker-options: -e SOURCE_COMMIT + args: --release --locked --out dist + - name: Test the installed wheel + run: python automation/scripts/release/smoke-python.py source/python/dist --tests source/python/tests --commit "$SOURCE_COMMIT" + - uses: actions/upload-artifact@v4 + with: + name: wheel-${{ matrix.runner }}-${{ matrix.target }} + path: source/python/dist/*.whl + if-no-files-found: error diff --git a/.gitignore b/.gitignore index f78a397..c231493 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ /work /tools/search-harness/target /tools/search-harness/__pycache__ +/python/target +/python/dist +/python/**/__pycache__ +/python/.pytest_cache diff --git a/Cargo.toml b/Cargo.toml index c35dc53..78bae27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] members = ["crates/ournotes-sim", "crates/ournotes-search"] # separate packages with their own lockfiles and release profiles -exclude = ["wasm/replay", "wasm/recommend", "tools/search-harness"] +exclude = ["wasm/replay", "wasm/recommend", "python", "tools/search-harness"] resolver = "3" [workspace.package] diff --git a/README.en.md b/README.en.md index 6ebf387..bb3d757 100644 --- a/README.en.md +++ b/README.en.md @@ -15,8 +15,9 @@ The repository is a Cargo workspace of two crates; the search depends on the mod Depend on `ournotes-sim` for computation or simulation alone (chart statistics, replay); depend on `ournotes-search` to search, and take the model's types from `ournotes-sim`. `wasm/replay` is the replay WASM over `ournotes-sim`, -`wasm/recommend` the recommendation WASM over `ournotes-search`, and `tools/search-harness` measures the search; these -three are separate packages outside the workspace, each with its own `Cargo.lock`. Both crates have a +`wasm/recommend` the recommendation WASM over `ournotes-search`, `python` the Python package `ournotes-sim` (the +chart statistics of `ournotes-sim`, see [python/README.md](python/README.md)), and `tools/search-harness` measures the +search; these four are separate packages outside the workspace, each with its own `Cargo.lock`. Both crates have a `search-diagnostics` feature; `ournotes-search`'s also enables `ournotes-sim`'s. ## Recommendation pipeline @@ -168,6 +169,16 @@ for deck in &out.results { } ``` +From Python, the PyPI package `ournotes-sim` measures the chart statistics of a deck data document with the model of +the same release: + +```python +import json +import ournotes_sim + +stats = json.loads(ournotes_sim.chart_stats(open("deck-data.json", encoding="utf-8").read())) +``` + ## Command line Use one entry point, `ournotes-deck`. JSON recommendation requests use its @@ -336,7 +347,8 @@ fn statistics(data: &DeckData) -> Result { `cargo test` at the repository root runs the unit and integration tests of both crates: it reads synthetic deck data files and compares the search with exhaustive enumeration on small synthetic pools. The two WASM packages build with -`cargo build --manifest-path wasm//Cargo.toml --target wasm32-unknown-unknown --release`. `OURNOTES_DECK_ORACLE_CASES`, `OURNOTES_DECK_ORACLE_SEED0`, +`cargo build --manifest-path wasm//Cargo.toml --target wasm32-unknown-unknown --release`. The Python +package builds and installs with `pip install ./python[test]`; `pytest python/tests` tests it. `OURNOTES_DECK_ORACLE_CASES`, `OURNOTES_DECK_ORACLE_SEED0`, `OURNOTES_DECK_ORACLE_MEMBERS` and `OURNOTES_DECK_ORACLE_SNAPS` enlarge that comparison. For the live score with snap skills, `OURNOTES_DECK_SNAPS_CASES`, `OURNOTES_DECK_SNAPS_SEED0`, `OURNOTES_DECK_SNAPS_MEMBERS`, `OURNOTES_DECK_SNAPS_SNAPS`, `OURNOTES_DECK_SNAPS_NOTES` and `OURNOTES_DECK_SNAPS_VARIANTS` do the same. @@ -344,8 +356,8 @@ skills, `OURNOTES_DECK_SNAPS_CASES`, `OURNOTES_DECK_SNAPS_SEED0`, `OURNOTES_DECK ## Changelog [CHANGELOG.md](CHANGELOG.md) lists the changes, generated from the commits (Conventional Commits) by -[git-cliff](https://git-cliff.org/). The commit that bumps the version regenerates it with -`git cliff --tag vX.Y.Z -o CHANGELOG.md`. +[git-cliff](https://git-cliff.org/). The commit that bumps the version (the workspace, `wasm/*` and `python` +manifests with their lockfiles) regenerates it with `git cliff --tag vX.Y.Z -o CHANGELOG.md`. ## Licence diff --git a/README.md b/README.md index 4ee723f..f17143a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ BanG Dream! Our Notes 的综合力、跳过分数与演出分数计算,以及 只需要计算或模拟(例如谱面统计、回放)时依赖 `ournotes-sim`;要搜索时依赖 `ournotes-search`,模型类型仍从 `ournotes-sim` 引用。`wasm/replay` 是基于 `ournotes-sim` 的回放 WASM,`wasm/recommend` 是基于 `ournotes-search` 的推荐 WASM, -`tools/search-harness` 是搜索的测量工具;这三个是 workspace 之外的独立 package,各有自己的 `Cargo.lock`。 +`python` 是 Python 包 `ournotes-sim`(`ournotes-sim` 的谱面统计,见 [python/README.md](python/README.md)), +`tools/search-harness` 是搜索的测量工具;这四个是 workspace 之外的独立 package,各有自己的 `Cargo.lock`。 两个 crate 都有 `search-diagnostics` 特性;`ournotes-search` 的这个特性会同时打开 `ournotes-sim` 的。 ## 推荐主链 @@ -148,6 +149,15 @@ for deck in &out.results { } ``` +在 Python 里,PyPI 包 `ournotes-sim` 用同一版本的模型计算 deck data 文件的谱面统计: + +```python +import json +import ournotes_sim + +stats = json.loads(ournotes_sim.chart_stats(open("deck-data.json", encoding="utf-8").read())) +``` + ## 命令行 统一入口为 `ournotes-deck`。JSON 推荐请求使用 `recommend` 子命令: @@ -238,7 +248,8 @@ Just 率插值和 Great 比例缩放是统计摘要的近似使用方式,完 在仓库根目录运行 `cargo test`,即运行两个 crate 的单元测试与集成测试:读取合成的 deck data 文件,并在小规模合成卡池上 将搜索结果与穷举结果逐项比较。两个 WASM package 用 `cargo build --manifest-path wasm//Cargo.toml ---target wasm32-unknown-unknown --release` 构建。 +--target wasm32-unknown-unknown --release` 构建。Python 包用 `pip install ./python[test]` 构建并安装, +`pytest python/tests` 测试它。 可用 `OURNOTES_DECK_ORACLE_CASES`、`OURNOTES_DECK_ORACLE_SEED0`、`OURNOTES_DECK_ORACLE_MEMBERS`、 `OURNOTES_DECK_ORACLE_SNAPS` 扩大比较规模。含 snap 技能的演出分数可用 `OURNOTES_DECK_SNAPS_CASES`、 `OURNOTES_DECK_SNAPS_SEED0`、`OURNOTES_DECK_SNAPS_MEMBERS`、`OURNOTES_DECK_SNAPS_SNAPS`、`OURNOTES_DECK_SNAPS_NOTES`、 @@ -246,7 +257,7 @@ Just 率插值和 Great 比例缩放是统计摘要的近似使用方式,完 ## 版本记录 -改动见 [CHANGELOG.md](CHANGELOG.md),由 [git-cliff](https://git-cliff.org/) 根据提交(Conventional Commits)生成。发版时,改版本号的那个提交同时重新生成它:`git cliff --tag vX.Y.Z -o CHANGELOG.md`。 +改动见 [CHANGELOG.md](CHANGELOG.md),由 [git-cliff](https://git-cliff.org/) 根据提交(Conventional Commits)生成。发版时,改版本号的那个提交(workspace、`wasm/*` 与 `python` 的清单及其锁文件)同时重新生成它:`git cliff --tag vX.Y.Z -o CHANGELOG.md`。 ## 许可证 diff --git a/docs/release-artifacts.md b/docs/release-artifacts.md index f186242..8f91f00 100644 --- a/docs/release-artifacts.md +++ b/docs/release-artifacts.md @@ -21,6 +21,13 @@ owned snapshot; `power`, `skip`, `live`, and `chart-stats` provide the task-spec interfaces. Use `ournotes-deck --help` and `ournotes-deck recommend --help`. Rust library consumers use the tagged Git dependencies described in the README. +The same release publishes the Python package `ournotes-sim` to PyPI: one abi3 +wheel (CPython 3.11 and later) for each of Linux x64 and ARM64 (manylinux 2.28), +Windows x64, and macOS Intel and Apple Silicon (macOS 11 or later). Its +`info()["commit"]` is the release commit. The package is published as wheels +only, so every installation runs the model whose `sourceSha256` the release +reports. + Both WASM packages include JavaScript, TypeScript declarations, and the matching `_bg.wasm` files. Keep each binding directory together. The web directory uses ES modules; the Node directory uses CommonJS. Serve the web package over HTTP, with @@ -50,6 +57,11 @@ Worker. The worker checks Power and Free recommendation results against native references and replay output against the Node binding. These package checks do not establish additional native-game coverage. +Each wheel is installed in a fresh environment on its own platform, where the +package tests run against it and its reported commit is checked against the tag. + Only after all builds and checks pass does the workflow upload the archives and `SHA256SUMS`, download the published assets again, and verify every archive hash. +The PyPI job then checks the five wheels with `twine check` and publishes them +through PyPI trusted publishing. The workflow can add artifacts to an already published source-only Release. diff --git a/python/Cargo.lock b/python/Cargo.lock new file mode 100644 index 0000000..8285db0 --- /dev/null +++ b/python/Cargo.lock @@ -0,0 +1,284 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ournotes-sim" +version = "0.0.6" +dependencies = [ + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "ournotes-sim-py" +version = "0.0.6" +dependencies = [ + "ournotes-sim", + "pyo3", + "serde_json", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/python/Cargo.toml b/python/Cargo.toml new file mode 100644 index 0000000..d2be46a --- /dev/null +++ b/python/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ournotes-sim-py" +# the workspace version (../Cargo.toml); the wheel takes its version from here +version = "0.0.6" +edition = "2024" +rust-version = "1.88" +description = "ournotes_sim._native: the chart statistics of ournotes-sim as a Python extension module" +license = "MIT OR Apache-2.0" +repository = "https://github.com/empty-sekai/ournotes-deck" +publish = false + +[lib] +name = "_native" +crate-type = ["cdylib"] + +[dependencies] +ournotes-sim = { path = "../crates/ournotes-sim" } +pyo3 = { version = "0.29", features = ["abi3-py311"] } +serde_json = { version = "1", features = ["raw_value", "arbitrary_precision"] } + +[profile.release] +lto = "thin" +codegen-units = 1 diff --git a/python/LICENSE-APACHE b/python/LICENSE-APACHE new file mode 100644 index 0000000..22d867b --- /dev/null +++ b/python/LICENSE-APACHE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, +and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by +the copyright owner that is granting the License. + +Legal Entity" shall mean the union of the acting entity and all +other entities that control, are controlled by, or are under common +control with that entity. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the +direction or management of such entity, whether by contract or +otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity +exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical +transformation or translation of a Source form, including but +not limited to compiled object code, generated documentation, +and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or +Object form, made available under the License, as indicated by a +copyright notice that is included in or attached to the work +(an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based on (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including +the original version of the Work and any modifications or additions +to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of +the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, +and issue tracking systems that are managed by, or on behalf of, the +Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity +on behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the +Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, +where such license applies only to those patent claims licensable +by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) +with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the +Work or Derivative Works thereof in any medium, with or without +modifications, and in Source or Object form, provided that You +meet the following conditions: + +(a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + +(b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + +You may add Your own copyright statement to Your modifications and +may provide additional or different license terms and conditions +for use, reproduction, or distribution of Your modifications, or +for any such Derivative Works as a whole, provided Your use, +reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, +any Contribution intentionally submitted for inclusion in the Work +by You to the Licensor shall be under the terms and conditions of +this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify +the terms of any separate license agreement you may have executed +with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade +names, trademarks, service marks, or product names of the Licensor, +except as required for reasonable and customary use in describing the +origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or +agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, +unless required by applicable law (such as deliberate and grossly +negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, +incidental, or consequential damages of any character arising as a +result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing +the Work or Derivative Works thereof, You may choose to offer, +and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this +License. However, in accepting such obligations, You may act only +on Your own behalf and on Your sole responsibility, not on behalf +of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +Copyright (c) 2026 The ournotes-deck contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/python/LICENSE-MIT b/python/LICENSE-MIT new file mode 100644 index 0000000..4ef67eb --- /dev/null +++ b/python/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The ournotes-deck contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..2e95532 --- /dev/null +++ b/python/README.md @@ -0,0 +1,44 @@ +# ournotes-sim + +The deck model of [ournotes-deck](https://github.com/empty-sekai/ournotes-deck) for Python: the chart statistics of +the Rust crate `ournotes-sim` on a deck data document, measured in parallel in native code. + +```sh +pip install ournotes-sim +``` + +Wheels are built for CPython 3.11 and later (one abi3 wheel per platform) on Linux (x86_64, aarch64; manylinux +2.28), Windows (x64) and macOS 11 or later (x86_64, arm64). + +## Use + +```python +import json +import ournotes_sim + +text = open("deck-data.json", encoding="utf-8").read() # nnnotes.deck-data/1 +stats = json.loads(ournotes_sim.chart_stats(text)) # ournotes-deck.chart-stats/3 +print(ournotes_sim.info()) +``` + +| name | does | +|---|---| +| `chart_stats(data, seeds=None, workers=None, aptitude=True)` | the chart statistics document (JSON text) of a deck data document (JSON text); `seeds`: replay seeds (default 8), `workers`: threads (default: the available parallelism), `aptitude`: include the single Gekisou skill shapes. Raises `ValueError` for invalid data or a failed expectation check | +| `chart_stats_cached(data, seeds=None, workers=None, aptitude=True, *, cache_dir)` | `chart_stats` with the simulation programs kept in `cache_dir`, and the cache's counters (`requests`, `hits`, `computed`, `writes`, `invalid`, `bytes`); the document is the same as `chart_stats`'s | +| `info()` | the model: `name`, `version`, `source`, `commit` (the source commit of a release build, `None` for other builds), `sourceSha256` (the SHA-256 of the ournotes-sim sources: equal for builds that run the same model), `dataFormat` and `format` | +| `MASTER_TABLES` | the master tables a deck data document holds (each one is required, zero rows are allowed) | + +Every version of the package carries one model, the ournotes-sim of the same release. The package contains no game +data: deck data documents are made from each user's own master data, for example with +[nnnotes](https://github.com/MetaSekaiLab/nnnotes). + +## Building + +```sh +pip install ./python[test] # from the repository root: builds the extension module with maturin +pytest python/tests +``` + +## Licence + +MIT OR Apache-2.0. diff --git a/python/ournotes_sim/__init__.py b/python/ournotes_sim/__init__.py new file mode 100644 index 0000000..c73346e --- /dev/null +++ b/python/ournotes_sim/__init__.py @@ -0,0 +1,9 @@ +"""The deck model of ournotes-deck (the Rust crate ournotes-sim) for Python. + +`chart_stats` and `chart_stats_cached` measure the chart statistics (`ournotes-deck.chart-stats/3`) of a deck data +document (`nnnotes.deck-data/1`); `info` names the model and the formats; `MASTER_TABLES` lists the master tables a +deck data document holds. +""" +from ._native import MASTER_TABLES, __version__, chart_stats, chart_stats_cached, info + +__all__ = ["MASTER_TABLES", "__version__", "chart_stats", "chart_stats_cached", "info"] diff --git a/python/ournotes_sim/_native.pyi b/python/ournotes_sim/_native.pyi new file mode 100644 index 0000000..368df9b --- /dev/null +++ b/python/ournotes_sim/_native.pyi @@ -0,0 +1,33 @@ +import os +from typing import TypedDict + +class Info(TypedDict): + name: str + version: str + source: str + commit: str | None + sourceSha256: str + dataFormat: str + format: str + +class CacheCounts(TypedDict): + requests: int + hits: int + computed: int + writes: int + invalid: int + bytes: int + +__version__: str +MASTER_TABLES: tuple[str, ...] + +def chart_stats(data: str, seeds: int | None = None, workers: int | None = None, aptitude: bool = True) -> str: ... +def chart_stats_cached( + data: str, + seeds: int | None = None, + workers: int | None = None, + aptitude: bool = True, + *, + cache_dir: str | os.PathLike[str], +) -> tuple[str, CacheCounts]: ... +def info() -> Info: ... diff --git a/python/ournotes_sim/py.typed b/python/ournotes_sim/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..40a40aa --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["maturin>=1.9,<2"] +build-backend = "maturin" + +[project] +name = "ournotes-sim" +description = "The BanG Dream! Our Notes deck model (ournotes-sim) for Python: chart statistics of deck data documents" +readme = "README.md" +license = "MIT OR Apache-2.0" +license-files = ["LICENSE-MIT", "LICENSE-APACHE"] +requires-python = ">=3.11" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Rust", + "Operating System :: OS Independent", + "Typing :: Typed", +] +dynamic = ["version"] + +[project.optional-dependencies] +test = ["pytest>=8"] + +[project.urls] +Homepage = "https://github.com/empty-sekai/ournotes-deck" +Source = "https://github.com/empty-sekai/ournotes-deck" +Issues = "https://github.com/empty-sekai/ournotes-deck/issues" + +[tool.maturin] +# the package ournotes_sim (next to this file) with the extension module ournotes_sim._native built from Cargo.toml; +# the version is Cargo.toml's +module-name = "ournotes_sim._native" +profile = "release" +locked = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" diff --git a/python/src/lib.rs b/python/src/lib.rs new file mode 100644 index 0000000..a16d7be --- /dev/null +++ b/python/src/lib.rs @@ -0,0 +1,159 @@ +//! `ournotes_sim._native`: the chart statistics of the deck model ournotes-sim (`ournotes-deck.chart-stats/3`) on a +//! deck data document held in memory, the charts measured in parallel. + +use std::path::PathBuf; +use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use ournotes_sim::chartstats::{self, ChartStats, ChartStatsCache, Options, REPLAY_SEEDS}; +use ournotes_sim::data::DeckData; +use ournotes_sim::error::Error; +use ournotes_sim::master::TABLES; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyTuple}; + +fn value_error(e: impl std::fmt::Display) -> PyErr { + PyValueError::new_err(e.to_string()) +} + +/// The statistics of every chart, in chart order, on `workers` threads. +fn measure( + data: &DeckData, + options: &Options, + workers: usize, + cache: Option<&ChartStatsCache>, +) -> Result, Error> { + let kinds = chartstats::kinds(&data.master); + let n = data.charts.len(); + let next = AtomicUsize::new(0); + let out: Mutex>>> = Mutex::new((0..n).map(|_| None).collect()); + std::thread::scope(|s| { + for _ in 0..workers.clamp(1, n.max(1)) { + s.spawn(|| { + loop { + let i = next.fetch_add(1, Ordering::Relaxed); + if i >= n { + break; + } + let c = &data.charts[i]; + // as chartstats::document names a chart's domain errors + let r = match cache { + Some(cache) => chartstats::chart_stats_with_cache(&data.master, c, &kinds, options, cache), + None => chartstats::chart_stats_with(&data.master, c, &kinds, options), + } + .map_err(|e| match e { + Error::Domain(m) => Error::Domain(format!("chart {}: {m}", c.score_id)), + e => e, + }); + let failed = r.is_err(); + out.lock().expect("results")[i] = Some(r); + if failed { + next.store(n, Ordering::Relaxed); + } + } + }); + } + }); + // charts are taken in order and a taken chart always completes, so every chart before the first failure has + // its result: the error returned is the first in chart order, as chartstats::document's + let mut stats = Vec::with_capacity(n); + for r in out.into_inner().expect("results") { + stats.push(r.expect("a chart before the first failure has run")?); + } + Ok(stats) +} + +/// The chart statistics document of `data` as JSON text. +fn document( + data: &str, + seeds: Option, + workers: Option, + aptitude: bool, + cache: Option<&ChartStatsCache>, +) -> PyResult { + let options = Options { replay_seeds: seeds.unwrap_or(REPLAY_SEEDS), aptitude }; + let workers = workers.unwrap_or_else(|| std::thread::available_parallelism().map_or(1, |n| n.get())); + let mut data = DeckData::from_json(data).map_err(value_error)?; + let charts = std::mem::take(&mut data.charts); + let mut doc = chartstats::document_with(&data, &options).map_err(value_error)?; + data.charts = charts; + let stats = measure(&data, &options, workers, cache).map_err(value_error)?; + doc["charts"] = serde_json::to_value(stats).map_err(value_error)?; + serde_json::to_string(&doc).map_err(value_error) +} + +/// chart_stats(data, seeds=None, workers=None, aptitude=True) -> str +/// +/// The `ournotes-deck.chart-stats/3` document (JSON text) of a deck data document (`nnnotes.deck-data/1` JSON text). +/// `seeds`: the number of replay seeds (default 8); expectations use independent nominal probabilities. +/// `workers`: the number of threads the charts are measured on (default: the available parallelism). +/// `aptitude`: include the single Gekisou skill shapes (default true). +/// Raises ValueError for invalid data or a failed expectation check. +#[pyfunction] +#[pyo3(signature = (data, seeds=None, workers=None, aptitude=true))] +fn chart_stats( + py: Python<'_>, + data: &str, + seeds: Option, + workers: Option, + aptitude: bool, +) -> PyResult { + py.detach(|| document(data, seeds, workers, aptitude, None)) +} + +/// chart_stats_cached(data, seeds=None, workers=None, aptitude=True, *, cache_dir) -> (str, dict) +/// +/// chart_stats with the simulation programs kept in `cache_dir` (under a directory named by the model's source +/// SHA-256), and the cache's counters (requests, hits, computed, writes, invalid, bytes). Programs completed before a +/// failure stay in the cache; every call validates the whole input and builds the document's header again. +#[pyfunction] +#[pyo3(signature = (data, seeds=None, workers=None, aptitude=true, *, cache_dir))] +fn chart_stats_cached<'py>( + py: Python<'py>, + data: &str, + seeds: Option, + workers: Option, + aptitude: bool, + cache_dir: PathBuf, +) -> PyResult<(String, Bound<'py, PyDict>)> { + let (text, stats) = py.detach(|| { + let cache = ChartStatsCache::new(&cache_dir).map_err(value_error)?; + let text = document(data, seeds, workers, aptitude, Some(&cache))?; + Ok::<_, PyErr>((text, cache.snapshot())) + })?; + let counts = PyDict::new(py); + counts.set_item("requests", stats.requests)?; + counts.set_item("hits", stats.hits)?; + counts.set_item("computed", stats.computed)?; + counts.set_item("writes", stats.writes)?; + counts.set_item("invalid", stats.invalid)?; + counts.set_item("bytes", stats.bytes)?; + Ok((text, counts)) +} + +/// info() -> dict: the deck model of this module (name: the ournotes-deck repository; version and source: the +/// package as built; commit: the source commit of a release build, None otherwise; sourceSha256: the SHA-256 of the +/// ournotes-sim sources, equal for builds that run the same model) and the formats it reads and writes. +#[pyfunction] +fn info(py: Python<'_>) -> PyResult> { + let d = PyDict::new(py); + d.set_item("name", "ournotes-deck")?; + d.set_item("version", env!("CARGO_PKG_VERSION"))?; + d.set_item("source", env!("CARGO_PKG_REPOSITORY"))?; + d.set_item("commit", option_env!("SOURCE_COMMIT"))?; + d.set_item("sourceSha256", ournotes_sim::SOURCE_SHA256)?; + d.set_item("dataFormat", ournotes_sim::data::FORMAT)?; + d.set_item("format", chartstats::FORMAT)?; + Ok(d) +} + +#[pymodule] +fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("__version__", env!("CARGO_PKG_VERSION"))?; + m.add("MASTER_TABLES", PyTuple::new(m.py(), TABLES)?)?; + m.add_function(wrap_pyfunction!(chart_stats, m)?)?; + m.add_function(wrap_pyfunction!(chart_stats_cached, m)?)?; + m.add_function(wrap_pyfunction!(info, m)?)?; + Ok(()) +} diff --git a/python/tests/test_ournotes_sim.py b/python/tests/test_ournotes_sim.py new file mode 100644 index 0000000..4aa10a8 --- /dev/null +++ b/python/tests/test_ournotes_sim.py @@ -0,0 +1,66 @@ +"""The installed package on synthetic deck data documents.""" +import json +import tomllib +from importlib import metadata +from pathlib import Path + +import pytest + +import ournotes_sim + +PACKAGE = Path(__file__).resolve().parents[1] + + +def empty_data(charts=()) -> str: + info = ournotes_sim.info() + master = {name: {"columns": [], "rows": []} for name in ournotes_sim.MASTER_TABLES} + return json.dumps({"format": info["dataFormat"], "master": master, "charts": list(charts)}) + + +def test_info(): + info = ournotes_sim.info() + assert info["name"] == "ournotes-deck" + assert info["version"] == ournotes_sim.__version__ == metadata.version("ournotes-sim") + assert info["source"] == "https://github.com/empty-sekai/ournotes-deck" + assert info["dataFormat"] == "nnnotes.deck-data/1" + assert info["format"] == "ournotes-deck.chart-stats/3" + assert len(info["sourceSha256"]) == 64 and set(info["sourceSha256"]) <= set("0123456789abcdef") + assert info["commit"] is None or (len(info["commit"]) == 40 and set(info["commit"]) <= set("0123456789abcdef")) + + +def test_master_tables(): + assert ournotes_sim.MASTER_TABLES + assert len(set(ournotes_sim.MASTER_TABLES)) == len(ournotes_sim.MASTER_TABLES) + assert all(name.startswith("Master") for name in ournotes_sim.MASTER_TABLES) + + +def test_chart_stats_without_charts(): + stats = json.loads(ournotes_sim.chart_stats(empty_data(), workers=1, aptitude=False)) + assert stats["format"] == ournotes_sim.info()["format"] + assert stats["charts"] == [] + + +def test_chart_stats_cached_without_charts(tmp_path): + text, counts = ournotes_sim.chart_stats_cached(empty_data(), workers=1, aptitude=False, cache_dir=tmp_path) + assert text == ournotes_sim.chart_stats(empty_data(), workers=1, aptitude=False) + assert counts == {"requests": 0, "hits": 0, "computed": 0, "writes": 0, "invalid": 0, "bytes": 0} + assert (tmp_path / "chart-program-v1" / ournotes_sim.info()["sourceSha256"]).is_dir() + + +def test_invalid_data(): + with pytest.raises(ValueError, match="format"): + ournotes_sim.chart_stats(json.dumps({"format": "other/1", "master": {}, "charts": []})) + data = json.loads(empty_data()) + del data["master"][ournotes_sim.MASTER_TABLES[0]] + with pytest.raises(ValueError, match=ournotes_sim.MASTER_TABLES[0]): + ournotes_sim.chart_stats(json.dumps(data)) + + +@pytest.mark.skipif(not (PACKAGE / "Cargo.toml").is_file(), reason="needs the source tree") +def test_source_tree(): + package = tomllib.loads((PACKAGE / "Cargo.toml").read_text(encoding="utf-8"))["package"] + workspace = tomllib.loads((PACKAGE.parent / "Cargo.toml").read_text(encoding="utf-8"))["workspace"]["package"] + assert package["version"] == workspace["version"] == ournotes_sim.__version__ + assert package["license"] == workspace["license"] + for name in ("LICENSE-MIT", "LICENSE-APACHE"): + assert (PACKAGE / name).read_bytes() == (PACKAGE.parent / name).read_bytes(), name diff --git a/scripts/release/smoke-python.py b/scripts/release/smoke-python.py new file mode 100644 index 0000000..ca5747e --- /dev/null +++ b/scripts/release/smoke-python.py @@ -0,0 +1,41 @@ +"""Install a built ournotes-sim wheel in a fresh environment and run the package tests against it.""" +import argparse +import os +import subprocess +import tempfile +import venv +from pathlib import Path + +CHECK = """ +import sys +from pathlib import Path + +import ournotes_sim + +assert Path(ournotes_sim.__file__).resolve().is_relative_to(Path(sys.prefix).resolve()), ournotes_sim.__file__ +print(ournotes_sim.info()["commit"]) +""" + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("dist", type=Path, help="directory holding exactly one wheel") + parser.add_argument("--tests", required=True, type=Path, help="the package tests (python/tests)") + parser.add_argument("--commit", help="the source commit the wheel must report") + args = parser.parse_args() + wheel, = args.dist.resolve().glob("*.whl") + tests = args.tests.resolve() + with tempfile.TemporaryDirectory(prefix="ournotes-sim-wheel-") as directory: + root = Path(directory) + venv.EnvBuilder(with_pip=True).create(root / "venv") + python = root / "venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + subprocess.run([python, "-I", "-m", "pip", "install", "--quiet", str(wheel), "pytest>=8"], cwd=root, check=True) + subprocess.run([python, "-I", "-m", "pytest", "-q", "-p", "no:cacheprovider", str(tests)], cwd=root, check=True) + commit = subprocess.check_output([python, "-I", "-c", CHECK], cwd=root, encoding="utf-8").strip() + if args.commit: + assert commit == args.commit, f"the wheel reports commit {commit}, not {args.commit}" + print(f"{wheel.name}: tests passed, commit {commit}") + + +if __name__ == "__main__": + main()