From 9e7d0e52a07100cdb1b556a4c9898a397f97f1d6 Mon Sep 17 00:00:00 2001 From: Vincent Emonet Date: Wed, 3 Apr 2024 12:11:18 +0200 Subject: [PATCH] Add more functions to python bindings (get predefined converters, list, chain...), add tests for python bindings, improve tests of R bindings, migrate docs website framewrok from mdbook to mkdocs (looks better, much nicer to navigate, easier to setup, more functionalities), complete docs for python bindings --- .github/workflows/build.yml | 199 +++++++------- .github/workflows/manylinux_build.sh | 21 -- .github/workflows/musllinux_build.sh | 19 -- .github/workflows/test.yml | 86 ++++-- .gitignore | 5 + .pre-commit-config.yaml | 4 +- Cargo.toml | 4 + book.toml | 29 -- js/Cargo.toml | 4 +- js/src/api.rs | 18 +- js/tests/curies.test.ts | 14 +- lib/docs/SUMMARY.md | 7 - lib/docs/contributing.md | 191 -------------- lib/docs/docs/architecture.md | 63 +++++ lib/docs/docs/assets/custom.css | 4 + lib/docs/docs/assets/logo.png | Bin 0 -> 89371 bytes lib/docs/docs/assets/syntax_demo.svg | 1 + lib/docs/docs/contributing.md | 171 ++++++++++++ lib/docs/{introduction.md => docs/index.md} | 29 +- lib/docs/docs/javascript-example-framework.md | 51 ++++ lib/docs/docs/javascript-example-html.md | 43 +++ lib/docs/docs/javascript.md | 77 ++++++ lib/docs/docs/python.md | 182 +++++++++++++ lib/docs/docs/r.md | 33 +++ lib/docs/{use_rust.md => docs/rust.md} | 2 +- lib/docs/docs/struct.md | 86 ++++++ lib/docs/includes/abbreviations.md | 68 +++++ lib/docs/mkdocs.yml | 127 +++++++++ lib/docs/requirements.txt | 6 + lib/docs/use_javascript.md | 160 ----------- lib/docs/use_python.md | 42 --- lib/src/api.rs | 67 ++++- lib/src/lib.rs | 3 +- lib/tests/curies_test.rs | 11 +- python/Cargo.toml | 5 +- python/requirements.dev.txt | 1 - python/requirements.txt | 4 + python/src/api.rs | 248 ++++++++++++++++++ python/src/lib.rs | 101 +------ python/tests/test_api.py | 110 +++++++- python/tests/test_docs.py | 9 + r/NAMESPACE | 1 - r/R/extendr-wrappers.R | 6 +- r/README.md | 24 +- r/man/hello_world.Rd | 11 - r/src/rust/Cargo.toml | 3 +- r/src/rust/src/lib.rs | 23 +- r/tests/test-curies.R | 11 + r/tests/testthat.R | 4 - r/tests/testthat/test-hello.R | 3 - rust-toolchain.toml | 5 + scripts/build-js.sh | 9 - scripts/build-python.sh | 10 - scripts/bump.sh | 16 +- scripts/docs-build.sh | 14 - scripts/docs-install.sh | 10 - scripts/docs-serve.sh | 11 - scripts/docs.sh | 16 ++ scripts/install-dev.sh | 6 +- scripts/test-all.sh | 8 + scripts/test-js.sh | 23 ++ scripts/test-python.sh | 27 ++ scripts/test-r.sh | 24 ++ 63 files changed, 1728 insertions(+), 842 deletions(-) delete mode 100755 .github/workflows/manylinux_build.sh delete mode 100755 .github/workflows/musllinux_build.sh delete mode 100644 book.toml delete mode 100644 lib/docs/SUMMARY.md delete mode 100644 lib/docs/contributing.md create mode 100644 lib/docs/docs/architecture.md create mode 100644 lib/docs/docs/assets/custom.css create mode 100644 lib/docs/docs/assets/logo.png create mode 100644 lib/docs/docs/assets/syntax_demo.svg create mode 100644 lib/docs/docs/contributing.md rename lib/docs/{introduction.md => docs/index.md} (70%) create mode 100644 lib/docs/docs/javascript-example-framework.md create mode 100644 lib/docs/docs/javascript-example-html.md create mode 100644 lib/docs/docs/javascript.md create mode 100644 lib/docs/docs/python.md create mode 100644 lib/docs/docs/r.md rename lib/docs/{use_rust.md => docs/rust.md} (97%) create mode 100644 lib/docs/docs/struct.md create mode 100644 lib/docs/includes/abbreviations.md create mode 100644 lib/docs/mkdocs.yml create mode 100644 lib/docs/requirements.txt delete mode 100644 lib/docs/use_javascript.md delete mode 100644 lib/docs/use_python.md delete mode 100644 python/requirements.dev.txt create mode 100644 python/requirements.txt create mode 100644 python/src/api.rs create mode 100644 python/tests/test_docs.py delete mode 100644 r/man/hello_world.Rd create mode 100644 r/tests/test-curies.R delete mode 100644 r/tests/testthat.R delete mode 100644 r/tests/testthat/test-hello.R create mode 100644 rust-toolchain.toml delete mode 100755 scripts/build-js.sh delete mode 100755 scripts/build-python.sh delete mode 100755 scripts/docs-build.sh delete mode 100755 scripts/docs-install.sh delete mode 100755 scripts/docs-serve.sh create mode 100755 scripts/docs.sh create mode 100755 scripts/test-all.sh create mode 100755 scripts/test-js.sh create mode 100755 scripts/test-python.sh create mode 100755 scripts/test-r.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7ec32c4..2d3d247 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,9 @@ on: release: types: - published + push: + tags: + - "v*.*.*" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -18,11 +21,11 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install wasm-pack for JS + - name: Install wasm-pack run: cargo install wasm-pack - name: Setup NodeJS - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 registry-url: 'https://registry.npmjs.org' @@ -58,129 +61,107 @@ jobs: - run: cargo publish working-directory: ./lib - wheel_linux: - name: 🐍🐧 Build wheel for Linux - runs-on: ubuntu-latest + # Inspired by pydantic: https://github.com/pydantic/pydantic-core/blob/main/.github/workflows/ci.yml + build_wheels: + name: 🐍 Wheels for ${{ matrix.os }} (${{ matrix.target }} - ${{ matrix.interpreter || 'all' }}${{ matrix.os == 'linux' && format(' - {0}', matrix.manylinux == 'auto' && 'manylinux' || matrix.manylinux) || '' }}) strategy: + fail-fast: false matrix: - architecture: [ "x86_64", "aarch64" ] - continue-on-error: true + os: [linux, macos, windows] + target: [x86_64, aarch64] + manylinux: [auto] + include: + # Using pypy when possible for performance + # manylinux for various platforms, plus x86_64 pypy + - os: linux + manylinux: auto + target: i686 + - os: linux + manylinux: auto + target: aarch64 # -bit ARM + - os: linux + manylinux: auto + target: armv7 # 32-bit ARM + interpreter: 3.8 3.9 3.10 3.11 3.12 + - os: linux + manylinux: auto + target: ppc64le + interpreter: 3.8 3.9 3.10 3.11 3.12 + - os: linux + manylinux: auto + target: s390x + interpreter: 3.8 3.9 3.10 3.11 3.12 + - os: linux + manylinux: auto + target: x86_64 + # musllinux + - os: linux + manylinux: musllinux_1_1 + target: x86_64 + - os: linux + manylinux: musllinux_1_1 + target: aarch64 + # MacOS + - os: macos + target: x86_64 + - os: macos + target: aarch64 + # Windows + # x86_64 pypy builds are not PGO optimized, i686 not supported by pypy, aarch64 only 3.11 and up, also not PGO optimized + # x86_64 pypy3.9 pypy3.10 failing due to ring + - os: windows + target: x86_64 + interpreter: 3.8 3.9 3.10 3.11 3.12 + - os: windows + target: i686 + python-architecture: x86 + interpreter: 3.8 3.9 3.10 3.11 3.12 + - os: windows + target: aarch64 + interpreter: 3.11 3.12 + + runs-on: ${{ (matrix.os == 'linux' && 'ubuntu') || matrix.os }}-latest steps: - uses: actions/checkout@v4 - with: - submodules: true - uses: docker/setup-qemu-action@v3 + if: matrix.os == 'linux' && matrix.target == 'aarch64' with: - platforms: linux/${{ matrix.architecture }} - if: github.event_name == 'release' && matrix.architecture != 'x86_64' - - run: sed 's/%arch%/${{ matrix.architecture }}/g' .github/workflows/manylinux_build.sh | sed 's/%for_each_version%/${{ github.event_name == 'release' || '' }}/g' > .github/workflows/manylinux_build_script.sh - - run: docker run -v "$(pwd)":/workdir --platform linux/${{ matrix.architecture }} quay.io/pypa/manylinux2014_${{ matrix.architecture }} /bin/bash /workdir/.github/workflows/manylinux_build_script.sh - if: github.event_name == 'release' || matrix.architecture == 'x86_64' - - uses: actions/upload-artifact@v4 - with: - name: curies_wheel_${{ matrix.architecture }}_linux - path: target/wheels/*.whl - - uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.PYPI_TOKEN }} - packages-dir: target/wheels - if: github.event_name == 'release' - - - wheel_linux_musl: - name: 🐍🐧 Build wheel for MUSL Linux - runs-on: ubuntu-latest - strategy: - matrix: - architecture: - - "x86_64" - # - "aarch64" # Takes too long (1h30) - continue-on-error: true - steps: - - uses: actions/checkout@v4 - with: - submodules: true - - uses: docker/setup-qemu-action@v3 - with: - platforms: linux/${{ matrix.architecture }} - if: github.event_name == 'release' && matrix.architecture != 'x86_64' - - run: sed 's/%arch%/${{ matrix.architecture }}/g' .github/workflows/musllinux_build.sh | sed 's/%for_each_version%/${{ github.event_name == 'release' || '' }}/g' > .github/workflows/musllinux_build_script.sh - - run: docker run -v "$(pwd)":/workdir --platform linux/${{ matrix.architecture }} quay.io/pypa/musllinux_1_1_${{ matrix.architecture }} /bin/bash /workdir/.github/workflows/musllinux_build_script.sh - if: github.event_name == 'release' || matrix.architecture == 'x86_64' - - uses: actions/upload-artifact@v4 - with: - name: curies_wheel_${{ matrix.architecture }}_linux_musl - path: target/wheels/*.whl - - - uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.PYPI_TOKEN }} - packages-dir: target/wheels - if: github.event_name == 'release' - + platforms: linux/${{ matrix.target }} - wheel_mac: - name: 🐍🍎 Build wheel for MacOS - runs-on: macos-latest - env: - DEVELOPER_DIR: '/Applications/Xcode.app/Contents/Developer' - SDKROOT: '/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk' - MACOSX_DEPLOYMENT_TARGET: '10.14' - steps: - - uses: actions/checkout@v4 - - run: rustup update && rustup target add aarch64-apple-darwin - uses: actions/setup-python@v5 with: - python-version: "3.10" - cache: pip - cache-dependency-path: '**/requirements.dev.txt' - - run: pip install -r python/requirements.dev.txt - - run: maturin build --release -m python/Cargo.toml - - run: pip install --no-index --find-links=target/wheels/ curies-rs - - run: rm -r target/wheels - - run: maturin build --release --target universal2-apple-darwin -m python/Cargo.toml - - run: maturin build --release -m python/Cargo.toml - if: github.event_name == 'release' - - run: maturin build --release --target aarch64-apple-darwin -m python/Cargo.toml - if: github.event_name == 'release' - - uses: actions/upload-artifact@v4 - with: - name: curies_wheel_universal2_mac - path: target/wheels/*.whl - - name: Publish to PyPI - if: github.event_name == 'release' - run: pip install twine && twine upload target/wheels/* - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + python-version: '3.11' + architecture: ${{ matrix.python-architecture || 'x64' }} + - run: pip install -U twine 'ruff==0.1.3' typing_extensions + # generate self-schema now, so we don't have to do so inside docker in maturin build + # - run: python generate_self_schema.py - wheel_windows: - name: 🐍πŸͺŸ Build wheel for Windows - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - name: Build wheels + uses: PyO3/maturin-action@v1 with: - python-version: "3.10" - cache: pip - cache-dependency-path: '**/requirements.dev.txt' - - run: Remove-Item -LiteralPath "C:\msys64\" -Force -Recurse - - run: pip install -r python/requirements.dev.txt - - run: maturin build --release -m python/Cargo.toml - - run: pip install --no-index --find-links=target/wheels/ curies-rs - - run: rm -r target/wheels - - run: maturin build --release -m python/Cargo.toml - - run: maturin sdist -m python/Cargo.toml + target: ${{ matrix.target }} + manylinux: ${{ matrix.manylinux }} + args: --release --out dist --interpreter ${{ matrix.interpreter || '3.8 3.9 3.10 3.11 3.12 pypy3.8 pypy3.9 pypy3.10' }} + rust-toolchain: stable + docker-options: -e CI + working-directory: python + # env: + # CFLAGS_aarch64_unknown_linux_gnu: -D__ARM_ARCH=8 + # # NOTE: ring linux aarch64 error: https://github.com/briansmith/ring/issues/1728 + + - run: ${{ (matrix.os == 'windows' && 'dir') || 'ls -lh' }} python/dist/ + - run: twine check --strict python/dist/* + - uses: actions/upload-artifact@v4 with: - name: curies_wheel_x86_64_windows - path: target/wheels/*.whl + name: nanopub_wheel_${{ matrix.os }}_${{ matrix.target }}_${{ matrix.interpreter || 'all' }}_${{ matrix.manylinux }} + path: python/dist + - name: Publish to PyPI if: github.event_name == 'release' - run: pip install twine && twine upload target/wheels/* + # if: startsWith(github.ref, 'refs/tags/') + run: twine upload python/dist/* env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/manylinux_build.sh b/.github/workflows/manylinux_build.sh deleted file mode 100755 index 2dc0e36..0000000 --- a/.github/workflows/manylinux_build.sh +++ /dev/null @@ -1,21 +0,0 @@ -cd /workdir -yum -y install centos-release-scl-rh openssl-devel -yum -y install llvm-toolset-7.0 -source scl_source enable llvm-toolset-7.0 -curl https://static.rust-lang.org/rustup/dist/%arch%-unknown-linux-gnu/rustup-init --output rustup-init -chmod +x rustup-init -./rustup-init -y --profile minimal -source "$HOME/.cargo/env" -export PATH="${PATH}:/opt/python/cp37-cp37m/bin:/opt/python/cp38-cp38/bin:/opt/python/cp39-cp39/bin:/opt/python/cp310-cp310/bin:/opt/python/cp311-cp311/bin" -cd python -python3.10 -m venv venv -source venv/bin/activate -pip install -r requirements.dev.txt -maturin develop --release -m Cargo.toml -# python generate_stubs.py pyoxigraph pyoxigraph.pyi --black -maturin build --release -m Cargo.toml --compatibility manylinux2014 -if [ %for_each_version% ]; then - for VERSION in 7 8 9 10 11; do - maturin build --release -m Cargo.toml --interpreter "python3.$VERSION" --compatibility manylinux2014 - done -fi diff --git a/.github/workflows/musllinux_build.sh b/.github/workflows/musllinux_build.sh deleted file mode 100755 index 1fb6f05..0000000 --- a/.github/workflows/musllinux_build.sh +++ /dev/null @@ -1,19 +0,0 @@ -cd /workdir -apk add clang-dev openssl-dev -curl https://static.rust-lang.org/rustup/dist/%arch%-unknown-linux-musl/rustup-init --output rustup-init -chmod +x rustup-init -./rustup-init -y --profile minimal -source "$HOME/.cargo/env" -export PATH="${PATH}:/opt/python/cp37-cp37m/bin:/opt/python/cp38-cp38/bin:/opt/python/cp39-cp39/bin:/opt/python/cp310-cp310/bin:/opt/python/cp311-cp311/bin" -cd python -python3.10 -m venv venv -source venv/bin/activate -pip install -r requirements.dev.txt -maturin develop --release -m Cargo.toml -# python generate_stubs.py pyoxigraph pyoxigraph.pyi --black -maturin build --release -m Cargo.toml --compatibility musllinux_1_1 -if [ %for_each_version% ]; then - for VERSION in 7 8 9 10 11; do - maturin build --release -m Cargo.toml --interpreter "python3.$VERSION" --compatibility musllinux_1_1 - done -fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 73538e3..ee34917 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,5 @@ name: Tests - on: workflow_dispatch: workflow_call: @@ -14,13 +13,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - run: rustup update && rustup component add clippy rustfmt && cargo install cargo-deny + - run: rustup update && cargo install cargo-deny - run: cargo fmt -- --check - run: cargo clippy --all --all-targets --all-features - run: cargo deny check - test: - name: πŸ§ͺ Tests + test-rust: + name: πŸ§ͺ Test Rust runs-on: ubuntu-latest env: CARGO_TERM_COLOR: always @@ -31,8 +30,8 @@ jobs: env: RUST_BACKTRACE: 1 - cov: - name: β˜‚οΈ Coverage + cov-rust: + name: β˜‚οΈ Coverage Rust runs-on: ubuntu-latest container: image: xd009642/tarpaulin:develop-nightly @@ -48,35 +47,84 @@ jobs: fail_ci_if_error: false # token: ${{secrets.CODECOV_TOKEN}} + + test-js: + name: 🟨 Test JavaScript + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup NodeJS + uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: 'https://registry.npmjs.org' + cache: npm + cache-dependency-path: "./js/package.json" + - run: rustup update && cargo install wasm-pack + - name: Run tests + run: ./scripts/test-js.sh + + test-python: + name: 🐍 Test Python + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.12 + - run: rustup update + - run: pip install -r python/requirements.txt + - name: Run tests + run: ./scripts/test-python.sh + + test-r: + name: πŸ“ˆ Test R + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: r-lib/actions/setup-r@v2 + - run: rustup update + - name: Run tests + run: ./scripts/test-r.sh --install + docs: - name: πŸ“– Update docs + name: πŸ“š Update docs website + if: github.event_name != 'pull_request' runs-on: ubuntu-latest permissions: contents: read pages: write id-token: write - # Allow one concurrent deployment concurrency: group: "pages" - cancel-in-progress: true - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} + cancel-in-progress: false + steps: - uses: actions/checkout@v4 - - run: bash ./scripts/docs-install.sh - - uses: Swatinem/rust-cache@v2 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.11 - - run: bash ./scripts/docs-build.sh + - name: Install dependencies + run: pip install -r lib/docs/requirements.txt + + - name: Deploy mkdocs on GitHub Pages + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: mkdocs build -f lib/docs/mkdocs.yml -d dist + # mkdocs gh-deploy dont support new pages - name: Setup Pages - uses: actions/configure-pages@v3 + id: pages + uses: actions/configure-pages@v4 - name: Upload artifact - uses: actions/upload-pages-artifact@v1 + uses: actions/upload-pages-artifact@v3 with: - path: './target/doc' + path: './lib/docs/dist' - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v2 + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 06c4825..db23feb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ tarpaulin-report.html cobertura.xml benchmark.md hyperfine_*.deb +/index.html # Python .venv @@ -19,7 +20,11 @@ package-lock.json *.lock js/LICENSE +# R +*.rds + # Docs +lib/docs/dist /theme/ mdbook-admonish.css diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f474e1b..4800f0c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: - id: trailing-whitespace name: βœ‚οΈ Trim trailing whitespaces - repo: https://github.com/crate-ci/typos - rev: v1.16.25 + rev: v1.19.0 hooks: - id: typos name: βœ’οΈ Check typos @@ -33,7 +33,7 @@ repos: pass_filenames: false - id: deny name: ❌ Check dependencies - entry: cargo deny check + entry: cargo deny check licenses language: system pass_filenames: false ci: diff --git a/Cargo.toml b/Cargo.toml index 99e359a..012ed53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,5 +24,9 @@ authors = [ "Vincent Emonet " ] +[workspace.dependencies] +curies = { version = "0.1.1", path = "./lib" } +serde = { version = "1.0" } + [profile.release] lto = true diff --git a/book.toml b/book.toml deleted file mode 100644 index 26959a5..0000000 --- a/book.toml +++ /dev/null @@ -1,29 +0,0 @@ -[book] -authors = [ - "Charles Tapley Hoyt ", - "Vincent Emonet ", -] -language = "en" -multilingual = false -src = "./lib/docs" -title = "CURIEs" -description = "A cross-platform library for idiomatic conversion between URIs and compact URIs (CURIEs)" - -[build] -build-dir = "./target/doc" -create-missing = false - -[preprocessor] - -[preprocessor.admonish] -command = "mdbook-admonish" -assets_version = "3.0.1" # do not edit: managed by `mdbook-admonish install` - -[preprocessor.pagetoc] # Does not work for no reason, managed to make it work once though -[output.html] -git-repository-url = "https://github.com/biopragmatics/curies.rs" -additional-css = [ - "theme/mdbook-admonish.css", - "theme/pagetoc.css", -] -additional-js = ["theme/pagetoc.js"] diff --git a/js/Cargo.toml b/js/Cargo.toml index cbeeddd..b1cd68f 100644 --- a/js/Cargo.toml +++ b/js/Cargo.toml @@ -15,13 +15,13 @@ categories.workspace = true crate-type = ["cdylib"] [dependencies] -curies = { version = "0.1.1", path = "../lib" } +curies.workspace = true +serde.workspace = true wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" serde-wasm-bindgen = "0.6" js-sys = "0.3" console_error_panic_hook = "0.1" -serde = { version = "1.0" } [dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/js/src/api.rs b/js/src/api.rs index 55c4f07..468483f 100644 --- a/js/src/api.rs +++ b/js/src/api.rs @@ -12,6 +12,7 @@ use serde_wasm_bindgen::to_value; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::future_to_promise; +/// JavaScript binding for a `Record` struct #[wasm_bindgen(js_name = Record )] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RecordJs { @@ -52,6 +53,7 @@ impl RecordJs { } } +/// JavaScript binding for a `Converter` struct #[wasm_bindgen(js_name = Converter)] pub struct ConverterJs { converter: Converter, @@ -62,6 +64,7 @@ pub struct ConverterJs { #[allow(unused_variables, clippy::inherent_to_string)] #[wasm_bindgen(js_class = Converter)] impl ConverterJs { + /// Create blank `Converter` #[wasm_bindgen(constructor)] pub fn new() -> Result { Ok(Self { @@ -69,6 +72,7 @@ impl ConverterJs { }) } + /// Load `Converter` from prefix map JSON string or URL #[wasm_bindgen(static_method_of = ConverterJs, js_name = fromPrefixMap)] pub fn from_prefix_map(prefix_map: String) -> Promise { future_to_promise(async move { @@ -79,6 +83,7 @@ impl ConverterJs { }) } + /// Load `Converter` from JSON-LD string or URL #[wasm_bindgen(static_method_of = ConverterJs, js_name = fromJsonld)] pub fn from_jsonld(jsonld: String) -> Promise { future_to_promise(async move { @@ -89,6 +94,7 @@ impl ConverterJs { }) } + /// Load `Converter` from extended prefix map JSON string or URL #[wasm_bindgen(static_method_of = ConverterJs, js_name = fromExtendedPrefixMap)] pub fn from_extended_prefix_map(prefix_map: String) -> Promise { future_to_promise(async move { @@ -99,6 +105,7 @@ impl ConverterJs { }) } + /// Add `Record` to the `Converter` #[wasm_bindgen(js_name = addRecord)] pub fn add_record(&mut self, record: RecordJs) -> Result<(), JsValue> { self.converter @@ -106,31 +113,37 @@ impl ConverterJs { .map_err(|e| JsValue::from_str(&e.to_string())) } + /// Add a CURIE as `Record` to the `Converter` #[wasm_bindgen(js_name = addCurie)] - pub fn add_curie(&mut self, prefix: &str, uri_prefix: &str) -> Result<(), JsValue> { + pub fn add_prefix(&mut self, prefix: &str, uri_prefix: &str) -> Result<(), JsValue> { self.converter - .add_curie(prefix, uri_prefix) + .add_prefix(prefix, uri_prefix) .map_err(|e| JsValue::from_str(&e.to_string())) } + /// Chain with another `Converter` pub fn chain(&self, converter: &ConverterJs) -> Result { Converter::chain(vec![self.converter.clone(), converter.converter.clone()]) .map(|converter| ConverterJs { converter }) .map_err(|e| JsValue::from_str(&e.to_string())) } + /// Expand a CURIE to URI pub fn expand(&self, curie: String) -> Result { self.converter .expand(&curie) .map_err(|e| JsValue::from_str(&e.to_string())) } + /// Compress a URI to CURIE pub fn compress(&self, uri: String) -> Result { self.converter .compress(&uri) .map_err(|e| JsValue::from_str(&e.to_string())) } + // TODO: Use Vec instead of JsValue possible? + /// Expand a list of CURIEs to URIs #[wasm_bindgen(js_name = expandList)] pub fn expand_list(&self, curies: JsValue) -> Result { let curies_vec: Vec = serde_wasm_bindgen::from_value(curies) @@ -144,6 +157,7 @@ impl ConverterJs { Ok(JsValue::from(js_array)) } + /// Compress a list of URIs to CURIEs #[wasm_bindgen(js_name = compressList)] pub fn compress_list(&self, curies: JsValue) -> Result { let curies_vec: Vec = serde_wasm_bindgen::from_value(curies) diff --git a/js/tests/curies.test.ts b/js/tests/curies.test.ts index d1b2884..ec95457 100644 --- a/js/tests/curies.test.ts +++ b/js/tests/curies.test.ts @@ -1,5 +1,5 @@ import {describe, expect, test} from '@jest/globals'; -import {Record, Converter, getOboConverter, getBioregistryConverter} from "../pkg/node"; +import {Record, Converter, getOboConverter, getBioregistryConverter, getMonarchConverter, getGoConverter} from "../pkg/node"; describe('Tests for the curies npm package', () => { // NOTE: `await init()` only needed in browser environment @@ -101,6 +101,18 @@ describe('Tests for the curies npm package', () => { expect(converter.expand("doid:1234")).toBe("http://purl.obolibrary.org/obo/DOID_1234"); }); + test('get GO converter', async () => { + const converter = await getGoConverter(); + expect(converter.compress("http://identifiers.org/ncbigene/100010")).toBe("NCBIGene:100010"); + expect(converter.expand("NCBIGene:100010")).toBe("http://identifiers.org/ncbigene/100010"); + }); + + test('get Monarch converter', async () => { + const converter = await getMonarchConverter(); + expect(converter.compress("http://purl.obolibrary.org/obo/CHEBI_24867")).toBe("CHEBI:24867"); + expect(converter.expand("CHEBI:24867")).toBe("http://purl.obolibrary.org/obo/CHEBI_24867"); + }); + test('chain converters', async () => { const customConverter1 = await Converter.fromPrefixMap(`{ "DOID": "http://purl.obolibrary.org/obo/SPECIAL_DOID_" diff --git a/lib/docs/SUMMARY.md b/lib/docs/SUMMARY.md deleted file mode 100644 index 8c9ec39..0000000 --- a/lib/docs/SUMMARY.md +++ /dev/null @@ -1,7 +0,0 @@ -# Summary - -- [Introduction](introduction.md) -- [Use Rust crate](use_rust.md) -- [Use NPM package](use_javascript.md) -- [Use Python package](use_python.md) -- [Contributing](contributing.md) diff --git a/lib/docs/contributing.md b/lib/docs/contributing.md deleted file mode 100644 index 46ae7f3..0000000 --- a/lib/docs/contributing.md +++ /dev/null @@ -1,191 +0,0 @@ -# πŸ› οΈ Contributing - -The usual process to make a contribution is to: - -1. Check for existing related [issues on GitHub](https://github.com/biopragmatics/curies.rs/issues) -2. [Fork](https://github.com/biopragmatics/curies.rs/fork) the repository and create a new branch -3. Make your changes -4. Make sure formatting, linting and tests passes. -5. Add tests if possible to cover the lines you added. -6. Commit, and send a Pull Request. - -## οΈπŸ—ΊοΈ Architecture details - -### πŸ—ƒοΈ Folder structure - -``` -curies.rs/ -β”œβ”€β”€ lib/ -β”‚ β”œβ”€β”€ src/ -β”‚ β”‚ └── πŸ¦€ Source code for the core Rust crate. -β”‚ β”œβ”€β”€ tests/ -β”‚ β”‚ └── πŸ§ͺ Tests for the core Rust crate. -β”‚ └── docs/ -β”‚ └── πŸ“– Markdown and HTML files for the documentation website. -β”œβ”€β”€ python/ -β”‚ └── 🐍 Python bindings for interacting with the Rust crate. -β”œβ”€β”€ js/ -β”‚ └── 🌐 JavaScript bindings for integrating into JS environments. -β”œβ”€β”€ scripts/ -β”‚ └── πŸ› οΈ Development scripts (build docs, testing). -└── .github/ - └── workflows/ - └── βš™οΈ Automated CI/CD workflows. -``` - -## πŸ§‘β€πŸ’» Development workflow - -[![Build](https://github.com/biopragmatics/curies.rs/actions/workflows/build.yml/badge.svg)](https://github.com/biopragmatics/curies.rs/actions/workflows/build.yml) [![Lint and Test](https://github.com/biopragmatics/curies.rs/actions/workflows/test.yml/badge.svg)](https://github.com/biopragmatics/curies.rs/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/biopragmatics/curies.rs/graph/badge.svg?token=BF15PSO6GN)](https://codecov.io/gh/biopragmatics/curies.rs) [![dependency status](https://deps.rs/repo/github/biopragmatics/curies.rs/status.svg)](https://deps.rs/repo/github/biopragmatics/curies.rs) - -[Rust](https://www.rust-lang.org/tools/install), python, and NodeJS are required for development. - -> If you are using VSCode we strongly recommend to install the `rust-lang.rust-analyzer` extension. - -Install development dependencies: - -```bash -# Activate python virtual env -python3 -m venv .venv -source .venv/bin/activate -# Install python dependencies -pip install maturin -# Install rust dev tools -rustup update -rustup component add rustfmt clippy -cargo install wasm-pack cargo-tarpaulin mdbook mdbook-admonish cargo-deny -``` - -### πŸ“₯️ Clone the repository - -Clone the `curies.rs` repository, `cd` into it, and create a new branch for your contribution: - -```bash -cd curies.rs -git checkout -b add-my-contribution -``` - -### πŸ§ͺ Run tests - -Run tests for all packages: - -```bash -cargo test -``` - -Display prints: - -```bash -cargo test -- --nocapture -``` - -Run a specific test: - -```bash -cargo test new_empty_converter -- --nocapture -``` - -If tests panic without telling on which test it failed, use: - -```bash -cargo test -- --test-threads=1 -``` - -Test the `curies` crate with code coverage: - -```bash -./scripts/cov.sh -``` - -### 🐍 Run python - -Build the pip package, and run the `python/try.py` script: - -```bash -./scripts/build-python.sh -``` - -Or just run the tests: - -```bash -source .venv/bin/activate -python -m pytest python/tests/ -``` - -### 🟨 Run JavaScript - -Build the npm package, and run the TypeScript tests in a NodeJS environment: - -```bash -./scripts/build-js.py -``` - -Start a web server: - -```bash -python -m http.server 3000 --directory ./js -``` - -Open [localhost:3000](http://localhost:3000) in your web browser to check the browser version. - -### ✨ Format - -```bash -cargo fmt -``` - -### 🧹 Lint - -```bash -cargo clippy --all --all-targets --all-features -``` - -### πŸ“– Generate docs locally - -Install dependencies: - -```bash -./scripts/docs-install.sh -``` - -Build and serve: - -```bash -./scripts/docs-serve.sh -``` - -### ️⛓️ Check supply chain - -Check the dependency supply chain, only accept dependencies with OSI or FSF approved licenses. - -```bash -cargo deny check -``` - -### 🏷️ New release - -Publishing artifacts will be done by the `build.yml` workflow, make sure you have set the following tokens as secrets -for this repository: `PYPI_TOKEN`, `NPM_TOKEN`, `CRATES_IO_TOKEN`, `CODECOV_TOKEN` - -1. Install dependency: - - ```bash - cargo install cargo-outdated - ``` - -2. Make sure dependencies have been updated: - - ```bash - cargo update - cargo outdated - ``` - -3. Bump the version in the `Cargo.toml` file in folders `lib`, `python`, and `js`: - - ```bash - ./scripts/bump.sh 0.1.2 - ``` - -4. Commit, push, and **create a new release on GitHub**. - -5. The `build.yml` workflow will automatically build artifacts (pip wheel, npm package), add them to the new release, - and publish to public registries (crates.io, PyPI, NPM). diff --git a/lib/docs/docs/architecture.md b/lib/docs/docs/architecture.md new file mode 100644 index 0000000..64a1f6d --- /dev/null +++ b/lib/docs/docs/architecture.md @@ -0,0 +1,63 @@ +# πŸ—ΊοΈ Architecture details + +This page presents the project architecture and some technical details. + +### ✨ Features + +List of features availability per language binding, based on features defined in [curies.readthedocs.io](https://curies.readthedocs.io): + +| Feature | Rust (core) | Python | JS | R | +| ------------------------------------------------ | ----------- | ------ | ---- | ---- | +| compress | βœ… | βœ… | βœ… | βœ… | +| expand | βœ… | βœ… | βœ… | βœ… | +| compress_list | βœ… | βœ… | βœ… | | +| expand_list | βœ… | βœ… | βœ… | | +| standardize (prefix, curie, uri) | | | | | +| chain converters | βœ… | βœ… | βœ… | | +| Record object and converter.add_record() | βœ… | βœ… | βœ… | | +| converter.add_prefix(prefix, ns) | βœ… | | βœ… | | +| converter.get_prefixes() and .get_uri_prefixes() | βœ… | | | | +| Load from prefix map | βœ… | βœ… | βœ… | | +| Load from extended prefix map | βœ… | βœ… | βœ… | | +| Load from JSON-LD | βœ… | βœ… | βœ… | | +| Load from SHACL shape | | | | | +| Load OBO converter | βœ… | βœ… | βœ… | | +| Load GO converter | βœ… | βœ… | βœ… | | +| Load Bioregistry converter | βœ… | βœ… | βœ… | βœ… | +| Load Monarch converter | βœ… | βœ… | βœ… | | +| Write converter to prefix map | βœ… | | | | +| Write converter to extended prefix map | βœ… | | | | +| Write converter to JSON-LD | βœ… | | | | +| Prefixes discovery | | | | | + +⚠️ Important differences between rust core and bindings: + +1. **Load prefix map**, extended prefix map and JSON-LD can take `HashMap` as input in rust. But for JS and python, we currently need to pass it as `String` (we need to figure out how to pass arbitrary objects). You can pass either a URL or a JSON object as string, the lib will automatically retrieve the content of the URL if it is one. The original python lib was taking directly JSON objects for all loaders, apart from SHACL which takes a URL (which was not convenient when wanting to provide a local SHACL file) +2. In rust **chain()** is a static function taking a list of converters, `chained = Converter::chain([conv1, conv2])`. In JS and python we cannot easily pass a list of complex objects like converters, so chain is a normal function that takes 1 converter to chain: `chained = conv1.chain(conv2)` +3. Currently **write** prefix map returns a HashMap, write extended map returns a JSON as string, and write JSON-LD returns `serde::json` type. In the original python lib it was writing to a file. + +### πŸ—ƒοΈ Folder structure + +``` +curies.rs/ +β”œβ”€β”€ lib/ +β”‚ β”œβ”€β”€ src/ +β”‚ β”‚ └── πŸ¦€ Source code for the core Rust crate. +β”‚ β”œβ”€β”€ tests/ +β”‚ β”‚ └── πŸ§ͺ Tests for the core Rust crate. +β”‚ └── docs/ +β”‚ └── πŸ“– Markdown and HTML files for the documentation website. +β”œβ”€β”€ python/ +β”‚ └── 🐍 Python bindings +β”œβ”€β”€ js/ +β”‚ └── 🟨 JavaScript bindings +β”œβ”€β”€ r/ +β”‚ └── πŸ“ˆ R bindings +β”œβ”€β”€ scripts/ +β”‚ └── πŸ› οΈ Development scripts (build, test, gen docs). +└── .github/ + └── workflows/ + └── βš™οΈ Automated CI/CD workflows. +``` + +### diff --git a/lib/docs/docs/assets/custom.css b/lib/docs/docs/assets/custom.css new file mode 100644 index 0000000..4477fa0 --- /dev/null +++ b/lib/docs/docs/assets/custom.css @@ -0,0 +1,4 @@ +/* We use emojis for every bullet lists */ +ul li { + list-style-type: none; +} diff --git a/lib/docs/docs/assets/logo.png b/lib/docs/docs/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..89ebc3dfd2911db876e749c3d51ef6e7a9399610 GIT binary patch literal 89371 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 UB_B1*T+RRlp00i_>zopr06nFsW&i*H literal 0 HcmV?d00001 diff --git a/lib/docs/docs/assets/syntax_demo.svg b/lib/docs/docs/assets/syntax_demo.svg new file mode 100644 index 0000000..2c9bbb1 --- /dev/null +++ b/lib/docs/docs/assets/syntax_demo.svg @@ -0,0 +1 @@ + diff --git a/lib/docs/docs/contributing.md b/lib/docs/docs/contributing.md new file mode 100644 index 0000000..40f10be --- /dev/null +++ b/lib/docs/docs/contributing.md @@ -0,0 +1,171 @@ +# πŸ› οΈ Development + +[![Build](https://github.com/biopragmatics/curies.rs/actions/workflows/build.yml/badge.svg)](https://github.com/biopragmatics/curies.rs/actions/workflows/build.yml) [![Lint and Test](https://github.com/biopragmatics/curies.rs/actions/workflows/test.yml/badge.svg)](https://github.com/biopragmatics/curies.rs/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/biopragmatics/curies.rs/graph/badge.svg?token=BF15PSO6GN)](https://codecov.io/gh/biopragmatics/curies.rs) [![dependency status](https://deps.rs/repo/github/biopragmatics/curies.rs/status.svg)](https://deps.rs/repo/github/biopragmatics/curies.rs) + +The usual process to make a contribution is to: + +1. Check for existing related [issues on GitHub](https://github.com/biopragmatics/curies.rs/issues) +2. [Fork](https://github.com/biopragmatics/curies.rs/fork) the repository and create a new branch +3. Make your changes +4. Make sure formatting, linting and tests passes. +5. Add tests if possible to cover the lines you added. +6. Commit, and send a Pull Request. + + +## πŸ“₯️ Clone the repository + +Clone the `curies.rs` repository, `cd` into it, and create a new branch for your contribution: + +```bash +git clone https://github.com/biopragmatics/curies.rs.git +cd curies.rs +``` + +## βš™οΈ Install dependencies + +[Rust](https://www.rust-lang.org/tools/install), [Python](https://www.python.org/downloads/), [NodeJS](https://nodejs.org/en/download), and [R](https://www.r-project.org/) are required for development. + +Install development dependencies: + +```bash +rustup update +cargo install wasm-pack cargo-tarpaulin cargo-deny cargo-outdated +``` + +> If you are using VSCode we strongly recommend to install the [`rust-lang.rust-analyzer`](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) extension. + +## πŸ§ͺ Run tests + +### πŸ¦€ Test Rust crate + +Run tests for all packages: + +```bash +cargo test +``` + +!!! example "More options" + + Display prints: + + ```bash + cargo test -- --nocapture + ``` + + Run a specific test: + + ```bash + cargo test new_empty_converter -- --nocapture + ``` + + If tests panic without telling on which test it failed, use: + + ```bash + cargo test -- --test-threads=1 + ``` + + + +Test the `curies` crate with code coverage: + +```bash +./scripts/cov.sh +``` + +### 🐍 Test Python + +Build the pip package, and run pytest: + +```bash +./scripts/test-python.sh +``` + +Or just run the tests: + +```bash +source .venv/bin/activate +python -m pytest python/tests/ +``` + +### 🟨 Test JavaScript + +Build the npm package, and run the jest tests in a NodeJS environment: + +```bash +./scripts/test-js.sh +``` + +Start a web server to use the dev page: + +```bash +python -m http.server 3000 --directory ./js +``` + +Open [localhost:3000](http://localhost:3000) in your web browser to check the browser dev page. + +### πŸ“ˆ Test R + +Build and test R bindings: + +```bash +./scripts/test-r.sh +``` + +The first time you will need to add the `--install` flag to install dependencies: + +```bash +./scripts/test-r.sh --install +``` + +> You can force `rextendr` to re-build the bindings by making a change to one of the docstring `///` in the `/r/rust/src` code + +## 🧹 Format and lint + +Format code with `rustfmt`: + +```bash +cargo fmt +``` + +Lint check with clippy: + +```bash +cargo clippy --all --all-targets --all-features +``` + +## πŸ“– Generate docs locally + +Build and serve: + +```bash +./scripts/docs.sh +``` + +## ️⛓️ Check supply chain + +Check the dependency supply chain, only accept dependencies with OSI or FSF approved licenses. + +```bash +cargo deny check +``` + +Make sure dependencies are up-to-date: + +```bash +cargo update +cargo outdated +``` + +## 🏷️ New release + +Publishing artifacts will be done by the `build.yml` workflow, make sure you have set the following tokens as secrets for this repository: `PYPI_TOKEN`, `NPM_TOKEN`, `CRATES_IO_TOKEN`, `CODECOV_TOKEN` + +1. Bump the version in the `Cargo.toml` file in folders `lib`, `python`, and `js`: + + ```bash + ./scripts/bump.sh 0.1.2 + ``` + +2. Commit, push, and **create a new release on GitHub**. + +3. The `build.yml` workflow will automatically build artifacts (pip wheel, npm package), add them to the new release, and publish to public registries (crates.io, PyPI, NPM). diff --git a/lib/docs/introduction.md b/lib/docs/docs/index.md similarity index 70% rename from lib/docs/introduction.md rename to lib/docs/docs/index.md index e07435d..60a903f 100644 --- a/lib/docs/introduction.md +++ b/lib/docs/docs/index.md @@ -8,34 +8,35 @@ A cross-platform Rust library for idiomatic conversion between URIs and compact Whether you're a developer looking to work with CURIEs (e.g. expand or compress) in your application, or a researcher seeking an efficient way to handle CURIEs, `curies` offers a suite of tools tailored to meet your needs. -## πŸ”‘ Key Features +## ✨ CURIEs management -### ✨ CURIEs management - -- πŸ› οΈ **Create** your custom converter -- πŸ“₯ **Import converters** from JSON or JSON-LD context, with helper functions for popular converters, such as `get_obo_converter()`. +- πŸ“₯ **Import converters** from JSON or JSON-LD context, with helper functions for popular converters, such as `get_obo_converter()`, or create a custom converter programmatically. - πŸ”— **Expand CURIEs** from their compressed form to URIs. - πŸ—œοΈ **Compress URIs** to CURIEs. -### πŸ“¦οΈ Packaged for multiple interfaces +Example: + +| CURIE | URI | +| ----------- | ------------------------------------------------------------ | +| `doid:1234` | [http://purl.obolibrary.org/obo/DOID_1234](http://purl.obolibrary.org/obo/DOID_1234) | + + +## πŸ“¦οΈ Packaged for multiple interfaces This library is packaged for easy use across various interfaces and languages: -- πŸ¦€ **Rust developers**: available as a Rust crate `curies`. -- 🐍 **Python programmers**: available as a Python pip package `curies-rs`. +- πŸ¦€ **Rust developers**: available as a Rust crate `curies` +- 🐍 **Python programmers**: available as a Python pip package `curies-rs` - 🌐 **Web developers**: available as a NPM package `@biopragmatics/curies`, compiled to [WebAssembly](https://webassembly.org/), for browser integrations with JavaScript, or NodeJS. +- πŸ“ˆ **R data scientists**: soon available as a R package `curies.r` -### βš”οΈ Cross-platform support +## βš”οΈ Cross-platform support -It runs seamlessly on: +It runs seamlessly on x86 and ARM architectures for many platforms: - 🐧 Linux - 🍎 MacOS - πŸͺŸ Windows - 🦊 Web browsers -## πŸš€ Getting started - -Checkout the page most adapted to your use-case to get started. - > πŸ’‘ **Need Help or Have Suggestions?** We welcome your input and feedback! If you encounter any issues or have ideas to enhance this tool, please [create an issue](https://github.com/biopragmatics/curies.rs/issues) on our GitHub repository. diff --git a/lib/docs/docs/javascript-example-framework.md b/lib/docs/docs/javascript-example-framework.md new file mode 100644 index 0000000..4013073 --- /dev/null +++ b/lib/docs/docs/javascript-example-framework.md @@ -0,0 +1,51 @@ +# βš›οΈ Use from any JavaScript framework + +It can be used from any JavaScript framework, or NodeJS. + +For example, to use it in a nextjs react app: + +1. Create the project and `cd` into your new app folder + + ```bash + npx create-next-app@latest --typescript + ``` + +2. Add the `@biopragmatics/curies` dependency to your project: + + ```bash + npm install --save @biopragmatics/curies + ``` + +3. Add code, e.g. in `src/app/page.tsx` running on the client: + + ```typescript title="src/app/page.tsx" + 'use client' + import { useEffect, useState } from 'react'; + import init, { getBioregistryConverter } from "@biopragmatics/curies"; + + export default function Home() { + const [output, setOutput] = useState(''); + useEffect(() => { + + // Initialize the wasm library and use it + init().then(async () => { + const converter = await getBioregistryConverter(); + const curie = converter.compress("http://purl.obolibrary.org/obo/DOID_1234"); + const uri = converter.expand("doid:1234"); + setOutput(`${curie}: ${uri}`); + }); + }, []); + + return ( +
+

{output}

+
+ ); + } + ``` + +4. Start in dev: + + ```bash + npm run dev + ``` diff --git a/lib/docs/docs/javascript-example-html.md b/lib/docs/docs/javascript-example-html.md new file mode 100644 index 0000000..31e4131 --- /dev/null +++ b/lib/docs/docs/javascript-example-html.md @@ -0,0 +1,43 @@ +# πŸš€ Example in bare HTML files + +When using the library directly in the client browser you will need to initialize the wasm binary with `await init()`, after that you can use the same functions as in the NodeJS environments. + +You can easily import the NPM package from a CDN, and work with `curies` directly in a simple `index.html` file: + +```html title="index.html" + + + + + CURIEs example + + + +

+

+ + + + +``` + +Then just start the web server from the directory where the `index.html` file is with: + +```bash +npx http-server +# Or: +python -m http.server +``` diff --git a/lib/docs/docs/javascript.md b/lib/docs/docs/javascript.md new file mode 100644 index 0000000..77a73ae --- /dev/null +++ b/lib/docs/docs/javascript.md @@ -0,0 +1,77 @@ +# 🟨 Use from JavaScript + +[![npm](https://img.shields.io/npm/v/@biopragmatics/curies)](https://www.npmjs.com/package/@biopragmatics/curies) + +You can easily work with CURIEs in the browser or NodeJS, from JavaScript or TypeScript, with the [`@biopragmatics/curies`](https://www.npmjs.com/package/@biopragmatics/curies) NPM package. + +## πŸ“₯️ Install + +Install the `npm` package (use `yarn` or `pnpm` if you prefer) to use it from your favorite framework: + +```bash +npm install @biopragmatics/curies +# or +pnpm add @biopragmatics/curies +# or +yarn add @biopragmatics/curies +# or +bun add @biopragmatics/curies +``` + +## 🟒 Use in a NodeJS environment + +There are multiple methods available for creating or importing converters: + +```javascript +import {Record, Converter, getOboConverter, getBioregistryConverter} from "@biopragmatics/curies"; + +async function main() { + // Populate from Records + const rec1 = new Record("obo", "http://purl.obolibrary.org/obo/", [], []); + console.log(rec1.toString()); + console.log(rec1.toJs()); + const converter = new Converter(); + converter.addRecord(rec1); + + // Load from a prefix map json (string or URI) + const converterFromMap = await Converter.fromPrefixMap(`{ + "doid": "http://purl.obolibrary.org/obo/MY_DOID_" + }`); + + // Load from an extended prefix map (string or URI) + const converterFromUrl = await Converter.fromExtendedPrefixMap("https://raw.githubusercontent.com/biopragmatics/bioregistry/main/exports/contexts/bioregistry.epm.json") + + // Load from a JSON-LD context (string or URI) + const converterFromJsonld = await Converter.fromJsond("http://purl.obolibrary.org/meta/obo_context.jsonld"); + + // Load from one of the predefined source + const converterFromSource = await getBioregistryConverter(); + + // Chain multiple converters in one + const converter = converterFromMap + .chain(converterFromUrl) + .chain(converterFromSource) + + // Expand CURIE and compress URI + const curie = converter.compress("http://purl.obolibrary.org/obo/DOID_1234"); + const uri = converter.expand("doid:1234"); + + // Expand and compress list of CURIEs and URIs + const curies = converter.compressList(["http://purl.obolibrary.org/obo/DOID_1234"]); + const uris = converter.expandList(["doid:1234"]); +} +main(); +``` + +## 🦊 Use it in a browser + +When using in a client browser you will need to initialize the wasm binary with `await init()`, after that you can use the same functions as in the NodeJS environments. + +!!! bug "Use HTTPS" + + When importing converters from URLs in JS always prefer importing from HTTPS URLs, otherwise you will face `Mixed Content` errors. + + +!!! warning "CORS exists" + + When executing JS in the browser we are bound to the same rules as everyone on the web, such as CORS. If CORS are not enabled on the server you are fetching the converter from, then you will need to use a proxy such as [corsproxy.io](https://corsproxy.io). diff --git a/lib/docs/docs/python.md b/lib/docs/docs/python.md new file mode 100644 index 0000000..a219072 --- /dev/null +++ b/lib/docs/docs/python.md @@ -0,0 +1,182 @@ +# 🐍 Use from Python + +[![PyPI](https://img.shields.io/pypi/v/curies-rs)](https://pypi.org/project/curies-rs/) + +You can easily work with `curies` from Python. + +## πŸ“₯️ Install + +Install the `pip` package: + +```bash +pip install curies-rs +``` + +## πŸš€ Usage + +Initialize a converter, then use it to `compress` URIs to CURIEs, or `expand` CURIEs to URIs: + +```python title="curies_conversion.py" +from curies_rs import get_bioregistry_converter + +# Initialize converter (here we use the predefined Bioregistry converter) +converter = get_bioregistry_converter() + +# Compress a URI, or expand a CURIE +curie = converter.compress("http://purl.obolibrary.org/obo/DOID_1234") +uri = converter.expand("DOID:1234") + +# Compress/expand a list +curies = converter.compress_list(["http://purl.obolibrary.org/obo/DOID_1234"]) +uris = converter.expand_list(["DOID:1234"]) +``` + +## πŸŒ€ Converter initialization + +There are many ways to initialize a CURIE/URI converter. + +### πŸ“¦ Import a predefined converter + +Easiest way to get started is to simply use one of the function available to import a converter from popular namespaces registries: + +#### [Bioregistry](https://bioregistry.io/) converter + +```python +from curies_rs import get_bioregistry_converter + +converter = get_bioregistry_converter() +``` + +#### [OBO](http://obofoundry.org/) converter + +```python +from curies_rs import get_obo_converter + +converter = get_obo_converter() +``` + +#### [GO](https://geneontology.org/) converter + +```python +from curies_rs import get_go_converter + +converter = get_go_converter() +``` + +#### [Monarch Initiative](https://monarchinitiative.org/) converter + +```python +from curies_rs import get_monarch_converter + +converter = get_monarch_converter() +``` + +### πŸ“‚ Load converter from prefix map + +Converter can be loaded from a prefix map, an extended prefix map (which enables to provide more information for each prefix), or a JSON-LD context. + +For each function you can either provide the string to the prefix map JSON, or the URL to it. + +#### Load from prefix map + +```python +from curies_rs import Converter + +prefix_map = """{ + "GO": "http://purl.obolibrary.org/obo/GO_", + "DOID": "http://purl.obolibrary.org/obo/DOID_", + "OBO": "http://purl.obolibrary.org/obo/" +}""" +converter = Converter.from_prefix_map(prefix_map) +``` + +#### Load from extended prefix map + +Enable to provide prefix/URI synonyms and ID RegEx pattern for each record: + +```python +from curies_rs import Converter + +extended_pm = """[ + { + "prefix": "DOID", + "prefix_synonyms": [ + "doid" + ], + "uri_prefix": "http://purl.obolibrary.org/obo/DOID_", + "uri_prefix_synonyms": [ + "http://bioregistry.io/DOID:" + ], + "pattern": "^\\\\d+$" +}, +{ + "prefix": "GO", + "prefix_synonyms": [ + "go" + ], + "uri_prefix": "http://purl.obolibrary.org/obo/GO_", + "pattern": "^\\\\d{7}$" +}, +{ + "prefix": "OBO", + "prefix_synonyms": [ + "obo" + ], + "uri_prefix": "http://purl.obolibrary.org/obo/" +}]""" +converter = Converter.from_extended_prefix_map(extended_pm) +``` + +#### Load from JSON-LD context + +```python +from curies_rs import Converter + +jsonld = """{ + "@context": { + "GO": "http://purl.obolibrary.org/obo/GO_", + "DOID": "http://purl.obolibrary.org/obo/DOID_", + "OBO": "http://purl.obolibrary.org/obo/" + } +}""" +converter = Converter.from_jsonld(jsonld) +``` + +Or directly use a URL: + +```python +from curies_rs import Converter + +converter = Converter.from_jsonld("https://purl.obolibrary.org/meta/obo_context.jsonld") +``` + +### πŸ› οΈ Build the converter programmatically + +Create an empty `Converter`, and populate it with `Record`: + +```python +from curies_rs import Converter, Record + +rec1 = Record("doid", "http://purl.obolibrary.org/obo/DOID_", ["DOID"], ["https://identifiers.org/doid/"]) +rec2 = Record("obo", "http://purl.obolibrary.org/obo/") +print(rec1.dict()) + +converter = Converter() +converter.add_record(rec1) +converter.add_record(rec2) +``` + +### ⛓️ Chain converters + +Chain together multiple converters: + +```python +from curies_rs import get_obo_converter, get_go_converter, get_monarch_converter + +converter = ( + get_obo_converter() + .chain(get_go_converter()) + .chain(get_monarch_converter()) +) +print(len(converter)) +``` diff --git a/lib/docs/docs/r.md b/lib/docs/docs/r.md new file mode 100644 index 0000000..49abc69 --- /dev/null +++ b/lib/docs/docs/r.md @@ -0,0 +1,33 @@ +# πŸ“ˆ Use from R + +[![PyPI](https://img.shields.io/pypi/v/curies-rs)](https://pypi.org/project/curies-rs/) + +!!! warning "Work in progress" + + R bindings are not yet fully published. Checkout the [Development](contributing.md) section to try it by building from source! + +You can easily work with `curies` from R. + +## πŸ“₯️ Install + +Install the R package: + +```bash +Rscript -e 'rextendr::document("./r")' +``` + +## πŸš€ Usage + +Initialize a converter, then use it to `compress` URIs to CURIEs, or `expand` CURIEs to URIs: + +```r title="curies_conversion.R" +library(curiesr) + +converter <- ConverterR$new() + +curie <- converter$compress("http://purl.obolibrary.org/obo/DOID_1234") +uri <- converter$expand("doid:1234") + +print(curie) +print(uri) +``` diff --git a/lib/docs/use_rust.md b/lib/docs/docs/rust.md similarity index 97% rename from lib/docs/use_rust.md rename to lib/docs/docs/rust.md index 143f816..9892b73 100644 --- a/lib/docs/use_rust.md +++ b/lib/docs/docs/rust.md @@ -46,7 +46,7 @@ rt.block_on(async { ## πŸ—οΈ Build a converter -You can also build a `Converter` from scratch: +You can also build a `Converter` programmatically from `Record`: ```rust extern crate curies; diff --git a/lib/docs/docs/struct.md b/lib/docs/docs/struct.md new file mode 100644 index 0000000..4940e68 --- /dev/null +++ b/lib/docs/docs/struct.md @@ -0,0 +1,86 @@ +# πŸŽ„ Data Structures + +A *semantic space* is a collections of identifiers for concepts. For example, the Chemical Entities of Biomedical Interest (ChEBI) has a semantic space including identifiers for chemicals. Within ChEBI's semantic space, 138488 corresponds to the chemical [alsterpaullone](https://www.ebi.ac.uk/chebi/searchId.do?chebiId=138488). + +!!! Warning "Identifiers are local to a namespace" + + 138488 is a *local unique identifier*. Other semantic spaces might use the same local unique identifier to refer to a different concept in their respective domain. + +Therefore, local unique identifiers should be qualified with some additional information saying what semantic space it comes from. The two common formalisms for doing this are Uniform Resource Identifiers (URIs) and Compact URIs (CURIEs): + +[![Demo of URI and CURIE for alsterpaullone.](assets/syntax_demo.svg)](assets/syntax_demo.svg) + +In many applications, it's important to be able to convert between CURIEs and URIs. Therefore, we need a data structure that connects the CURIE prefixes like `CHEBI` to the URI prefixes like `http://purl.obolibrary.org/obo/CHEBI_`. + +## Prefix Maps + +A prefix map is a dictionary data structure where keys represent CURIE prefixes and their associated values represent URI prefixes. Ideally, these are constrained to be bijective (i.e., no duplicate keys, no duplicate values), but this is not always done in practice. Here's an example prefix map containing information about semantic spaces from a small selection of OBO Foundry ontologies: + +```json +{ + "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", + "MONDO": "http://purl.obolibrary.org/obo/MONDO_", + "GO": "http://purl.obolibrary.org/obo/GO_" +} +``` + +Prefix maps have the benefit of being simple and straightforward. They appear in many linked data applications, including: + +- the `@prefix` declarations at the top of Turtle (RDF) documents and SPARQL queries +- [JSON-LD](https://www.w3.org/TR/json-ld11/#prefix-definitions) +- XML documents +- OWL ontologies + +!!! tip "Loading prefix maps" + + Prefix maps can be loaded using `Converter.from_prefix_map`. + +*However*, prefix maps have the main limitation that they do not have first-class support for synonyms of CURIE prefixes or URI prefixes. In practice, a variety of synonyms are used for both. For example, the NCBI Taxonomy database appears with many different CURIE prefixes: + +| CURIE Prefix | Resource(s) | +| ------------ | ------------------------------------ | +| `taxonomy` | Identifiers.org, Name-to-Thing | +| `taxon` | Gene Ontology Registry | +| `NCBITaxon` | OBO Foundry, Prefix Commons, OntoBee | +| `NCBITAXON` | BioPortal | +| `NCBI_TaxID` | Cellosaurus | +| `ncbitaxon` | OLS | +| `P685` | Wikidata | +| `fj07xj` | FAIRsharing | + +Similarly, many different URIs can be constructed for the same ChEBI local unique identifier. Using alsterpaullone as an example, this includes (many omitted): + +| URI Prefix | Provider | +| -------------------------------------------------- | ------------------- | +| `https://www.ebi.ac.uk/chebi/searchId.do?chebiId=` | ChEBI (first-party) | +| `https://identifiers.org/CHEBI:` | Identifiers.org | +| `https://identifiers.org/CHEBI/` | Identifiers.org | +| `http://identifiers.org/CHEBI:` | Identifiers.org | +| `http://identifiers.org/CHEBI/` | Identifiers.org | +| `http://purl.obolibrary.org/obo/CHEBI_` | OBO Foundry | +| `https://n2t.net/chebi:` | Name-to-thing | + +In practice, we need to be able to support the fact that there are many CURIE prefixes and URI prefixes for most semantic spaces as well as specify which CURIE prefix and URI prefix is the "preferred" one in a given context. Prefix maps, unfortunately, have no way to address this. Therefore, we're going to introduce a new data structure. + +## Extended Prefix Maps + +Extended Prefix Maps (EPMs) address the issues with prefix maps by including explicit fields for CURIE prefix synonyms and URI prefix synonyms while maintaining an explicit field for the preferred CURIE prefix and URI prefix. An abbreviated example (just containing an entry for ChEBI) looks like: + +``` +[ + { + "prefix": "CHEBI", + "uri_prefix": "http://purl.obolibrary.org/obo/CHEBI_", + "prefix_synonyms": ["chebi"], + "uri_prefix_synonyms": [ + "https://identifiers.org/chebi:" + ] + } +] +``` + +An EPM is simply a list of records (see `Record`). EPMs have the benefit that they are still encoded in JSON and can easily be encoded in YAML, TOML, RDF, and other schemata. Further, prefix maps can be automatically upgraded into EPMs (with some caveats) using `curies.upgrade_prefix_map`. + +!!! tip "Loading extended prefix maps" + + We are introducing this as a new standard in the `curies` package. They can be loaded using `Converter.from_extended_prefix_map`. Later, we hope to have an external, stable definition of this data schema. diff --git a/lib/docs/includes/abbreviations.md b/lib/docs/includes/abbreviations.md new file mode 100644 index 0000000..f592f98 --- /dev/null +++ b/lib/docs/includes/abbreviations.md @@ -0,0 +1,68 @@ +*[HTML]: Hyper Text Markup Language +*[HTTP]: HyperText Transfer Protocol +*[HTTPS]: HyperText Transfer Protocol Secure +*[NPM]: Node Package Manager +*[CDN]: Content Delivery Network +*[CORS]: Cross-Origin Resource Sharing +*[RSA]: RSA (Rivest–Shamir–Adleman) is a public-key cryptosystem, one of the oldest widely used for secure data transmission +*[ORCID]: Open Researcher and Contributor ID +*[OSI]: Open Source Initiative +*[FSF]: Free Software Foundation +*[CVE]: Common Vulnerabilities and Exposures +*[WSL]: Windows Subsystem Linux +*[LLM]: Large Language Model +*[LLMs]: Large Language Models +*[ML]: Machine Learning +*[DL]: Deep Learning +*[QA]: Question Answering +*[API]: Application Programming Interface +*[UI]: User Interface +*[CLI]: Command-Line Interface +*[PIP]: Pip Install Packages +*[PyPI]: Python Packaging Index +*[PyPA]: Python Packaging Authority +*[PEP]: Python Enhancement Proposal +*[RDF]: Resource Description Framework +*[JSON-LD]: JavaScript Object Notation - Linked Data +*[JSON]: JavaScript Object Notation +*[YAML]: Depending on whom you ask, YAML stands for "Yet Another Markup Language", or "YAML Ain't Markup Language" +*[TOML]: Tom's Obvious, Minimal Language +*[OWL]: Web Ontology Language +*[XML]: Extensible Markup Language +*[SPARQL]: SPARQL Protocol and RDF Query Language +*[Faiss]: Faiss is a library for efficient similarity search and clustering of dense vectors. It contains algorithms that search in sets of vectors of any size, up to ones that possibly do not fit in RAM. It also contains supporting code for evaluation and parameter tuning. +*[GGML]: GGML is a C library for machine learning (ML) - the "GG" refers to the initials of its originator (Georgi Gerganov). In addition to defining low-level machine learning primitives (like a tensor type), GGML defines a binary format for distributing large language models (LLMs). +*[GGUF]: GPT-Generated Unified Format, successor to GGML, is a quantization method that allows users to use the CPU to run a LLM, but also offload some of its layers to the GPU for a speed up. +*[GPTQ]: GPTQ is a quantization algorithm that lightly reoptimizes the weights during quantization so that the accuracy loss is compensated relative to a round-to-nearest quantization. 4-bit GPTQ models reduce VRAM usage by about 75%. +*[URL]: Uniform Resource Locator +*[URLs]: Uniform Resource Locators +*[URI]: Uniform Resource Identifier +*[URIs]: Uniform Resource Identifiers +*[CURIE]: Compact Uniform Resource Identifier +*[CURIEs]: Compact Uniform Resource Identifiers +*[ID]: Identifier +*[IDs]: Identifiers +*[vectorstore]: A vector store takes care of storing embedded data, and performing vector search for you +*[GPU]: Graphics Processing Unit +*[CPU]: Central Processing Unit +*[TPU]: Tensor Processing Unit +*[aarch64]: ARM64 architecture +*[wasm]: WebAssembly +*[PDF]: Portable Document Format +*[ZSH]: The Z shell (Zsh) is a Unix shell that can be used as an interactive login shell, and as a command interpreter for shell scripting. +*[BASH]: The Bourne-Again SHell (BASH) is a Unix shell that can be used as an interactive login shell, and as a command interpreter for shell scripting. +*[OpenAPI]: OpenAPI, formerly known as Swagger, is a specification language for HTTP APIs that defines structure and syntax in a way that is not wedded to the programming language the API is created in. +*[JS]: JavaScript +*[TS]: TypeScript +*[CSS]: CSS is the acronym of "Cascading Style Sheets". CSS is a computer language for laying out and structuring web pages (HTML or XML) +*[CSV]: Comma-Separated Value +*[TSV]: Tab-Separated Value +*[PSV]: Pipe-Separated Value +*[ODT]: Open Document Format +*[RegEx]: Regular Expression +*[OBO]: Open Biological and Biomedical Ontology +*[GO]: Gene Ontology +*[EPM]: Extended Prefix Map +*[EPMs]: Extended Prefix Maps +*[ChEBI]: Chemical Entities of Biological Interest +*[NCBI]: The National Center for Biotechnology Information (USA) diff --git a/lib/docs/mkdocs.yml b/lib/docs/mkdocs.yml new file mode 100644 index 0000000..2c6776a --- /dev/null +++ b/lib/docs/mkdocs.yml @@ -0,0 +1,127 @@ +site_name: CURIEs +site_description: A cross-platform library for idiomatic conversion between URIs and compact URIs (CURIEs) +site_author: Charles Tapley Hoyt & Vincent Emonet +site_url: https://biopragmatics.github.io/curies.rs +repo_name: biopragmatics/curies.rs +repo_url: https://github.com/biopragmatics/curies.rs +edit_uri: "edit/main/docs/" +copyright: Copyright © 2024 Charles Tapley Hoyt & Vincent Emonet + +theme: + name: "material" + favicon: assets/logo.png + logo: assets/logo.png + icon: + admonition: + server: material/server + language: en + # Change color: https://squidfunk.github.io/mkdocs-material/setup/changing-the-colors/#primary-color + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: blue grey + accent: deep orange + toggle: + icon: material/weather-night + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: blue grey + accent: deep orange + toggle: + icon: material/weather-sunny + name: Switch to light mode + features: + - navigation.indexes + - navigation.sections + - navigation.tabs + - navigation.top + - navigation.tracking + - content.code.copy + - content.code.annotate + - content.code.select + - search.highlight + - search.share + - search.suggest + - toc.follow + # - header.autohide + # - navigation.tabs.sticky + # - navigation.expand + # - navigation.instant + # - content.tabs.link + +# Find icons: https://fontawesome.com/icons/ +# https://squidfunk.github.io/mkdocs-material/reference/icons-emojis/ +nav: + - Docs: + - Introduction: index.md + - Use from Rust: rust.md + - Data structures: struct.md + - Architecture details: architecture.md + - Development: contributing.md + - Python: + - Use from Python: python.md + - JavaScript: + - Use from JavaScript: javascript.md + - Example bare HTML: javascript-example-html.md + - Example JS framework: javascript-example-framework.md + - R: + - Use from R: r.md + # - Issues: https://github.com/biopragmatics/curies.rs/issues" target="_blank + +plugins: +- search +- open-in-new-tab +- autorefs +- mkdocstrings: + default_handler: python + handlers: + python: + options: + show_source: true + # custom_templates: templates + +watch: + - ../src + - docs + +markdown_extensions: + - admonition + # Supported admonititions: https://squidfunk.github.io/mkdocs-material/reference/admonitions/#supported-types + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - pymdownx.details + - pymdownx.extra + - abbr + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.snippets: + auto_append: + - lib/docs/includes/abbreviations.md + - attr_list + - smarty + # - md_in_html + + +extra_css: + - assets/custom.css + +extra: + social: + - icon: fontawesome/brands/rust + link: https://crates.io/crates/curies + - icon: fontawesome/brands/python + link: https://pypi.org/project/curies-rs + - icon: fontawesome/brands/npm + link: https://www.npmjs.com/package/@biopragmatics/curies + - icon: fontawesome/brands/r-project + link: https://cran.r-project.org/web/packages/curies.r + - icon: fontawesome/brands/github + link: https://github.com/biopragmatics + # - icon: fontawesome/brands/docker + # link: https://github.com/biopragmatics/curies.rs/pkgs/container/curies.rs diff --git a/lib/docs/requirements.txt b/lib/docs/requirements.txt new file mode 100644 index 0000000..89fe7bc --- /dev/null +++ b/lib/docs/requirements.txt @@ -0,0 +1,6 @@ +mkdocs >=1.4.2 +mkdocs-material >=8.2.7 +mkdocstrings[python] >=0.19.1 +mdx-include >=1.4.1 +mkdocs-markdownextradata-plugin >=0.2.5 +mkdocs-open-in-new-tab diff --git a/lib/docs/use_javascript.md b/lib/docs/use_javascript.md deleted file mode 100644 index 714b08e..0000000 --- a/lib/docs/use_javascript.md +++ /dev/null @@ -1,160 +0,0 @@ -# 🟨 Use from JavaScript - -[![npm](https://img.shields.io/npm/v/@biopragmatics/curies)](https://www.npmjs.com/package/@biopragmatics/curies) - -You can easily work with CURIEs from JavaScript, or TypeScript with the [`@biopragmatics/curies`](https://www.npmjs.com/package/@biopragmatics/curies) NPM package. - -## πŸ“₯️ Install - -Install the `npm` package (use `yarn` or `pnpm` if you prefer) to use it from your favorite framework: - -```bash -npm install @biopragmatics/curies -# or -pnpm add @biopragmatics/curies -# or -yarn add @biopragmatics/curies -``` - -## 🟒 Use it in a NodeJS environment - -There are multiple methods available for creating or importing converters: - -```javascript -import {Record, Converter, getOboConverter, getBioregistryConverter} from "@biopragmatics/curies"; - -async function main() { - // Populate from Records - const rec1 = new Record("obo", "http://purl.obolibrary.org/obo/", [], []); - console.log(rec1.toString()); - console.log(rec1.toJs()); - const converter = new Converter(); - converter.addRecord(rec1); - - // Load from a prefix map json (string or URI) - const converterFromMap = await Converter.fromPrefixMap(`{ - "doid": "http://purl.obolibrary.org/obo/MY_DOID_" - }`); - - // Load from an extended prefix map (string or URI) - const converterFromUrl = await Converter.fromExtendedPrefixMap("https://raw.githubusercontent.com/biopragmatics/bioregistry/main/exports/contexts/bioregistry.epm.json") - - // Load from a JSON-LD context (string or URI) - const converterFromJsonld = await Converter.fromJsond("http://purl.obolibrary.org/meta/obo_context.jsonld"); - - // Load from one of the predefined source - const converterFromSource = await getBioregistryConverter(); - - // Chain multiple converters in one - const converter = converterFromMap - .chain(converterFromUrl) - .chain(converterFromSource) - - // Expand CURIE and compress URI - const curie = converter.compress("http://purl.obolibrary.org/obo/DOID_1234"); - const uri = converter.expand("doid:1234"); - - // Expand and compress list of CURIEs and URIs - const curies = converter.compressList(["http://purl.obolibrary.org/obo/DOID_1234"]); - const uris = converter.expandList(["doid:1234"]); -} -main(); -``` - -## 🦊 Use it in a browser - -When using in a client browser you will need to initialize the wasm binary with `await init()`, after that you can use the same functions as in the NodeJS environments. - -### πŸš€ In bare HTML files - -You can easily import the NPM package from a CDN, and work with `curies` from a simple `index.html` file: - -```html - - - - - CURIEs example - - - -

-

- - - - -``` - -Then just start the web server from the directory where the HTML file is with: - -```bash -npx http-server -# Or: -python -m http.server -``` - -### βš›οΈ From any JavaScript framework - -It can be used from any JavaScript framework, or NodeJS. - -For example, to use it in a nextjs react app: - -1. Create the project and `cd` into your new app folder - - ```bash - npx create-next-app@latest --typescript - ``` - -2. Add the `@biopragmatics/curies` dependency to your project: - - ```bash - npm install --save @biopragmatics/curies - ``` - -3. Add code, e.g. in `src/app/page.tsx` running on the client: - - ```typescript - 'use client' - import { useEffect, useState } from 'react'; - import init, { getBioregistryConverter } from "@biopragmatics/curies"; - - export default function Home() { - const [output, setOutput] = useState(''); - useEffect(() => { - - // Initialize the wasm library and use it - init().then(async () => { - const converter = await getBioregistryConverter(); - const curie = converter.compress("http://purl.obolibrary.org/obo/DOID_1234"); - const uri = converter.expand("doid:1234"); - setOutput(`${curie}: ${uri}`); - }); - }, []); - - return ( -
-

{output}

-
- ); - } - ``` - -4. Start in dev: - - ```bash - npm run dev - ``` diff --git a/lib/docs/use_python.md b/lib/docs/use_python.md deleted file mode 100644 index b68b6cc..0000000 --- a/lib/docs/use_python.md +++ /dev/null @@ -1,42 +0,0 @@ -# 🐍 Use from Python - -[![PyPI](https://img.shields.io/pypi/v/curies-rs)](https://pypi.org/project/curies-rs/) - -You can easily work with `curies` from Python. - -```admonish warning title="Work in progress" -This package is a work in progress. This documentation might not be always up-to-date. -``` - -## πŸ“₯️ Install - -Install the `pip` package: - -```bash -pip install curies-rs -``` - -## πŸš€ Use - -Create a `Converter`, and expand/compress: - -```python -from curies_rs import Record, Converter - -rec1 = Record("doid", "http://purl.obolibrary.org/obo/DOID_", [], []) - -converter = Converter() -converter.add_record(rec1) - -uri = converter.compress("http://purl.obolibrary.org/obo/DOID_1234") - -print(uri) - -print(rec1.dict()) -``` - -Run the script: - -```bash -python curies.py -``` diff --git a/lib/src/api.rs b/lib/src/api.rs index 4cc4529..eeac7cf 100644 --- a/lib/src/api.rs +++ b/lib/src/api.rs @@ -4,8 +4,8 @@ use crate::error::CuriesError; use crate::fetch::{ExtendedPrefixMapSource, PrefixMapSource}; use ptrie::Trie; use regex::Regex; -use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde::{Deserialize, Serialize, Serializer}; +use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; use std::fmt; use std::sync::Arc; @@ -92,6 +92,16 @@ pub struct Converter { delimiter: String, } +impl Serialize for Converter { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let records: Vec<&Record> = self.records.iter().map(|r| &**r).collect(); + records.serialize(serializer) + } +} + impl Converter { /// Create an empty `Converter` /// @@ -235,10 +245,61 @@ impl Converter { } /// Add a CURIE prefix and its prefix URI to the `Converter` - pub fn add_curie(&mut self, prefix: &str, uri_prefix: &str) -> Result<(), CuriesError> { + pub fn add_prefix(&mut self, prefix: &str, uri_prefix: &str) -> Result<(), CuriesError> { self.add_record(Record::new(prefix, uri_prefix)) } + /// Get all prefixes in the `Converter` as a list + pub fn get_prefixes(&self, include_synonyms: bool) -> Vec { + if include_synonyms { + self.prefix_map.keys().cloned().collect() + } else { + self.records.iter().map(|r| r.prefix.clone()).collect() + } + } + + /// Get all URI prefixes in the `Converter` as a list + pub fn get_uri_prefixes(&self, include_synonyms: bool) -> Vec { + if include_synonyms { + let mut prefixes: Vec = Vec::new(); + for record in &self.records { + prefixes.push(record.uri_prefix.clone()); + for synonym in &record.uri_prefix_synonyms { + prefixes.push(synonym.clone()); + } + } + prefixes + } else { + self.records.iter().map(|r| r.uri_prefix.clone()).collect() + } + } + + /// Write the extended prefix map as a JSON string + pub fn write_extended_prefix_map(&self) -> Result { + Ok(serde_json::to_string(&self)?) + } + + /// Write the prefix map as a HashMap where keys are prefixes and values are URI prefixes. + pub fn write_prefix_map(&self) -> HashMap { + self.records + .iter() + .map(|record| (record.prefix.clone(), record.uri_prefix.clone())) + .collect() + } + + /// Write the JSON-LD representation of the prefix map. + pub fn write_jsonld(&self) -> serde_json::Value { + let mut context = json!({}); + for record in &self.records { + context[record.prefix.clone()] = record.uri_prefix.clone().into(); + // TODO: do we add prefix synonyms to the context? + for synonym in &record.prefix_synonyms { + context[synonym.clone()] = record.uri_prefix.clone().into(); + } + } + json!({"@context": context}) + } + /// Chain multiple `Converters` into a single `Converter`. The first `Converter` in the list is used as the base. /// If the same prefix is found in multiple converters, the first occurrence is kept, /// but the `uri_prefix` and synonyms are added as synonyms if they are different. diff --git a/lib/src/lib.rs b/lib/src/lib.rs index b5070b1..53f7781 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -1,4 +1,5 @@ -#![doc = include_str!("../docs/use_rust.md")] +#![doc = include_str!("../docs/docs/index.md")] +#![doc = include_str!("../docs/docs/rust.md")] // #![warn(missing_docs)] // #![doc(issue_tracker_base_url = "https://github.com/biopragmatics/curies.rs/issues")] diff --git a/lib/tests/curies_test.rs b/lib/tests/curies_test.rs index 37b6511..5166d9e 100644 --- a/lib/tests/curies_test.rs +++ b/lib/tests/curies_test.rs @@ -36,6 +36,15 @@ fn new_empty_converter() -> Result<(), Box> { converter.add_record(record3)?; assert_eq!(converter.len(), 3); assert!(!converter.is_empty()); + assert!(converter.get_prefixes(true).len() > converter.get_prefixes(false).len()); + assert!(converter.get_uri_prefixes(true).len() > converter.get_uri_prefixes(false).len()); + assert!(converter.write_extended_prefix_map()?.starts_with("[{")); + assert!(converter.write_prefix_map().len() == 3); + assert!(converter.write_jsonld()["@context"] + .to_string() + .starts_with("{")); + println!("{:?}", converter.write_jsonld()); + // println!("{:?}", converter.write_extended_prefix_map()); // Find Record by prefix or URI assert_eq!(converter.find_by_prefix("doid")?.prefix, "doid"); @@ -90,7 +99,7 @@ fn new_empty_converter() -> Result<(), Box> { // Test wrong calls assert!(converter - .add_curie("doid", "http://purl.obolibrary.org/obo/DOID_") + .add_prefix("doid", "http://purl.obolibrary.org/obo/DOID_") .map_err(|e| assert!(e.to_string().starts_with("Duplicate record"))) .is_err()); assert!(converter diff --git a/python/Cargo.toml b/python/Cargo.toml index 948c8b9..fae085e 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -16,8 +16,9 @@ name = "curies_rs" crate-type = ["cdylib"] [dependencies] -curies = { version = "0.1.1", path = "../lib" } +curies.workspace = true +serde.workspace = true pyo3 = { version = "0.20", features = ["extension-module"] } +# pyo3-asyncio = { version = "0.20", features = ["tokio-runtime"] } pythonize = "0.20" -serde = { version = "1.0" } tokio = { version = "1.34", features = ["rt-multi-thread"] } diff --git a/python/requirements.dev.txt b/python/requirements.dev.txt deleted file mode 100644 index dbf962f..0000000 --- a/python/requirements.dev.txt +++ /dev/null @@ -1 +0,0 @@ -maturin diff --git a/python/requirements.txt b/python/requirements.txt new file mode 100644 index 0000000..cfa066c --- /dev/null +++ b/python/requirements.txt @@ -0,0 +1,4 @@ +maturin[patchelf] +pre-commit +pytest +mktestdocs diff --git a/python/src/api.rs b/python/src/api.rs new file mode 100644 index 0000000..ed09777 --- /dev/null +++ b/python/src/api.rs @@ -0,0 +1,248 @@ +use ::curies::{ + sources::{ + get_bioregistry_converter as get_bioregistry_converter_rs, + get_go_converter as get_go_converter_rs, get_monarch_converter as get_monarch_converter_rs, + get_obo_converter as get_obo_converter_rs, + }, + Converter, Record, +}; +use pyo3::{exceptions::PyException, prelude::*}; +use pythonize::pythonize; +use serde::{Deserialize, Serialize}; +use tokio::runtime::Runtime; + +#[pyclass(name = "Record", module = "curies_rs")] +// #[pyclass(extends=Record, name = "Record", module = "curies_rs")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecordPy { + record: Record, +} + +#[pymethods] +impl RecordPy { + #[new] + #[pyo3(text_signature = "(prefix, uri_prefix, prefix_synonyms, uri_prefix_synonyms)")] + fn new( + prefix: String, + uri_prefix: String, + prefix_synonyms: Option>, + uri_prefix_synonyms: Option>, + ) -> PyResult { + Ok(Self { + record: Record { + prefix, + uri_prefix, + prefix_synonyms: prefix_synonyms.unwrap_or_default().into_iter().collect(), + uri_prefix_synonyms: uri_prefix_synonyms + .unwrap_or_default() + .into_iter() + .collect(), + pattern: None, + }, + }) + } + + // Return the Record as a python dictionary + #[pyo3(text_signature = "($self)")] + fn dict(&self, py: Python<'_>) -> PyResult { + pythonize(py, &self.record).map_err(|e| { + PyErr::new::(format!("Error converting struct Record to dict: {e}")) + }) + } +} + +/// Python bindings for the CURIE/URI Converter struct +#[pyclass(name = "Converter", module = "curies_rs", sequence)] +pub struct ConverterPy { + converter: Converter, +} + +#[pymethods] +impl ConverterPy { + #[new] + #[pyo3(text_signature = "()")] + fn new() -> PyResult { + Ok(Self { + converter: Converter::default(), + }) + // Handle errors: + // Converter::default() + // .map(|converter| Self { converter }) + // .map_err(|e| PyErr::new::(format!("{e}"))) + } + + /// Load a `Converter` from an extended prefix map JSON string or URL + #[staticmethod] + #[pyo3(text_signature = "(data)")] + fn from_extended_prefix_map(data: &str) -> PyResult { + // Use a tokio runtime to wait on the async operation + let rt = Runtime::new().map_err(|e| { + PyErr::new::(format!("Failed to create Tokio runtime: {e}")) + })?; + rt.block_on(async move { + Converter::from_extended_prefix_map(data) + .await + .map(|converter| Self { converter }) + .map_err(|e| PyErr::new::(e.to_string())) + }) + } + + /// Load a `Converter` from a prefix map JSON string or URL + #[staticmethod] + #[pyo3(text_signature = "(data)")] + fn from_prefix_map(data: &str) -> PyResult { + let rt = Runtime::new().map_err(|e| { + PyErr::new::(format!("Failed to create Tokio runtime: {e}")) + })?; + rt.block_on(async move { + Converter::from_prefix_map(data) + .await + .map(|converter| Self { converter }) + .map_err(|e| PyErr::new::(e.to_string())) + }) + } + + /// Load a `Converter` from a JSON-LD context string or URL + #[staticmethod] + #[pyo3(text_signature = "(data)")] + fn from_jsonld(data: &str) -> PyResult { + let rt = Runtime::new().map_err(|e| { + PyErr::new::(format!("Failed to create Tokio runtime: {e}")) + })?; + rt.block_on(async move { + Converter::from_jsonld(data) + .await + .map(|converter| Self { converter }) + .map_err(|e| PyErr::new::(e.to_string())) + }) + } + + /// Add a record to the `Converter` + #[pyo3(text_signature = "($self, record)")] + fn add_record(&mut self, record: RecordPy) -> PyResult<()> { + self.converter + .add_record(record.record) + .map_err(|e| PyErr::new::(e.to_string())) + } + + /// Compress a URI + #[pyo3(text_signature = "($self, uri)")] + fn compress(&self, uri: String) -> PyResult { + self.converter + .compress(&uri) + .map_err(|e| PyErr::new::(e.to_string())) + } + + /// Expand a CURIE + #[pyo3(text_signature = "($self, curie)")] + fn expand(&self, curie: String) -> PyResult { + self.converter + .expand(&curie) + .map_err(|e| PyErr::new::(e.to_string())) + } + + /// Expand a list of CURIEs + #[pyo3(text_signature = "($self, curies)")] + fn expand_list(&self, curies: Vec<&str>) -> Vec> { + self.converter.expand_list(curies) + } + + /// Compress a list of URIs + #[pyo3(text_signature = "($self, uris)")] + fn compress_list(&self, uris: Vec<&str>) -> Vec> { + self.converter.compress_list(uris) + } + + /// Chain with another `Converter` + #[pyo3(text_signature = "($self, converter)")] + fn chain(&self, converter: &ConverterPy) -> PyResult { + Converter::chain(vec![self.converter.clone(), converter.converter.clone()]) + .map(|converter| ConverterPy { converter }) + .map_err(|e| PyErr::new::(e.to_string())) + } + + // NOTE: could there be a way to pass a list of converters? + // #[staticmethod] + // #[pyo3(text_signature = "(converters)")] + // fn chain(converters: Vec>) -> PyResult { + // Converter::chain(converters.into_iter().map(|c| c.converter).collect()) + // .map(|converter| ConverterPy { converter.clone() }) + // .map_err(|e| PyErr::new::(e.to_string())) + // } + + /// Support for python `len()` + fn __len__(&self) -> usize { + self.converter.len() + } +} + +#[pyfunction] +pub fn get_obo_converter() -> PyResult { + let rt = Runtime::new().map_err(|e| { + PyErr::new::(format!("Failed to create Tokio runtime: {e}")) + })?; + rt.block_on(async { + get_obo_converter_rs() + .await + .map(|converter| ConverterPy { converter }) + .map_err(|e| PyErr::new::(e.to_string())) + }) +} + +#[pyfunction] +pub fn get_bioregistry_converter(py: Python<'_>) -> PyResult { + // TODO: https://pyo3.rs/v0.21.1/ecosystem/async-await + let rt = Runtime::new().map_err(|e| { + PyErr::new::(format!("Failed to create Tokio runtime: {e}")) + })?; + rt.block_on(async { + get_bioregistry_converter_rs() + .await + .map(|converter| ConverterPy { converter }) + .map_err(|e| PyErr::new::(e.to_string())) + }) + // pyo3_asyncio::tokio::future_into_py(py, async { + // get_bioregistry_converter_rs() + // .await + // .map(|converter| ConverterPy { converter }) + // .map_err(|e| PyErr::new::(e.to_string())) + // }) + // pyo3_asyncio::tokio::future_into_py(py, async { + // let py_converter = get_bioregistry_converter_rs().await.map_err(|e| PyErr::new::(e.to_string()))?; + // let converter = py_converter.try_into()?; + // Ok(ConverterPy { converter }) + // }) +} + +// Maybe we need to implement IntoPy? +// impl IntoPy> for ConverterPy { +// fn into_py(self, py: Python<'_>) -> Py { +// self.0 +// } +// } + +#[pyfunction] +pub fn get_monarch_converter() -> PyResult { + let rt = Runtime::new().map_err(|e| { + PyErr::new::(format!("Failed to create Tokio runtime: {e}")) + })?; + rt.block_on(async { + get_monarch_converter_rs() + .await + .map(|converter| ConverterPy { converter }) + .map_err(|e| PyErr::new::(e.to_string())) + }) +} + +#[pyfunction] +pub fn get_go_converter() -> PyResult { + let rt = Runtime::new().map_err(|e| { + PyErr::new::(format!("Failed to create Tokio runtime: {e}")) + })?; + rt.block_on(async { + get_go_converter_rs() + .await + .map(|converter| ConverterPy { converter }) + .map_err(|e| PyErr::new::(e.to_string())) + }) +} diff --git a/python/src/lib.rs b/python/src/lib.rs index dfa575b..419ef15 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -1,8 +1,8 @@ -use ::curies::{Converter, Record}; -use pyo3::{exceptions::PyException, prelude::*}; -use pythonize::pythonize; -use serde::{Deserialize, Serialize}; -use tokio::runtime::Runtime; +mod api; + +use crate::api::*; +use pyo3::prelude::*; +use pyo3::wrap_pyfunction; /// Python bindings #[pymodule] @@ -12,90 +12,9 @@ fn curies_rs(_py: Python<'_>, m: &PyModule) -> PyResult<()> { m.add("__author__", env!("CARGO_PKG_AUTHORS").replace(':', "\n"))?; m.add_class::()?; - m.add_class::() -} - -#[pyclass(name = "Record", module = "curies_rs")] -// #[pyclass(extends=Record, name = "Record", module = "curies_rs")] -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RecordPy { - record: Record, -} - -#[pymethods] -impl RecordPy { - #[new] - #[pyo3(text_signature = "(prefix, uri_prefix, prefix_synonyms, uri_prefix_synonyms)")] - fn new( - prefix: String, - uri_prefix: String, - prefix_synonyms: Vec, - uri_prefix_synonyms: Vec, - ) -> PyResult { - Ok(Self { - record: Record { - prefix, - uri_prefix, - prefix_synonyms: prefix_synonyms.into_iter().collect(), - uri_prefix_synonyms: uri_prefix_synonyms.into_iter().collect(), - pattern: None, - }, - }) - } - - // Return the Record as a python dictionary - #[pyo3(text_signature = "($self)")] - fn dict(&self, py: Python<'_>) -> PyResult { - pythonize(py, &self.record).map_err(|e| { - PyErr::new::(format!("Error converting struct Record to dict: {e}")) - }) - } -} - -#[pyclass(name = "Converter", module = "curies_rs")] -pub struct ConverterPy { - converter: Converter, -} - -#[pymethods] -impl ConverterPy { - #[new] - #[pyo3(text_signature = "()")] - fn new() -> PyResult { - Ok(Self { - converter: Converter::default(), - }) - // Handle errors: - // Converter::default() - // .map(|converter| Self { converter }) - // .map_err(|e| PyErr::new::(format!("{e}"))) - } - - #[staticmethod] - #[pyo3(text_signature = "(data)")] - fn load_extended_prefix_map(data: &str) -> PyResult { - // Use a tokio runtime to wait on the async operation - let rt = Runtime::new().map_err(|e| { - PyErr::new::(format!("Failed to create Tokio runtime: {e}")) - })?; - let result = rt.block_on(async move { - Converter::from_extended_prefix_map(data) - .await - .map_err(|e| PyErr::new::(e.to_string())) - }); - result.map(|converter| Self { converter }) - } - - #[pyo3(text_signature = "($self, record)")] - fn add_record(&mut self, record: RecordPy) -> PyResult<()> { - self.converter - .add_record(record.record) - .map_err(|e| PyErr::new::(e.to_string())) - } - - fn compress(&self, uri: String) -> PyResult { - self.converter - .compress(&uri) - .map_err(|e| PyErr::new::(e.to_string())) - } + m.add_class::()?; + m.add_wrapped(wrap_pyfunction!(get_obo_converter))?; + m.add_wrapped(wrap_pyfunction!(get_bioregistry_converter))?; + m.add_wrapped(wrap_pyfunction!(get_monarch_converter))?; + m.add_wrapped(wrap_pyfunction!(get_go_converter)) } diff --git a/python/tests/test_api.py b/python/tests/test_api.py index bbc6897..44dab1a 100644 --- a/python/tests/test_api.py +++ b/python/tests/test_api.py @@ -1,17 +1,117 @@ import unittest -from curies_rs import Record, Converter +from curies_rs import Record, Converter, get_obo_converter, get_bioregistry_converter, get_monarch_converter, get_go_converter class TestAPI(unittest.TestCase): """Test the API.""" def test_converter(self): - """Test the converter.""" - rec1 = Record("doid", "http://purl.obolibrary.org/obo/DOID_", [], []) + """Test the converter: create, add record, compress, expand, chain converters.""" + rec1 = Record("doid", "http://purl.obolibrary.org/obo/DOID_") converter = Converter() converter.add_record(rec1) - uri = converter.compress("http://purl.obolibrary.org/obo/DOID_1234") - self.assertEqual("doid:1234", uri) + self.assertEqual(converter.compress("http://purl.obolibrary.org/obo/DOID_1234"), "doid:1234") + self.assertEqual(converter.expand("doid:1234"), "http://purl.obolibrary.org/obo/DOID_1234") + + self.assertEqual(converter.expand_list(["doid:1234"]), ["http://purl.obolibrary.org/obo/DOID_1234"]) + self.assertEqual(converter.compress_list(["http://purl.obolibrary.org/obo/DOID_1234"]), ["doid:1234"]) + + # Test chain + rec2 = Record("obo", "http://purl.obolibrary.org/obo/", [], []) + converter2 = Converter() + converter2.add_record(rec2) + + merged = converter.chain(converter2) + self.assertEqual(merged.expand("doid:1234"), "http://purl.obolibrary.org/obo/DOID_1234") + self.assertEqual(merged.expand("obo:1234"), "http://purl.obolibrary.org/obo/1234") + + + def test_from_prefix_map(self): + """Test creating the converter from prefix map.""" + prefix_map = """{ + "GO": "http://purl.obolibrary.org/obo/GO_", + "DOID": "http://purl.obolibrary.org/obo/DOID_", + "OBO": "http://purl.obolibrary.org/obo/" + }""" + conv = Converter.from_prefix_map(prefix_map) + self.assertEqual(conv.expand("DOID:1234"), "http://purl.obolibrary.org/obo/DOID_1234") + self.assertEqual(conv.compress("http://purl.obolibrary.org/obo/DOID_1234"), "DOID:1234") + + + def test_from_extended_prefix_map(self): + """Test creating the converter from extended prefix map.""" + extended_pm = """[ + { + "prefix": "DOID", + "prefix_synonyms": [ + "doid" + ], + "uri_prefix": "http://purl.obolibrary.org/obo/DOID_", + "uri_prefix_synonyms": [ + "http://bioregistry.io/DOID:" + ], + "pattern": "^\\\\d+$" + }, + { + "prefix": "GO", + "prefix_synonyms": [ + "go" + ], + "uri_prefix": "http://purl.obolibrary.org/obo/GO_", + "pattern": "^\\\\d{7}$" + }, + { + "prefix": "OBO", + "prefix_synonyms": [ + "obo" + ], + "uri_prefix": "http://purl.obolibrary.org/obo/" + }]""" + conv = Converter.from_extended_prefix_map(extended_pm) + self.assertEqual(conv.expand("doid:1234"), "http://purl.obolibrary.org/obo/DOID_1234") + self.assertEqual(conv.compress("http://purl.obolibrary.org/obo/DOID_1234"), "DOID:1234") + + + def test_from_jsonld(self): + """Test creating the converter from JSON-LD context.""" + jsonld = """{ + "@context": { + "GO": "http://purl.obolibrary.org/obo/GO_", + "DOID": "http://purl.obolibrary.org/obo/DOID_", + "OBO": "http://purl.obolibrary.org/obo/" + } + }""" + conv = Converter.from_jsonld(jsonld) + self.assertEqual(conv.expand("DOID:1234"), "http://purl.obolibrary.org/obo/DOID_1234") + self.assertEqual(conv.compress("http://purl.obolibrary.org/obo/DOID_1234"), "DOID:1234") + + + def test_predefined_converters(self): + """Test the predefined converters.""" + obo = get_obo_converter() + self.assertEqual(obo.expand("DOID:1234"), "http://purl.obolibrary.org/obo/DOID_1234") + self.assertEqual(obo.compress("http://purl.obolibrary.org/obo/DOID_1234"), "DOID:1234") + + bioregistry = get_bioregistry_converter() + self.assertEqual(bioregistry.expand("doid:1234"), "http://purl.obolibrary.org/obo/DOID_1234") + self.assertEqual(bioregistry.compress("http://purl.obolibrary.org/obo/DOID_1234"), "doid:1234") + + go = get_go_converter() + self.assertEqual(go.expand("NCBIGene:100010"), "http://identifiers.org/ncbigene/100010") + self.assertEqual(go.compress("http://identifiers.org/ncbigene/100010"), "NCBIGene:100010") + + monarch = get_monarch_converter() + self.assertEqual(monarch.expand("CHEBI:24867"), "http://purl.obolibrary.org/obo/CHEBI_24867") + self.assertEqual(monarch.compress("http://purl.obolibrary.org/obo/CHEBI_24867"), "CHEBI:24867") + + def test_chain(self): + converter = ( + get_obo_converter() + .chain(get_go_converter()) + .chain(get_monarch_converter()) + ) + self.assertEqual(converter.expand("CHEBI:24867"), "http://purl.obolibrary.org/obo/CHEBI_24867") + print(len(converter)) diff --git a/python/tests/test_docs.py b/python/tests/test_docs.py new file mode 100644 index 0000000..4c5af3f --- /dev/null +++ b/python/tests/test_docs.py @@ -0,0 +1,9 @@ +import pathlib +import pytest + +from mktestdocs import check_md_file + +# Note the use of `str`, makes for pretty output +@pytest.mark.parametrize('fpath', pathlib.Path("../lib/docs").glob("**/*.md"), ids=str) +def test_files_good(fpath): + check_md_file(fpath=fpath) diff --git a/r/NAMESPACE b/r/NAMESPACE index 0ce7125..6dce5af 100644 --- a/r/NAMESPACE +++ b/r/NAMESPACE @@ -2,5 +2,4 @@ S3method("$",ConverterR) S3method("[[",ConverterR) -export(hello_world) useDynLib(curiesr, .registration = TRUE) diff --git a/r/R/extendr-wrappers.R b/r/R/extendr-wrappers.R index b42b03a..234367b 100644 --- a/r/R/extendr-wrappers.R +++ b/r/R/extendr-wrappers.R @@ -11,16 +11,14 @@ #' @useDynLib curiesr, .registration = TRUE NULL -#' Return string `"Hello world!"` to R. -#' @export -hello_world <- function() .Call(wrap__hello_world) - ConverterR <- new.env(parent = emptyenv()) ConverterR$new <- function() .Call(wrap__ConverterR__new) ConverterR$compress <- function(uri) .Call(wrap__ConverterR__compress, self, uri) +ConverterR$expand <- function(curie) .Call(wrap__ConverterR__expand, self, curie) + #' @export `$.ConverterR` <- function (self, name) { func <- ConverterR[[name]]; environment(func) <- environment(); func } diff --git a/r/README.md b/r/README.md index deaf7b9..adb1b87 100644 --- a/r/README.md +++ b/r/README.md @@ -1,10 +1,14 @@ # CURIES R package -https://cran.r-project.org/web/packages/rextendr/vignettes/package.html +`rextendr`docs (to scaffold project with `extendr` bindings): https://cran.r-project.org/web/packages/rextendr/vignettes/package.html -Library: https://github.com/extendr/extendr +`extendr` library: https://github.com/extendr/extendr -Complete example: https://github.com/extendr/curiesr +`extendr` API docs: https://extendr.github.io/extendr/extendr_api + +Complete example: https://github.com/extendr/helloextendr + +## Install dependencies Start R shell: @@ -27,6 +31,8 @@ Or install from GitHub: remotes::install_github("extendr/rextendr") ``` +## Build + Compile: ```r @@ -36,7 +42,7 @@ rextendr::document("./r") Run tests: ```r -CMD check . +library(testthat); test_dir("r/tests"); ``` Load R package: @@ -55,19 +61,11 @@ R Compile and install: -``` r +```r rextendr::document("r") ``` After installation, the following should work: -```r -library(curiesr) - -hello_world() -#> [1] "Hello world!" -``` - -The R code for our converter should look like this: ```r library(curiesr) diff --git a/r/man/hello_world.Rd b/r/man/hello_world.Rd deleted file mode 100644 index 8291afd..0000000 --- a/r/man/hello_world.Rd +++ /dev/null @@ -1,11 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/extendr-wrappers.R -\name{hello_world} -\alias{hello_world} -\title{Return string \code{"Hello world!"} to R.} -\usage{ -hello_world() -} -\description{ -Return string \code{"Hello world!"} to R. -} diff --git a/r/src/rust/Cargo.toml b/r/src/rust/Cargo.toml index ba339a3..c4e581a 100644 --- a/r/src/rust/Cargo.toml +++ b/r/src/rust/Cargo.toml @@ -10,7 +10,6 @@ crate-type = [ 'staticlib' ] name = 'curiesr' [dependencies] -# extendr-api = '*' +curies.workspace = true extendr-api = '0.6.0' -curies = { version = "0.1.1", path = "../../../lib" } tokio = { version = "1.34", features = ["rt-multi-thread"] } diff --git a/r/src/rust/src/lib.rs b/r/src/rust/src/lib.rs index e375532..90a71ca 100644 --- a/r/src/rust/src/lib.rs +++ b/r/src/rust/src/lib.rs @@ -3,17 +3,10 @@ use extendr_api::prelude::*; use ::curies::{sources::get_bioregistry_converter, Converter}; use tokio::runtime::Runtime; -/// Return string `"Hello world!"` to R. +/// Converter struct for R /// @export -#[extendr] -fn hello_world() -> &'static str { - "Hello world!" -} - pub struct ConverterR { converter: Converter, - // Getting error when using the converter struct - // "Error in `value[[3L]]()`: Failed to generate wrapper functions" pub name: String, } @@ -41,8 +34,13 @@ impl ConverterR { fn compress(&self, uri: &str) -> String { self.converter.compress(uri).unwrap() } + + fn expand(&self, curie: &str) -> String { + self.converter.expand(curie).unwrap() + } } +/// Initialize converter fn init_converter() -> Converter { let rt = Runtime::new().unwrap(); rt.block_on(async { get_bioregistry_converter().await.unwrap() }) @@ -52,6 +50,13 @@ fn init_converter() -> Converter { // See corresponding C code in `entrypoint.c`. extendr_module! { mod curiesr; - fn hello_world; impl ConverterR; + // fn hello_world; } + +// Return string `"Hello world!"` to R. +// @export +// #[extendr] +// fn hello_world() -> &'static str { +// "Hello world!" +// } diff --git a/r/tests/test-curies.R b/r/tests/test-curies.R new file mode 100644 index 0000000..303543d --- /dev/null +++ b/r/tests/test-curies.R @@ -0,0 +1,11 @@ +library(testthat) +library(curiesr) + +test_that("Create curiesr default converter, compress and expand", { + converter <- ConverterR$new() + expect_equal(converter$compress("http://purl.obolibrary.org/obo/DOID_1234"), "doid:1234") + expect_equal(converter$expand("doid:1234"), "http://purl.obolibrary.org/obo/DOID_1234") + + # curie <- converter$compress("http://purl.obolibrary.org/obo/DOID_1234") + # print(curie) +}) diff --git a/r/tests/testthat.R b/r/tests/testthat.R deleted file mode 100644 index c2f267c..0000000 --- a/r/tests/testthat.R +++ /dev/null @@ -1,4 +0,0 @@ -library(testthat) -library(curiesr) - -test_check("curiesr") diff --git a/r/tests/testthat/test-hello.R b/r/tests/testthat/test-hello.R deleted file mode 100644 index 3e36d97..0000000 --- a/r/tests/testthat/test-hello.R +++ /dev/null @@ -1,3 +0,0 @@ -test_that("Call to Rust function `hello_world()` works", { - expect_equal(hello_world(), "Hello world!") -}) diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..ce97650 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "stable" +components = [ "rustfmt", "clippy" ] +targets = [ "wasm32-unknown-unknown" ] +# profile = "minimal" diff --git a/scripts/build-js.sh b/scripts/build-js.sh deleted file mode 100755 index 87fd8ea..0000000 --- a/scripts/build-js.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -e - -cd js - -npm run test - -python3 -m http.server -# Or npm run start diff --git a/scripts/build-python.sh b/scripts/build-python.sh deleted file mode 100755 index 8f35777..0000000 --- a/scripts/build-python.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -set -e - -source .venv/bin/activate -cd python - -maturin develop - -python -m pip install pytest -python -m pytest diff --git a/scripts/bump.sh b/scripts/bump.sh index 8a5b730..fb71bd5 100755 --- a/scripts/bump.sh +++ b/scripts/bump.sh @@ -8,21 +8,9 @@ if [ "$#" -ne 1 ]; then fi new_version=$1 -files=( - "lib/Cargo.toml" - "python/Cargo.toml" - "js/Cargo.toml" -) sed -i "s/^version = \"[0-9]*\.[0-9]*\.[0-9]*\"\$/version = \"$new_version\"/" "Cargo.toml" - -for file in "${files[@]}"; do - if [ -f "$file" ]; then - sed -i "s/curies = { version = \"[0-9]*\.[0-9]*\.[0-9]*\"/curies = { version = \"$new_version\"/" "$file" - echo "🏷️ Updated version in $file" - else - echo "⚠️ File not found: $file" - fi -done +sed -i "s/curies = { version = \"[0-9]*\.[0-9]*\.[0-9]*\"/curies = { version = \"$new_version\"/" "Cargo.toml" +echo "🏷️ Updated version in Cargo.toml" gmsg "🏷️ Bump to $new_version" || true diff --git a/scripts/docs-build.sh b/scripts/docs-build.sh deleted file mode 100755 index 3acd59e..0000000 --- a/scripts/docs-build.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -set -e - -rm -rf target/doc - -mdbook build - -cargo doc --workspace --no-deps --exclude curies-js --exclude curies-py --target-dir target/doc - -echo "Docs generated in the target/doc folder" - -# rustdoc --extend-css custom.css src/lib.rs -# rustdoc --theme awesome.css src/lib.rs -# https://github.com/rust-lang/rust/blob/master/src/librustdoc/html/static/css/themes/ayu.css diff --git a/scripts/docs-install.sh b/scripts/docs-install.sh deleted file mode 100755 index 7816331..0000000 --- a/scripts/docs-install.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -set -e -# Script to install dependencies for development and enable pre-commit hooks - -rustup update - -cargo install mdbook mdbook-admonish mdbook-pagetoc - -mkdir -p theme -wget -O theme/mdbook-admonish.css https://raw.githubusercontent.com/leptos-rs/leptos/a8e25af5233bb014d3cee85e4e9be8b3e4586de9/docs/book/mdbook-admonish.css diff --git a/scripts/docs-serve.sh b/scripts/docs-serve.sh deleted file mode 100755 index 37c4584..0000000 --- a/scripts/docs-serve.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -set -e - -source scripts/docs-build.sh - -echo "πŸ¦€ Rust doc at http://0.0.0.0:3000/doc/curies" -echo "πŸ“– MdBook at http://0.0.0.0:3000" - -python -m http.server 3000 --directory ./target/doc - -# python3 -m webbrowser ./target/doc/ diff --git a/scripts/docs.sh b/scripts/docs.sh new file mode 100755 index 0000000..2aedaad --- /dev/null +++ b/scripts/docs.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -e + +# Start mkdocs in development + +if [ ! -d ".venv" ]; then + echo ".venv virtual environment does not exist. Creating it" + python -m venv .venv +fi + +echo "Activating virtual environment" +source .venv/bin/activate + +pip install -q -r lib/docs/requirements.txt + +mkdocs serve -a localhost:8001 -f lib/docs/mkdocs.yml diff --git a/scripts/install-dev.sh b/scripts/install-dev.sh index d4f861d..817716c 100755 --- a/scripts/install-dev.sh +++ b/scripts/install-dev.sh @@ -5,14 +5,12 @@ set -e python3 -m venv .venv source .venv/bin/activate -pip install "maturin[patchelf]" pre-commit +pip install -r python/requirements.txt +pip install -r lib/docs/requirements.txt rustup update rustup toolchain install nightly # For tarpaulin -rustup component add rustfmt clippy cargo install wasm-pack cargo-tarpaulin cargo-make -source scripts/docs-install.sh - pre-commit install diff --git a/scripts/test-all.sh b/scripts/test-all.sh new file mode 100755 index 0000000..93edeb3 --- /dev/null +++ b/scripts/test-all.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -e + +cargo test + +./scripts/test-js.sh +./scripts/test-python.sh +./scripts/test-r.sh diff --git a/scripts/test-js.sh b/scripts/test-js.sh new file mode 100755 index 0000000..1c5a6fc --- /dev/null +++ b/scripts/test-js.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -e + +# Check for --no-build flag +SKIP_BUILD=false +for arg in "$@"; do + if [[ $arg == "--no-build" ]]; then + SKIP_BUILD=true + break + fi +done + +cd js +npm install + +if [ "$SKIP_BUILD" = false ]; then + npm run test -- --silent=false +else + npm run jest -- --silent=false +fi + +# python -m http.server +# Or npm run start diff --git a/scripts/test-python.sh b/scripts/test-python.sh new file mode 100755 index 0000000..69420e1 --- /dev/null +++ b/scripts/test-python.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -e + +# Check for --no-build flag +SKIP_BUILD=false +for arg in "$@"; do + if [[ $arg == "--no-build" ]]; then + SKIP_BUILD=true + break + fi +done + +if [ ! -d ".venv" ]; then + echo ".venv virtual environment does not exist. Creating it" + python -m venv .venv +fi + +echo "Activating virtual environment" +source .venv/bin/activate + +cd python + +if [ "$SKIP_BUILD" = false ]; then + maturin develop +fi + +python -m pytest -s diff --git a/scripts/test-r.sh b/scripts/test-r.sh new file mode 100755 index 0000000..66f8cec --- /dev/null +++ b/scripts/test-r.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -e + +# Build and run tests for R bindings + +# Check for --install flag +INSTALL_DEPS=false +for arg in "$@"; do + if [[ $arg == "--install" ]]; then + INSTALL_DEPS=true + break + fi +done + +if [ "$INSTALL_DEPS" = true ]; then + Rscript -e 'install.packages("usethis")' + Rscript -e 'install.packages("devtools")' + Rscript -e 'install.packages("testthat")' + + # Rscript -e 'install.packages("rextendr")' + Rscript -e 'remotes::install_github("extendr/rextendr")' +fi + +Rscript -e 'rextendr::document("./r"); library(testthat); test_dir("r/tests");'