diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 35c9a52..b219ae2 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -5,49 +5,159 @@ on: tags: - 'v*' workflow_dispatch: + inputs: + tag: + description: 'Tag name to use for release (optional for manual dispatch)' + required: false + prerelease: + description: 'Mark release as prerelease' + required: false + default: 'true' + publish: + description: 'Create GitHub release (if false, workflow only builds and tests)' + required: false + default: 'true' + run_smoke_test: + description: 'Run smoke test before publishing' + required: false + default: 'true' permissions: contents: write packages: write jobs: - build-and-release: - runs-on: ubuntu-latest + build: + name: Build distribution + runs-on: ${{ vars.BUILD_RUNNER || 'ubuntu-latest' }} + outputs: + tag: ${{ steps.set-tag.outputs.tag }} steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Set up JDK 21 - uses: actions/setup-java@v4 + - name: Set up JDK 25 (GitHub-hosted fallback) + if: vars.BUILD_RUNNER == '' + uses: actions/setup-java@v5 with: distribution: 'temurin' - java-version: '21' + java-version: '25' cache: 'gradle' + - name: Use preinstalled JDK 25 + if: vars.BUILD_RUNNER != '' + run: | + set -euo pipefail + if [ ! -x /opt/java/openjdk/bin/java ]; then + echo "::error::Expected preinstalled Temurin 25 at /opt/java/openjdk" + exit 1 + fi + /opt/java/openjdk/bin/java -version 2>&1 | tee java-version.txt + if ! grep -q 'version "25\.' java-version.txt; then + echo "::error::Expected Java 25 on the self-hosted runner" + exit 1 + fi + echo "JAVA_HOME=/opt/java/openjdk" >> "$GITHUB_ENV" + echo "/opt/java/openjdk/bin" >> "$GITHUB_PATH" + + - name: Determine tag + id: set-tag + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + if [ -n "${{ github.event.inputs.tag }}" ]; then + TAG_NAME="${{ github.event.inputs.tag }}" + else + # Fallback to gradle version if no input provided + TAG_NAME=$(./gradlew -q properties | grep ^version: | awk '{print $2}') + fi + else + TAG_NAME="${GITHUB_REF#refs/tags/}" + fi + echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT + - name: Build package run: | chmod +x ./gradlew - ./gradlew --no-daemon clean packDist + ./gradlew --no-daemon -Pversion=${{ steps.set-tag.outputs.tag }} clean build shadowJar packDist - name: Prepare artifact and checksum run: | set -euo pipefail - ARTIFACT=$(ls build/distributions/*.zip | head -n1) - if [ -z "${ARTIFACT:-}" ]; then - echo "No distribution zip found in build/distributions" >&2 - ls -la build || true - exit 1 - fi - sha256sum "$ARTIFACT" > "$ARTIFACT.sha256" - echo "Created $ARTIFACT and checksum" - ls -l "$ARTIFACT" "$ARTIFACT.sha256" + TAG=${{ steps.set-tag.outputs.tag }} + # Look for the zip regardless of name, then rename it to a standard format for the artifact + RAW_ZIP=$(ls build/distributions/*.zip | head -n1) + cp "$RAW_ZIP" "IJava-${TAG}.zip" + sha256sum "IJava-${TAG}.zip" > "IJava-${TAG}.zip.sha256" + + - name: Upload distribution artifact + uses: actions/upload-artifact@v4 + with: + name: distribution + path: | + IJava-${{ steps.set-tag.outputs.tag }}.zip + IJava-${{ steps.set-tag.outputs.tag }}.zip.sha256 + + smoke-test: + name: Smoke-test distribution + needs: build + runs-on: ubuntu-latest + # Run if manual dispatch asks for it OR if it's a tag push + if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.run_smoke_test == 'true' }} + steps: + - name: Download distribution + uses: actions/download-artifact@v4 + with: + name: distribution + + - name: Set up JDK 25 for smoke-test + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '25' + + - name: Run smoke test + run: | + set -euo pipefail + DIST=$(ls IJava-*.zip | head -n1) + unzip -q "$DIST" -d smoke + + # Find the JAR (supporting both flat and nested structures) + JAR=$(find smoke -name "*.jar" | head -n1) + + python3 -m venv venv + ./venv/bin/pip install jupyter-client nbconvert + + # Install through the release installer + ./venv/bin/python smoke/install.py --sys-prefix --replace + + # Create and execute test notebook + echo '{"cells":[{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":["System.out.println(\"Hello from IJava\");","42"]}],"metadata":{},"nbformat":4,"nbformat_minor":4}' > test.ipynb + ./venv/bin/python -m nbconvert --to notebook --execute test.ipynb --ExecutePreprocessor.kernel_name=java + + publish: + name: Create Release + needs: [build, smoke-test] + runs-on: ubuntu-latest # No need for self-hosted here usually, ubuntu-latest is safer for API calls + if: ${{ always() && (needs.smoke-test.result == 'success' || needs.smoke-test.result == 'skipped') && (startsWith(github.ref, 'refs/tags/') || github.event.inputs.publish == 'true') }} + steps: + - name: Checkout code (for UPGRADE.md) + uses: actions/checkout@v4 + + - name: Download distribution + uses: actions/download-artifact@v4 + with: + name: distribution - - name: Create GitHub Release and upload assets - if: startsWith(github.ref, 'refs/tags/') - uses: softprops/action-gh-release@v1 + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 # Updated to v2 with: - # include a release body if you maintain UPGRADE.md or CHANGELOG + tag_name: ${{ needs.build.outputs.tag }} body_path: UPGRADE.md files: | - build/distributions/*.zip - build/distributions/*.zip.sha256 + IJava-${{ needs.build.outputs.tag }}.zip + IJava-${{ needs.build.outputs.tag }}.zip.sha256 + prerelease: ${{ github.event.inputs.prerelease == 'true' || contains(github.ref, '-rc') || contains(github.ref, '-pr') }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml new file mode 100644 index 0000000..253f2a5 --- /dev/null +++ b/.github/workflows/sonarqube.yml @@ -0,0 +1,95 @@ +name: SonarQube + +on: + workflow_dispatch: + pull_request: + branches: ['**'] + push: + branches: ['**'] + +concurrency: + group: sonarqube-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + sonarqube: + name: Analyze with SonarQube + if: github.event_name == 'workflow_dispatch' || vars.SONAR_HOST_URL != '' || vars.SONAR_PUBLIC_URL != '' + runs-on: ${{ vars.SONAR_RUNNER || 'ubuntu-latest' }} + env: + SONAR_HOST_URL: ${{ vars.SONAR_RUNNER && vars.SONAR_HOST_URL || vars.SONAR_PUBLIC_URL || vars.SONAR_HOST_URL }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_RUNNER: ${{ vars.SONAR_RUNNER }} + SONAR_MAIN_BRANCH: ${{ vars.SONAR_MAIN_BRANCH || 'master' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK 25 (GitHub-hosted fallback) + if: vars.SONAR_RUNNER == '' + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '25' + cache: 'gradle' + + - name: Use preinstalled JDK 25 + if: vars.SONAR_RUNNER != '' + run: | + set -euo pipefail + if [ ! -x /opt/java/openjdk/bin/java ]; then + echo "::error::Expected preinstalled Temurin 25 at /opt/java/openjdk" + exit 1 + fi + /opt/java/openjdk/bin/java -version 2>&1 | tee java-version.txt + if ! grep -q 'version "25\.' java-version.txt; then + echo "::error::Expected Java 25 on the self-hosted runner" + exit 1 + fi + echo "JAVA_HOME=/opt/java/openjdk" >> "$GITHUB_ENV" + echo "/opt/java/openjdk/bin" >> "$GITHUB_PATH" + + - name: Validate SonarQube configuration + run: | + if [ -z "$SONAR_HOST_URL" ]; then + echo "::error::Set SONAR_HOST_URL or SONAR_PUBLIC_URL to the SonarQube URL." + exit 1 + fi + if [ -z "$SONAR_TOKEN" ]; then + echo "::error::Set the repository secret SONAR_TOKEN to a SonarQube analysis token." + exit 1 + fi + if [ -z "$SONAR_RUNNER" ] && [[ "$SONAR_HOST_URL" == *"svc.cluster.local"* ]]; then + echo "::error::SONAR_HOST_URL is cluster-local. Set SONAR_RUNNER to an in-cluster self-hosted runner, or set SONAR_PUBLIC_URL to an ingress/load-balancer URL." + exit 1 + fi + + - name: Build and test + run: | + chmod +x ./gradlew + ./gradlew --no-daemon clean build shadowJar packDist + + - name: Prepare SonarQube analysis target + env: + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.number }} + HEAD_REF: ${{ github.head_ref }} + BASE_REF: ${{ github.base_ref }} + REF_NAME: ${{ github.ref_name }} + MAIN_BRANCH: ${{ vars.SONAR_MAIN_BRANCH || 'master' }} + COMMIT_SHA: ${{ github.sha }} + run: | + if [ "$EVENT_NAME" = "pull_request" ]; then + echo "SONAR_SCAN_ARGS=-Dsonar.pullRequest.key=$PR_NUMBER -Dsonar.pullRequest.branch=$HEAD_REF -Dsonar.pullRequest.base=$BASE_REF -Dsonar.projectVersion=$COMMIT_SHA" >> "$GITHUB_ENV" + elif [ "$REF_NAME" = "$MAIN_BRANCH" ]; then + echo "SONAR_SCAN_ARGS=-Dsonar.projectVersion=$COMMIT_SHA" >> "$GITHUB_ENV" + else + echo "SONAR_SCAN_ARGS=-Dsonar.branch.name=$REF_NAME -Dsonar.projectVersion=$COMMIT_SHA" >> "$GITHUB_ENV" + fi + + - name: SonarQube Scan + uses: sonarsource/sonarqube-scan-action@v6 + with: + args: ${{ env.SONAR_SCAN_ARGS }} diff --git a/.gitignore b/.gitignore index 0779f7f..21e4b3b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,29 @@ build/ out/.vscode .idea/ + +# Ignore generated notebook artifacts +docs/notebooks/example.txt +docs/notebooks/ijava_sample_notebook.html +docs/notebooks/generated_html/ + +# Installer/script outputs +install.sh + +# Generated java resources +src/main/resources/java/ + +# Tests artifacts +tests/ + +.envrc +.use-google-ai +.opencode/ + + +# Eclipse +.classpath +.factorypath +.project +.settings/ +bin/ diff --git a/README.md b/README.md index 7130ad1..c28d3e9 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Fork from [SpencerPark](https://github.com/SpencerPark)/[IJava](https://github.com/SpencerPark/IJava), but with some new features and magics: -* Upgrade to jdk 17 and gradle 7.3.3 +* Upgrade to JDK 25 and Gradle 9.7.1 * Print with variable name or source ![timeout](docs/img/print-with-var-name.png) * add `print` function and `printerPrefix` line magic @@ -20,6 +20,16 @@ features and magics: ![r-w](docs/img/write-cell-magic.png) * add `cmd` line magic ![cmd](docs/img/cmd-line-magic.png) +* add `benchmark` cell magic (compare implementations, SVG chart) +* add `rdbmsSchema` / `sqlAsTable` / `tableSchema` magics (JDBC-backed schema diagrams and queries) +* add `classDiagram` magic (UML class diagrams via PlantUML) +* add `git-graph-mermaid` line magic (Mermaid git graph of the current repo) +* add `shell` / `commonshell` magics (one-shot and persistent shell sessions) +* add `where`, `class-info`, `javadoc-html`, `reload-class`, `classpath-snapshot` line magics +* add `javasrc*` cell magics (source extraction via JavaParser) +* default statement timeout of 60 seconds for teaching use (disable with `IJAVA_TIMEOUT=-1`) + +See [docs/magics.md](docs/magics.md) for the full magic reference. [//]: # ([![badge](https://img.shields.io/badge/launch-binder-E66581.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFkAAABZCAMAAABi1XidAAAB8lBMVEX///9XmsrmZYH1olJXmsr1olJXmsrmZYH1olJXmsr1olJXmsrmZYH1olL1olJXmsr1olJXmsrmZYH1olL1olJXmsrmZYH1olJXmsr1olL1olJXmsrmZYH1olL1olJXmsrmZYH1olL1olL0nFf1olJXmsrmZYH1olJXmsq8dZb1olJXmsrmZYH1olJXmspXmspXmsr1olL1olJXmsrmZYH1olJXmsr1olL1olJXmsrmZYH1olL1olLeaIVXmsrmZYH1olL1olL1olJXmsrmZYH1olLna31Xmsr1olJXmsr1olJXmsrmZYH1olLqoVr1olJXmsr1olJXmsrmZYH1olL1olKkfaPobXvviGabgadXmsqThKuofKHmZ4Dobnr1olJXmsr1olJXmspXmsr1olJXmsrfZ4TuhWn1olL1olJXmsqBi7X1olJXmspZmslbmMhbmsdemsVfl8ZgmsNim8Jpk8F0m7R4m7F5nLB6jbh7jbiDirOEibOGnKaMhq+PnaCVg6qWg6qegKaff6WhnpKofKGtnomxeZy3noG6dZi+n3vCcpPDcpPGn3bLb4/Mb47UbIrVa4rYoGjdaIbeaIXhoWHmZYHobXvpcHjqdHXreHLroVrsfG/uhGnuh2bwj2Hxk17yl1vzmljzm1j0nlX1olL3AJXWAAAAbXRSTlMAEBAQHx8gICAuLjAwMDw9PUBAQEpQUFBXV1hgYGBkcHBwcXl8gICAgoiIkJCQlJicnJ2goKCmqK+wsLC4usDAwMjP0NDQ1NbW3Nzg4ODi5+3v8PDw8/T09PX29vb39/f5+fr7+/z8/Pz9/v7+zczCxgAABC5JREFUeAHN1ul3k0UUBvCb1CTVpmpaitAGSLSpSuKCLWpbTKNJFGlcSMAFF63iUmRccNG6gLbuxkXU66JAUef/9LSpmXnyLr3T5AO/rzl5zj137p136BISy44fKJXuGN/d19PUfYeO67Znqtf2KH33Id1psXoFdW30sPZ1sMvs2D060AHqws4FHeJojLZqnw53cmfvg+XR8mC0OEjuxrXEkX5ydeVJLVIlV0e10PXk5k7dYeHu7Cj1j+49uKg7uLU61tGLw1lq27ugQYlclHC4bgv7VQ+TAyj5Zc/UjsPvs1sd5cWryWObtvWT2EPa4rtnWW3JkpjggEpbOsPr7F7EyNewtpBIslA7p43HCsnwooXTEc3UmPmCNn5lrqTJxy6nRmcavGZVt/3Da2pD5NHvsOHJCrdc1G2r3DITpU7yic7w/7Rxnjc0kt5GC4djiv2Sz3Fb2iEZg41/ddsFDoyuYrIkmFehz0HR2thPgQqMyQYb2OtB0WxsZ3BeG3+wpRb1vzl2UYBog8FfGhttFKjtAclnZYrRo9ryG9uG/FZQU4AEg8ZE9LjGMzTmqKXPLnlWVnIlQQTvxJf8ip7VgjZjyVPrjw1te5otM7RmP7xm+sK2Gv9I8Gi++BRbEkR9EBw8zRUcKxwp73xkaLiqQb+kGduJTNHG72zcW9LoJgqQxpP3/Tj//c3yB0tqzaml05/+orHLksVO+95kX7/7qgJvnjlrfr2Ggsyx0eoy9uPzN5SPd86aXggOsEKW2Prz7du3VID3/tzs/sSRs2w7ovVHKtjrX2pd7ZMlTxAYfBAL9jiDwfLkq55Tm7ifhMlTGPyCAs7RFRhn47JnlcB9RM5T97ASuZXIcVNuUDIndpDbdsfrqsOppeXl5Y+XVKdjFCTh+zGaVuj0d9zy05PPK3QzBamxdwtTCrzyg/2Rvf2EstUjordGwa/kx9mSJLr8mLLtCW8HHGJc2R5hS219IiF6PnTusOqcMl57gm0Z8kanKMAQg0qSyuZfn7zItsbGyO9QlnxY0eCuD1XL2ys/MsrQhltE7Ug0uFOzufJFE2PxBo/YAx8XPPdDwWN0MrDRYIZF0mSMKCNHgaIVFoBbNoLJ7tEQDKxGF0kcLQimojCZopv0OkNOyWCCg9XMVAi7ARJzQdM2QUh0gmBozjc3Skg6dSBRqDGYSUOu66Zg+I2fNZs/M3/f/Grl/XnyF1Gw3VKCez0PN5IUfFLqvgUN4C0qNqYs5YhPL+aVZYDE4IpUk57oSFnJm4FyCqqOE0jhY2SMyLFoo56zyo6becOS5UVDdj7Vih0zp+tcMhwRpBeLyqtIjlJKAIZSbI8SGSF3k0pA3mR5tHuwPFoa7N7reoq2bqCsAk1HqCu5uvI1n6JuRXI+S1Mco54YmYTwcn6Aeic+kssXi8XpXC4V3t7/ADuTNKaQJdScAAAAAElFTkSuQmCC)](https://mybinder.org/v2/gh/SpencerPark/ijava-binder/master) [![badge](https://img.shields.io/badge/launch-binder%20lab-579ACA.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFkAAABZCAMAAABi1XidAAAB8lBMVEX///9XmsrmZYH1olJXmsr1olJXmsrmZYH1olJXmsr1olJXmsrmZYH1olL1olJXmsr1olJXmsrmZYH1olL1olJXmsrmZYH1olJXmsr1olL1olJXmsrmZYH1olL1olJXmsrmZYH1olL1olL0nFf1olJXmsrmZYH1olJXmsq8dZb1olJXmsrmZYH1olJXmspXmspXmsr1olL1olJXmsrmZYH1olJXmsr1olL1olJXmsrmZYH1olL1olLeaIVXmsrmZYH1olL1olL1olJXmsrmZYH1olLna31Xmsr1olJXmsr1olJXmsrmZYH1olLqoVr1olJXmsr1olJXmsrmZYH1olL1olKkfaPobXvviGabgadXmsqThKuofKHmZ4Dobnr1olJXmsr1olJXmspXmsr1olJXmsrfZ4TuhWn1olL1olJXmsqBi7X1olJXmspZmslbmMhbmsdemsVfl8ZgmsNim8Jpk8F0m7R4m7F5nLB6jbh7jbiDirOEibOGnKaMhq+PnaCVg6qWg6qegKaff6WhnpKofKGtnomxeZy3noG6dZi+n3vCcpPDcpPGn3bLb4/Mb47UbIrVa4rYoGjdaIbeaIXhoWHmZYHobXvpcHjqdHXreHLroVrsfG/uhGnuh2bwj2Hxk17yl1vzmljzm1j0nlX1olL3AJXWAAAAbXRSTlMAEBAQHx8gICAuLjAwMDw9PUBAQEpQUFBXV1hgYGBkcHBwcXl8gICAgoiIkJCQlJicnJ2goKCmqK+wsLC4usDAwMjP0NDQ1NbW3Nzg4ODi5+3v8PDw8/T09PX29vb39/f5+fr7+/z8/Pz9/v7+zczCxgAABC5JREFUeAHN1ul3k0UUBvCb1CTVpmpaitAGSLSpSuKCLWpbTKNJFGlcSMAFF63iUmRccNG6gLbuxkXU66JAUef/9LSpmXnyLr3T5AO/rzl5zj137p136BISy44fKJXuGN/d19PUfYeO67Znqtf2KH33Id1psXoFdW30sPZ1sMvs2D060AHqws4FHeJojLZqnw53cmfvg+XR8mC0OEjuxrXEkX5ydeVJLVIlV0e10PXk5k7dYeHu7Cj1j+49uKg7uLU61tGLw1lq27ugQYlclHC4bgv7VQ+TAyj5Zc/UjsPvs1sd5cWryWObtvWT2EPa4rtnWW3JkpjggEpbOsPr7F7EyNewtpBIslA7p43HCsnwooXTEc3UmPmCNn5lrqTJxy6nRmcavGZVt/3Da2pD5NHvsOHJCrdc1G2r3DITpU7yic7w/7Rxnjc0kt5GC4djiv2Sz3Fb2iEZg41/ddsFDoyuYrIkmFehz0HR2thPgQqMyQYb2OtB0WxsZ3BeG3+wpRb1vzl2UYBog8FfGhttFKjtAclnZYrRo9ryG9uG/FZQU4AEg8ZE9LjGMzTmqKXPLnlWVnIlQQTvxJf8ip7VgjZjyVPrjw1te5otM7RmP7xm+sK2Gv9I8Gi++BRbEkR9EBw8zRUcKxwp73xkaLiqQb+kGduJTNHG72zcW9LoJgqQxpP3/Tj//c3yB0tqzaml05/+orHLksVO+95kX7/7qgJvnjlrfr2Ggsyx0eoy9uPzN5SPd86aXggOsEKW2Prz7du3VID3/tzs/sSRs2w7ovVHKtjrX2pd7ZMlTxAYfBAL9jiDwfLkq55Tm7ifhMlTGPyCAs7RFRhn47JnlcB9RM5T97ASuZXIcVNuUDIndpDbdsfrqsOppeXl5Y+XVKdjFCTh+zGaVuj0d9zy05PPK3QzBamxdwtTCrzyg/2Rvf2EstUjordGwa/kx9mSJLr8mLLtCW8HHGJc2R5hS219IiF6PnTusOqcMl57gm0Z8kanKMAQg0qSyuZfn7zItsbGyO9QlnxY0eCuD1XL2ys/MsrQhltE7Ug0uFOzufJFE2PxBo/YAx8XPPdDwWN0MrDRYIZF0mSMKCNHgaIVFoBbNoLJ7tEQDKxGF0kcLQimojCZopv0OkNOyWCCg9XMVAi7ARJzQdM2QUh0gmBozjc3Skg6dSBRqDGYSUOu66Zg+I2fNZs/M3/f/Grl/XnyF1Gw3VKCez0PN5IUfFLqvgUN4C0qNqYs5YhPL+aVZYDE4IpUk57oSFnJm4FyCqqOE0jhY2SMyLFoo56zyo6becOS5UVDdj7Vih0zp+tcMhwRpBeLyqtIjlJKAIZSbI8SGSF3k0pA3mR5tHuwPFoa7N7reoq2bqCsAk1HqCu5uvI1n6JuRXI+S1Mco54YmYTwcn6Aeic+kssXi8XpXC4V3t7/ADuTNKaQJdScAAAAAElFTkSuQmCC)](https://mybinder.org/v2/gh/SpencerPark/ijava-binder/master?urlpath=lab)) @@ -85,17 +95,14 @@ Currently the kernel supports ### Requirements -1. ~~[Java JDK >= 9](http://www.oracle.com/technetwork/java/javase/downloads/index.html). **Not the JRE**. Java 12 is - the current release and should be considered if selecting a version but if a java 9, 10, or 11 build is installed, - everything _should_ still be working - fine.~~[Java JDK >= 17](http://www.oracle.com/technetwork/java/javase/downloads/index.html). **Not the JRE**. +1. [Java JDK >= 25](http://www.oracle.com/technetwork/java/javase/downloads/index.html). **Not the JRE**. - 1. Ensure that the `java` command is in the PATH and is using version 9. For example: + 1. Ensure that the `java` command is in the PATH and is using a modern version. For example: ```bash > java -version - java version "17.0.2" 2022-01-18 LTS - Java(TM) SE Runtime Environment (build 17.0.2+8-LTS-86) - Java HotSpot(TM) 64-Bit Server VM (build 17.0.2+8-LTS-86, mixed mode, sharing) + openjdk version "25.0.4" + OpenJDK Runtime Environment Temurin-25.0.4 (build 25.0.4) + OpenJDK 64-Bit Server VM Temurin-25.0.4 (build 25.0.4, mixed mode, sharing) ``` 2. Next ensure that `java` is in a location where the jdk was installed and not just the jre. Use @@ -185,7 +192,7 @@ or `gradlew installKernel --param ...:...`) should use the names in the _Paramet | Environment variable | Parameter name | Default | Description | |----------------------|----------------|---------|-------------| | `IJAVA_COMPILER_OPTS` | `comp-opts` | `""` | A space delimited list of command line options that would be passed to the `javac` command when compiling a project. For example `-parameters` to enable retaining parameter names for reflection. | -| `IJAVA_TIMEOUT` | `timeout` | `"-1"` | A duration specifying a timeout (in milliseconds by default) for a _single top level statement_. If less than `1` then there is no timeout. If desired a time may be specified with a [`TimeUnit`](https://docs.oracle.com/javase/9/docs/api/java/util/concurrent/TimeUnit.html) may be given following the duration number (ex `"30 SECONDS"`). | +| `IJAVA_TIMEOUT` | `timeout` | `"60 SECONDS"` | A duration specifying a timeout for a _single top level statement_. The default of 60 seconds suits teaching use, where a runaway statement should not hang the kernel. Set `"-1"` to disable the timeout. If desired a time may be specified with a [`TimeUnit`](https://docs.oracle.com/javase/9/docs/api/java/util/concurrent/TimeUnit.html) may be given following the duration number (ex `"30 SECONDS"`). | | `IJAVA_CLASSPATH` | `classpath` | `""` | A file path separator delimited list of classpath entries that should be available to the user code. **Important:** no matter what OS, this should use forward slash "/" as the file separator. Also each path may actually be a [simple glob](#simple-glob-syntax). | | `IJAVA_STARTUP_SCRIPTS_PATH` | `startup-scripts-path` | `""` | A file path seperator delimited list of `.jshell` scripts to run on startup. This includes [ijava-jshell-init.jshell](src/main/resources/ijava-jshell-init.jshell) and [ijava-display-init.jshell](src/main/resources/ijava-display-init.jshell). **Important:** no matter what OS, this should use forward slash "/" as the file separator. Also each path may actually be a [simple glob](#simple-glob-syntax). | | `IJAVA_STARTUP_SCRIPT` | `startup-script` | `""` | A block of java code to run when the kernel starts up. This may be something like `import my.utils;` to setup some default imports or even `void sleep(long time) { try {Thread.sleep(time); } catch (InterruptedException e) { throw new RuntimeException(e); }}` to declare a default utility method to use in the notebook. | diff --git a/UPGRADE.md b/UPGRADE.md index 5715a7a..6fb4cf3 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,9 +1,25 @@ -Updated: +# IJava 1.4.6-pr12 -1. fix `CompilerMagics.compile` auto generated package path error -2. remain `JupyterIO.jupyterXXX.env`, keep thread stdout rewrite to jupyter +## Highlights +- Default per-statement timeout is now 60 seconds; override with the `timeout` magic parameter or `IJAVA_TIMEOUT=-1` to disable it. +- Vendored `jupyter-jvm-basekernel` into the repository as a Gradle module for a reproducible, self-contained build. +- Upgraded the build to JDK 25 and Gradle 9.7.1. +- Hardened the Shadow JAR packaging pipeline for duplicate classes, service files, and kernel metadata. +- Fixed kernel installation so `--replace` is honored and installed kernel paths are handled robustly. +- Improved the release smoke test to install through the real `install.py` path. +- Added deterministic magic test coverage and stabilized Maven dependency resolution tests. +- `%%rdbmsSchema` now supports `sourceOnly`, `source-only`, and `--source-only` to display PlantUML source without rendering. +- `%%commonshell` now selects an available shell instead of assuming `/bin/zsh`. +- Added SonarQube multi-branch analysis and optimized CI to use the preinstalled JDK 25 on the `ebpro` ARC runner. -TODO: +## Upgrade instructions +1. Download `IJava-v1.4.6-pr12.zip`. +2. Unzip it. +3. Install the kernel with the same Python environment used by Jupyter: + - `python install.py --user --replace` + - or `python install.py --sys-prefix --replace` +4. Restart Jupyter. -1. reload `CompilerMagics.compile` class? DirectExecutionControl > DefaultLoaderDelegate -2. thread stdout JupyterIO.retractEnv; BaseKernel.replaceOutputStreams +## Requirements +- Java JDK 25. +- A Jupyter-compatible environment with `jupyter_client` available for installation. diff --git a/basekernel/LICENSE b/basekernel/LICENSE new file mode 100644 index 0000000..4dc0e0f --- /dev/null +++ b/basekernel/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2017 Spencer Park + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/basekernel/README.md b/basekernel/README.md new file mode 100644 index 0000000..2ca64c9 --- /dev/null +++ b/basekernel/README.md @@ -0,0 +1,7 @@ +# jupyter-jvm-basekernel (vendored) + +This module is a vendored copy of [jupyter-jvm-basekernel](https://github.com/SpencerPark/jupyter-jvm-basekernel) v2.3.0. + +- Source revision: `dc59d998aa4f3c7316c9b3b2ef0c3f3ef3f85705` +- License: MIT, see `LICENSE` +- IJava-specific adaptations are applied to `BaseKernel`, `TextColor`, and `StringStyler` to preserve the existing IJava runtime behavior. diff --git a/basekernel/build.gradle b/basekernel/build.gradle new file mode 100644 index 0000000..38a2536 --- /dev/null +++ b/basekernel/build.gradle @@ -0,0 +1,50 @@ +import org.apache.tools.ant.filters.ReplaceTokens + +plugins { + id 'java-library' +} + +group = 'io.github.spencerpark' +version = '2.3.0-ijava.1' +description = 'Vendored jupyter-jvm-basekernel' + +base.archivesName.set('jupyter-jvm-basekernel') + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } +} + +repositories { + mavenCentral() +} + +dependencies { + api 'org.zeromq:jeromq:0.6.0' + api 'com.google.code.gson:gson:2.10.1' + + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.hamcrest:hamcrest-all:1.3' + testImplementation 'com.google.jimfs:jimfs:1.1' +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + options.deprecation = true + options.release = 25 + options.compilerArgs << '-parameters' +} + +processResources { + def tokens = [ + 'version': project.version, + 'project': 'jupyter-jvm-basekernel' + ] + inputs.properties(tokens) + filter ReplaceTokens, tokens: tokens +} + +test { + useJUnit() +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/DefaultReplyEnvironment.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/DefaultReplyEnvironment.java new file mode 100644 index 0000000..9d95270 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/DefaultReplyEnvironment.java @@ -0,0 +1,111 @@ +package io.github.spencerpark.jupyter.channels; + +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.Message; +import io.github.spencerpark.jupyter.messages.MessageContext; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.publish.PublishStatus; +import io.github.spencerpark.jupyter.messages.reply.ErrorReply; + +import java.util.Deque; +import java.util.LinkedList; + +public class DefaultReplyEnvironment implements ReplyEnvironment { + private final JupyterSocket shell; + private final JupyterSocket iopub; + + private final MessageContext context; + + private Deque deferred = new LinkedList<>(); + private boolean defer = false; + + public DefaultReplyEnvironment(JupyterSocket shell, JupyterSocket iopub, MessageContext context) { + this.shell = shell; + this.iopub = iopub; + this.context = context; + } + + public JupyterSocket getShell() { + return shell; + } + + public JupyterSocket getIopub() { + return iopub; + } + + public MessageContext getContext() { + return context; + } + + @Override + public void publish(Message msg) { + if (defer) { + deferred.push(() -> iopub.sendMessage(msg)); + this.defer = false; + } else { + iopub.sendMessage(msg); + } + } + + @Override + public void reply(Message msg) { + if (defer) { + deferred.push(() -> shell.sendMessage(msg)); + this.defer = false; + } else { + shell.sendMessage(msg); + } + } + + @Override + public ReplyEnvironment defer() { + this.defer = true; + return this; + } + + @Override + public void defer(Runnable action) { + this.deferred.push(action); + } + + @Override + public void resolveDeferrals() { + if (this.defer) + throw new IllegalStateException("Reply environment is in defer mode but a resolution was request."); + + while (!deferred.isEmpty()) + deferred.pop().run(); + } + + @Override + public > void publish(T content) { + publish(new Message<>(context, content.getType(), content)); + } + + @Override + public > void reply(T content) { + reply(new Message<>(context, content.getType(), content)); + } + + @Override + @SuppressWarnings("unchecked") + public void replyError(MessageType type, ErrorReply error) { + reply(new Message(context, type, error)); + } + + @Override + public void setStatusBusy() { + publish(PublishStatus.BUSY); + } + + @Override + public void setStatusIdle() { + publish(PublishStatus.IDLE); + } + + @Override + public void setBusyDeferIdle() { + setStatusBusy(); + defer().setStatusIdle(); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/HeartbeatChannel.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/HeartbeatChannel.java new file mode 100644 index 0000000..2442d6f --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/HeartbeatChannel.java @@ -0,0 +1,86 @@ +package io.github.spencerpark.jupyter.channels; + +import io.github.spencerpark.jupyter.kernel.KernelConnectionProperties; +import io.github.spencerpark.jupyter.messages.HMACGenerator; +import org.zeromq.SocketType; +import org.zeromq.ZMQ; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class HeartbeatChannel extends JupyterSocket { + private static final long HB_DEFAULT_SLEEP_MS = 500; + + private static final AtomicInteger HEARTBEAT_ID = new AtomicInteger(); + + private final long sleep; + private volatile Loop pulse; + + public HeartbeatChannel(ZMQ.Context context, HMACGenerator hmacGenerator, long sleep) { + super(context, SocketType.REP, hmacGenerator, Logger.getLogger("HeartbeatChannel")); + this.sleep = sleep; + } + + public HeartbeatChannel(ZMQ.Context context, HMACGenerator hmacGenerator) { + this(context, hmacGenerator, HB_DEFAULT_SLEEP_MS); + } + + private boolean isBound() { + return this.pulse != null; + } + + @Override + public void bind(KernelConnectionProperties connProps) { + if (this.isBound()) + throw new IllegalStateException("Heartbeat channel already bound"); + + String channelThreadName = "Heartbeat-" + HEARTBEAT_ID.getAndIncrement(); + String addr = JupyterSocket.formatAddress(connProps.getTransport(), connProps.getIp(), connProps.getHbPort()); + + logger.log(Level.INFO, String.format("Binding %s to %s.", channelThreadName, addr)); + super.bind(addr); + + ZMQ.Poller poller = super.ctx.poller(1); + poller.register(this, ZMQ.Poller.POLLIN); + + this.pulse = new Loop(channelThreadName, this.sleep, () -> { + int events = poller.poll(0); + if (events > 0) { + byte[] msg = this.recv(); + if (msg == null) { + //Error during receive, just continue + super.logger.log(Level.SEVERE, "Poll returned 1 event but could not read the echo string"); + return; + } + if (!this.send(msg)) { + super.logger.log(Level.SEVERE, "Could not send heartbeat reply"); + } + super.logger.log(Level.FINEST, "Heartbeat pulse"); + } + }); + this.pulse.onClose(() -> { + logger.log(Level.INFO, channelThreadName + " shutdown."); + this.pulse = null; + }); + this.pulse.start(); + logger.log(Level.INFO, "Polling on " + channelThreadName); + } + + @Override + public void close() { + if (this.isBound()) + this.pulse.shutdown(); + + super.close(); + } + + @Override + public void waitUntilClose() { + if (this.pulse != null) { + try { + this.pulse.join(); + } catch (InterruptedException ignored) { } + } + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/IOPubChannel.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/IOPubChannel.java new file mode 100644 index 0000000..a72e96a --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/IOPubChannel.java @@ -0,0 +1,23 @@ +package io.github.spencerpark.jupyter.channels; + +import io.github.spencerpark.jupyter.kernel.KernelConnectionProperties; +import io.github.spencerpark.jupyter.messages.HMACGenerator; +import org.zeromq.SocketType; +import org.zeromq.ZMQ; + +import java.util.logging.Level; +import java.util.logging.Logger; + +public class IOPubChannel extends JupyterSocket { + public IOPubChannel(ZMQ.Context context, HMACGenerator hmacGenerator) { + super(context, SocketType.PUB, hmacGenerator, Logger.getLogger("IOPubChannel")); + } + + @Override + public void bind(KernelConnectionProperties connProps) { + String addr = JupyterSocket.formatAddress(connProps.getTransport(), connProps.getIp(), connProps.getIopubPort()); + + logger.log(Level.INFO, String.format("Binding iopub to %s.", addr)); + super.bind(addr); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/JupyterConnection.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/JupyterConnection.java new file mode 100644 index 0000000..c4a8dd7 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/JupyterConnection.java @@ -0,0 +1,88 @@ +package io.github.spencerpark.jupyter.channels; + +import io.github.spencerpark.jupyter.kernel.KernelConnectionProperties; +import io.github.spencerpark.jupyter.messages.Message; +import io.github.spencerpark.jupyter.messages.MessageContext; +import io.github.spencerpark.jupyter.messages.HMACGenerator; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.publish.PublishStatus; +import org.zeromq.ZMQ; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; + +public class JupyterConnection { + private final KernelConnectionProperties connProps; + + private boolean isConnected = false; + private final ZMQ.Context ctx; + + protected final HeartbeatChannel heartbeat; + protected final ShellChannel shell; + protected final ShellChannel control; + protected final StdinChannel stdin; + protected final IOPubChannel iopub; + + private final Map handlers; + + public JupyterConnection(KernelConnectionProperties connProps) throws NoSuchAlgorithmException, InvalidKeyException { + this.connProps = connProps; + this.ctx = ZMQ.context(1); + + HMACGenerator hmacGenerator = connProps.createHMACGenerator(); + + this.heartbeat = new HeartbeatChannel(this.ctx, hmacGenerator); + this.shell = new ShellChannel(this.ctx, hmacGenerator, false, this); + this.control = new ShellChannel(this.ctx, hmacGenerator, true, this); + this.stdin = new StdinChannel(this.ctx, hmacGenerator); + this.iopub = new IOPubChannel(this.ctx, hmacGenerator); + + this.handlers = new HashMap<>(); + } + + public void connect() { + if (!isConnected) { + forEachSocket(s -> s.bind(this.connProps)); + PublishStatus publishStatus = PublishStatus.STARTING; + this.getIOPub().sendMessage(new Message<>(null, PublishStatus.MESSAGE_TYPE, publishStatus)); + this.isConnected = true; + } + } + + public IOPubChannel getIOPub() { + return this.iopub; + } + + public void setHandler(MessageType type, ShellHandler handler) { + this.handlers.put(type, handler); + } + + @SuppressWarnings("unchecked") + public ShellHandler getHandler(MessageType type) { + return this.handlers.get(type); + } + + public ShellReplyEnvironment prepareReplyEnv(ShellChannel shell, MessageContext context) { + return new ShellReplyEnvironment(shell, this.stdin, this.iopub, context); + } + + private void forEachSocket(Consumer consumer) { + consumer.accept(this.heartbeat); + consumer.accept(this.shell); + consumer.accept(this.control); + consumer.accept(this.stdin); + consumer.accept(this.iopub); + } + + public void close() { + forEachSocket(JupyterSocket::close); + this.ctx.close(); + } + + public void waitUntilClose() { + forEachSocket(JupyterSocket::waitUntilClose); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/JupyterInputStream.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/JupyterInputStream.java new file mode 100644 index 0000000..a6b7cca --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/JupyterInputStream.java @@ -0,0 +1,148 @@ +package io.github.spencerpark.jupyter.channels; + +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.Objects; + +public class JupyterInputStream extends InputStream { + private final Charset encoding; + + private ShellReplyEnvironment env; + private boolean enabled; + private byte[] data = null; + private int bufferPos = 0; + + public JupyterInputStream(Charset encoding, ShellReplyEnvironment env, boolean enabled) { + this.encoding = encoding; + + this.env = env; + this.enabled = enabled; + } + + public JupyterInputStream(Charset encoding) { + this(encoding, null, false); + } + + public JupyterInputStream(ShellReplyEnvironment env, boolean enabled) { + this(JupyterSocket.UTF_8, env, enabled); + } + + public void setEnv(ShellReplyEnvironment env) { + this.env = env; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public void retractEnv(ShellReplyEnvironment env) { + if (this.env == env) + this.env = null; + } + + public boolean isAttached() { + return this.env != null; + } + + public Charset getEncoding() { + return encoding; + } + + public boolean isEnabled() { + return enabled; + } + + private byte[] readFromFrontend() { + if (this.enabled) + return this.env.readFromStdIn().getBytes(this.encoding); + return new byte[0]; + } + + @Override + public synchronized int read() { + if (this.data == null) { + if (this.env != null) { + //Buffer is empty and there is an environment to read from so + //ask the frontend for input + this.data = this.readFromFrontend(); + this.bufferPos = 0; + } else { + return -1; + } + } + if (this.bufferPos >= this.data.length) { + this.data = null; + if (this.env != null && this.enabled) { + this.data = this.readFromFrontend(); + this.bufferPos = 0; + } else { + return -1; + } + } + + return this.data[this.bufferPos++]; + } + + @Override + public int read(byte[] into, int intoOffset, int len) { + Objects.requireNonNull(into, "Target buffer cannot be null"); + + if (intoOffset < 0) + throw new IndexOutOfBoundsException("intoOffset must be >= 0 but was " + intoOffset); + else if (len < 0) + throw new IndexOutOfBoundsException("len must be >= 0 but was " + len); + else if (len > into.length - intoOffset) + throw new IndexOutOfBoundsException(String.format("Reading len (%d) bytes starting at %d would overflow the buffer.", len, intoOffset)); + + // If the request for some reason asks for 0 bytes then we don't have + // to do anything. + if (len == 0) + return 0; + + // If the first read "ends" then the entire read "ends". Otherwise + // any extra we can batch into this read is great! + int c = this.read(); + if (c == -1) + return -1; + + // Save the first read character, the rest will start at `intoOffset + 1`. + into[intoOffset] = (byte) c; + + // Check how much we can read without blocking. + int available = this.available(); + + // If no extra characters are available immediately then we will stop here + // with only the single first character read. + if (available <= 0) + return 1; + + // If the entire `len` is available in the buffer then that is how much + // we will read. Otherwise we only want to read the amount available so that + // there is no extra blocking read. + int amountToTakeFromBuffer = Math.min(available, len); + + System.arraycopy( + // Copy from the buffered data starting at the current position. + this.data, this.bufferPos, + // Copy into the given buffer starting at `intoOffset + 1` because + // we already read a single character. Don't worry about indexing + // issues as these were checked at the start. + into, intoOffset + 1, + // Copy whatever amount we decided we could take without blocking + // while remaining <= `len`. + amountToTakeFromBuffer + ); + + // Make sure to mark the amount we have taken from the buffer. + this.bufferPos += amountToTakeFromBuffer; + + // We have read what we copied into the buffer plus the initial single + // character that was read. + return amountToTakeFromBuffer + 1; + } + + @Override + public int available() { + return (this.data != null ? this.data.length : 0) - this.bufferPos; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/JupyterOutputStream.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/JupyterOutputStream.java new file mode 100644 index 0000000..b0959ae --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/JupyterOutputStream.java @@ -0,0 +1,45 @@ +package io.github.spencerpark.jupyter.channels; + +import java.io.ByteArrayOutputStream; +import java.util.function.BiConsumer; + +public class JupyterOutputStream extends ByteArrayOutputStream { + private static final int INITIAL_BUFFER_CAP = 1024; + + private ShellReplyEnvironment env; + private final BiConsumer write; + + public JupyterOutputStream(ShellReplyEnvironment env, BiConsumer write) { + super(INITIAL_BUFFER_CAP); + this.env = env; + this.write = write; + } + + public JupyterOutputStream(BiConsumer write) { + this(null, write); + } + + public void setEnv(ShellReplyEnvironment env) { + this.env = env; + } + + public void retractEnv(ShellReplyEnvironment env) { + if (this.env == env) + this.env = null; + } + + public boolean isAttached() { + return this.env != null; + } + + @Override + public void flush() { + if (this.env != null) { + String contents = new String(super.buf, 0, super.count, JupyterSocket.UTF_8); + if (!contents.isEmpty()) + this.write.accept(this.env, contents); + } + + super.reset(); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/JupyterSocket.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/JupyterSocket.java new file mode 100644 index 0000000..621e867 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/JupyterSocket.java @@ -0,0 +1,175 @@ +package io.github.spencerpark.jupyter.channels; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.google.gson.reflect.TypeToken; +import io.github.spencerpark.jupyter.kernel.ExpressionValue; +import io.github.spencerpark.jupyter.kernel.KernelConnectionProperties; +import io.github.spencerpark.jupyter.kernel.history.HistoryEntry; +import io.github.spencerpark.jupyter.messages.*; +import io.github.spencerpark.jupyter.messages.adapters.*; +import io.github.spencerpark.jupyter.messages.publish.PublishStatus; +import io.github.spencerpark.jupyter.messages.reply.ErrorReply; +import io.github.spencerpark.jupyter.messages.request.HistoryRequest; +import org.zeromq.SocketType; +import org.zeromq.ZMQ; + +import java.lang.reflect.Type; +import java.nio.charset.Charset; +import java.util.*; +import java.util.logging.Logger; + +public abstract class JupyterSocket extends ZMQ.Socket { + protected static String formatAddress(String transport, String ip, int port) { + return transport + "://" + ip + ":" + Integer.toString(port); + } + + public static final Charset ASCII = Charset.forName("ascii"); + public static final Charset UTF_8 = Charset.forName("utf8"); + + private static final byte[] IDENTITY_BLOB_DELIMITER = "".getBytes(ASCII); // Comes from a python bytestring + private static final Gson replyGson = new GsonBuilder() + .registerTypeAdapter(HistoryEntry.class, HistoryEntryAdapter.INSTANCE) + .registerTypeAdapter(ExpressionValue.class, ExpressionValueAdapter.INSTANCE) + .create(); + private static final Gson gson = new GsonBuilder() + .registerTypeAdapter(KernelTimestamp.class, KernelTimestampAdapter.INSTANCE) + .registerTypeAdapter(Header.class, HeaderAdapter.INSTANCE) + .registerTypeAdapter(MessageType.class, MessageTypeAdapter.INSTANCE) + .registerTypeAdapter(PublishStatus.class, PublishStatusAdapter.INSTANCE) + .registerTypeAdapter(HistoryRequest.class, HistoryRequestAdapter.INSTANCE) + .registerTypeHierarchyAdapter(ReplyType.class, new ReplyTypeAdapter(replyGson)) + //.setPrettyPrinting() + .create(); + private static final JsonParser json = new JsonParser(); + private static final byte[] EMPTY_JSON_OBJECT = "{}".getBytes(UTF_8); + private static final Type JSON_OBJ_AS_MAP = new TypeToken>() { + }.getType(); + + public static final Logger JUPYTER_LOGGER = Logger.getLogger("Jupyter"); + + protected final ZMQ.Context ctx; + protected final HMACGenerator hmacGenerator; + protected final Logger logger; + protected boolean closed; + + protected JupyterSocket(ZMQ.Context context, SocketType type, HMACGenerator hmacGenerator, Logger logger) { + super(context, type); + this.ctx = context; + this.hmacGenerator = hmacGenerator; + logger.setParent(JUPYTER_LOGGER); + this.logger = logger; + this.closed = false; + } + + public abstract void bind(KernelConnectionProperties connProps); + + public synchronized Message readMessage() { + if (this.closed) + return null; + + List identities = new LinkedList<>(); + byte[] identity = super.recv(); + while (!Arrays.equals(IDENTITY_BLOB_DELIMITER, identity)) { + identities.add(identity); + identity = super.recv(); + } + + //A hex string + String receivedSig = super.recvStr(); + + byte[] headerRaw = super.recv(); + byte[] parentHeaderRaw = super.recv(); + byte[] metadataRaw = super.recv(); + byte[] contentRaw = super.recv(); + + List blobs = new LinkedList<>(); + while (super.hasReceiveMore()) blobs.add(super.recv()); + + String calculatedSig = this.hmacGenerator.calculateSignature(headerRaw, parentHeaderRaw, metadataRaw, contentRaw); + + if (calculatedSig != null && !calculatedSig.equals(receivedSig)) + throw new SecurityException("Message received had invalid signature"); + + Header header = gson.fromJson(new String(headerRaw, UTF_8), Header.class); + + Header parentHeader = null; + JsonElement parentHeaderJson = json.parse(new String(parentHeaderRaw, UTF_8)); + if (parentHeaderJson.isJsonObject() && parentHeaderJson.getAsJsonObject().size() > 0) + parentHeader = gson.fromJson(parentHeaderJson, Header.class); + + Map metadata = gson.fromJson(new String(metadataRaw, UTF_8), JSON_OBJ_AS_MAP); + Object content = gson.fromJson(new String(contentRaw, UTF_8), header.getType().getContentType()); + if (content instanceof ErrorReply) + header = new Header<>(header.getId(), header.getUsername(), header.getSessionId(), header.getTimestamp(), header.getType().error(), header.getVersion()); + + @SuppressWarnings("unchecked") + Message message = new Message(identities, header, parentHeader, metadata, content, blobs); + + logger.finer(() -> "Received from " + super.base().getSocketOptx(zmq.ZMQ.ZMQ_LAST_ENDPOINT) + ":\n" + gson.toJson(message)); + + return message; + } + + @SuppressWarnings("unchecked") + public Message readMessage(MessageType type) { + Message message = readMessage(); + if (message.getHeader().getType() != type) { + throw new RuntimeException("Expected a " + type + " message but received a " + message.getHeader().getType() + " message."); + } + return (Message) message; + } + + public synchronized void sendMessage(Message message) { + if (this.closed) + return; + + byte[] headerRaw = gson.toJson(message.getHeader()).getBytes(UTF_8); + byte[] parentHeaderRaw = message.hasParentHeader() + ? gson.toJson(message.getParentHeader()).getBytes(UTF_8) + : EMPTY_JSON_OBJECT; + byte[] metadata = message.hasMetadata() + ? gson.toJson(message.getMetadata()).getBytes(UTF_8) + : EMPTY_JSON_OBJECT; + byte[] content = gson.toJson(message.getContent()).getBytes(UTF_8); + + String hmac = hmacGenerator.calculateSignature(headerRaw, parentHeaderRaw, metadata, content); + + logger.finer(() -> "Sending to " + super.base().getSocketOptx(zmq.ZMQ.ZMQ_LAST_ENDPOINT) + ":\n" + gson.toJson(message)); + + message.getIdentities().forEach(super::sendMore); + super.sendMore(IDENTITY_BLOB_DELIMITER); + super.sendMore(hmac.getBytes(ASCII)); + super.sendMore(headerRaw); + super.sendMore(parentHeaderRaw); + super.sendMore(metadata); + + if (message.getBlobs() == null) + super.send(content); + else { + super.sendMore(content); + //The last call needs to be a "send" call so as long as "blobs.hasNext()" + //there will be something sent later and so the call needs to be "sendMore" + Iterator blobs = message.getBlobs().iterator(); + byte[] blob; + while (blobs.hasNext()) { + blob = blobs.next(); + if (blobs.hasNext()) + super.sendMore(blob); + else + super.send(blob); + } + } + } + + @Override + public void close() { + super.close(); + this.closed = true; + } + + public void waitUntilClose() { + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/Loop.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/Loop.java new file mode 100644 index 0000000..344afe2 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/Loop.java @@ -0,0 +1,126 @@ +package io.github.spencerpark.jupyter.channels; + +import java.util.Queue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.function.LongSupplier; +import java.util.function.ToLongFunction; +import java.util.logging.Logger; + +public class Loop extends Thread { + private final Logger logger; + + private volatile boolean running = false; + private final LongSupplier loopBody; + + private volatile Runnable onCloseCb; + private volatile ToLongFunction onErrorCb; + private final Queue runNextQueue; + + public Loop(String name, long sleep, Runnable target) { + this(name, () -> { + target.run(); + return sleep; + }); + } + + public Loop(String name, LongSupplier target) { + super(name); + + this.loopBody = target; + + this.runNextQueue = new LinkedBlockingQueue<>(); + + this.logger = Logger.getLogger("Loop-" + name); + } + + public void onClose(Runnable callback) { + if (this.onCloseCb != null) { + Runnable oldCallback = this.onCloseCb; + this.onCloseCb = () -> { + oldCallback.run(); + callback.run(); + }; + } else { + this.onCloseCb = callback; + } + } + + public void onError(ToLongFunction callback) { + if (this.onErrorCb == null) { + this.onErrorCb = callback; + return; + } + + // Adding a second handler will only be invoked if the + // previous one throws (or rethrows) the incoming exception. + // The callback is invoked with the rethrown exception. + ToLongFunction oldCallback = this.onErrorCb; + this.onErrorCb = t -> { + try { + return oldCallback.applyAsLong(t); + } catch (Throwable tPrime) { + return callback.applyAsLong(tPrime); + } + }; + } + + public void doNext(Runnable next) { + this.runNextQueue.offer(next); + } + + @Override + public void run() { + Runnable next; + while (this.running) { + long sleep; + try { + // Run the loop body + sleep = this.loopBody.getAsLong(); + + // Run all queued tasks + while ((next = this.runNextQueue.poll()) != null) + next.run(); + } catch (Throwable t) { + if (this.onErrorCb != null) + sleep = this.onErrorCb.applyAsLong(t); + else + throw t; + } + + if (sleep > 0) { + try { + Thread.sleep(sleep); + } catch (InterruptedException e) { + this.logger.info("Loop interrupted. Stopping..."); + this.running = false; + } + } else if (sleep < 0) { + this.logger.info("Loop interrupted by a negative sleep request. Stopping..."); + this.running = false; + } + } + + this.logger.info("Running loop shutdown callback."); + + if (this.onCloseCb != null) + this.onCloseCb.run(); + this.onCloseCb = null; + + this.logger.info("Loop stopped."); + } + + @Override + public synchronized void start() { + this.logger.info("Loop starting..."); + + this.running = true; + super.start(); + + this.logger.info("Loop started."); + } + + public void shutdown() { + this.running = false; + this.logger.info("Loop shutdown."); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/ReplyEnvironment.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/ReplyEnvironment.java new file mode 100644 index 0000000..17a0011 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/ReplyEnvironment.java @@ -0,0 +1,60 @@ +package io.github.spencerpark.jupyter.channels; + +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.Message; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.reply.ErrorReply; + +public interface ReplyEnvironment { + void publish(Message msg); + + void reply(Message msg); + + /** + * Defer the next message send until {@link #resolveDeferrals()}. Deferrals + * are resolve in a Last In First Out (LIFO) order. + *

+ * The use case that inspired this functionality is the busy-idle protocol + * component required by Jupyter. + * + *

+     *      ShellReplyEnvironment env = ...;
+     *
+     *      env.setStatusBusy();
+     *      env.defer().setStatusIdle(); //Push idle message to defer stack
+     *
+     *      env.defer().reply(new ExecuteReply(...)); //Push reply to stack
+     *
+     *      env.writeToStdOut("Test"); //Write "Test" to std out now
+     *
+     *      env.resolveDeferrals();
+     *      //Send the reply
+     *      //Send the idle message
+     * 
+ * + * @return this instance for call chaining + */ + ReplyEnvironment defer(); + + /** + * Defer an arbitrary action. See {@link #defer()} but instead of + * deferring the next message send, defer a specific action. + * + * @param action the action to run when the deferrals are resolved + */ + void defer(Runnable action); + + void resolveDeferrals(); + + > void publish(T content); + + > void reply(T content); + + void replyError(MessageType type, ErrorReply error); + + void setStatusBusy(); + + void setStatusIdle(); + + void setBusyDeferIdle(); +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/ShellChannel.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/ShellChannel.java new file mode 100644 index 0000000..4af0fd9 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/ShellChannel.java @@ -0,0 +1,106 @@ +package io.github.spencerpark.jupyter.channels; + +import io.github.spencerpark.jupyter.kernel.KernelConnectionProperties; +import io.github.spencerpark.jupyter.messages.HMACGenerator; +import io.github.spencerpark.jupyter.messages.Message; +import org.zeromq.SocketType; +import org.zeromq.ZMQ; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class ShellChannel extends JupyterSocket { + private static final long SHELL_DEFAULT_LOOP_SLEEP_MS = 50; + private static final AtomicInteger SHELL_ID = new AtomicInteger(); + + private volatile Loop ioloop; + + private final boolean isControl; + private final JupyterConnection connection; + private final long sleep; + + public ShellChannel(ZMQ.Context context, HMACGenerator hmacGenerator, boolean isControl, JupyterConnection connection, long sleep) { + super(context, SocketType.ROUTER, hmacGenerator, Logger.getLogger(isControl ? "ControlChannel" : "ShellChannel")); + this.isControl = isControl; + this.connection = connection; + this.sleep = sleep; + } + + public ShellChannel(ZMQ.Context context, HMACGenerator hmacGenerator, boolean isControl, JupyterConnection connection) { + this(context, hmacGenerator, isControl, connection, SHELL_DEFAULT_LOOP_SLEEP_MS); + } + + private boolean isBound() { + return this.ioloop != null; + } + + @Override + @SuppressWarnings("unchecked") + public void bind(KernelConnectionProperties connProps) { + if (this.isBound()) + throw new IllegalStateException("Shell channel already bound"); + + String channelThreadName = "Shell-" + SHELL_ID.getAndIncrement(); + String addr = JupyterSocket.formatAddress(connProps.getTransport(), connProps.getIp(), + isControl ? connProps.getControlPort() : connProps.getShellPort()); + + logger.log(Level.INFO, String.format("Binding %s to %s.", channelThreadName, addr)); + super.bind(addr); + + ZMQ.Poller poller = super.ctx.poller(1); + poller.register(this, ZMQ.Poller.POLLIN); + + this.ioloop = new Loop(channelThreadName, this.sleep, () -> { + int events = poller.poll(0); + if (events > 0) { + Message message = super.readMessage(); + + ShellHandler handler = connection.getHandler(message.getHeader().getType()); + if (handler != null) { + super.logger.info("Handling message: " + message.getHeader().getType().getName()); + ShellReplyEnvironment env = connection.prepareReplyEnv(this, message); + try { + handler.handle(env, message); + } catch (Exception e) { + super.logger.log(Level.SEVERE, "Unhandled exception handling " + message.getHeader().getType().getName() + ". " + e.getClass().getSimpleName() + " - " + e.getLocalizedMessage()); + } finally { + env.resolveDeferrals(); + } + if (env.isMarkedForShutdown()) { + super.logger.info(channelThreadName + " shutting down connection as environment was marked for shutdown."); + this.connection.close(); + } + } else { + super.logger.log(Level.SEVERE, "Unhandled message: " + message.getHeader().getType().getName()); + } + } + }); + + this.ioloop.onClose(() -> { + logger.log(Level.INFO, channelThreadName + " shutdown."); + this.ioloop = null; + }); + + this.ioloop.start(); + + logger.log(Level.INFO, "Polling on " + channelThreadName); + } + + @Override + public void close() { + if (this.isBound()) + this.ioloop.shutdown(); + + super.close(); + } + + @Override + public void waitUntilClose() { + if (this.ioloop != null) { + try { + this.ioloop.join(); + } catch (InterruptedException ignored) { } + } + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/ShellHandler.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/ShellHandler.java new file mode 100644 index 0000000..6a53e7f --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/ShellHandler.java @@ -0,0 +1,8 @@ +package io.github.spencerpark.jupyter.channels; + +import io.github.spencerpark.jupyter.messages.Message; + +@FunctionalInterface +public interface ShellHandler { + public void handle(ShellReplyEnvironment env, Message message); +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/ShellReplyEnvironment.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/ShellReplyEnvironment.java new file mode 100644 index 0000000..27ac09b --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/ShellReplyEnvironment.java @@ -0,0 +1,49 @@ +package io.github.spencerpark.jupyter.channels; + +import io.github.spencerpark.jupyter.messages.MessageContext; +import io.github.spencerpark.jupyter.messages.publish.PublishStream; + +public class ShellReplyEnvironment extends DefaultReplyEnvironment { + private final StdinChannel stdin; + + private boolean requestShutdown = false; + + protected ShellReplyEnvironment(ShellChannel shell, StdinChannel stdin, JupyterSocket iopub, MessageContext context) { + super(shell, iopub, context); + this.stdin = stdin; + } + + @Override + public ShellReplyEnvironment defer() { + super.defer(); + return this; + } + + public void markForShutdown() { + this.requestShutdown = true; + } + + public boolean isMarkedForShutdown() { + return this.requestShutdown; + } + + public void writeToStdOut(String msg) { + publish(new PublishStream(PublishStream.StreamType.OUT, msg)); + } + + public void writeToStdErr(String msg) { + publish(new PublishStream(PublishStream.StreamType.ERR, msg)); + } + + public String readFromStdIn(String prompt, boolean isPassword) { + return this.stdin.getInput(super.getContext(), prompt, isPassword); + } + + public String readFromStdIn(String prompt) { + return this.readFromStdIn(prompt, false); + } + + public String readFromStdIn() { + return this.readFromStdIn("", false); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/StdinChannel.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/StdinChannel.java new file mode 100644 index 0000000..8c8f4f5 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/channels/StdinChannel.java @@ -0,0 +1,51 @@ +package io.github.spencerpark.jupyter.channels; + +import io.github.spencerpark.jupyter.kernel.KernelConnectionProperties; +import io.github.spencerpark.jupyter.messages.HMACGenerator; +import io.github.spencerpark.jupyter.messages.Message; +import io.github.spencerpark.jupyter.messages.MessageContext; +import io.github.spencerpark.jupyter.messages.reply.InputReply; +import io.github.spencerpark.jupyter.messages.request.InputRequest; +import org.zeromq.SocketType; +import org.zeromq.ZMQ; + +import java.util.logging.Level; +import java.util.logging.Logger; + +public class StdinChannel extends JupyterSocket { + public StdinChannel(ZMQ.Context context, HMACGenerator hmacGenerator) { + super(context, SocketType.ROUTER, hmacGenerator, Logger.getLogger("StdinChannel")); + } + + @Override + public void bind(KernelConnectionProperties connProps) { + String addr = JupyterSocket.formatAddress(connProps.getTransport(), connProps.getIp(), connProps.getStdinPort()); + + logger.log(Level.INFO, String.format("Binding stdin to %s.", addr)); + super.bind(addr); + } + + /** + * Ask the frontend for input. + *

+ * Do not ask for input if an execute request has `allow_stdin=False` + * + * @param context a message that the request with input was invoked by such as an execute request + * @param prompt a prompt string for the front end to include with the input request + * @param isPasswordRequest a flag specifying if the input request is for a password, if so + * the frontend should obscure the user input (for example with password + * dots or not echoing the input) + * + * @return the input string from the frontend. + */ + public synchronized String getInput(MessageContext context, String prompt, boolean isPasswordRequest) { + InputRequest content = new InputRequest(prompt, isPasswordRequest); + Message request = new Message<>(context, InputRequest.MESSAGE_TYPE, content); + + super.sendMessage(request); + + Message reply = super.readMessage(InputReply.MESSAGE_TYPE); + + return reply.getContent().getValue() + System.lineSeparator(); + } +} diff --git a/src/main/java/io/github/spencerpark/jupyter/kernel/BaseKernel.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/BaseKernel.java similarity index 100% rename from src/main/java/io/github/spencerpark/jupyter/kernel/BaseKernel.java rename to basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/BaseKernel.java diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/DisplayStream.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/DisplayStream.java new file mode 100644 index 0000000..ccf2809 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/DisplayStream.java @@ -0,0 +1,41 @@ +package io.github.spencerpark.jupyter.kernel; + +import io.github.spencerpark.jupyter.channels.ShellReplyEnvironment; +import io.github.spencerpark.jupyter.kernel.display.DisplayData; +import io.github.spencerpark.jupyter.messages.publish.PublishDisplayData; +import io.github.spencerpark.jupyter.messages.publish.PublishUpdateDisplayData; + +public class DisplayStream { + private ShellReplyEnvironment env; + + protected void setEnv(ShellReplyEnvironment env) { + this.env = env; + } + + protected void retractEnv(ShellReplyEnvironment env) { + if (this.env == env) + this.env = null; + } + + public boolean isAttached() { + return this.env != null; + } + + public void display(DisplayData data) { + if (this.env != null) + this.env.publish(new PublishDisplayData(data)); + } + + public void updateDisplay(DisplayData data) { + if (!data.hasDisplayId()) + throw new IllegalArgumentException("Data must have a display_id in order to update an existing display."); + + if (this.env != null) + this.env.publish(new PublishUpdateDisplayData(data)); + } + + public void updateDisplay(String id, DisplayData data) { + data.setDisplayId(id); + this.updateDisplay(data); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/ExpressionValue.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/ExpressionValue.java new file mode 100644 index 0000000..c3af9aa --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/ExpressionValue.java @@ -0,0 +1,71 @@ +package io.github.spencerpark.jupyter.kernel; + +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.kernel.display.DisplayData; + +import java.util.List; + +public abstract class ExpressionValue { + + private ExpressionValue() { } // Seal the class + + /** + * Check if this {@link ExpressionValue} is a {@link ExpressionValue.Success Success} + * or not (an {@link ExpressionValue.Error Error}. If this method returns {@code true} + * then this object can be safely cast to a {@link ExpressionValue.Success} or if {@code false} + * then {@link ExpressionValue.Error}. + * + * @return true if this values is an instance of {@link ExpressionValue.Success} and + * false if {@link ExpressionValue.Error}. + */ + public abstract boolean isSuccess(); + + public static class Error extends ExpressionValue { + @SerializedName("ename") + protected final String errName; + @SerializedName("evalue") + protected final String errMsg; + @SerializedName("traceback") + protected final List stacktrace; + + public Error(String errName, String errMsg, List stacktrace) { + this.errName = errName; + this.errMsg = errMsg; + this.stacktrace = stacktrace; + } + + @Override + public boolean isSuccess() { + return false; + } + + public String getErrName() { + return this.errName; + } + + public String getErrMsg() { + return this.errMsg; + } + + public List getStacktrace() { + return this.stacktrace; + } + } + + public static class Success extends ExpressionValue { + protected final DisplayData data; + + public Success(DisplayData data) { + this.data = data; + } + + @Override + public boolean isSuccess() { + return true; + } + + public DisplayData getData() { + return this.data; + } + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/JupyterIO.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/JupyterIO.java new file mode 100644 index 0000000..1f02bfd --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/JupyterIO.java @@ -0,0 +1,68 @@ +package io.github.spencerpark.jupyter.kernel; + +import io.github.spencerpark.jupyter.channels.JupyterInputStream; +import io.github.spencerpark.jupyter.channels.JupyterOutputStream; +import io.github.spencerpark.jupyter.channels.JupyterSocket; +import io.github.spencerpark.jupyter.channels.ShellReplyEnvironment; + +import java.io.InputStream; +import java.io.PrintStream; +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; + +public class JupyterIO { + private final JupyterOutputStream jupyterOut; + private final JupyterOutputStream jupyterErr; + private final JupyterInputStream jupyterIn; + + public final DisplayStream display; + + public final PrintStream out; + public final PrintStream err; + public final InputStream in; + + public JupyterIO(Charset encoding) { + this.jupyterOut = new JupyterOutputStream(ShellReplyEnvironment::writeToStdOut); + this.jupyterErr = new JupyterOutputStream(ShellReplyEnvironment::writeToStdErr); + this.jupyterIn = new JupyterInputStream(encoding); + + this.display = new DisplayStream(); + + try { + this.out = new PrintStream(this.jupyterOut, true, encoding.name()); + this.err = new PrintStream(this.jupyterErr, true, encoding.name()); + this.in = this.jupyterIn; + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("Couldn't lookup the charset by name even though it is already a charset...", e); + } + } + + public JupyterIO() { + this(JupyterSocket.UTF_8); + } + + public boolean isAttached() { + return this.jupyterOut.isAttached() + && this.jupyterErr.isAttached() + && this.jupyterIn.isAttached() + && this.display.isAttached(); + } + + protected void setEnv(ShellReplyEnvironment env) { + this.jupyterOut.setEnv(env); + this.jupyterErr.setEnv(env); + this.jupyterIn.setEnv(env); + this.display.setEnv(env); + } + + protected void retractEnv(ShellReplyEnvironment env) { + this.jupyterOut.retractEnv(env); + this.jupyterErr.retractEnv(env); + this.jupyterIn.retractEnv(env); + this.display.retractEnv(env); + } + + protected void setJupyterInEnabled(boolean enabled) { + this.jupyterIn.setEnabled(enabled); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/KernelConnectionProperties.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/KernelConnectionProperties.java new file mode 100644 index 0000000..4624a3c --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/KernelConnectionProperties.java @@ -0,0 +1,111 @@ +package io.github.spencerpark.jupyter.kernel; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.messages.HMACGenerator; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +public class KernelConnectionProperties { + + public static KernelConnectionProperties parse(String raw) { + return new Gson().fromJson(raw, KernelConnectionProperties.class); + } + + private String ip; + + @SerializedName("control_port") + private int controlPort; + @SerializedName("shell_port") + private int shellPort; + @SerializedName("stdin_port") + private int stdinPort; + @SerializedName("hb_port") + private int hbPort; + @SerializedName("iopub_port") + private int iopubPort; + + private String transport; + + @SerializedName("signature_scheme") + private String signatureScheme; + private String key; + + private KernelConnectionProperties() { + } + + public KernelConnectionProperties(String ip, int controlPort, int shellPort, int stdinPort, int hbPort, int iopubPort, String transport, String signatureScheme, String key) { + this.ip = ip; + this.controlPort = controlPort; + this.shellPort = shellPort; + this.stdinPort = stdinPort; + this.hbPort = hbPort; + this.iopubPort = iopubPort; + this.transport = transport; + this.signatureScheme = signatureScheme; + this.key = key; + } + + public String getIp() { + return ip; + } + + public int getControlPort() { + return controlPort; + } + + public int getShellPort() { + return shellPort; + } + + public int getStdinPort() { + return stdinPort; + } + + public int getHbPort() { + return hbPort; + } + + public int getIopubPort() { + return iopubPort; + } + + public String getTransport() { + return transport; + } + + public String getSignatureScheme() { + return signatureScheme; + } + + public String getKey() { + return key; + } + + public HMACGenerator createHMACGenerator() throws InvalidKeyException, NoSuchAlgorithmException { + if (key == null || key.isEmpty()) + return HMACGenerator.NO_AUTH_INSTANCE; + else + return new HMACGenerator(signatureScheme, key); + } + + public String toJsonString() { + return new Gson().toJson(this); + } + + @Override + public String toString() { + return "KernelConnectionProperties{" + + "ip='" + ip + '\'' + + ", controlPort=" + controlPort + + ", shellPort=" + shellPort + + ", stdinPort=" + stdinPort + + ", hbPort=" + hbPort + + ", iopubPort=" + iopubPort + + ", transport='" + transport + '\'' + + ", signatureScheme='" + signatureScheme + '\'' + + ", key='" + key + '\'' + + '}'; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/LanguageInfo.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/LanguageInfo.java new file mode 100644 index 0000000..71acf3a --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/LanguageInfo.java @@ -0,0 +1,219 @@ +package io.github.spencerpark.jupyter.kernel; + +import com.google.gson.annotations.SerializedName; + +import java.util.Map; + +public class LanguageInfo { + public static class Help { + protected String text; + protected String url; + + public Help(String text, String url) { + this.text = text; + this.url = url; + } + + public String getText() { + return text; + } + + public String getUrl() { + return url; + } + } + + public static class Builder { + private final String name; + private String version = null; + private String mimetype = "text/plain"; + private String fileExt = ".txt"; + private String pygmentsLexer = null; + private Object codemirrorMode = null; + private String exporter = null; + + public Builder(String name) { + this.name = name; + } + + /** + * Set the version for the language described by this info. It + * is recommended to be a semantic version (eg. 1.2.3) + * + * @param version the version string + * + * @return this builder for chaining + */ + public Builder version(String version) { + this.version = version; + return this; + } + + /** + * Set the mimetype for scripts written in this language. For example + * {@code text/html} or {@code application/javascript}. + * + * @param mimetype the mimetype for scripts written in this language. + * + * @return this builder for chaining + */ + public Builder mimetype(String mimetype) { + this.mimetype = mimetype; + return this; + } + + /** + * Set the file extension for scripts written in this language. For + * example {@code .py} or {@code .mlod}. This allows for a "Download as" + * menu option for this language. + * + * @param ext the file extension including the dot + * + * @return this builder for chaining + */ + public Builder fileExtension(String ext) { + this.fileExt = ext; + return this; + } + + /** + * Set the {@code pygments} lexer for syntax highlighting. By default + * it will be the language name. Use this to set it to something + * different. + *

+ * A list of the default installed lexers can be found + * on the pygments website + * + * @param lexer the name of the lexer + * + * @return this builder for chaining + */ + public Builder pygments(String lexer) { + this.pygmentsLexer = lexer; + return this; + } + + /** + * Set the {@code codemirror} mode for syntax highlighting in the + * notebook. By default it will be the language name. Use this to set it + * to something different. See default modes + * and the codemirror mode option + *

+ * This may also be a mimetype or a language config (see {@link #codemirror(Map)}) + * + * @param mode the code mirror mode + * + * @return this builder for chaining + */ + public Builder codemirror(String mode) { + this.codemirrorMode = mode; + return this; + } + + /** + * Set the {@code codemirror} mode for syntax highlighting in the + * notebook. By default it will be the the language name. Use this to set it + * to something different. For setting the mode by name use {@link #codemirror(String)}. + *

+ * This is a language config + * + * @param mode the code mirror mode config. Must contain a {@code "name"} key + * + * @return this builder for chaining + */ + public Builder codemirror(Map mode) { + this.codemirrorMode = mode; + return this; + } + + /** + * Set the exported for scripts written in this language. By default it just uses + * the {@code "script"} exporter which exports all of the code cells into a file. + * + * @param exporter the name of the exporter if a custom one is also being loaded by + * the kernel + * + * @return this builder for chaining + */ + public Builder exporter(String exporter) { + this.exporter = exporter; + return this; + } + + public LanguageInfo build() { + return new LanguageInfo(name, version, mimetype, fileExt, pygmentsLexer, codemirrorMode, exporter); + } + } + + protected final String name; + + /** + * Semantic version string. X.Y.Z. Language version + */ + protected final String version; + + protected String mimetype; + + @SerializedName("file_extension") + protected String fileExtension; + + /** + * If not defined defaults to {@link #name} + */ + @SerializedName("pygments_lexer") + protected String pygmentsLexer; + + /** + * If not defined defaults to {@link #name}. + *

+ * It may be a {@link String} describing the name of the lexer or the + * MIME type. Otherwise it may be a json object with a `name` field for + * the name/MIME type of the lexer as well as other configuration options. + */ + @SerializedName("codemirror_mode") + protected Object codemirrorMode; + + /** + * If not defined defaults to the general 'script' + */ + @SerializedName("nbconvert_exporter") + protected String nbconvertExporter; + + public LanguageInfo(String name, String version, String mimetype, String fileExtension, String pygmentsLexer, Object codemirrorMode, String nbconvertExporter) { + this.name = name; + this.version = version; + this.mimetype = mimetype; + this.fileExtension = fileExtension; + this.pygmentsLexer = pygmentsLexer; + this.codemirrorMode = codemirrorMode; + this.nbconvertExporter = nbconvertExporter; + } + + public String getName() { + return name; + } + + public String getVersion() { + return version; + } + + public String getMimetype() { + return mimetype; + } + + public String getFileExtension() { + return fileExtension; + } + + public String getPygmentsLexer() { + return pygmentsLexer; + } + + public Object getCodemirrorMode() { + return codemirrorMode; + } + + public String getNbconvertExporter() { + return nbconvertExporter; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/ReplacementOptions.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/ReplacementOptions.java new file mode 100644 index 0000000..dc99c14 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/ReplacementOptions.java @@ -0,0 +1,28 @@ +package io.github.spencerpark.jupyter.kernel; + +import java.util.List; + +public class ReplacementOptions { + private final List replacements; + + private final int sourceStart; + private final int sourceEnd; + + public ReplacementOptions(List replacements, int sourceStart, int sourceEnd) { + this.replacements = replacements; + this.sourceStart = sourceStart; + this.sourceEnd = sourceEnd; + } + + public List getReplacements() { + return replacements; + } + + public int getSourceStart() { + return sourceStart; + } + + public int getSourceEnd() { + return sourceEnd; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/comm/Comm.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/comm/Comm.java new file mode 100644 index 0000000..bf291eb --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/comm/Comm.java @@ -0,0 +1,97 @@ +package io.github.spencerpark.jupyter.kernel.comm; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.github.spencerpark.jupyter.messages.Message; +import io.github.spencerpark.jupyter.messages.comm.CommCloseCommand; +import io.github.spencerpark.jupyter.messages.comm.CommMsgCommand; + +import java.util.List; +import java.util.Map; + +public abstract class Comm { + private final CommManager manager; + private final String id; + private final String targetName; + + private boolean closed = false; + + public Comm(CommManager manager, String id, String targetName) { + this(manager, id, targetName, null); + } + + public Comm(CommManager manager, String id, String targetName, JsonElement initializationData) { + this.manager = manager; + this.id = id; + this.targetName = targetName; + } + + public String getID() { + return this.id; + } + + public String getTargetName() { + return this.targetName; + } + + public boolean isClosed() { + return closed; + } + + public void send(JsonObject data) { + this.manager.messageComm(this.getID(), data); + } + + public void send(JsonObject data, Map metadata, List blobs) { + this.manager.messageComm(this.getID(), data, metadata, blobs); + } + + /** + * A callback for when the kernel receives a message who's destination is + * this comm. This handler gets access to the entire message so that if desired + * the comms may make use of the low level blob segments or want to make use of + * the parent, identities, etc. + *

+ * The data that most comms would be interested in is the {@link CommMsgCommand#getData()} + * which is the payload attached to a message when sent via the frontend's {@code comm.send({})} + * function. Since this can be any arbitrary JSON serializable thing is is given as a + * {@link com.google.gson.JsonElement}. It is recommended to deserialize it this handler to avoid + * passing the JsonElement to too many other classes in case the serialization library changes in + * the future. + * + * @param message the message received from the frontend that is targeted at this comm + */ + protected abstract void onMessage(Message message); + + /** + * Invoked when this comm is closed. The similar {@link Comm#close()} method is used + * to close this comm where as this {@code onClose} is a callback to clean up this comm + * when it is closed either by this side or as the result of a message from the frontend. + *

+ *
If {@code sending}:
+ *
+ * then this method is free to modify the {@code closeMessage} to add any additional data + * to the {@link CommCloseCommand#getData()} or the {@link Message#getBlobs()}. The message + * will be sent after the execution of this method. + *
+ *
If {@code !sending}:
+ *
+ * then this method may be interested in using the destructuring data in {@link CommCloseCommand#getData()} + * that is snt by the front-end upon triggering th close. + *
+ *
+ * + * @param closeMessage the message triggering the close if from. This may contain some destructuring + * parameters in {@link CommCloseCommand#getData()} if the frontend component + * decided to send something. + * @param sending a boolean flag signaling if the close is the being triggered by this side + * ({@code sending == true}) or from the front-end ({@code sending == false}). + */ + protected abstract void onClose(Message closeMessage, boolean sending); + + public final void close() { + if (this.closed) return; + this.manager.closeComm(this); + this.closed = true; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/comm/CommFactory.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/comm/CommFactory.java new file mode 100644 index 0000000..55d4b1b --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/comm/CommFactory.java @@ -0,0 +1,20 @@ +package io.github.spencerpark.jupyter.kernel.comm; + +import io.github.spencerpark.jupyter.messages.Message; +import io.github.spencerpark.jupyter.messages.comm.CommOpenCommand; + +@FunctionalInterface +public interface CommFactory { + + /** + * Create a new {@link Comm} and optionally attach data to the open message before it is sent. + * @param manager the {@link CommManager} that will be responsible for transporting messages + * to and from the created {@link Comm}. + * @param id the id of the new {@link Comm} + * @param target the name of the target on the front-end to communicate with + * @param openMessageToSend the message that will be sent after creating the comm. There are 2 places to attach + * additional data to the send + * @return a new comm. If data must be immediately sent it should be appended to the {@code openMessageToSend}. + */ + public T produce(CommManager manager, String id, String target, Message openMessageToSend); +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/comm/CommManager.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/comm/CommManager.java new file mode 100644 index 0000000..939d76e --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/comm/CommManager.java @@ -0,0 +1,256 @@ +package io.github.spencerpark.jupyter.kernel.comm; + +import com.google.gson.JsonObject; +import io.github.spencerpark.jupyter.channels.JupyterSocket; +import io.github.spencerpark.jupyter.channels.ReplyEnvironment; +import io.github.spencerpark.jupyter.messages.Message; +import io.github.spencerpark.jupyter.messages.MessageContext; +import io.github.spencerpark.jupyter.messages.comm.CommCloseCommand; +import io.github.spencerpark.jupyter.messages.comm.CommMsgCommand; +import io.github.spencerpark.jupyter.messages.comm.CommOpenCommand; +import io.github.spencerpark.jupyter.messages.reply.CommInfoReply; +import io.github.spencerpark.jupyter.messages.request.CommInfoRequest; + +import java.util.*; + +/** + * A CommManager is responsible for keeping track of a group of comms created by any + * of their registered {@link CommTarget}s. + */ +public class CommManager implements Iterable { + protected Map targets; + protected Map comms; + protected JupyterSocket iopub; + protected MessageContext context; + + public CommManager() { + this.targets = new HashMap<>(); + this.comms = new HashMap<>(); + this.iopub = null; + } + + public void setIOPubChannel(JupyterSocket iopub) { + this.iopub = iopub; + } + + public void setMessageContext(MessageContext context) { + this.context = context; + } + + @Override + public Iterator iterator() { + return this.comms.values().iterator(); + } + + /** + * Lookup a comm by its unique id. If the id is unknown + * to this manager it may return null. + * + * @param id the comm id + * + * @return the {@link Comm} with the associated id or null if the id is unknown + */ + public Comm getCommByID(String id) { + return this.comms.get(id); + } + + /** + * Register a new comm that this manager should forward messages to in the event that + * it receives one addressed to a comm with the the {@code comm}'s id. + * + * @param comm the comm to register with this handler + */ + public void registerComm(Comm comm) { + this.comms.put(comm.getID(), comm); + } + + /** + * Unregister a comm from this manager. This prevents the manager from forwarding messages + * to a previously {@link #registerComm(Comm) registered} comm with the {@code id}. + * + * @param id the id of the destination to unregister + * + * @return the comm that was unregistered or null if nothing was unregistered. + */ + public Comm unregisterComm(String id) { + return this.comms.remove(id); + } + + /** + * Open a communication with the frontend. In the event that the front end does + * not have a target registered with the {@code targetName} the expected behaviour is for + * it to send a {@code comm_close} message as soon as possible but there is never any + * confirmation that the comm is open. + * + * @param targetName the name of the target on the frontend to message + * @param factory a comm producer. This is used to create the comm. + * @param the type of {@link Comm} that the {@code factory} produces. + * + * @return a comm who's {@link Comm#send(JsonObject) send} method is targeted at a new comm + * create on the frontend by the target registered with the {@code targetName} or + * {@code null} if the manager could not open the comm. + *

+ * The latter may happen if the manager is not connected to the frontend + */ + public T openComm(String targetName, CommFactory factory) { + if (this.iopub == null) + return null; + String id = UUID.randomUUID().toString(); + + CommOpenCommand content = new CommOpenCommand(id, targetName, new JsonObject()); + Message message = new Message<>(this.context, CommOpenCommand.MESSAGE_TYPE, content); + + T comm = factory.produce(this, id, targetName, message); + + this.iopub.sendMessage(message); + + this.registerComm(comm); + + return comm; + } + + /** + * Send a message to a comm's frontend component. See {@link Comm#send(JsonObject, Map, List)} as well as + * {@link Comm#send(JsonObject)} which is more likely the method to use as the metadata and blobs are lower level + * constructs exposed for completeness but are often not necessary. + *

+ * See {@link #messageComm(String, JsonObject)} for the higher level partner to this method. + * + * @param commID the id of the target comm (or the id of the sending comm as both share the same id) + * @param data the data to send to the frontend + * @param metadata any metadata to attach to the message being sent. May be {@code null} if no metadata is present. + * @param blobs any additional raw data to attach to the message. May be {@code null} if no blobs are present. + */ + public void messageComm(String commID, JsonObject data, Map metadata, List blobs) { + CommMsgCommand content = new CommMsgCommand(commID, data); + Message message = new Message<>(this.context, CommMsgCommand.MESSAGE_TYPE, content, blobs, metadata); + + this.iopub.sendMessage(message); + } + + /** + * Send a message to a comm's frontend component. See {@link Comm#send(JsonObject)} + * + * @param commID the id of the target comm (or the id of the sending comm as both share the same id) + * @param data the data to send to the frontend + */ + public void messageComm(String commID, JsonObject data) { + this.messageComm(commID, data, null, null); + } + + /** + * Close both sides of a communication. This should be invoked whenever a comm is no longer + * is use or destroyed as a counterpart is living in the frontend. Failing to invoke this may + * leak comm instances on the frontend as well as possibly leaving the manager holding on to + * dead references. See {@link Comm#close()}. + * + * @param comm the comm to close + */ + public void closeComm(Comm comm) { + CommCloseCommand content = new CommCloseCommand(comm.getID(), new JsonObject()); + Message message = new Message<>(this.context, CommCloseCommand.MESSAGE_TYPE, content); + + this.iopub.sendMessage(message); + + Comm unregistered = this.unregisterComm(comm.getID()); + if (unregistered != null) + unregistered.onClose(message, true); + } + + /** + * Register a target for comm creation at the frontend's request. A target must + * first be registered in the kernel so that the frontend may ask to create a new + * comm for speaking with the target. + * + * @param targetName the name of the target which must be specified by frontend's + * opening up the communication + * @param target a {@link CommTarget} responsible for creating new comms at this + * target name + */ + public void registerTarget(String targetName, CommTarget target) { + this.targets.put(targetName, target); + } + + /** + * Unregister a target. This doesn't unregister comms with that target name but rather + * prevents the target from creating anything new. + *

+ * See also {@link #registerTarget(String, CommTarget)} + * + * @param targetName the name of the target to unregister + */ + public void unregisterTarget(String targetName) { + this.targets.remove(targetName); + } + + /** + * Lookup a target with the given name. See {@link #registerTarget(String, CommTarget)} + * + * @param targetName the target name to lookup + * + * @return the {@link CommTarget} registered with the {@code targetName} + */ + public CommTarget getTarget(String targetName) { + return this.targets.get(targetName); + } + + // Default comm message handlers. These shouldn't need to be overridden but are more like + // lambda targets that capture this comm manager in it's scope. + + public void handleCommOpenCommand(ReplyEnvironment env, Message commOpenCommandMessage) { + CommOpenCommand openCommand = commOpenCommandMessage.getContent(); + + env.setBusyDeferIdle(); + + CommTarget target = this.getTarget(openCommand.getTargetName()); + if (target == null) { + CommCloseCommand closeCommand = new CommCloseCommand(openCommand.getCommID(), new JsonObject()); + env.publish(closeCommand); + } else { + Comm comm = target.createComm(this, openCommand.getCommID(), openCommand.getTargetName(), commOpenCommandMessage); + this.registerComm(comm); + } + } + + public void handleCommMsgCommand(ReplyEnvironment env, Message commMsgCommandMessage) { + CommMsgCommand msgCommand = commMsgCommandMessage.getContent(); + + env.setBusyDeferIdle(); + + Comm comm = this.getCommByID(msgCommand.getCommID()); + if (comm != null) { + comm.onMessage(commMsgCommandMessage); + } + } + + public void handleCommCloseCommand(ReplyEnvironment env, Message commCloseCommandMessage) { + CommCloseCommand closeCommand = commCloseCommandMessage.getContent(); + + env.setBusyDeferIdle(); + + Comm comm = this.unregisterComm(closeCommand.getCommID()); + if (comm != null) { + comm.onClose(commCloseCommandMessage, false); + } + } + + public void handleCommInfoRequest(ReplyEnvironment env, Message commInfoRequestMessage) { + CommInfoRequest request = commInfoRequestMessage.getContent(); + + env.setBusyDeferIdle(); + + Map comms = new LinkedHashMap<>(); + + String targetNameFilter = request.getTargetName(); + if (targetNameFilter != null) { + this.forEach(comm -> { + if (targetNameFilter.equals(comm.getTargetName())) + comms.put(comm.getID(), new CommInfoReply.CommInfo(comm.getTargetName())); + }); + } else { + this.forEach(comm -> comms.put(comm.getID(), new CommInfoReply.CommInfo(comm.getTargetName()))); + } + + env.reply(new CommInfoReply(comms)); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/comm/CommTarget.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/comm/CommTarget.java new file mode 100644 index 0000000..7d6193f --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/comm/CommTarget.java @@ -0,0 +1,27 @@ +package io.github.spencerpark.jupyter.kernel.comm; + +import io.github.spencerpark.jupyter.messages.Message; +import io.github.spencerpark.jupyter.messages.comm.CommOpenCommand; + +@FunctionalInterface +public interface CommTarget { + /** + * Create a new comm as a result of the frontend making a {@code comm_open} request. This + * is designed to be a constructor reference to a class that extends {@link Comm} overriding + * the {@link Comm#onMessage(Message)}. + * + * For example a plain no-op handler may be {@code CommTarget noop = Comm::new;}. Which would create + * comms that do nothing when the receive a message. + * + * @param commManager the manager that will be responsible for forwarding messages from + * the frontend + * @param id the id for the comm. This will be unique. + * @param targetName the name of this target + * @param msg the entire message that the manager received commanding it to open the comm. This may + * carry additional data in the messages content. Specifically the {@link + * CommOpenCommand#getData() data field}. + * + * @return the newly created comm + */ + public Comm createComm(CommManager commManager, String id, String targetName, Message msg); +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/DisplayData.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/DisplayData.java new file mode 100644 index 0000000..e3d1f99 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/DisplayData.java @@ -0,0 +1,175 @@ +package io.github.spencerpark.jupyter.kernel.display; + +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.kernel.display.mime.MIMEType; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +public class DisplayData { + public static final String DISPLAY_ID_KEY = "display_id"; + + public static final DisplayData EMPTY = new DisplayData(Collections.emptyMap()); + + public static final DisplayData EMPTY_STRING = new DisplayData(""); + + public static DisplayData emptyIfNull(DisplayData bundle) { + return bundle == null ? EMPTY : bundle; + } + + private final Map data; + + private Map metadata = new LinkedHashMap<>(); + + @SerializedName("transient") + private Map transientData = null; + + private DisplayData(Map data) { + this.data = data; + } + + public DisplayData(DisplayData that) { + this.data = that.data; + this.metadata = that.metadata; + this.transientData = that.transientData; + } + + public DisplayData(String textData) { + this(); + this.putText(textData); + } + + public DisplayData() { + this(new LinkedHashMap<>()); + } + + private void ensureTransientDataInitialized() { + if (this.transientData == null) + this.transientData = new LinkedHashMap<>(); + } + + public void putData(String mimeType, Object data) { + this.data.put(mimeType, data); + } + + public void putMetaData(String key, Object value) { + this.metadata.put(key, value); + } + + /** + * Add a data point (key-value pair) to the transient data dictionary. This is + * not always applicable but in such cases there is no harm in adding it but the + * front-end may just ignore it. + *

+ * As of writing this the only transient data key supported by iPython is + * {@code display_id} which is only used in the {@code display_data} and + * {@code update_display_data} messages. + *

+ * Third parties may utilize this for other purposes as well. + * + * @param key the data key + * @param value the data value + */ + public void putTransientData(String key, Object value) { + this.ensureTransientDataInitialized(); + this.transientData.put(key, value); + } + + public void putData(MIMEType type, Object data) { + this.putData(type.toString(), data); + } + + public void putMetaData(MIMEType type, Object data) { + this.putMetaData(type.toString(), data); + } + + public void putData(MIMEType type, Object data, Object metadata) { + this.putData(type, data); + this.putMetaData(type, metadata); + } + + public Object getData(MIMEType type) { + return this.data.get(type.toString()); + } + + public boolean hasDataForType(MIMEType type) { + return this.data.containsKey(type.toString()); + } + + public void assign(DisplayData data) { + this.data.putAll(data.data); + + if (this.metadata == null) + this.metadata = data.metadata; + else if (data.metadata != null) + this.metadata.putAll(data.metadata); + + if (this.transientData == null) + this.transientData = data.transientData; + else if (data.transientData != null) + this.transientData.putAll(data.transientData); + } + + public void setDisplayId(String id) { + this.putTransientData(DISPLAY_ID_KEY, id); + } + + public boolean hasDisplayId() { + return this.transientData != null + && this.transientData.containsKey(DISPLAY_ID_KEY); + } + + public String getDisplayId() { + if (this.transientData == null) return null; + + Object id = this.transientData.get(DISPLAY_ID_KEY); + if (id == null) return null; + + return String.valueOf(id); + } + + public void putText(String text) { + this.putData("text/plain", text); + } + + public void putHTML(String html) { + this.putData("text/html", html); + } + + public void putLatex(String latex) { + this.putData("text/latex", latex); + } + + /** + * Add some latex math to the output. + * + * @param math the latex math code EXCLUDING the starting and + * trailing {@code $$} or other latex math mode switchers + */ + public void putMath(String math) { + this.putLatex("$$" + math + "$$"); + } + + public void putMarkdown(String markdown) { + this.putData("text/markdown", markdown); + } + + public void putJavaScript(String javascript) { + this.putData("application/javascript", javascript); + } + + public void putJSON(String json) { + this.putData("application/json", json); + } + + public void putJSON(String json, boolean expanded) { + this.putJSON(json); + this.putMetaData("expanded", expanded); + } + + public void putSVG(String svg) { + this.putData("image/svg+xml", svg); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/DisplayDataRenderable.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/DisplayDataRenderable.java new file mode 100644 index 0000000..5d9b8ba --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/DisplayDataRenderable.java @@ -0,0 +1,80 @@ +package io.github.spencerpark.jupyter.kernel.display; + +import io.github.spencerpark.jupyter.kernel.display.mime.MIMEType; + +import java.util.Collections; +import java.util.Set; +import java.util.function.BiConsumer; + +@FunctionalInterface +public interface DisplayDataRenderable { + static Set ANY = Collections.singleton(MIMEType.ANY); + + /** + * Specifies a set of {@link MIMEType}s that this class may be rendered as. + *

+ * NOTE: Specifying the supported render types does not prevent {@link #render(RenderContext)} + * from being invoked with other types. Implementations should handle these cases gracefully + * with a no-op. + *

+ * When used in conjunction with {@link Renderer} this annotation provides information to the + * routing algorithm. + *

+ * In particular {@link Renderer#render(Object)} will request that the object + * is rendered as the {@link #getPreferredRenderTypes()} types. + * + * @return The set of {@link MIMEType}s that this object can be rendered as. + */ + public default Set getSupportedRenderTypes() { + return DisplayDataRenderable.ANY; + } + + /** + * Species a subset of {@link #getSupportedRenderTypes()} in which this class + * prefers to be rendered as. + *

+ * For example a class may support rendering as {@code application/json} and + * {@code application/xml} but when given a choice should only be rendered as + * {@code application/json}. In this case {@code getPreferredRenderTypes()} should + * be {@code "application/json"}. + * + * @return a set of {@link MIMEType}s that this class + * prefers to be rendered as. + */ + public default Set getPreferredRenderTypes() { + return this.getSupportedRenderTypes(); + } + + /** + * Render this object into the {@link RenderContext#getOutputContainer()} based on the requested types + * from the {@code context}. Implementations may also use the {@code context} + * to delegate rendering. + *

+ * Implementations should test if the {@link RenderContext#wantsDataRenderedAs(MIMEType)} + * for all of the supported types and if true store the rendered data in the container + * at the resolved MIME type, not the supported one. Use the type returned + * by {@link RenderContext#resolveRequestedType(MIMEType)}. + *

+ * For convenience implementations may use {@link RenderContext#renderIfRequested(MIMEType, BiConsumer)} + * which streamlines these operations: + *

+     * {@code private static MIMEType PNG = MIMEType.parse("image/png");
+     *     private String renderAsPNG() {...}
+     *     public void render(RenderContext context) {
+     *         context.renderIfRequested(PNG, (type, out) -> {
+     *             out.putData(type, this.renderAsPNG());
+     *         });
+     *         // or to store the return value of renderAsPNG at the correct
+     *         // type use
+     *         context.renderIfRequested(PNG, this::renderAsPNG);
+     *         // or if you need the type to make a rendering decision and then store
+     *         // the return value at the correct type
+     *         context.renderIfRequested(PNG, type -> this.renderAsPNG());
+     *     }
+     * }
+     * 
+ * + * @param context the context that the render is taking place in. + */ + public void render(RenderContext context); +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/MIMESuffixAssociation.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/MIMESuffixAssociation.java new file mode 100644 index 0000000..74b4988 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/MIMESuffixAssociation.java @@ -0,0 +1,18 @@ +package io.github.spencerpark.jupyter.kernel.display; + +import io.github.spencerpark.jupyter.kernel.display.mime.MIMEType; + +@FunctionalInterface +public interface MIMESuffixAssociation { + static final MIMESuffixAssociation NONE = s -> null; + + /** + * Returns the delegate MIME type associated with a suffix. For example the + * suffix {@code json} is associated with the {@code application/json} type. + * + * @param suffix the suffix to resolve + * + * @return the delegate {@link MIMEType} + */ + MIMEType resolveSuffix(String suffix); +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/RenderContext.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/RenderContext.java new file mode 100644 index 0000000..1fae2c4 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/RenderContext.java @@ -0,0 +1,130 @@ +package io.github.spencerpark.jupyter.kernel.display; + +import io.github.spencerpark.jupyter.kernel.display.mime.MIMEType; + +import java.util.Collections; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.function.Supplier; + +public class RenderContext { + private final RenderRequestTypes requestedTypes; + private final Renderer renderer; + private final Map params; + private final DisplayData out; + + public RenderContext(RenderRequestTypes requestedTypes, Renderer renderer, Map params, DisplayData out) { + this.requestedTypes = requestedTypes; + this.renderer = renderer; + this.params = params; + this.out = out; + } + + public Renderer getRenderer() { + return this.renderer; + } + + public DisplayData getOutputContainer() { + return this.out; + } + + public Map getParams() { + return Collections.unmodifiableMap(this.params); + } + + public Object getParameter(String key) { + return this.params.get(key); + } + + public Object getParameter(String key, Object defaultValue) { + return this.params.getOrDefault(key, defaultValue); + } + + public String getParameterAsString(String key) { + Object value = this.getParameter(key); + return value == null ? null : String.valueOf(value); + } + + public String getParameterAsString(String key, String defaultValue) { + String value = this.getParameterAsString(key); + return value == null ? defaultValue : value; + } + + public Integer getParameterAsInt(String key) { + Object value = this.getParameter(key); + return value == null + ? null + : value instanceof Number + ? ((Number) value).intValue() + : Integer.parseInt(String.valueOf(value)); + } + + public Integer getParameterAsInt(String key, Integer defaultValue) { + Integer value = this.getParameterAsInt(key); + return value == null ? defaultValue : value; + } + + public Double getParameterAsDouble(String key) { + Object value = this.getParameter(key); + return value == null + ? null + : value instanceof Number + ? ((Number) value).doubleValue() + : Double.parseDouble(String.valueOf(value)); + } + + public Double getParameterAsDouble(String key, Double defaultValue) { + Double value = this.getParameterAsDouble(key); + return value == null ? defaultValue : value; + } + + public Boolean getParameterAsBoolean(String key) { + Object value = this.getParameter(key); + return value == null + ? null + : value instanceof Boolean + ? (Boolean) value + : Boolean.parseBoolean(String.valueOf(value)); + } + + public Boolean getParameterAsBoolean(String key, Boolean defaultValue) { + Boolean value = this.getParameterAsBoolean(key); + return value == null ? defaultValue : value; + } + + public boolean wantsDataRenderedAs(MIMEType type) { + return this.requestedTypes.resolveSupportedType(type) != null; + } + + public MIMEType resolveRequestedType(MIMEType supported) { + return this.requestedTypes.resolveSupportedType(supported); + } + + public boolean renderIfRequested(MIMEType supportedType, BiConsumer renderFunction) { + MIMEType resolvedType = this.requestedTypes.resolveSupportedType(supportedType); + if (resolvedType != null) { + renderFunction.accept(resolvedType, this.getOutputContainer()); + return true; + } + return false; + } + + public boolean renderIfRequested(MIMEType supportedType, Function renderFunction) { + MIMEType resolvedType = this.requestedTypes.resolveSupportedType(supportedType); + if (resolvedType != null) { + this.getOutputContainer().putData(resolvedType, renderFunction.apply(resolvedType)); + return true; + } + return false; + } + + public boolean renderIfRequested(MIMEType supportedType, Supplier render) { + MIMEType resolvedType = this.requestedTypes.resolveSupportedType(supportedType); + if (resolvedType != null) { + this.getOutputContainer().putData(resolvedType, render.get()); + return true; + } + return false; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/RenderFunction.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/RenderFunction.java new file mode 100644 index 0000000..db17669 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/RenderFunction.java @@ -0,0 +1,6 @@ +package io.github.spencerpark.jupyter.kernel.display; + +@FunctionalInterface +public interface RenderFunction { + void render(T data, RenderContext context); +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/RenderParams.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/RenderParams.java new file mode 100644 index 0000000..3d73074 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/RenderParams.java @@ -0,0 +1,58 @@ +package io.github.spencerpark.jupyter.kernel.display; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A utility class for inline map construction for use in the context of rendering. + * + * See: {@link Renderer#render(Object, Map)} and {@link Renderer#renderAs(Object, Map, String...)} + * which take a parameter map. + */ +public class RenderParams extends LinkedHashMap { + //TODO use the path map from MellowD to support a getAll query or one with wildcard patterns + public static class Param { + public final String key; + public final T value; + + public Param(String key, T value) { + this.key = key; + this.value = value; + } + } + + public static Param param(String key, T value) { + return new Param<>(key, value); + } + + public static RenderParams paramsOf(Param... params) { + RenderParams renderParams = new RenderParams(); + for (Param p : params) + renderParams.put(p.key, p.value); + return renderParams; + } + + public static RenderParams paramsOf(String key, Object value) { + RenderParams renderParams = new RenderParams(); + renderParams.put(key, value); + return renderParams; + } + + public RenderParams with(String key, Object value) { + this.put(key, value); + return this; + } + + public RenderParams with(Param param) { + this.put(param.key, param.value); + return this; + } + + public RenderParams and(String key, Object value) { + return with(key, value); + } + + public RenderParams and(Param param) { + return with(param); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/RenderRequestTypes.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/RenderRequestTypes.java new file mode 100644 index 0000000..fd42eab --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/RenderRequestTypes.java @@ -0,0 +1,185 @@ +package io.github.spencerpark.jupyter.kernel.display; + +import io.github.spencerpark.jupyter.kernel.display.mime.MIMEType; + +import java.util.*; + +/** + * A smarter set of {@link MIMEType}s. + */ +public class RenderRequestTypes { + public static class Builder { + private final MIMESuffixAssociation suffixAssociation; + + private boolean requestsWildcard; + private final Set entireGroupRequests; + private final Set requestedTypes; + + public Builder(MIMESuffixAssociation suffixAssociation) { + this.suffixAssociation = suffixAssociation; + + this.requestsWildcard = false; + this.entireGroupRequests = new LinkedHashSet<>(); + this.requestedTypes = new LinkedHashSet<>(); + } + + public Builder withType(String type) { + MIMEType mimeType = MIMEType.parse(type); + return this.withType(mimeType); + } + + public Builder withType(MIMEType type) { + if (type.isWildcard()) + this.requestsWildcard = true; + else if (!type.hasSubtype() || type.subtypeIsWildcard()) + this.entireGroupRequests.add(type.getGroup()); + else + this.requestedTypes.add(type); + return this; + } + + public RenderRequestTypes build() { + return new RenderRequestTypes( + this.suffixAssociation, + this.requestsWildcard, + this.requestsWildcard || this.entireGroupRequests.isEmpty() + ? Collections.emptySet() + : this.entireGroupRequests, + this.requestsWildcard || this.requestedTypes.isEmpty() + ? Collections.emptySet() + : this.requestedTypes + ); + } + } + + private final MIMESuffixAssociation suffixAssociation; + + private final boolean requestsWildcard; + private final Set entireGroupRequests; + private final Set requestedTypes; + private final Map> requestedTypesByGroup; + + private RenderRequestTypes(MIMESuffixAssociation suffixAssociation, boolean requestsWildcard, Set entireGroupRequests, Set requestedTypes) { + this.suffixAssociation = suffixAssociation; + this.requestsWildcard = requestsWildcard; + this.entireGroupRequests = entireGroupRequests; + this.requestedTypes = requestedTypes; + + this.requestedTypesByGroup = new LinkedHashMap<>(); + requestedTypes.forEach(t -> + this.requestedTypesByGroup.compute(t.getGroup(), (k, v) -> { + List l = v == null ? new LinkedList<>() : v; + l.add(t); + return l; + }) + ); + } + + /** + * Resolve the requested {@link MIMEType} from a supported type. This query usually returns + * {@code null} or the original {@code supportedType} except in special cases with the suffix. + *

+ * If the {@code supportedType} with the {@link MIMEType#getSuffix() suffix} dropped is requested + * then the resolved type is the {@code supportedType} with the {@link MIMEType#getSuffix() suffix} dropped. + *

+ * If the {@code supportedType}'s {@link MIMEType#getSuffix() suffix} has a {@link MIMESuffixAssociation#resolveSuffix(String) resolved suffix type} + * (like {@code +json} being compatible with {@code application/json}) and the {@link MIMESuffixAssociation#resolveSuffix(String) resolved suffix type} + * is requested, then the {@link MIMESuffixAssociation#resolveSuffix(String) resolved suffix type} is the resolved type. + * + * @param supportedType the type to resolve to one of the requested types. + * + * @return the requested type or {@code null} if the type is not requested. + */ + public MIMEType resolveSupportedType(MIMEType supportedType) { + if (supportedType.isWildcard() || supportedType.subtypeIsWildcard()) + throw new IllegalArgumentException("Cannot resolve type of wildcard MIME type: '" + supportedType.toString() + "'"); + + // Everything is supported + if (this.requestsWildcard) + return supportedType; + + // If the exact type is supported or the group is supported then the exact type + // is supported. + if (this.requestedTypes.contains(supportedType) + || this.entireGroupRequests.contains(supportedType.getGroup())) + return supportedType; + + if (supportedType.hasSuffix()) { + // If dropping the supported type without the suffix is supported then that + // is compatible and is the resolved type. + MIMEType withoutSuffix = supportedType.withoutSuffix(); + if (this.requestedTypes.contains(withoutSuffix)) + return withoutSuffix; + + // If the type association of the suffix is supported then use the association. + MIMEType suffixDelegate = this.suffixAssociation.resolveSuffix(supportedType.getSuffix()); + if (suffixDelegate != null && ( + this.requestedTypes.contains(suffixDelegate) + || this.entireGroupRequests.contains(suffixDelegate.getGroup()))) + return suffixDelegate; + } + + // The type is not supported + return null; + } + + public boolean isRequestedExactly(MIMEType type) { + return this.requestsWildcard + || this.entireGroupRequests.contains(type.getGroup()) + || this.requestedTypes.contains(type); + } + + public void removeFulfilledRequests(DisplayData out) { + this.requestedTypes.removeIf(t -> { + if (out.hasDataForType(t)) { + this.requestedTypesByGroup.compute(t.getGroup(), (k, v) -> { + if (v == null) return null; + v.remove(t); + return v.isEmpty() ? null : v; + }); + return true; + } + return false; + }); + } + + /** + * Check if the request wants something rendered as any of the supported types. + * + * @param supported a set of supported types + * + * @return true if any of the supported types is requested + */ + public boolean anyRequestedIsSupported(Set supported) { + // The request wants everything. As long as something is supported, it is requested. + if (this.requestsWildcard) + return !supported.isEmpty(); + + for (MIMEType t : supported) { + // If any request is supported then as long as the request is not empty, something + // is requested. + if (t.isWildcard() && !this.isEmpty()) + return true; + + // If an entire group is supported then as long as that group is requested or + // something requested has the same group, something is requested. + if (t.subtypeIsWildcard() && ( + this.entireGroupRequests.contains(t.getGroup()) + || this.requestedTypesByGroup.containsKey(t.getGroup()))) + return true; + + // If the supported type can be resolved then it must be requested. + if (this.resolveSupportedType(t) != null) + return true; + } + + // Nothing supported is requested. + return false; + } + + public boolean isEmpty() { + return !this.requestsWildcard + && this.entireGroupRequests.isEmpty() + && this.requestedTypes.isEmpty(); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/Renderer.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/Renderer.java new file mode 100644 index 0000000..d68b591 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/Renderer.java @@ -0,0 +1,277 @@ +package io.github.spencerpark.jupyter.kernel.display; + +import io.github.spencerpark.jupyter.kernel.display.mime.MIMEType; +import io.github.spencerpark.jupyter.kernel.util.InheritanceIterator; + +import java.util.*; + +/** + * A default renderer may be set and maps a group to a specific subtype. + *

+ * A suffix may be mapped to a type. + *

+ * A type has a default mime type (may be a list) and is always also rendered + * as text/plain with toString(). + *

+ * A type must also have other supported types. + *

+ * Objects that implement the render interface override their default renders + * but in the event that renderAs (or displayAs) is invoked the specified types + * override the defaults. + */ +public class Renderer { + private static class RenderFunctionProps { + private final RenderFunction function; + private final Set supportedTypes; + private final Set preferredTypes; + + public RenderFunctionProps(RenderFunction function, Set supportedTypes, Set preferredTypes) { + this.function = function; + this.supportedTypes = supportedTypes; + this.preferredTypes = preferredTypes; + } + + public RenderFunction getFunction() { + return function; + } + + public Set getSupportedTypes() { + return supportedTypes; + } + + public Set getPreferredTypes() { + return preferredTypes; + } + } + + public class RenderRegistration { + private final Set supported; + private final Set preferred; + private final Set> types; + + public RenderRegistration(Class type) { + this.supported = new LinkedHashSet<>(); + this.preferred = new LinkedHashSet<>(); + this.types = new LinkedHashSet<>(); + this.types.add(type); + } + + public RenderRegistration supporting(MIMEType... types) { + Collections.addAll(this.supported, types); + return this; + } + + public RenderRegistration preferring(MIMEType... types) { + supporting(types); + Collections.addAll(this.preferred, types); + return this; + } + + public RenderRegistration supporting(String... types) { + for (String type : types) + this.supported.add(MIMEType.parse(type)); + return this; + } + + public RenderRegistration preferring(String... types) { + supporting(types); + for (String type : types) + this.preferred.add(MIMEType.parse(type)); + return this; + } + + public RenderRegistration onType(Class type) { + this.types.add(type); + return this; + } + + public void register(RenderFunction function) { + Set supported = this.supported.isEmpty() ? DisplayDataRenderable.ANY : this.supported; + Set preferred = this.preferred.isEmpty() ? supported : this.preferred; + Renderer.this.register(supported, preferred, types, function); + } + } + + private final Map> renderFunctions; + private final Map suffixMappings; + + public Renderer() { + this.renderFunctions = new HashMap<>(); + this.suffixMappings = new HashMap<>(); + } + + public RenderRegistration createRegistration(Class type) { + return new RenderRegistration<>(type); + } + + public void register(Set supported, Set preferred, Set> types, RenderFunction function) { + RenderFunctionProps props = new RenderFunctionProps(function, supported, preferred); + + types.forEach(c -> this.renderFunctions.compute(c, (k, v) -> { + List functions = v != null ? v : new LinkedList<>(); + functions.add(props); + return functions; + })); + } + + private static DisplayData finalizeDisplayData(DisplayData data, Object value) { + if (!data.hasDataForType(MIMEType.TEXT_PLAIN)) + data.putText(String.valueOf(value)); + + return data; + } + + /** + * Render the object with the preferred render type. + *

+ * The rendering algorithm is as follows: + *

    + *
  1. + * The object is rendered as {@code text/plain} with {@link String#valueOf(Object)}. + *
  2. + *
  3. + * If the object is {@link DisplayDataRenderable} ask it to render itself as the {@link DisplayDataRenderable#getPreferredRenderTypes() preferred types}. + *
  4. + *
  5. + * Else iterate over the implemented with the {@link InheritanceIterator} until a render function is found. Use this + * function to render the object. + *
  6. + *
+ * + * @param value the object to render. + * @param params a map of parameters that render functions may use. + * + * @return the data container holding the rendered view of the {@code value}. + */ + @SuppressWarnings("unchecked") + public DisplayData render(Object value, Map params) { + DisplayData out = new DisplayData(); + + if (value instanceof DisplayDataRenderable) { + DisplayDataRenderable renderable = (DisplayDataRenderable) value; + + RenderRequestTypes.Builder requestTypes = new RenderRequestTypes.Builder(this.suffixMappings::get); + requestTypes.withType(MIMEType.TEXT_PLAIN); + renderable.getPreferredRenderTypes().forEach(requestTypes::withType); + + renderable.render(new RenderContext(requestTypes.build(), this, params, out)); + + return finalizeDisplayData(out, value); + } + + Iterator inheritedTypes = new InheritanceIterator(value.getClass()); + while (inheritedTypes.hasNext()) { + Class type = inheritedTypes.next(); + + List allRenderFunctionProps = this.renderFunctions.get(type); + if (allRenderFunctionProps != null && !allRenderFunctionProps.isEmpty()) { + for (RenderFunctionProps renderFunctionProps : allRenderFunctionProps) { + RenderRequestTypes.Builder requestTypes = new RenderRequestTypes.Builder(this.suffixMappings::get); + requestTypes.withType(MIMEType.TEXT_PLAIN); + renderFunctionProps.getPreferredTypes().forEach(requestTypes::withType); + + renderFunctionProps.getFunction().render( + value, + new RenderContext(requestTypes.build(), this, params, out) + ); + } + + return finalizeDisplayData(out, value); + } + } + + return finalizeDisplayData(out, value); + } + + /** + * A {@link #render(Object, Map)} variant that supplies an empty parameter map. + * + * @param value the object to render. + * + * @return a {@link DisplayData} container with all the rendered data. + */ + public DisplayData render(Object value) { + return render(value, new LinkedHashMap<>()); + } + + /** + * Render the object as the specified types if possible. + *

+ * The rendering algorithm is as follows: + *

    + *
  1. + * The object is rendered as {@code text/plain} with {@link String#valueOf(Object)} no + * matter what types are requested. + *
  2. + *
  3. + * If the object is {@link DisplayDataRenderable} and any of it's {@link DisplayDataRenderable#getSupportedRenderTypes() supported types} + * are requested, it is asked to render itself. + *
  4. + *
  5. + * While all of the requested types have not be rendered yet: + *
      + *
    1. + * For every type in the {@link InheritanceIterator}, apply the same scheme as step 2. + *
    2. + *
    3. + * Remove all rendered types from the request. + *
    4. + *
    + *
  6. + *
+ * + * @param value the object to render. + * @param params a map of parameters that render functions may use. + * @param types the {@link MIMEType#parse(String) MIME types} to render the object as. + * + * @return a {@link DisplayData} container with all the rendered data. + */ + @SuppressWarnings("unchecked") + public DisplayData renderAs(Object value, Map params, String... types) { + DisplayData out = new DisplayData(); + + RenderRequestTypes.Builder builder = new RenderRequestTypes.Builder(this.suffixMappings::get); + builder.withType(MIMEType.TEXT_PLAIN); + for (String type : types) + builder.withType(type); + + RenderRequestTypes requestTypes = builder.build(); + RenderContext context = new RenderContext(requestTypes, this, params, out); + + if (value instanceof DisplayDataRenderable) { + DisplayDataRenderable renderable = (DisplayDataRenderable) value; + if (requestTypes.anyRequestedIsSupported(renderable.getSupportedRenderTypes())) { + renderable.render(context); + requestTypes.removeFulfilledRequests(out); + } + } + + Iterator inheritedTypes = new InheritanceIterator(value.getClass()); + while (inheritedTypes.hasNext() && !requestTypes.isEmpty()) { + Class type = inheritedTypes.next(); + List allRenderFunctionProps = this.renderFunctions.get(type); + if (allRenderFunctionProps != null) { + for (RenderFunctionProps renderFunctionProps : allRenderFunctionProps) { + if (requestTypes.anyRequestedIsSupported(renderFunctionProps.getSupportedTypes())) { + renderFunctionProps.getFunction().render(value, context); + requestTypes.removeFulfilledRequests(out); + } + } + } + } + + return finalizeDisplayData(out, value); + } + + /** + * A {@link #renderAs(Object, Map, String...)} variant that supplies an empty parameter map. + * + * @param value the object to render. + * @param types the {@link MIMEType#parse(String) MIME types} to render the object as. + * + * @return a {@link DisplayData} container with all the rendered data. + */ + public DisplayData renderAs(Object value, String... types) { + return this.renderAs(value, new LinkedHashMap<>(), types); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/common/Image.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/common/Image.java new file mode 100644 index 0000000..d3bbbcb --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/common/Image.java @@ -0,0 +1,55 @@ +package io.github.spencerpark.jupyter.kernel.display.common; + +import io.github.spencerpark.jupyter.kernel.display.RenderContext; +import io.github.spencerpark.jupyter.kernel.display.Renderer; +import io.github.spencerpark.jupyter.kernel.display.mime.MIMEType; + +import javax.imageio.ImageIO; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Base64; + +public class Image { + public static final MIMEType PNG = MIMEType.IMAGE_PNG; + public static final MIMEType JPEG = MIMEType.IMAGE_JPEG; + public static final MIMEType GIF = MIMEType.IMAGE_GIF; + public static final MIMEType SVG = MIMEType.IMAGE_SVG; + + public static void registerAll(Renderer renderer) { + renderer.createRegistration(java.awt.image.RenderedImage.class) + .preferring(PNG) + .supporting(JPEG, GIF) + .register(Image::renderImage); + renderer.createRegistration(InputStream.class) + .preferring(PNG) + .supporting(JPEG, GIF) + .register(Image::renderImageFromStream); + } + + private static String imageTob64(java.awt.image.RenderedImage image, String fmt) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + try { + ImageIO.write(image, fmt, Base64.getEncoder().wrap(out)); + + return out.toString("UTF-8"); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static void renderImage(java.awt.image.RenderedImage data, RenderContext context) { + context.renderIfRequested(PNG, () -> imageTob64(data, "png")); + context.renderIfRequested(JPEG, () -> imageTob64(data, "jpeg")); + context.renderIfRequested(GIF, () -> imageTob64(data, "gif")); + } + + public static void renderImageFromStream(InputStream data, RenderContext context) { + try { + renderImage(ImageIO.read(data), context); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/common/Text.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/common/Text.java new file mode 100644 index 0000000..a92fff2 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/common/Text.java @@ -0,0 +1,34 @@ +package io.github.spencerpark.jupyter.kernel.display.common; + +import io.github.spencerpark.jupyter.kernel.display.RenderContext; +import io.github.spencerpark.jupyter.kernel.display.Renderer; +import io.github.spencerpark.jupyter.kernel.display.mime.MIMEType; + +public class Text { + public static MIMEType JS = MIMEType.APPLICATION_JAVASCRIPT; + public static MIMEType PLAIN = MIMEType.TEXT_PLAIN; + public static MIMEType MARKDOWN = MIMEType.TEXT_MARKDOWN; + public static MIMEType LATEX = MIMEType.TEXT_LATEX; + public static MIMEType HTML = MIMEType.TEXT_HTML; + public static MIMEType CSS = MIMEType.TEXT_CSS; + public static MIMEType SVG = MIMEType.IMAGE_SVG; + public static MIMEType JSON = MIMEType.APPLICATION_JSON; + + public static void registerAll(Renderer renderer) { + renderer.createRegistration(CharSequence.class) + .preferring(PLAIN) + .supporting(JS, MARKDOWN, LATEX, HTML, CSS, SVG) + .register(Text::renderCharSequence); + } + + public static void renderCharSequence(CharSequence data, RenderContext context) { + context.renderIfRequested(JS, () -> data); + context.renderIfRequested(PLAIN, () -> data); + context.renderIfRequested(MARKDOWN, () -> data); + context.renderIfRequested(LATEX, () -> data); + context.renderIfRequested(HTML, () -> data); + context.renderIfRequested(CSS, () -> data); + context.renderIfRequested(SVG, () -> data); + context.renderIfRequested(JSON, () -> data); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/common/Url.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/common/Url.java new file mode 100644 index 0000000..9fac1bd --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/common/Url.java @@ -0,0 +1,87 @@ +package io.github.spencerpark.jupyter.kernel.display.common; + +import io.github.spencerpark.jupyter.kernel.display.DisplayData; +import io.github.spencerpark.jupyter.kernel.display.RenderContext; +import io.github.spencerpark.jupyter.kernel.display.Renderer; +import io.github.spencerpark.jupyter.kernel.display.mime.MIMEType; + +import java.io.IOException; +import java.util.Collections; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public class Url { + public static String EMBED_KEY = "embed"; + public static String HTML_TAG_KEY = "url.html.tag"; + public static String HTML_SRC_ATTR_KEY = "url.html.src-attr"; + + public static void registerAll(Renderer renderer) { + renderer.createRegistration(java.net.URL.class) + .supporting(MIMEType.ANY) + .register(Url::renderUrl); + renderer.createRegistration(java.net.URLConnection.class) + .supporting(MIMEType.ANY) + .register((conn, ctx) -> renderUrl(conn.getURL(), ctx)); + } + + public static void renderUrl(java.net.URL url, RenderContext context) { + if (context.getParameterAsBoolean(EMBED_KEY, false)) { + try { + Object content = url.getContent(); + DisplayData rendered = context.getRenderer().render(content, context.getParams()); + context.getOutputContainer().assign(rendered); + } catch (IOException e) { + e.printStackTrace(); + } + } else { + context.renderIfRequested(MIMEType.TEXT_HTML, () -> { + String tag = context.getParameterAsString(HTML_TAG_KEY, "a"); + String srcAttr = context.getParameterAsString(HTML_SRC_ATTR_KEY, "href"); + return renderHTML(tag, srcAttr, url, Collections.emptyMap()); + }); + } + } + + private static final Set HTML_VOID_ELEMENTS = Set.of("area", "base", "br", "col", "embed", "hr", + "img", "input", "link", "meta", "param", "source", "track", "wbr"); + + private static String renderHTML(String tag, String srcAttr, java.net.URL url, Map attrs) { + String safeTag = sanitizeHtmlName(tag, "a"); + String safeSrcAttr = sanitizeHtmlName(srcAttr, "href"); + String externalForm = url.toExternalForm(); + StringBuilder html = new StringBuilder("<"); + html.append(safeTag); + html.append(" ").append(safeSrcAttr).append("=\"").append(escapeHtml(externalForm)).append('"'); + if (safeTag.equals("a") && !attrs.containsKey("target")) + html.append(" target=\"_blank\""); + attrs.forEach((attr, val) -> { + if (val != null) { + String safeAttr = sanitizeHtmlName(attr, attr); + html.append(" ").append(safeAttr).append("=\"").append(escapeHtml(val)).append('"'); + } + }); + if (HTML_VOID_ELEMENTS.contains(safeTag.toLowerCase(Locale.ROOT))) { + html.append(" />"); + } else { + html.append(">").append(escapeHtml(externalForm)).append("'); + } + return html.toString(); + } + + private static String sanitizeHtmlName(String value, String defaultValue) { + if (value == null || !value.matches("[A-Za-z][A-Za-z0-9-]*")) + return defaultValue; + return value; + } + + private static String escapeHtml(String value) { + if (value == null) + return ""; + return value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/mime/MIMEGroup.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/mime/MIMEGroup.java new file mode 100644 index 0000000..a0fcbe2 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/mime/MIMEGroup.java @@ -0,0 +1,112 @@ +package io.github.spencerpark.jupyter.kernel.display.mime; + +import java.util.Objects; + +public class MIMEGroup { + public enum Type { + APPLICATION, + AUDIO, + EXAMPLE, + FONT, + IMAGE, + MESSAGE, + MODEL, + MULTIPART, + TEXT, + VIDEO, + OTHER; + + private final String groupName; + + Type() { + this.groupName = this.name().toLowerCase(); + } + + public String groupName() { + return this.groupName; + } + + @Override + public String toString() { + return this.groupName; + } + } + + public static final MIMEGroup APPLICATION = new MIMEGroup(Type.APPLICATION); + public static final MIMEGroup AUDIO = new MIMEGroup(Type.AUDIO); + public static final MIMEGroup EXAMPLE = new MIMEGroup(Type.EXAMPLE); + public static final MIMEGroup FONT = new MIMEGroup(Type.FONT); + public static final MIMEGroup IMAGE = new MIMEGroup(Type.IMAGE); + public static final MIMEGroup MESSAGE = new MIMEGroup(Type.MESSAGE); + public static final MIMEGroup MODEL = new MIMEGroup(Type.MODEL); + public static final MIMEGroup MULTIPART = new MIMEGroup(Type.MULTIPART); + public static final MIMEGroup TEXT = new MIMEGroup(Type.TEXT); + public static final MIMEGroup VIDEO = new MIMEGroup(Type.VIDEO); + + public static MIMEGroup of(String name) { + switch (name.toLowerCase()) { + case "application": + return APPLICATION; + case "audio": + return AUDIO; + case "example": + return EXAMPLE; + case "font": + return FONT; + case "image": + return IMAGE; + case "message": + return MESSAGE; + case "model": + return MODEL; + case "multipart": + return MULTIPART; + case "text": + return TEXT; + case "video": + return VIDEO; + default: + return new MIMEGroup(name); + } + } + + private final String name; + private final Type type; + + private MIMEGroup(Type type) { + this.name = type.groupName(); + this.type = type; + } + + private MIMEGroup(String other) { + this.name = other; + this.type = Type.OTHER; + } + + public String getName() { + return name; + } + + public Type getType() { + return type; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MIMEGroup mimeGroup = (MIMEGroup) o; + return (this.type == mimeGroup.type && this.type != Type.OTHER) + || Objects.equals(this.name, mimeGroup.name); + } + + @Override + public int hashCode() { + return Objects.hash(name, type); + } + + @Override + public String toString() { + return this.name; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/mime/MIMESubtype.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/mime/MIMESubtype.java new file mode 100644 index 0000000..ed20879 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/mime/MIMESubtype.java @@ -0,0 +1,38 @@ +package io.github.spencerpark.jupyter.kernel.display.mime; + +public class MIMESubtype { + public static class Tree { + public static final Tree VENDOR = new Tree("vnd"); + public static final Tree PERSONAL = new Tree("prs"); + public static final Tree UNREGISTERED = new Tree("x"); + + public static Tree of(String name) { + switch (name.toLowerCase()) { + case "vnd": return VENDOR; + default: + return new Tree(name); + } + } + + private final String name; + + private Tree(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + @Override + public String toString() { + return getName() + "."; + } + } + + public enum Application { + JSON, + XML, + + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/mime/MIMESuffix.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/mime/MIMESuffix.java new file mode 100644 index 0000000..3697dcf --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/mime/MIMESuffix.java @@ -0,0 +1,67 @@ +package io.github.spencerpark.jupyter.kernel.display.mime; + +/** + * RFC 6839 for the + * +xml, +json, +ber, +der, +fastinfoset, +wbxml, +zip + *

+ * RFC 7049 for the + * +cbor + */ +public class MIMESuffix { + public static final MIMESuffix XML = new MIMESuffix("xml", MIMEType.APPLICATION_XML); + public static final MIMESuffix JSON = new MIMESuffix("json", MIMEType.APPLICATION_JSON); + public static final MIMESuffix BER = new MIMESuffix("ber", null); + public static final MIMESuffix DER = new MIMESuffix("der", null); + public static final MIMESuffix FASTINFOSET = new MIMESuffix("fastinfoset", MIMEType.APPLICATION_FASTINFOSET); + public static final MIMESuffix WBXML = new MIMESuffix("wbxml", MIMEType.APPLICATION_VND_WAP_WBXML); + public static final MIMESuffix ZIP = new MIMESuffix("zip", MIMEType.APPLICATION_ZIP); + public static final MIMESuffix CBOR = new MIMESuffix("cbor", MIMEType.APPLICATION_CBOR); + + public static MIMESuffix of(String name) { + if (name == null) return null; + switch (name.toLowerCase()) { + case "xml": + return XML; + case "json": + return JSON; + case "ber": + return BER; + case "der": + return DER; + case "fastinfoset": + return FASTINFOSET; + case "wbxml": + return WBXML; + case "zip": + return ZIP; + case "cbor": + return CBOR; + default: + return new MIMESuffix(name.toLowerCase(), null); + } + } + + public static MIMESuffix of(MIMEType type) { + return MIMESuffix.of(type.getSuffix()); + } + + private final String suffix; + private final MIMEType delegate; + + private MIMESuffix(String suffix, MIMEType delegate) { + this.suffix = suffix; + this.delegate = delegate; + } + + public String getSuffix() { + return this.suffix; + } + + public MIMEType getDelegate() { + return this.delegate; + } + + public boolean hasDelegate() { + return this.delegate != null; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/mime/MIMEType.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/mime/MIMEType.java new file mode 100644 index 0000000..023c3ca --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/mime/MIMEType.java @@ -0,0 +1,259 @@ +package io.github.spencerpark.jupyter.kernel.display.mime; + +import io.github.spencerpark.jupyter.kernel.util.CharPredicate; + +import java.util.Locale; +import java.util.Objects; + +public class MIMEType { + //TODO look into caching parsed strings in a weakmap? + + private static final CharPredicate RESTRICTED_NAME_CHAR = CharPredicate.builder() + .inRange('a', 'z') + .inRange('A', 'Z') + .inRange('0', '9') + .match("!#$&-^_") + .build(); + + private static final String WILDCARD = "*"; + + public static final MIMEType ANY = new MIMEType(WILDCARD, null, WILDCARD, null); + + public static final MIMEType APPLICATION_XML = MIMEType.parse("application/xml"); + public static final MIMEType APPLICATION_JSON = MIMEType.parse("application/json"); + public static final MIMEType APPLICATION_JAVASCRIPT = MIMEType.parse("application/javascript"); + public static final MIMEType APPLICATION_PDF = MIMEType.parse("application/pdf"); + + public static final MIMEType APPLICATION_FASTINFOSET = MIMEType.parse("application/fastinfoset"); + public static final MIMEType APPLICATION_VND_WAP_WBXML = MIMEType.parse("application/vnd.wap.wbxml"); + public static final MIMEType APPLICATION_ZIP = MIMEType.parse("application/zip"); + /** + * There is a cbor {@code <->} json conversion that can happen. + */ + public static final MIMEType APPLICATION_CBOR = MIMEType.parse("application/cbor"); + + public static final MIMEType TEXT_HTML = MIMEType.parse("text/html"); + public static final MIMEType TEXT_MARKDOWN = MIMEType.parse("text/markdown"); + public static final MIMEType TEXT_LATEX = MIMEType.parse("text/latex"); + public static final MIMEType TEXT_PLAIN = MIMEType.parse("text/plain"); + public static final MIMEType TEXT_CSS = MIMEType.parse("text/css"); + + public static final MIMEType IMAGE_PNG = MIMEType.parse("image/png"); + public static final MIMEType IMAGE_JPEG = MIMEType.parse("image/jpeg"); + public static final MIMEType IMAGE_GIF = MIMEType.parse("image/gif"); + public static final MIMEType IMAGE_SVG = MIMEType.parse("image/svg+xml"); + + /** + * Construct a {@link MIMEType} from a string representation. The grammar + * is from RFC 6838 Section 4.2. + *

+     *     type-name = restricted-name
+     *     subtype-name = restricted-name
+     *
+     *     restricted-name = restricted-name-first *126restricted-name-chars
+     *     restricted-name-first  = ALPHA / DIGIT
+     *     restricted-name-chars  = ALPHA / DIGIT / "!" / "#" /
+     *                              "$" / "&" / "-" / "^" / "_"
+     *     restricted-name-chars =/ "." ; Characters before first dot always
+     *                                  ; specify a facet name
+     *     restricted-name-chars =/ "+" ; Characters after last plus always
+     *                                  ; specify a structured syntax suffix
+     * 
+ * The parser makes some modifications to the specification: + *
    + *
  1. No length restriction on the segments
  2. + *
  3. A subtype may also match exactly "*"
  4. + *
+ * + * @param raw the MIME type represented by a string + * + * @return the {@link MIMEType} represented by the string + * + * @throws MIMETypeParseException if the string representation doesn't match + * the specification + */ + public static MIMEType parse(String raw) throws MIMETypeParseException { + if (WILDCARD.equals(raw)) + return ANY; + + String type = null; + String tree = null; + String subtype; + String suffix = null; + + int subtypeStart = 0; + int pos = -1; + + while (++pos < raw.length()) { + char c = raw.charAt(pos); + switch (c) { + case '+': + case '.': + continue; + } + if (RESTRICTED_NAME_CHAR.test(c)) + continue; + if (c != '/') + throw new MIMETypeParseException(raw, pos, String.format("Expected '/' but got %c", c)); + type = raw.substring(0, pos); + subtypeStart = pos + 1; + break; + } + + if (pos == raw.length()) { + return new MIMEType(raw, null, null, null); + } else if (subtypeStart + 1 == raw.length() && raw.charAt(pos + 1) == '*') { + return new MIMEType(type, null, WILDCARD, null); + } + + int lastSuffixStartPos = -1; + while (++pos < raw.length()) { + char c = raw.charAt(pos); + switch (c) { + case '.': + if (tree == null) { + tree = raw.substring(subtypeStart, pos); + subtypeStart = pos + 1; + } + continue; + case '+': + lastSuffixStartPos = pos; + continue; + } + if (RESTRICTED_NAME_CHAR.test(c)) + continue; + + throw new MIMETypeParseException(raw, pos, String.format("Unexpected char '%c'", c)); + } + + if (lastSuffixStartPos != -1) { + subtype = raw.substring(subtypeStart, lastSuffixStartPos); + suffix = raw.substring(lastSuffixStartPos + 1); + } else { + subtype = raw.substring(subtypeStart); + } + + return new MIMEType(type, tree, subtype, suffix); + } + + private final String group; + private final String tree; + private final String subtype; + private final String suffix; + + public MIMEType(String group, String tree, String subtype, String suffix) { + if (group == null) + throw new IllegalArgumentException("Group must be given."); + + this.group = group.toLowerCase(Locale.ENGLISH); + this.tree = tree != null ? tree.toLowerCase(Locale.ENGLISH) : null; + this.subtype = subtype != null ? subtype.toLowerCase(Locale.ENGLISH) : null; + this.suffix = suffix != null ? suffix.toLowerCase(Locale.ENGLISH) : null; + } + + public String getGroup() { + return group; + } + + public String getTree() { + return tree; + } + + public String getSubtype() { + return subtype; + } + + public String getSuffix() { + return suffix; + } + + public boolean hasTree() { + return this.tree != null; + } + + public boolean hasSubtype() { + return this.subtype != null; + } + + public boolean hasSuffix() { + return this.suffix != null; + } + + public MIMEType withoutSuffix() { + return !this.hasSuffix() + ? this + : new MIMEType(this.group, this.tree, this.subtype, null); + } + + public boolean subtypeIsWildcard() { + return WILDCARD.equals(this.subtype); + } + + public boolean isWildcard() { + return WILDCARD.equals(this.group); + } + + public boolean groupEquals(String group) { + return this.getGroup().equalsIgnoreCase(group); + } + + public boolean treeEquals(String tree) { + return this.hasTree() + ? this.getTree().equalsIgnoreCase(tree) + : tree == null; + } + + public boolean subtypeEquals(String subtype) { + return this.hasSubtype() + ? this.getSubtype().equalsIgnoreCase(subtype) + : subtype == null; + } + + public boolean suffixEquals(String suffix) { + return this.hasSuffix() + ? this.getSuffix().equalsIgnoreCase(suffix) + : suffix == null; + } + + public boolean hasSameGroupAs(MIMEType other) { + return this.getGroup().equals(other.getGroup()); + } + + public boolean hasSameTreeAs(MIMEType other) { + return Objects.equals(this.getTree(), other.getTree()); + } + + public boolean hasSameSubtypeAs(MIMEType other) { + return Objects.equals(this.getSubtype(), other.getSubtype()); + } + + public boolean hasSameSuffixAs(MIMEType other) { + return Objects.equals(this.getSuffix(), other.getSubtype()); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MIMEType mimeType = (MIMEType) o; + return Objects.equals(group, mimeType.group) && + Objects.equals(tree, mimeType.tree) && + Objects.equals(subtype, mimeType.subtype) && + Objects.equals(suffix, mimeType.suffix); + } + + @Override + public int hashCode() { + return Objects.hash(group, tree, subtype, suffix); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(getGroup()); + if (hasSubtype()) sb.append('/'); + if (hasTree()) sb.append(getTree()).append('.'); + if (hasSubtype()) sb.append(getSubtype()); + if (hasSuffix()) sb.append('+').append(getSuffix()); + return sb.toString(); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/mime/MIMETypeParseException.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/mime/MIMETypeParseException.java new file mode 100644 index 0000000..66e020b --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/display/mime/MIMETypeParseException.java @@ -0,0 +1,33 @@ +package io.github.spencerpark.jupyter.kernel.display.mime; + +public class MIMETypeParseException extends RuntimeException { + private final String raw; + private final int position; + private final String problem; + + public MIMETypeParseException(String raw, int position, String problem) { + super(raw + '@' + position + ": " + problem); + this.raw = raw; + this.position = position; + this.problem = problem; + } + + public MIMETypeParseException(String raw, int position, String problem, Throwable cause) { + super(raw + '@' + position + ": " + problem, cause); + this.raw = raw; + this.position = position; + this.problem = problem; + } + + public String getSource() { + return raw; + } + + public int getPosition() { + return position; + } + + public String getProblem() { + return problem; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/history/HistoryEntry.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/history/HistoryEntry.java new file mode 100644 index 0000000..567d909 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/history/HistoryEntry.java @@ -0,0 +1,48 @@ +package io.github.spencerpark.jupyter.kernel.history; + +public class HistoryEntry { + protected final int session; + + protected final int cellNumber; + + protected final String input; + + /** + * null if output was specified as false in the request + */ + protected final String output; + + public HistoryEntry(int session, int cellNumber, String input) { + this.session = session; + this.cellNumber = cellNumber; + this.input = input; + this.output = null; + } + + public HistoryEntry(int session, int cellNumber, String input, String output) { + this.session = session; + this.cellNumber = cellNumber; + this.input = input; + this.output = output; + } + + public int getSession() { + return session; + } + + public int getCellNumber() { + return cellNumber; + } + + public String getInput() { + return input; + } + + public String getOutput() { + return output; + } + + public boolean hasOutput() { + return output != null; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/history/HistoryManager.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/history/HistoryManager.java new file mode 100644 index 0000000..f643022 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/history/HistoryManager.java @@ -0,0 +1,146 @@ +package io.github.spencerpark.jupyter.kernel.history; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +public interface HistoryManager { + public enum ResultFlag { + /** + * Signals that the results should include the transformed output rather than + * the raw output. + */ + TRANSFORMED_INPUT, + + /** + * Signals that the results should include the cell output in addition to the + * input. When set, the manager should take care to include an empty string + * when there is no output rather than {@code null}. + */ + INCLUDE_OUTPUT, + + /** + * Signals that all results should include unique inputs only. + */ + UNIQUE, + } + + /** + * Lookup a specified range of input cells executed by the kernel that this manager + * is working for. + * + * @param sessionOffset an offset index describing the session to search. The current session is represented by 0, + * the previous by -1, and so on. + * @param startCell the index (inclusive) of the first cell to include in the results. + * @param endCell the index (exclusive) of the last cell to include in the results. + * @param flags result affecting flags. Inclusion in the set specifies that the flag is set. + * + * @return a list of history entries in the range. + */ + public default List lookupRange(int sessionOffset, int startCell, int endCell, Set flags) { + return null; + } + + /** + * Lookup a specified range of input cells executed by the kernel that this manager + * is working for. + * + * @param sessionOffset an offset index describing the session to search. The current session is represented by 0, + * the previous by -1, and so on. + * @param startCell the index (inclusive) of the first cell to include in the results. + * @param endCell the index (exclusive) of the last cell to include in the results. + * @param flags result affecting flags. Inclusion in the set specifies that the flag is set. + * + * @return a list of history entries in the range or {@code null} if the method is not supported. + */ + public default List lookupRange(int sessionOffset, int startCell, int endCell, ResultFlag... flags) { + Set flagSet = EnumSet.noneOf(ResultFlag.class); + Collections.addAll(flagSet, flags); + return lookupRange(sessionOffset, startCell, endCell, flagSet); + } + + /** + * Lookup the last {@code length} input cells executed by the kernel that this manager + * is working for. + * + * @param length the number of results to include in the results. + * @param flags result affecting flags. Inclusion in the set specifies that the flag is set. + * + * @return a list of the last {@code length} entries in the history or {@code null} if the method is not supported. + */ + public default List lookupTail(int length, Set flags) { + return null; + } + + /** + * Lookup the last {@code length} input cells executed by the kernel that this manager + * is working for. + * + * @param length the number of results to include in the results. + * @param flags result affecting flags. Inclusion in the set specifies that the flag is set. + * + * @return a list of the last {@code length} entries in the history or {@code null} if the method is not supported. + */ + public default List lookupTail(int length, ResultFlag... flags) { + Set flagSet = EnumSet.noneOf(ResultFlag.class); + Collections.addAll(flagSet, flags); + return lookupTail(length, flagSet); + } + + /** + * Lookup the last {@code length} input cells that match the {@code pattern}. + *

+ * The {@code pattern} is an sqlite glob. More specifically: + *

    + *
  • asterisk ({@code *}) matches 0 or more of any characters
  • + *
  • question mark ({@code ?}) matches exactly 1 of any character
  • + *
  • + * list wildcard ({@code []}) matches any character from the list + *
      + *
    • character ranges are supported with {@code [a-z]} syntax to match {@code a} to {@code z} inclusive
    • + *
    • starting a list wildcard with {@code ^} negates the wildcard
    • + *
    + *
  • + *
+ * + * @param pattern a glob pattern that input cells must match. + * @param length the number of results to include in the results. + * @param flags result affecting flags. Inclusion in the set specifies that the flag is set. + * + * @return a list of the last {@code length} entries in the history that match the {@code pattern} or {@code null} + * if the method is not supported. + */ + public default List search(String pattern, int length, Set flags) { + return null; + } + + /** + * Lookup the last {@code length} input cells that match the {@code pattern}. + *

+ * The {@code pattern} is an sqlite glob. More specifically: + *

    + *
  • asterisk ({@code *}) matches 0 or more of any characters
  • + *
  • question mark ({@code ?}) matches exactly 1 of any character
  • + *
  • + * list wildcard ({@code []}) matches any character from the list + *
      + *
    • character ranges are supported with {@code [a-z]} syntax to match {@code a} to {@code z} inclusive
    • + *
    • starting a list wildcard with {@code ^} negates the wildcard
    • + *
    + *
  • + *
+ * + * @param pattern a glob pattern that input cells must match. + * @param length the number of results to include in the results. + * @param flags result affecting flags. Inclusion in the set specifies that the flag is set. + * + * @return a list of the last {@code length} entries in the history that match the {@code pattern} or {@code null} + * if the method is not supported. + */ + public default List search(String pattern, int length, ResultFlag... flags) { + Set flagSet = EnumSet.noneOf(ResultFlag.class); + Collections.addAll(flagSet, flags); + return search(pattern, length, flagSet); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/CellMagicArgs.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/CellMagicArgs.java new file mode 100644 index 0000000..0619be1 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/CellMagicArgs.java @@ -0,0 +1,26 @@ +package io.github.spencerpark.jupyter.kernel.magic; + +import java.util.List; + +public interface CellMagicArgs extends LineMagicArgs { + public static CellMagicArgs of(String name, List args, String body) { + return new CellMagicArgs() { + @Override + public String getBody() { + return body; + } + + @Override + public String getName() { + return name; + } + + @Override + public List getArgs() { + return args; + } + }; + } + + public String getBody(); +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/CellMagicParseContext.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/CellMagicParseContext.java new file mode 100644 index 0000000..29555a7 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/CellMagicParseContext.java @@ -0,0 +1,28 @@ +package io.github.spencerpark.jupyter.kernel.magic; + +public interface CellMagicParseContext { + public static CellMagicParseContext of(CellMagicArgs args, String rawArgsLine, String rawCell) { + return new CellMagicParseContext() { + @Override + public CellMagicArgs getMagicCall() { + return args; + } + + @Override + public String getRawArgsLine() { + return rawArgsLine; + } + + @Override + public String getRawCell() { + return rawCell; + } + }; + } + + public CellMagicArgs getMagicCall(); + + public String getRawArgsLine(); + + public String getRawCell(); +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/LineMagicArgs.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/LineMagicArgs.java new file mode 100644 index 0000000..a7b01a8 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/LineMagicArgs.java @@ -0,0 +1,23 @@ +package io.github.spencerpark.jupyter.kernel.magic; + +import java.util.List; + +public interface LineMagicArgs { + public static LineMagicArgs of(String name, List args) { + return new LineMagicArgs() { + @Override + public String getName() { + return name; + } + + @Override + public List getArgs() { + return args; + } + }; + } + + public String getName(); + + public List getArgs(); +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/LineMagicParseContext.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/LineMagicParseContext.java new file mode 100644 index 0000000..4990dc1 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/LineMagicParseContext.java @@ -0,0 +1,44 @@ +package io.github.spencerpark.jupyter.kernel.magic; + +public interface LineMagicParseContext { + public static LineMagicParseContext of(LineMagicArgs args, String raw, String rawCell, String rawContextPrefix) { + return new LineMagicParseContext() { + @Override + public LineMagicArgs getMagicCall() { + return args; + } + + @Override + public String getRaw() { + return raw; + } + + @Override + public String getRawCell() { + return rawCell; + } + + @Override + public String getRawContextPrefix() { + return rawContextPrefix; + } + }; + } + + public LineMagicArgs getMagicCall(); + + public String getRaw(); + + public String getRawCell(); + + public String getRawContextPrefix(); + + public default String getLinePrefix() { + String cellPrefix = getRawContextPrefix(); + return cellPrefix.substring(cellPrefix.lastIndexOf('\n') + 1); + } + + public default String getEntireLine() { + return getLinePrefix() + getRaw(); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/MagicParser.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/MagicParser.java new file mode 100644 index 0000000..848cfc8 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/MagicParser.java @@ -0,0 +1,117 @@ +package io.github.spencerpark.jupyter.kernel.magic; + +import java.util.LinkedList; +import java.util.List; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class MagicParser { + protected static List split(String args) { + args = args.trim(); + + List split = new LinkedList<>(); + + StringBuilder current = new StringBuilder(); + boolean inQuotes = false; + boolean escape = false; + for (char c : args.toCharArray()) { + switch (c) { + case ' ': + case '\t': + if (inQuotes) { + current.append(c); + } else if (current.length() > 0) { + // If whitespace is closing the string the add the current and reset + split.add(current.toString()); + current.setLength(0); + } + break; + case '\\': + if (escape) { + current.append("\\\\"); + escape = false; + } else { + escape = true; + } + break; + case '\"': + if (escape) { + current.append('"'); + escape = false; + } else { + if (current.length() > 0 && inQuotes) { + split.add(current.toString()); + current.setLength(0); + inQuotes = false; + } else { + inQuotes = true; + } + } + break; + default: + current.append(c); + } + } + + if (current.length() > 0) { + split.add(current.toString()); + } + + return split; + } + + private final Pattern lineMagicPattern; + private final Pattern cellMagicPattern; + + public MagicParser() { + this("^%", "%%"); + } + + public MagicParser(String lineMagicStart, String cellMagicStart) { + this.lineMagicPattern = Pattern.compile(lineMagicStart + "(?\\w.*?)$", Pattern.MULTILINE); + this.cellMagicPattern = Pattern.compile("^(?" + cellMagicStart + "(?\\w.*?))\\R(?(?sU).+?)$"); + } + + public String transformLineMagics(String cell, Function transformer) { + StringBuffer transformedCell = new StringBuffer(); + + Matcher m = this.lineMagicPattern.matcher(cell); + while (m.find()) { + String raw = m.group(); + String rawArgs = m.group("args"); + List split = split(rawArgs); + + LineMagicArgs args = LineMagicArgs.of(split.get(0), split.subList(1, split.size())); + LineMagicParseContext ctx = LineMagicParseContext.of(args, raw, cell, cell.substring(0, m.start())); + + String transformed = transformer.apply(ctx); + if (transformed == null) transformed = raw; + + m.appendReplacement(transformedCell, Matcher.quoteReplacement(transformed)); + } + m.appendTail(transformedCell); + + return transformedCell.toString(); + } + + public CellMagicParseContext parseCellMagic(String cell) { + Matcher m = this.cellMagicPattern.matcher(cell); + + if (!m.matches()) return null; + + String rawArgsLine = m.group("argsLine"); + String rawArgs = m.group("args"); + String body = m.group("body"); + List split = split(rawArgs); + + CellMagicArgs args = CellMagicArgs.of(split.get(0), split.subList(1, split.size()), body); + return CellMagicParseContext.of(args, rawArgsLine, cell); + } + + public String transformCellMagic(String cell, Function transformer) { + CellMagicParseContext ctx = this.parseCellMagic(cell); + + return ctx == null ? cell : transformer.apply(ctx); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/common/DisplayMagics.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/common/DisplayMagics.java new file mode 100644 index 0000000..fe37d1b --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/common/DisplayMagics.java @@ -0,0 +1,71 @@ +package io.github.spencerpark.jupyter.kernel.magic.common; + +import io.github.spencerpark.jupyter.kernel.DisplayStream; +import io.github.spencerpark.jupyter.kernel.display.DisplayData; +import io.github.spencerpark.jupyter.kernel.display.Renderer; +import io.github.spencerpark.jupyter.kernel.display.mime.MIMEType; +import io.github.spencerpark.jupyter.kernel.magic.registry.CellMagic; +import io.github.spencerpark.jupyter.kernel.magic.registry.MagicsArgs; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class DisplayMagics { + private static final MagicsArgs HTML_ARGS = MagicsArgs.builder() + .keyword("isolated", MagicsArgs.KeywordSpec.ONCE) + .onlyKnownFlags().onlyKnownKeywords() + .build(); + + private final Renderer renderer; + private final DisplayStream out; + + public DisplayMagics(Renderer renderer, DisplayStream out) { + this.renderer = renderer; + this.out = out; + } + + @CellMagic + public void html(List args, String body) { + Map> vals = HTML_ARGS.parse(args); + boolean isolated = !vals.get("isolated").isEmpty(); + + DisplayData data = this.renderer.renderAs(body, MIMEType.TEXT_HTML.toString()); + + if (isolated) { + Map meta = new LinkedHashMap<>(); + meta.put("isolated", true); + data.putMetaData(MIMEType.TEXT_HTML, meta); + } + + this.out.display(data); + } + + @CellMagic + public void markdown(List args, String body) { + this.out.display( + this.renderer.renderAs(body, MIMEType.TEXT_MARKDOWN.toString()) + ); + } + + @CellMagic + public void svg(List args, String body) { + this.out.display( + this.renderer.renderAs(body, MIMEType.IMAGE_SVG.toString()) + ); + } + + @CellMagic + public void latex(List args, String body) { + this.out.display( + this.renderer.renderAs(body, MIMEType.TEXT_LATEX.toString()) + ); + } + + @CellMagic(aliases = "js") + public void javascript(List args, String body) { + this.out.display( + this.renderer.renderAs(body, MIMEType.APPLICATION_JAVASCRIPT.toString()) + ); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/common/Load.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/common/Load.java new file mode 100644 index 0000000..c46522a --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/common/Load.java @@ -0,0 +1,150 @@ +package io.github.spencerpark.jupyter.kernel.magic.common; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.stream.JsonReader; +import io.github.spencerpark.jupyter.kernel.magic.registry.LineMagic; +import io.github.spencerpark.jupyter.kernel.magic.registry.MagicsArgs; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +public class Load { + @FunctionalInterface + public static interface Executor { + public void execute(String code) throws Exception; + } + + private static final ThreadLocal GSON = ThreadLocal.withInitial(() -> + new GsonBuilder().create()); + + private static final MagicsArgs LOAD_ARGS = MagicsArgs.builder() + .required("source") + .onlyKnownFlags().onlyKnownKeywords() + .build(); + + // This slightly verbose implementation is designed to take advantage of gson as a streaming parser + // in which we can only take what we need on the fly and pass each cell to the handler without needing + // to keep the entire notebook in memory. + // This should be a big help for larger notebooks. + private static void forEachCell(Path notebookPath, Executor handle) throws Exception { + try (Reader in = Files.newBufferedReader(notebookPath, StandardCharsets.UTF_8)) { + JsonReader reader = GSON.get().newJsonReader(in); + reader.beginObject(); + while (reader.hasNext()) { + String name = reader.nextName(); + if (!name.equals("cells")) { + reader.skipValue(); + continue; + } + + // Parsing cells + reader.beginArray(); + while (reader.hasNext()) { + Boolean isCode = null; + String source = null; + + reader.beginObject(); + while (reader.hasNext()) { + // If the cell type was parsed and wasn't code, then don't + // bother doing any more work. Skip the rest. + if (isCode != null && !isCode) { + reader.skipValue(); + continue; + } + + switch (reader.nextName()) { + case "cell_type": + // We are only concerned with code cells. + String cellType = reader.nextString(); + isCode = cellType.equals("code"); + break; + case "source": + // "source" is an array of lines. + StringBuilder srcBuilder = new StringBuilder(); + reader.beginArray(); + while (reader.hasNext()) + srcBuilder.append(reader.nextString()); + reader.endArray(); + source = srcBuilder.toString(); + break; + default: + reader.skipValue(); + break; + } + } + reader.endObject(); + + // Found a code cell! + if (isCode != null && isCode) + handle.execute(source); + } + reader.endArray(); + } + reader.endObject(); + } + } + + private final List fileExtensions; + private final Executor exec; + + public Load(List fileExtensions, Executor exec) { + this.fileExtensions = fileExtensions == null + ? Collections.emptyList() + : fileExtensions.stream() + .map(e -> e.startsWith(".") ? e : "." + e) + .collect(Collectors.toList()); + this.exec = exec; + } + + @LineMagic + public void load(List args) throws Exception { + Map> vals = LOAD_ARGS.parse(args); + + Path sourcePath = Paths.get(vals.get("source").get(0)).toAbsolutePath(); + + if (Files.isRegularFile(sourcePath)) { + if (sourcePath.getFileName().toString().endsWith(".ipynb")) { + // Execute a notebook, run all cells in there. + Load.forEachCell(sourcePath, this.exec); + return; + } + + String sourceContents = new String(Files.readAllBytes(sourcePath), StandardCharsets.UTF_8); + this.exec.execute(sourceContents); + return; + } + + String file = sourcePath.getFileName().toString(); + + // Try and see if adding any of the supported extensions gives a file. + for (String extension : this.fileExtensions) { + Path scriptPath = sourcePath.resolveSibling(file + extension); + if (Files.isRegularFile(scriptPath)) { + String sourceContents = new String(Files.readAllBytes(scriptPath), StandardCharsets.UTF_8); + this.exec.execute(sourceContents); + return; + } + } + + // Try a notebook last. + Path scriptPath = sourcePath.resolveSibling(file + ".ipynb"); + if (Files.isRegularFile(scriptPath)) { + // Execute a notebook, run all cells in there. + Load.forEachCell(scriptPath, this.exec); + return; + } + + throw new FileNotFoundException("Could not find any source at '" + sourcePath + "'. Also tried with extensions: [.ipynb, " + this.fileExtensions.stream().collect(Collectors.joining(", ")) + "]."); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/common/Shell.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/common/Shell.java new file mode 100644 index 0000000..ca5ce40 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/common/Shell.java @@ -0,0 +1,32 @@ +package io.github.spencerpark.jupyter.kernel.magic.common; + +import io.github.spencerpark.jupyter.kernel.magic.registry.LineMagic; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.util.LinkedList; +import java.util.List; + +public class Shell { + @LineMagic + public static List sh(List args) throws Exception { + Process p = new ProcessBuilder() + .command(args) + .start(); + + List output = new LinkedList<>(); + BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream())); + + String line; + while ((line = reader.readLine()) != null) + output.add(line); + + try { + p.waitFor(); + } catch (InterruptedException e) { + p.destroy(); + } + + return output; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/common/WriteFile.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/common/WriteFile.java new file mode 100644 index 0000000..3a5409a --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/common/WriteFile.java @@ -0,0 +1,39 @@ +package io.github.spencerpark.jupyter.kernel.magic.common; + +import io.github.spencerpark.jupyter.kernel.magic.registry.CellMagic; +import io.github.spencerpark.jupyter.kernel.magic.registry.MagicsArgs; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.Charset; +import java.nio.file.FileAlreadyExistsException; +import java.util.List; +import java.util.Map; + +public class WriteFile { + private static final MagicsArgs WRITEFILE_ARGS = MagicsArgs.builder() + .required("filename") + .flag("append", 'a') + .onlyKnownFlags().onlyKnownKeywords() + .build(); + + @CellMagic + public static Void writefile(List args, String body) throws Exception { + Map> vals = WRITEFILE_ARGS.parse(args); + + String fileName = vals.get("filename").get(0); + boolean append = !vals.get("append").isEmpty(); + + File file = new File(fileName); + + if (file.isDirectory()) + throw new FileAlreadyExistsException("Cannot write to file " + fileName + ". It is a directory."); + + try (OutputStreamWriter fileOut = new OutputStreamWriter(new FileOutputStream(file, append), Charset.forName("utf8"))) { + fileOut.write(body); + } + + return null; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/CellMagic.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/CellMagic.java new file mode 100644 index 0000000..88de0fa --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/CellMagic.java @@ -0,0 +1,14 @@ +package io.github.spencerpark.jupyter.kernel.magic.registry; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface CellMagic { + String value() default ""; + + String[] aliases() default {}; +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/CellMagicFunction.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/CellMagicFunction.java new file mode 100644 index 0000000..b9df5a1 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/CellMagicFunction.java @@ -0,0 +1,8 @@ +package io.github.spencerpark.jupyter.kernel.magic.registry; + +import java.util.List; + +@FunctionalInterface +public interface CellMagicFunction { + public T execute(List args, String body) throws Exception; +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/LineMagic.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/LineMagic.java new file mode 100644 index 0000000..776ce1c --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/LineMagic.java @@ -0,0 +1,14 @@ +package io.github.spencerpark.jupyter.kernel.magic.registry; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface LineMagic { + String value() default ""; + + String[] aliases() default {}; +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/LineMagicFunction.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/LineMagicFunction.java new file mode 100644 index 0000000..1298bfb --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/LineMagicFunction.java @@ -0,0 +1,8 @@ +package io.github.spencerpark.jupyter.kernel.magic.registry; + +import java.util.List; + +@FunctionalInterface +public interface LineMagicFunction { + public T execute(List args) throws Exception; +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/MagicArgsParseException.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/MagicArgsParseException.java new file mode 100644 index 0000000..f7e4a1d --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/MagicArgsParseException.java @@ -0,0 +1,18 @@ +package io.github.spencerpark.jupyter.kernel.magic.registry; + +public class MagicArgsParseException extends RuntimeException { + public MagicArgsParseException() { + } + + public MagicArgsParseException(String format, Object... args) { + super(String.format(format, args)); + } + + public MagicArgsParseException(String message, Throwable cause) { + super(message, cause); + } + + public MagicArgsParseException(Throwable cause) { + super(cause); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/Magics.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/Magics.java new file mode 100644 index 0000000..e2d7fc3 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/Magics.java @@ -0,0 +1,231 @@ +package io.github.spencerpark.jupyter.kernel.magic.registry; + +import java.lang.reflect.*; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class Magics { + private final Map> lineMagics; + private final Map> cellMagics; + + public Magics() { + this.lineMagics = new HashMap<>(); + this.cellMagics = new HashMap<>(); + } + + // Magic application + + public T applyLineMagic(String name, List args) throws Exception { + @SuppressWarnings("unchecked") + LineMagicFunction magic = (LineMagicFunction) this.lineMagics.get(name); + + if (magic == null) + throw new UndefinedMagicException(name, true); + + return magic.execute(args); + } + + public T applyCellMagic(String name, List args, String body) throws Exception { + @SuppressWarnings("unchecked") + CellMagicFunction magic = (CellMagicFunction) this.cellMagics.get(name); + + if (magic == null) + throw new UndefinedMagicException(name, false); + + return magic.execute(args, body); + } + + // Magic registration + + public void registerLineMagic(String name, LineMagicFunction magic) { + this.lineMagics.put(name, magic); + } + + public void registerCellMagic(String name, CellMagicFunction magic) { + this.cellMagics.put(name, magic); + } + + public &CellMagicFunction> void registerLineCellMagic(String name, T magic) { + this.lineMagics.put(name, magic); + this.cellMagics.put(name, magic); + } + + // Reflective magic registration + + public void registerMagics(Object magics) { + registerMagics(magics.getClass(), magics); + } + + public void registerMagics(Class magicsClass) { + registerMagics(magicsClass, null); + } + + private void registerMagics(Class magicsClass, Object magics) { + for (Method method : magicsClass.getDeclaredMethods()) { + LineMagic lineMagic = method.getAnnotation(LineMagic.class); + CellMagic cellMagic = method.getAnnotation(CellMagic.class); + + if (lineMagic == null && cellMagic == null) continue; + + if (method.getParameterCount() == 0) { + // Magic function with no arguments + registerNoArgsReflectionMagic(magics, method, lineMagic, cellMagic); + } else if (lineMagic != null && cellMagic != null) { + // Line cell magic with some arguments + registerLineCellReflectionMagic(magics, method, lineMagic, cellMagic); + } else if (lineMagic != null) { + // Just line magic + registerLineReflectionMagic(magics, method, lineMagic); + } else { + // Just cell magic + registerCellReflectionMagic(magics, method, cellMagic); + } + } + } + + private static Object invoke(Method m, Object instance, Object... args) throws Exception { + try { + return m.invoke(instance, args); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof Exception) + throw ((Exception) cause); + throw new RuntimeException(cause.getMessage(), cause); + } + } + + private static class NoArgsReflectionMagicFunction implements LineMagicFunction, CellMagicFunction { + private final Object instance; + private final Method method; + + NoArgsReflectionMagicFunction(Object instance, Method method) { + this.instance = instance; + this.method = method; + } + + @Override + public Object execute(List args, String body) throws Exception { + return invoke(method, instance); + } + + @Override + public Object execute(List args) throws Exception { + return invoke(method, instance); + } + } + + private static class LineCellReflectionMagicFunction implements LineMagicFunction, CellMagicFunction { + private final Object instance; + private final Method method; + + LineCellReflectionMagicFunction(Object instance, Method method) { + this.instance = instance; + this.method = method; + } + + @Override + public Object execute(List args, String body) throws Exception { + return invoke(method, instance, args, body); + } + + @Override + public Object execute(List args) throws Exception { + return invoke(method, instance, args, null); + } + } + + private static class LineReflectionMagicFunction implements LineMagicFunction { + private final Object instance; + private final Method method; + + LineReflectionMagicFunction(Object instance, Method method) { + this.instance = instance; + this.method = method; + } + + @Override + public Object execute(List args) throws Exception { + return invoke(method, instance, args); + } + } + + private static class CellReflectionMagicFunction implements CellMagicFunction { + private final Object instance; + private final Method method; + + CellReflectionMagicFunction(Object instance, Method method) { + this.instance = instance; + this.method = method; + } + + @Override + public Object execute(List args, String body) throws Exception { + return invoke(method, instance, args, body); + } + } + + private boolean isValidBodyParam(Parameter param) { + return !param.getType().isAssignableFrom(String.class); + } + + private boolean isValidArgsParam(Parameter param) { + if (!param.getType().isAssignableFrom(List.class)) return true; + + Type parameterizedType = param.getParameterizedType(); + if (parameterizedType instanceof ParameterizedType) { + Type genericType = ((ParameterizedType) parameterizedType).getActualTypeArguments()[0]; + return !String.class.equals(genericType); + } + + return false; + } + + private void registerLineMagic(Method method, LineMagic lineMagic, LineMagicFunction func) { + registerLineMagic(lineMagic.value().isEmpty() ? method.getName() : lineMagic.value(), func); + for (String alias : lineMagic.aliases()) + registerLineMagic(alias, func); + } + + private void registerCellMagic(Method method, CellMagic cellMagic, CellMagicFunction func) { + registerCellMagic(cellMagic.value().isEmpty() ? method.getName() : cellMagic.value(), func); + for (String alias : cellMagic.aliases()) + registerCellMagic(alias, func); + } + + private void registerNoArgsReflectionMagic(Object instance, Method method, LineMagic lineMagic, CellMagic cellMagic) { + NoArgsReflectionMagicFunction func = new NoArgsReflectionMagicFunction(instance, method); + + if (lineMagic != null) + registerLineMagic(method, lineMagic, func); + + if (cellMagic != null) + registerCellMagic(method, cellMagic, func); + } + + private void registerLineCellReflectionMagic(Object instance, Method method, LineMagic lineMagic, CellMagic cellMagic) { + Parameter[] params = method.getParameters(); + if (params.length != 2 || isValidArgsParam(params[0]) || isValidBodyParam(params[1])) + throw new IllegalArgumentException("Line-cell magic must accept a List and String as parameters. (Magic arguments and possible cell body)"); + + LineCellReflectionMagicFunction func = new LineCellReflectionMagicFunction(instance, method); + registerLineMagic(method, lineMagic, func); + registerCellMagic(method, cellMagic, func); + } + + private void registerLineReflectionMagic(Object instance, Method method, LineMagic lineMagic) { + Parameter[] params = method.getParameters(); + if (params.length != 1 || isValidArgsParam(params[0])) + throw new IllegalArgumentException("Line magic must accept a List as a parameter. (Magic arguments)"); + + registerLineMagic(method, lineMagic, new LineReflectionMagicFunction(instance, method)); + } + + private void registerCellReflectionMagic(Object instance, Method method, CellMagic cellMagic) { + Parameter[] params = method.getParameters(); + if (params.length != 2 || isValidArgsParam(params[0]) || isValidBodyParam(params[1])) + throw new IllegalArgumentException("Cell magic must accept a List and String as parameters. (Magic arguments and cell body)"); + + registerCellMagic(method, cellMagic, new CellReflectionMagicFunction(instance, method)); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/MagicsArgs.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/MagicsArgs.java new file mode 100644 index 0000000..46ec35b --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/MagicsArgs.java @@ -0,0 +1,318 @@ +package io.github.spencerpark.jupyter.kernel.magic.registry; + +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class MagicsArgs { + public enum KeywordSpec { + ONCE, + COLLECT, + REPLACE + } + + public static MagicsArgsBuilder builder() { + return new MagicsArgsBuilder(); + } + + public static class MagicsArgsBuilder { + private final List requiredPositional = new LinkedList<>(); + private final List optionalPositional = new LinkedList<>(); + private String varargs; + + private boolean acceptAnyKeyword = true; + private boolean acceptAnyFlag = true; + + private final Map> keywords = new LinkedHashMap<>(); + private final Map flags = new LinkedHashMap<>(); + private final Map flagDefaultValues = new LinkedHashMap<>(); + + public MagicsArgsBuilder required(String name) { + if (!this.optionalPositional.isEmpty() || this.varargs != null) + throw new IllegalStateException("Schema cannot have required positional arguments after optional ones."); + + this.requiredPositional.add(name); + + return this; + } + + public MagicsArgsBuilder optional(String name) { + this.optionalPositional.add(name); + + return this; + } + + public MagicsArgsBuilder varargs(String name) { + if (this.varargs != null) + throw new IllegalStateException("Schema already has varargs: " + this.varargs); + + this.varargs = name; + + return this; + } + + // --keyword value or --keyword=value + public MagicsArgsBuilder keyword(String name, KeywordSpec spec, KeywordSpec... specRest) { + this.keywords.put(name, EnumSet.of(spec, specRest)); + + return this; + } + + public MagicsArgsBuilder keyword(String name) { + return this.keyword(name, KeywordSpec.COLLECT); + } + + public MagicsArgsBuilder flag(String name, char shortName, String value) { + this.keyword(name); + this.flags.put(shortName, name); + this.flagDefaultValues.put(name, value); + + return this; + } + + public MagicsArgsBuilder flag(String name, char shortName) { + this.keyword(name); + this.flags.put(shortName, name); + + return this; + } + + public MagicsArgsBuilder anyKeyword() { + this.acceptAnyKeyword = true; + + return this; + } + + public MagicsArgsBuilder onlyKnownKeywords() { + this.acceptAnyKeyword = false; + + return this; + } + + public MagicsArgsBuilder anyFlag() { + this.acceptAnyFlag = true; + + return this; + } + + public MagicsArgsBuilder onlyKnownFlags() { + this.acceptAnyFlag = false; + + return this; + } + + private KeywordAggregator buildKeyword(Set spec) { + if (spec.contains(KeywordSpec.ONCE)) { + return (name, value, rest, args) -> { + if (args.containsKey(name) && !args.get(name).isEmpty()) + throw new MagicArgsParseException("'%s' may only be specified once.", name); + + if (value != null) { + args.put(name, Collections.singletonList(value)); + + return rest; + } else { + if (rest.isEmpty()) + throw new MagicArgsParseException("'%s' is a keyword argument but no value was supplied.", name); + + args.put(name, Collections.singletonList(rest.get(0))); + + return rest.subList(1, rest.size()); + } + }; + } else if (spec.contains(KeywordSpec.REPLACE)) { + return (name, value, rest, args) -> { + if (value != null) { + args.put(name, Collections.singletonList(value)); + + return rest; + } else { + if (rest.isEmpty()) + throw new MagicArgsParseException("'%s' is a keyword argument but no value was supplied.", name); + + args.put(name, Collections.singletonList(rest.get(0))); + + return rest.subList(1, rest.size()); + } + }; + } else /*default: if (spec.contains(KeywordSpec.COLLECT))*/ { + return (name, value, rest, args) -> { + args.compute(name, (k, values) -> { + if (values == null) + values = new LinkedList<>(); + + if (value != null) { + values.add(value); + } else { + if (rest.isEmpty()) + throw new MagicArgsParseException("'%s' is a keyword argument but no value was supplied.", name); + + values.add(rest.get(0)); + } + + return values; + }); + + return value != null ? rest : rest.subList(1, rest.size()); + }; + } + } + + public MagicsArgs build() { + Map kw = new HashMap<>(this.keywords.size()); + this.keywords.forEach((name, spec) -> + kw.put(name, this.buildKeyword(spec))); + + return new MagicsArgs( + new ArrayList<>(this.requiredPositional), + new ArrayList<>(this.optionalPositional), + this.varargs, + kw, + this.flags, + this.flagDefaultValues, + this.acceptAnyKeyword ? this.buildKeyword(EnumSet.noneOf(KeywordSpec.class)) : null, + this.acceptAnyFlag ? this.buildKeyword(EnumSet.noneOf(KeywordSpec.class)) : null + ); + } + } + + @FunctionalInterface + private static interface KeywordAggregator { + /** + * Consume the argument. + * + * @param name the name of the argument + * @param value the value attached to the keyword or null + * @param rest the remaining arguments + * @param args the collection to append to + * + * @return the new {@code rest} + */ + public List consume(String name, String value, List rest, Map> args) throws MagicArgsParseException; + } + + private static final Pattern KEYWORD_ARG_PATTERN = Pattern.compile("^--(?[^=]+)(?:=(?.+))?$"); + private static final Pattern FLAG_ARG_PATTERN = Pattern.compile("^-(?[a-zA-Z]+)$"); + + private final List positional; + private final List optional; + private final String varargs; + + private final Map keywords; + private final Map keywordFromFlag; + private final Map flagSuppliedDefaults; + + private final KeywordAggregator defaultKeywordAggregator; + private final KeywordAggregator defaultFlagAggregator; + + public MagicsArgs(List positional, List optional, String varargs, Map keywords, Map keywordFromFlag, Map flagSuppliedDefaults, KeywordAggregator defaultKeywordAggregator, KeywordAggregator defaultFlagAggregator) { + this.positional = positional; + this.optional = optional; + this.varargs = varargs; + this.keywords = keywords; + this.keywordFromFlag = keywordFromFlag; + this.flagSuppliedDefaults = flagSuppliedDefaults; + this.defaultKeywordAggregator = defaultKeywordAggregator; + this.defaultFlagAggregator = defaultFlagAggregator; + } + + public Map> parse(List args) throws MagicArgsParseException { + Map> collectedArgs = new LinkedHashMap<>(); + this.positional.forEach(a -> collectedArgs.put(a, new LinkedList<>())); + this.optional.forEach(a -> collectedArgs.put(a, new LinkedList<>())); + if (this.varargs != null) + collectedArgs.put(this.varargs, new LinkedList<>()); + this.keywords.keySet().forEach(a -> collectedArgs.put(a, new LinkedList<>())); + + int positionalsMatched = 0; + + while (!args.isEmpty()) { + String arg = args.get(0); + args = args.subList(1, args.size()); + + Matcher m = KEYWORD_ARG_PATTERN.matcher(arg); + if (m.matches()) { + String name = m.group("name"); + String value = m.group("val"); + + KeywordAggregator aggregator = this.keywords.getOrDefault(name, this.defaultKeywordAggregator); + + if (aggregator == null) + throw new MagicArgsParseException("Unknown keyword argument '%s'.", name); + + args = aggregator.consume(name, value, args, collectedArgs); + + continue; + } + + m = FLAG_ARG_PATTERN.matcher(arg); + if (m.matches()) { + String flags = m.group("flags"); + for (int i = 0; i < flags.length(); i++) { + char c = flags.charAt(i); + + String name = this.keywordFromFlag.getOrDefault(c, Character.toString(c)); + + KeywordAggregator aggregator = this.keywords.getOrDefault(name, this.defaultFlagAggregator); + + if (aggregator == null) + throw new MagicArgsParseException("Unknown flag argument '%s'.", name); + + args = aggregator.consume(name, this.flagSuppliedDefaults.getOrDefault(name, ""), args, collectedArgs); + } + + continue; + } + + if (positionalsMatched < this.positional.size()) + collectedArgs.compute(this.positional.get(positionalsMatched), (n, values) -> { + values = values != null ? values : new LinkedList<>(); + values.add(arg); + return values; + }); + else if (positionalsMatched < this.positional.size() + this.optional.size()) + collectedArgs.compute(this.optional.get(positionalsMatched - this.positional.size()), (n, values) -> { + values = values != null ? values : new LinkedList<>(); + values.add(arg); + return values; + }); + else if (this.varargs != null) + collectedArgs.compute(this.varargs, (n, values) -> { + values = values != null ? values : new LinkedList<>(); + values.add(arg); + return values; + }); + else + throw new MagicArgsParseException("Too many positional arguments."); + + positionalsMatched += 1; + } + + if (positionalsMatched < this.positional.size()) + throw new MagicArgsParseException("Missing required positional arguments: %s", this.positional.subList(positionalsMatched, this.positional.size())); + + return collectedArgs; + } + + @Override + public String toString() { + StringJoiner s = new StringJoiner(" "); + + this.positional.forEach(s::add); + this.optional.forEach(a -> s.add("[" + a + "]")); + if (this.varargs != null) + s.add(this.varargs + "..."); + + this.keywordFromFlag.keySet().forEach(c -> s.add("-" + c)); + if (this.defaultFlagAggregator != null) + s.add("-*"); + + this.keywords.keySet().stream() + .filter(a -> !this.keywordFromFlag.values().contains(a)) + .forEach(a -> s.add("--" + a)); + if (this.defaultKeywordAggregator != null) + s.add("--**"); + + return s.toString(); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/UndefinedMagicException.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/UndefinedMagicException.java new file mode 100644 index 0000000..3baf52a --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/magic/registry/UndefinedMagicException.java @@ -0,0 +1,24 @@ +package io.github.spencerpark.jupyter.kernel.magic.registry; + +public class UndefinedMagicException extends RuntimeException { + private final String name; + private final boolean line; + + public UndefinedMagicException(String name, boolean line) { + super("Undefined " + (line ? "line" : "cell") + " magic '" + name + "'"); + this.name = name; + this.line = line; + } + + public String getMagicName() { + return name; + } + + public boolean isLineMagic() { + return line; + } + + public boolean isCellMagic() { + return !line; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/CharPredicate.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/CharPredicate.java new file mode 100644 index 0000000..7665513 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/CharPredicate.java @@ -0,0 +1,157 @@ +package io.github.spencerpark.jupyter.kernel.util; + +import java.util.*; + +@FunctionalInterface +public interface CharPredicate { + + public boolean test(char c); + + public default CharPredicate and(CharPredicate condition) { + return c -> this.test(c) && condition.test(c); + } + + public default CharPredicate or(CharPredicate condition) { + return c -> this.test(c) || condition.test(c); + } + + public default CharPredicate not() { + return new NotCharPredicate(this); + } + + public static class NotCharPredicate implements CharPredicate { + private final CharPredicate test; + + public NotCharPredicate(CharPredicate test) { + this.test = test; + } + + @Override + public boolean test(char c) { + return !this.test.test(c); + } + + @Override + public CharPredicate not() { + return this.test; + } + } + + /** + * Match characters that fall between the given character bounds (inclusive). + * + * @param low the lower bound of the range (inclusive) + * @param high the upper bound of the range (inclusive) + * + * @return a predicate that returns true when testing a character in this range + * and false otherwise. + */ + public static CharPredicate inRange(char low, char high) { + return c -> low <= c && c <= high; + } + + /** + * Match a character that is the same as the {@code match} character. + * + * @param match the character to match with + * + * @return a predicate that returns true when testing a character that is + * the same as the {@code match} character and false otherwise. + */ + public static CharPredicate match(char match) { + return c -> c == match; + } + + /** + * Match any character in the {@code chars} string. + * + * @param chars a set of chars to match + * + * @return a predicate that returns true when testing a character that is + * the same as any character in the {@code chars} and false otherwise. + */ + public static CharPredicate anyOf(String chars) { + int[] cs = chars.chars().sorted().distinct().toArray(); + return c -> { + for (int cmpTo : cs) { + if (cmpTo == c) return true; + if (c < cmpTo) return false; + } + return false; + }; + } + + public static class CharRange { + public final char low; + public final char high; + + public CharRange(char low, char high) { + this.low = low; + this.high = high; + } + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private final List segments; + + public Builder() { + this.segments = new LinkedList<>(); + } + + public Builder inRange(char low, char high) { + if (high < low) + throw new IllegalArgumentException("Low char must be strictly less than high (low: " + low + ", high: " + high + ")"); + + this.segments.add(new CharRange(low, high)); + return this; + } + + public Builder match(char c) { + this.segments.add(new CharRange(c, c)); + return this; + } + + public Builder match(String chars) { + chars.chars().forEach(c -> this.segments.add(new CharRange((char) c, (char) c))); + return this; + } + + public CharPredicate build() { + List ranges = new ArrayList<>(this.segments.size()); + + if (!this.segments.isEmpty()) { + this.segments.sort((range1, range2) -> + range1.low != range2.low + ? range1.low - range2.low + : range1.high - range2.high); + + Iterator itr = this.segments.iterator(); + CharRange prev = itr.next(); + while (itr.hasNext()) { + CharRange next = itr.next(); + if (prev.high < next.low) { + ranges.add(prev); + prev = next; + } else { + prev = new CharRange(prev.low, (char) Math.max(prev.high, next.high)); + } + } + ranges.add(prev); + } + + CharRange[] test = ranges.toArray(new CharRange[ranges.size()]); + + return c -> { + for (CharRange range : test) { + if (c < range.low) return false; + if (c <= range.high) return true; + } + return false; + }; + } + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/GlobFinder.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/GlobFinder.java new file mode 100644 index 0000000..0cb0f2f --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/GlobFinder.java @@ -0,0 +1,230 @@ +package io.github.spencerpark.jupyter.kernel.util; + +import java.io.IOException; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A simplified glob implementation designed for finding files. The current implementation supports + * {@code "*"} to match 0 or more characters between {@code "/"} and {@code "?"} to + * match a single character. A glob ending in {@code "/"} will match all files in a directories matching + * the glob. + *

+ * Important note for Windows file systems: Globs should use {@code "/"} to separate the + * glob despite it not being the platform separator. + */ +public class GlobFinder { + private static class GlobSegment { + public enum FilterRestriction { + ONLY_FILES(true, false), + ONLY_DIRECTORIES(false, true), + ANYTHING(true, true); + + private final boolean acceptsFiles; + private final boolean acceptsDirectories; + + FilterRestriction(boolean acceptsFiles, boolean acceptsDirectories) { + this.acceptsFiles = acceptsFiles; + this.acceptsDirectories = acceptsDirectories; + } + + public boolean acceptsFiles() { + return acceptsFiles; + } + + public boolean acceptsDirectories() { + return acceptsDirectories; + } + } + + public static final GlobSegment ANY = new GlobSegment(Pattern.compile("^.*$")); + + private final String literal; + private final Pattern regex; + + public GlobSegment(String literal) { + this.literal = literal; + this.regex = null; + } + + public GlobSegment(Pattern regex) { + this.literal = null; + this.regex = regex; + } + + public boolean isLiteral() { + return this.literal != null; + } + + public DirectoryStream.Filter filter(FilterRestriction restriction) { + return s -> { + BasicFileAttributes attributes = Files.readAttributes(s, BasicFileAttributes.class); + + if ((attributes.isRegularFile() && !restriction.acceptsFiles()) || (attributes.isDirectory() && !restriction.acceptsDirectories())) + return false; + + Path pathName = s.getFileName(); + + if (pathName == null) + return false; + + String name = pathName.toString(); + return this.literal != null + ? this.literal.equals(name) + : this.regex.matcher(name).matches(); + }; + } + + @Override + public String toString() { + return this.isLiteral() ? this.literal : this.regex.pattern(); + } + } + + private static final Pattern GLOB_SEGMENT_COMPONENT = Pattern.compile( + "" + + "(?[^*?]+)" + + "|(?\\*)" + + "|(?\\?)" + + "|(?:\\\\(?[*?]))" + ); + + private static final Pattern SPLITTER = Pattern.compile("/+"); + + private final Path base; + private final List segments; + private final boolean isExplicitDirectory; + + public GlobFinder(FileSystem fs, String glob) { + // Split with "/" but match with the actual separator + String[] segments = SPLITTER.split(glob); + this.isExplicitDirectory = glob.endsWith("/"); + + List matchers = new ArrayList<>(segments.length); + int lastBaseSegmentIdx = 0; + + for (int i = 0; i < segments.length; i++) { + String segment = segments[i]; + + StringBuilder pattern = new StringBuilder(); + StringBuilder lit = new StringBuilder(); + int wildcards = 0; + int singleWildcards = 0; + + Matcher m = GLOB_SEGMENT_COMPONENT.matcher(segment); + while (m.find()) { + String literal = m.group("literal"); + if (literal == null) literal = m.group("escaped"); + if (literal != null) { + pattern.append(Pattern.quote(literal)); + lit.append(literal); + continue; + } + + String wildcard = m.group("wildcard"); + if (wildcard != null) { + pattern.append(".*"); + wildcards++; + continue; + } + + String singleWildcard = m.group("singleWildcard"); + // There are only 4 groups, 3 of which have been checked and are null so this + // on must be non-null. + assert singleWildcard != null : "Glob construction pattern incomplete."; + pattern.append("."); + singleWildcards++; + } + + assert m.hitEnd() : "Glob construction missed some characters."; + + if (wildcards == 0 && singleWildcards == 0) { + matchers.add(new GlobSegment(lit.toString())); + if (lastBaseSegmentIdx == i) lastBaseSegmentIdx++; + } else { + matchers.add(new GlobSegment(Pattern.compile("^" + pattern.toString() + "$"))); + } + } + + // Cannot use the very nice `new File(glob).isAbsolute()` solution as this is restricted to the default file + // system and doesn't use the `fs`. Additionally `Paths.get(glob).isAbsolute()` will fail with an illegal path + // exception when trying to parse a windows path with a * in it for example. Therefor we need a clean segment. + boolean isAbsolute = lastBaseSegmentIdx > 0 && fs.getPath(segments[0] + fs.getSeparator()).isAbsolute(); + String firstSeg = isAbsolute ? segments[0] + fs.getSeparator() : "." + fs.getSeparator(); + + this.base = fs.getPath(firstSeg, Arrays.copyOfRange(segments, isAbsolute ? 1 : 0, lastBaseSegmentIdx)); + this.segments = matchers.subList(lastBaseSegmentIdx, matchers.size()); + } + + public GlobFinder(String glob) { + this(FileSystems.getDefault(), glob); + } + + public Iterable computeMatchingPaths() throws IOException { + if (this.segments.isEmpty()) { + if (Files.exists(this.base)) + return Collections.singletonList(this.base); + else + return Collections.emptyList(); + } + + List paths = new ArrayList<>(); + GlobSegment head = this.segments.get(0); + List tail = this.segments.subList(1, this.segments.size()); + + collectExplicit(GlobSegment.FilterRestriction.ANYTHING, this.base, head, tail, paths); + + return paths; + } + + private void collectExplicit(GlobSegment.FilterRestriction finalFilterRestriction, Path dir, GlobSegment segment, List segments, Collection into) throws IOException { + boolean isMoreSegments = !segments.isEmpty(); + // Should match files if there are more segments in which case this must be a directory so + // we can continue. Otherwise we let the search determine if a file is acceptable. + GlobSegment.FilterRestriction filterRestriction = isMoreSegments ? GlobSegment.FilterRestriction.ONLY_DIRECTORIES : finalFilterRestriction; + + try (DirectoryStream files = Files.newDirectoryStream(dir, segment.filter(filterRestriction))) { + GlobSegment head = isMoreSegments ? segments.get(0) : null; + List tail = isMoreSegments ? segments.subList(1, segments.size()) : Collections.emptyList(); + + for (Path p : files) { + if (isMoreSegments) + collectExplicit(finalFilterRestriction, p, head, tail, into); + else + into.add(p); + } + } + } + + public Iterable computeMatchingFiles() throws IOException { + if (this.segments.isEmpty()) { + if (Files.isDirectory(this.base) && this.isExplicitDirectory) + return Files.newDirectoryStream(this.base, Files::isRegularFile); + if (Files.isRegularFile(this.base)) + return Collections.singleton(this.base); + return Collections.emptyList(); + } + + List paths = new ArrayList<>(); + GlobSegment head = this.segments.get(0); + List tail; + + // If explicitly ends with a "/" then the pattern means match all files in this directory + // otherwise we assume the last pattern is a file matcher. + if (this.isExplicitDirectory) { + tail = new ArrayList<>(this.segments.size() + 1); + Collections.copy(tail, this.segments.subList(1, this.segments.size())); + tail.add(GlobSegment.ANY); + } else { + tail = this.segments.subList(1, this.segments.size()); + } + + collectExplicit(GlobSegment.FilterRestriction.ONLY_FILES, this.base, head, tail, paths); + + return paths; + } +} + diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/InheritanceIterator.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/InheritanceIterator.java new file mode 100644 index 0000000..61b1996 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/InheritanceIterator.java @@ -0,0 +1,84 @@ +package io.github.spencerpark.jupyter.kernel.util; + +import java.util.*; + +/** + * Iterate over the types that an object is an {@code instanceof}. {@link Class}es in + * the iteration will not be duplicated (once a class is seen it will not be seen again + * even if, for example, an interface is declared to be implemented by 2 classes). + *

+ * Example: + *

+ * {@code interface I {}
+ *   interface J extends I {}
+ *   interface K extends J, I {} // Redundant but allowed
+ *   interface L extends J, K {}
+ *
+ *   class D {}
+ *   class E extends D implements L {}
+ *   class F extends E implements J, K {}
+ * }
+ * 
+ * Iterating over {@code new InheritanceIterator(F.class)} will yield: + * {@code F.class, J.class, K.class, I.class, E.class, L.class, D.class, Object.class} + */ +public class InheritanceIterator implements Iterator { + private final Set observedInterfaces; + + private Class concrete; + private Iterator implementedInterfaces; + + public InheritanceIterator(Class root) { + this.concrete = root; + this.observedInterfaces = new LinkedHashSet<>(); + } + + /** + * Construct an iterator that walks the implemented interfaces by the current {@link #concrete} + * class. The should skip all {@link #observedInterfaces}. + * + * @return and iterator over the implemented interfaces. + */ + private Iterator initializeImplementedInterfaces() { + List implemented = new LinkedList<>(); + getAllInterfaces(implemented, this.concrete.getInterfaces()); + return implemented.iterator(); + } + + private void getAllInterfaces(List allInterfaces, Class[] declaredImplementations) { + for (Class implementedInterface : declaredImplementations) { + if (this.observedInterfaces.add(implementedInterface)) + allInterfaces.add(implementedInterface); + } + + for (Class implementedInterface : declaredImplementations) + getAllInterfaces(allInterfaces, implementedInterface.getInterfaces()); + } + + @Override + public boolean hasNext() { + return this.implementedInterfaces == null + || this.implementedInterfaces.hasNext() + || this.concrete.getSuperclass() != null; + } + + @Override + public Class next() { + if (this.implementedInterfaces == null) { + this.implementedInterfaces = this.initializeImplementedInterfaces(); + return this.concrete; + } + + if (this.implementedInterfaces.hasNext()) + return this.implementedInterfaces.next(); + + Class superClass = this.concrete.getSuperclass(); + if (superClass != null) { + this.concrete = superClass; + this.implementedInterfaces = this.initializeImplementedInterfaces(); + return superClass; + } + + throw new NoSuchElementException(); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/SimpleAutoCompleter.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/SimpleAutoCompleter.java new file mode 100644 index 0000000..ecefde0 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/SimpleAutoCompleter.java @@ -0,0 +1,104 @@ +package io.github.spencerpark.jupyter.kernel.util; + +import java.util.*; + +/** + * A utility class to implement a prefix based auto completion algorithm. It + * is a good basic implementation for completing keywords or identifiers that + * have already been parsed or are in the current cell. + */ +public class SimpleAutoCompleter { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private Collection keywords; + private boolean caseSensitive = true; + private Comparator resultsSorter = null; + + private Builder() { + this.keywords = new ArrayList<>(); + } + + public Builder withKeywords(String... keywords) { + Collections.addAll(this.keywords, keywords); + return this; + } + + public Builder withKeywords(Collection keywords) { + this.keywords.addAll(keywords); + return this; + } + + public Builder caseSensitive() { + this.caseSensitive = true; + return this; + } + + public Builder caseInsensitive() { + this.caseSensitive = false; + return this; + } + + private void addSorter(Comparator comparator) { + this.resultsSorter = this.resultsSorter == null ? comparator : this.resultsSorter.thenComparing(comparator); + } + + public Builder preferShort() { + addSorter(SHORTER_BETTER); + return this; + } + + public Builder preferLong() { + addSorter(LONGER_BETTER); + return this; + } + + public Builder preferSmallerChars() { + addSorter(this.caseSensitive ? LOWER_ALPHA_BETTER_CASE : LOWER_ALPHA_BETTER_NO_CASE); + return this; + } + + public Builder preferLargerChars() { + addSorter(this.caseSensitive ? HIGHER_ALPHA_BETTER_CASE : HIGHER_ALPHA_BETTER_NO_CASE); + return this; + } + + public SimpleAutoCompleter build() { + return new SimpleAutoCompleter( + this.keywords, + this.caseSensitive, + this.resultsSorter + ); + } + } + + private static final Comparator SHORTER_BETTER = Comparator.comparingInt(String::length); + private static final Comparator LONGER_BETTER = SHORTER_BETTER.reversed(); + + private static final Comparator LOWER_ALPHA_BETTER_CASE = String::compareTo; + private static final Comparator HIGHER_ALPHA_BETTER_CASE = LOWER_ALPHA_BETTER_CASE.reversed(); + + private static final Comparator LOWER_ALPHA_BETTER_NO_CASE = String::compareToIgnoreCase; + private static final Comparator HIGHER_ALPHA_BETTER_NO_CASE = LOWER_ALPHA_BETTER_NO_CASE.reversed(); + + protected final SortedSet keywords; + protected final Comparator resultsSorter; + + public SimpleAutoCompleter(Collection keywords, boolean caseSensitive, Comparator resultsSorter) { + this.keywords = new TreeSet<>(caseSensitive ? String::compareTo : String::compareToIgnoreCase); + this.keywords.addAll(keywords); + this.resultsSorter = resultsSorter; + } + + public List autocomplete(String prefix) { + SortedSet results = keywords.subSet(prefix, prefix + Character.MAX_VALUE); + List sortedResults = new ArrayList<>(results.size()); + sortedResults.addAll(results); + if (this.resultsSorter != null && sortedResults.size() > 1) + sortedResults.sort(this.resultsSorter); + return sortedResults; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/StringSearch.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/StringSearch.java new file mode 100644 index 0000000..cb8c3e5 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/StringSearch.java @@ -0,0 +1,66 @@ +package io.github.spencerpark.jupyter.kernel.util; + +public class StringSearch { + public static class Range { + private final int low; + private final int high; + + public Range(int low, int high) { + this.low = low; + this.high = high; + } + + public int getLow() { + return low; + } + + public int getHigh() { + return high; + } + + public int getLength() { + return high - low; + } + + public String extractSubString(String original) { + return original.substring(low, high); + } + } + + /** + * Find the longest substring such that all characters in the substring match the + * {@code test}. + * + * @param code the code to preform the search in. + * @param at the position to start the search at. The returned range will contain this + * position. It is usually the position of a cursor. + * @param test a predicate that must evaluate to true if a character should be included in + * the match. + * + * @return a range specifying the bounds of the longest match containing the {@code at} + * position. If nothing matches then this returns {@code null}. + */ + public static Range findLongestMatchingAt(String code, int at, CharPredicate test) { + if (test == null || at < 0 || at > code.length()) + return null; + + int start, end; + if (at < code.length() && test.test(code.charAt(at))) { + //The code[at] is a valid char and so worst case start = end is the entire string + start = end = at; + } else if (at > 0 && test.test(code.charAt(at - 1))) { + //The code[at] isn't valid but the previous one is a good starting point + //which may happen if "at" is immediately following a word + start = end = at - 1; + } else { + return null; + } + + while (start > 0 && test.test(code.charAt(start - 1))) + start--; + while (end < code.length() - 1 && test.test(code.charAt(end + 1))) + end++; + + return new Range(start, end + 1); + } +} diff --git a/src/main/java/io/github/spencerpark/jupyter/kernel/util/StringStyler.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/StringStyler.java similarity index 99% rename from src/main/java/io/github/spencerpark/jupyter/kernel/util/StringStyler.java rename to basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/StringStyler.java index a929603..c836f0b 100644 --- a/src/main/java/io/github/spencerpark/jupyter/kernel/util/StringStyler.java +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/StringStyler.java @@ -1,7 +1,7 @@ /* * The MIT License (MIT) * - * Copyright (c) 2025 ${author} + * Copyright (c) 2025 ebpro * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal diff --git a/src/main/java/io/github/spencerpark/jupyter/kernel/util/TextColor.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/TextColor.java similarity index 99% rename from src/main/java/io/github/spencerpark/jupyter/kernel/util/TextColor.java rename to basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/TextColor.java index e0d3fc2..aeda199 100644 --- a/src/main/java/io/github/spencerpark/jupyter/kernel/util/TextColor.java +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/kernel/util/TextColor.java @@ -1,7 +1,7 @@ /* * The MIT License (MIT) * - * Copyright (c) 2025 ${author} + * Copyright (c) 2025 ebpro * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/ContentType.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/ContentType.java new file mode 100644 index 0000000..4ab280d --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/ContentType.java @@ -0,0 +1,5 @@ +package io.github.spencerpark.jupyter.messages; + +public interface ContentType { + public MessageType getType(); +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/HMACGenerator.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/HMACGenerator.java new file mode 100644 index 0000000..213850a --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/HMACGenerator.java @@ -0,0 +1,49 @@ +package io.github.spencerpark.jupyter.messages; + +import io.github.spencerpark.jupyter.channels.JupyterSocket; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +public class HMACGenerator { + private static final int MASK_INT_TO_BYTE = 0xFF; + private static final int MASK_BYTE_LOWER = 0x0F; + + public static final HMACGenerator NO_AUTH_INSTANCE = new HMACGenerator() { + @Override + public String calculateSignature(byte[]... messageParts) { + return ""; + } + }; + + private final Mac mac; + + public HMACGenerator(String algorithm, String key) throws NoSuchAlgorithmException, InvalidKeyException { + this.mac = Mac.getInstance(algorithm.replace("-", "")); + this.mac.init(new SecretKeySpec(key.getBytes(JupyterSocket.ASCII), algorithm)); + } + + private HMACGenerator() { + this.mac = null; + } + + private final static char[] HEX_CHAR = "0123456789abcdef".toCharArray(); + + public synchronized String calculateSignature(byte[]... messageParts) { + for (byte[] part : messageParts) + this.mac.update(part); + + byte[] sig = this.mac.doFinal(); + + char[] hex = new char[sig.length * 2]; + for (int j = 0; j < sig.length; j++) { + int b = sig[j] & MASK_INT_TO_BYTE; + hex[j * 2] = HEX_CHAR[b >>> 4]; + hex[j * 2 + 1] = HEX_CHAR[b & MASK_BYTE_LOWER]; + } + + return new String(hex); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/Header.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/Header.java new file mode 100644 index 0000000..58020fd --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/Header.java @@ -0,0 +1,83 @@ +package io.github.spencerpark.jupyter.messages; + +import com.google.gson.annotations.SerializedName; + +import java.util.UUID; + +public class Header { + public static final String KERNEL_USERNAME = "kernel"; + public static final String PROTOCOL_VERISON = "5.3"; + + private final String id; + private final String username; + + @SerializedName("session") + private final String sessionId; + + @SerializedName("date") + private final KernelTimestamp timestamp; + + @SerializedName("msg_type") + private final MessageType type; + + private final String version; + + public Header(MessageType type) { + this("", type); + } + + public Header(String sessionId, MessageType type) { + this( + UUID.randomUUID().toString(), + KERNEL_USERNAME, + sessionId, + KernelTimestamp.now(), + type, + PROTOCOL_VERISON + ); + } + + public Header(MessageContext ctx, MessageType type) { + this( + UUID.randomUUID().toString(), + ctx != null ? ctx.getHeader().getUsername() : KERNEL_USERNAME, + ctx != null ? ctx.getHeader().getSessionId() : null, + KernelTimestamp.now(), + type, + PROTOCOL_VERISON + ); + } + + public Header(String id, String username, String sessionId, KernelTimestamp timestamp, MessageType type, String version) { + this.id = id; + this.username = username; + this.sessionId = sessionId; + this.timestamp = timestamp; + this.type = type; + this.version = version; + } + + public String getId() { + return id; + } + + public String getUsername() { + return username; + } + + public String getSessionId() { + return sessionId; + } + + public KernelTimestamp getTimestamp() { + return timestamp; + } + + public MessageType getType() { + return type; + } + + public String getVersion() { + return version; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/KernelTimestamp.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/KernelTimestamp.java new file mode 100644 index 0000000..1fb03ec --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/KernelTimestamp.java @@ -0,0 +1,45 @@ +package io.github.spencerpark.jupyter.messages; + +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.TimeZone; + +/** + * A lazy date parser + */ +public class KernelTimestamp { + public static KernelTimestamp now() { + return new KernelTimestamp(new Date()); + } + + private static final ThreadLocal DATE_FORMAT = ThreadLocal.withInitial(() -> { + DateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mmZ"); + format.setTimeZone(TimeZone.getTimeZone("UTC")); + return format; + }); + + private String serialized; + private Date date; + + public KernelTimestamp(String serialized) { + this.serialized = serialized; + } + + public KernelTimestamp(Date date) { + this.date = date; + } + + public Date getDate() { + try { + return date != null ? date : (date = DATE_FORMAT.get().parse(serialized)); + } catch (ParseException e) { + throw new RuntimeException("Invalid date string '" + serialized + "'", e); + } + } + + public String getDateString() { + return serialized != null ? serialized : (serialized = DATE_FORMAT.get().format(date)); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/Message.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/Message.java new file mode 100644 index 0000000..f912d04 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/Message.java @@ -0,0 +1,119 @@ +package io.github.spencerpark.jupyter.messages; + +import java.util.*; + +public class Message implements MessageContext { + private List identities; + + private Header header; + + /** + * Optional, in a chain of messages this is copied from + * the parent so the client can better track where the messages + * come from. + */ + private Header parentHeader; + + private Map metadata; + + private T content; + + private List blobs; + + public Message(MessageContext ctx, MessageType type, T content) { + this(ctx, type, content, null, null); + } + + public Message(MessageContext ctx, MessageType type, T content, List blobs, Map metadata) { + this( + ctx != null ? ctx.getIdentities() : Collections.emptyList(), + new Header<>(ctx, type), + ctx != null ? ctx.getHeader() : null, + metadata, + content, + blobs + ); + } + + public Message(Header header, T content) { + this(Collections.emptyList(), header, null, null, content, null); + } + + public Message(Header header, T content, Map metadata, List blobs) { + this(Collections.emptyList(), header, null, metadata, content, blobs); + } + + public Message(List identities, Header header, T content) { + this(identities, header, null, null, content, null); + } + + public Message(List identities, Header header, Header parentHeader, Map metadata, T content, List blobs) { + this.identities = identities; + this.header = header; + this.parentHeader = parentHeader; + this.metadata = metadata; + this.content = content; + this.blobs = blobs; + } + + @Override + public List getIdentities() { + return identities; + } + + @Override + public Header getHeader() { + return header; + } + + public boolean hasParentHeader() { + return parentHeader != null; + } + + public Header getParentHeader() { + return parentHeader; + } + + public boolean hasMetadata() { + return metadata != null; + } + + public Map getMetadata() { + return metadata; + } + + public Map getNonNullMetadata() { + if (this.hasMetadata()) + return this.getMetadata(); + this.metadata = new LinkedHashMap<>(); + return this.metadata; + } + + public T getContent() { + return content; + } + + public List getBlobs() { + return blobs; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("Message {\n"); + sb.append("\tidentities = [\n"); + for (byte[] id : identities) + sb.append("\t\t").append(Arrays.toString(id)).append("\n"); + sb.append("\t]\n"); + sb.append("\theader = ").append(header).append("\n"); + sb.append("\tparentHeader = ").append(parentHeader).append("\n"); + sb.append("\tmetadata = ").append(metadata).append("\n"); + sb.append("\tcontent = ").append(content).append("\n"); + sb.append("\tblobs = [\n"); + if (blobs != null) + for (byte[] blob : blobs) + sb.append("\t\t").append(Arrays.toString(blob)).append("\n"); + sb.append("\t]\n"); + sb.append("}\n"); + return sb.toString(); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/MessageContext.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/MessageContext.java new file mode 100644 index 0000000..314980d --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/MessageContext.java @@ -0,0 +1,9 @@ +package io.github.spencerpark.jupyter.messages; + +import java.util.List; + +public interface MessageContext { + public List getIdentities(); + + public Header getHeader(); +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/MessageType.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/MessageType.java new file mode 100644 index 0000000..61e0713 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/MessageType.java @@ -0,0 +1,123 @@ +package io.github.spencerpark.jupyter.messages; + +import io.github.spencerpark.jupyter.messages.comm.CommCloseCommand; +import io.github.spencerpark.jupyter.messages.comm.CommMsgCommand; +import io.github.spencerpark.jupyter.messages.comm.CommOpenCommand; +import io.github.spencerpark.jupyter.messages.publish.*; +import io.github.spencerpark.jupyter.messages.reply.*; +import io.github.spencerpark.jupyter.messages.request.*; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +public class MessageType { + private static final AtomicInteger NEXT_ID = new AtomicInteger(0); + + private static final Map> TYPE_BY_NAME = new HashMap<>(); + + public static MessageType getType(String name) { + MessageType type = TYPE_BY_NAME.get(name); + return type == null ? UNKNOWN : type; + } + + //Request + public static final MessageType EXECUTE_REQUEST = new MessageType<>("execute_request", ExecuteRequest.class); + public static final MessageType INSPECT_REQUEST = new MessageType<>("inspect_request", InspectRequest.class); + public static final MessageType COMPLETE_REQUEST = new MessageType<>("complete_request", CompleteRequest.class); + public static final MessageType HISTORY_REQUEST = new MessageType<>("history_request", HistoryRequest.class); + public static final MessageType IS_COMPLETE_REQUEST = new MessageType<>("is_complete_request", IsCompleteRequest.class); + public static final MessageType COMM_INFO_REQUEST = new MessageType<>("comm_info_request", CommInfoRequest.class); + public static final MessageType KERNEL_INFO_REQUEST = new MessageType<>("kernel_info_request", KernelInfoRequest.class); + public static final MessageType SHUTDOWN_REQUEST = new MessageType<>("shutdown_request", ShutdownRequest.class); + public static final MessageType INTERRUPT_REQUEST = new MessageType<>("interrupt_request", InterruptRequest.class); + + //Reply + public static final MessageType EXECUTE_REPLY = new MessageType<>("execute_reply", ExecuteReply.class); + public static final MessageType INSPECT_REPLY = new MessageType<>("inspect_reply", InspectReply.class); + public static final MessageType COMPLETE_REPLY = new MessageType<>("complete_reply", CompleteReply.class); + public static final MessageType HISTORY_REPLY = new MessageType<>("history_reply", HistoryReply.class); + public static final MessageType IS_COMPLETE_REPLY = new MessageType<>("is_complete_reply", IsCompleteReply.class); + public static final MessageType COMM_INFO_REPLY = new MessageType<>("comm_info_reply", CommInfoReply.class); + public static final MessageType KERNEL_INFO_REPLY = new MessageType<>("kernel_info_reply", KernelInfoReply.class); + public static final MessageType SHUTDOWN_REPLY = new MessageType<>("shutdown_reply", ShutdownReply.class); + public static final MessageType INTERRUPT_REPLY = new MessageType<>("interrupt_reply", InterruptReply.class); + + //Publish + public static final MessageType PUBLISH_STREAM = new MessageType<>("stream", PublishStream.class); + public static final MessageType PUBLISH_DISPLAY_DATA = new MessageType<>("display_data", PublishDisplayData.class); + public static final MessageType PUBLISH_UPDATE_DISPLAY_DATA = new MessageType<>("update_display_data", PublishUpdateDisplayData.class); + public static final MessageType PUBLISH_EXECUTE_INPUT = new MessageType<>("execute_input", PublishExecuteInput.class); + public static final MessageType PUBLISH_EXECUTION_RESULT = new MessageType<>("execute_result", PublishExecuteResult.class); + public static final MessageType PUBLISH_ERROR = new MessageType<>("error", PublishError.class); + public static final MessageType PUBLISH_STATUS = new MessageType<>("status", PublishStatus.class); + public static final MessageType PUBLISH_CLEAR_OUTPUT = new MessageType<>("clear_output", PublishClearOutput.class); + + //Stdin + public static final MessageType INPUT_REQUEST = new MessageType<>("input_request", InputRequest.class); + + public static final MessageType INPUT_REPLY = new MessageType<>("input_reply", InputReply.class); + + //Comm + public static final MessageType COMM_OPEN_COMMAND = new MessageType<>("comm_open", CommOpenCommand.class); + public static final MessageType COMM_MSG_COMMAND = new MessageType<>("comm_msg", CommMsgCommand.class); + public static final MessageType COMM_CLOSE_COMMAND = new MessageType<>("comm_close", CommCloseCommand.class); + + public static final MessageType UNKNOWN = new MessageType<>("none", Object.class); + + private final String name; + private final Class contentType; + private final int id; + private final MessageType errorType; + + private MessageType(String name, Class contentType) { + this(name, contentType, false); + } + + private MessageType(String name, Class contentType, boolean isErrorType) { + this.name = name; + this.contentType = contentType; + this.id = NEXT_ID.getAndIncrement(); + if (!isErrorType) { + TYPE_BY_NAME.put(name, this); + this.errorType = new MessageType<>(name, ErrorReply.class, true); + } else { + this.errorType = null; + } + } + + public String getName() { + return this.name; + } + + public Class getContentType() { + return this.contentType; + } + + public MessageType error() { + return this.errorType; + } + + public boolean isError() { + return this.errorType == null; + } + + public boolean isErrorFor(MessageType other) { + return this.isError() && this == other.error(); + } + + @Override + public String toString() { + return name; + } + + @Override + public int hashCode() { + return id; + } + + @Override + public boolean equals(Object obj) { + return this == obj; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/ReplyType.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/ReplyType.java new file mode 100644 index 0000000..bac9dd2 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/ReplyType.java @@ -0,0 +1,5 @@ +package io.github.spencerpark.jupyter.messages; + +public interface ReplyType { + public MessageType getRequestType(); +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/RequestType.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/RequestType.java new file mode 100644 index 0000000..eea1d4d --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/RequestType.java @@ -0,0 +1,5 @@ +package io.github.spencerpark.jupyter.messages; + +public interface RequestType { + public MessageType getReplyType(); +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/ExpressionValueAdapter.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/ExpressionValueAdapter.java new file mode 100644 index 0000000..f9baad1 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/ExpressionValueAdapter.java @@ -0,0 +1,38 @@ +package io.github.spencerpark.jupyter.messages.adapters; + +import com.google.gson.*; +import io.github.spencerpark.jupyter.kernel.display.DisplayData; +import io.github.spencerpark.jupyter.kernel.ExpressionValue; + +import java.lang.reflect.Type; + +/** + * Decode/encode an {@link ExpressionValue} as either a {@link ExpressionValue.Error} or {@link ExpressionValue.Success} + * based on the {@code "status"} field. + */ +public class ExpressionValueAdapter implements JsonSerializer, JsonDeserializer { + public static final ExpressionValueAdapter INSTANCE = new ExpressionValueAdapter(); + + private ExpressionValueAdapter() { } + + @Override + public ExpressionValue deserialize(JsonElement jsonElement, Type type, JsonDeserializationContext ctx) throws JsonParseException { + if (jsonElement.isJsonObject()) { + JsonElement status = jsonElement.getAsJsonObject().get("status"); + if (status != null && status.isJsonPrimitive() + && status.getAsString().equalsIgnoreCase("error")) + return ctx.deserialize(jsonElement, ExpressionValue.Error.class); + } + + DisplayData data = ctx.deserialize(jsonElement, DisplayData.class); + return new ExpressionValue.Success(data); + } + + @Override + public JsonElement serialize(ExpressionValue exprVal, Type type, JsonSerializationContext ctx) { + if (exprVal.isSuccess()) + return ctx.serialize(exprVal, ExpressionValue.Success.class); + else + return ctx.serialize(exprVal, ExpressionValue.Error.class); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/HeaderAdapter.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/HeaderAdapter.java new file mode 100644 index 0000000..93ed349 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/HeaderAdapter.java @@ -0,0 +1,41 @@ +package io.github.spencerpark.jupyter.messages.adapters; + +import com.google.gson.*; +import io.github.spencerpark.jupyter.messages.Header; +import io.github.spencerpark.jupyter.messages.KernelTimestamp; +import io.github.spencerpark.jupyter.messages.MessageType; + +import java.lang.reflect.Type; + +public class HeaderAdapter implements JsonSerializer
, JsonDeserializer
{ + public static final HeaderAdapter INSTANCE = new HeaderAdapter(); + + private HeaderAdapter() { } + + @Override + public Header deserialize(JsonElement element, Type type, JsonDeserializationContext ctx) throws JsonParseException { + JsonObject object = element.getAsJsonObject(); + return new Header<>( + object.get("msg_id").getAsString(), + object.get("username").getAsString(), + object.get("session").getAsString(), + ctx.deserialize(object.get("date"), KernelTimestamp.class), + ctx.deserialize(object.get("msg_type"), MessageType.class), + object.get("version").getAsString() + ); + } + + @Override + public JsonElement serialize(Header header, Type type, JsonSerializationContext ctx) { + JsonObject object = new JsonObject(); + + object.addProperty("msg_id", header.getId()); + object.addProperty("username", header.getUsername()); + object.addProperty("session", header.getSessionId()); + object.add("date", ctx.serialize(header.getTimestamp())); + object.add("msg_type", ctx.serialize(header.getType())); + object.addProperty("version", header.getVersion()); + + return object; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/HistoryEntryAdapter.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/HistoryEntryAdapter.java new file mode 100644 index 0000000..8205c23 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/HistoryEntryAdapter.java @@ -0,0 +1,36 @@ +package io.github.spencerpark.jupyter.messages.adapters; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import io.github.spencerpark.jupyter.kernel.history.HistoryEntry; + +import java.lang.reflect.Type; + +public class HistoryEntryAdapter implements JsonSerializer { + public static final HistoryEntryAdapter INSTANCE = new HistoryEntryAdapter(); + + private HistoryEntryAdapter() { } + + @Override + public JsonElement serialize(HistoryEntry src, Type type, JsonSerializationContext ctx) { + JsonArray tuple = new JsonArray(); + + tuple.add(src.getSession()); + tuple.add(src.getCellNumber()); + + if (src.hasOutput()) { + JsonArray ioPair = new JsonArray(); + + ioPair.add(src.getInput()); + ioPair.add(src.getOutput()); + + tuple.add(ioPair); + } else { + tuple.add(src.getInput()); + } + + return tuple; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/HistoryRequestAdapter.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/HistoryRequestAdapter.java new file mode 100644 index 0000000..fc9cf3c --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/HistoryRequestAdapter.java @@ -0,0 +1,30 @@ +package io.github.spencerpark.jupyter.messages.adapters; + +import com.google.gson.*; +import io.github.spencerpark.jupyter.messages.request.HistoryRequest; + +import java.lang.reflect.Type; + +public class HistoryRequestAdapter implements JsonDeserializer { + public static final HistoryRequestAdapter INSTANCE = new HistoryRequestAdapter(); + + private HistoryRequestAdapter() { } + + @Override + public HistoryRequest deserialize(JsonElement element, Type type, JsonDeserializationContext ctx) throws JsonParseException { + JsonObject object = element.getAsJsonObject(); + JsonPrimitive accessTypeRaw = object.getAsJsonPrimitive("hist_access_type"); + + HistoryRequest.AccessType accessType = ctx.deserialize(accessTypeRaw, HistoryRequest.AccessType.class); + switch (accessType) { + case RANGE: + return ctx.deserialize(element, HistoryRequest.Range.class); + case TAIL: + return ctx.deserialize(element, HistoryRequest.Tail.class); + case SEARCH: + return ctx.deserialize(element, HistoryRequest.Search.class); + default: + throw new IllegalArgumentException("Unknown hist_access_type " + String.valueOf(accessTypeRaw)); + } + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/IdentityJsonElementAdapter.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/IdentityJsonElementAdapter.java new file mode 100644 index 0000000..6c86d8a --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/IdentityJsonElementAdapter.java @@ -0,0 +1,39 @@ +package io.github.spencerpark.jupyter.messages.adapters; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; + +/** + * A {@link JsonElement} type adapter that serializes null whether it is enabled on the + * writer or not. It must be explicitly enabled with the {@link com.google.gson.annotations.JsonAdapter @JsonAdapter} + * annotation. + */ +public class IdentityJsonElementAdapter extends TypeAdapter { + private static final ThreadLocal GSON = ThreadLocal.withInitial(() -> + new GsonBuilder().serializeNulls().create()); + + @Override + public void write(JsonWriter out, JsonElement value) throws IOException { + if (out.getSerializeNulls()) { + GSON.get().toJson(value, out); + } else { + out.setSerializeNulls(true); + try { + GSON.get().toJson(value, out); + } finally { + out.setSerializeNulls(false); + } + } + } + + @Override + public JsonElement read(JsonReader in) throws IOException { + return GSON.get().fromJson(in, JsonElement.class); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/KernelTimestampAdapter.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/KernelTimestampAdapter.java new file mode 100644 index 0000000..69e4eea --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/KernelTimestampAdapter.java @@ -0,0 +1,22 @@ +package io.github.spencerpark.jupyter.messages.adapters; + +import com.google.gson.*; +import io.github.spencerpark.jupyter.messages.KernelTimestamp; + +import java.lang.reflect.Type; + +public class KernelTimestampAdapter implements JsonSerializer, JsonDeserializer { + public static final KernelTimestampAdapter INSTANCE = new KernelTimestampAdapter(); + + private KernelTimestampAdapter() { } + + @Override + public KernelTimestamp deserialize(JsonElement element, Type type, JsonDeserializationContext ctx) { + return new KernelTimestamp(element.getAsString()); + } + + @Override + public JsonElement serialize(KernelTimestamp timestamp, Type type, JsonSerializationContext ctx) { + return new JsonPrimitive(timestamp.getDateString()); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/MessageTypeAdapter.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/MessageTypeAdapter.java new file mode 100644 index 0000000..e2801a6 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/MessageTypeAdapter.java @@ -0,0 +1,22 @@ +package io.github.spencerpark.jupyter.messages.adapters; + +import com.google.gson.*; +import io.github.spencerpark.jupyter.messages.MessageType; + +import java.lang.reflect.Type; + +public class MessageTypeAdapter implements JsonSerializer>, JsonDeserializer> { + public static final MessageTypeAdapter INSTANCE = new MessageTypeAdapter(); + + private MessageTypeAdapter() { } + + @Override + public MessageType deserialize(JsonElement jsonElement, Type type, JsonDeserializationContext ctx) throws JsonParseException { + return MessageType.getType(jsonElement.getAsString()); + } + + @Override + public JsonElement serialize(MessageType messageType, Type type, JsonSerializationContext ctx) { + return new JsonPrimitive(messageType.getName()); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/PublishStatusAdapter.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/PublishStatusAdapter.java new file mode 100644 index 0000000..6ce0377 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/PublishStatusAdapter.java @@ -0,0 +1,33 @@ +package io.github.spencerpark.jupyter.messages.adapters; + +import com.google.gson.*; +import io.github.spencerpark.jupyter.messages.publish.PublishStatus; + +import java.lang.reflect.Type; + +public class PublishStatusAdapter implements JsonDeserializer { + public static final PublishStatusAdapter INSTANCE = new PublishStatusAdapter(); + + private PublishStatusAdapter() { } + + @Override + public PublishStatus deserialize(JsonElement element, Type type, JsonDeserializationContext ctx) throws JsonParseException { + if (element == null || !element.isJsonObject()) + return null; + + JsonObject object = element.getAsJsonObject(); + JsonElement stateElement = object.get("execution_state"); + if (stateElement == null || stateElement.isJsonNull()) + return null; + + PublishStatus.State state; + try { + state = ctx.deserialize(stateElement, PublishStatus.State.class); + } catch (JsonParseException | IllegalArgumentException e) { + return null; + } + if (state == null) + return null; + return PublishStatus.forState(state); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/ReplyTypeAdapter.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/ReplyTypeAdapter.java new file mode 100644 index 0000000..0d1fdc9 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/adapters/ReplyTypeAdapter.java @@ -0,0 +1,35 @@ +package io.github.spencerpark.jupyter.messages.adapters; + +import com.google.gson.*; +import io.github.spencerpark.jupyter.messages.ReplyType; +import io.github.spencerpark.jupyter.messages.reply.ErrorReply; + +import java.lang.reflect.Type; + +public class ReplyTypeAdapter implements JsonDeserializer> { + private final Gson replyGson; + + /** + * Important: the given instance must not have this type + * adapter registered or deserialization with this deserializer will + * cause a stack overflow exception. + * + * @param replyGson the gson instance to use when deserializing replies. + */ + public ReplyTypeAdapter(Gson replyGson) { + this.replyGson = replyGson; + } + + @Override + public ReplyType deserialize(JsonElement jsonElement, Type type, JsonDeserializationContext ctx) throws JsonParseException { + // If the reply is an error, decode as an ErrorReply instead of the content type + if (jsonElement.isJsonObject()) { + JsonElement status = jsonElement.getAsJsonObject().get("status"); + if (status != null && status.isJsonPrimitive() + && status.getAsString().equalsIgnoreCase("error")) + return this.replyGson.fromJson(jsonElement, ErrorReply.class); + } + + return this.replyGson.fromJson(jsonElement, type); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/comm/CommCloseCommand.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/comm/CommCloseCommand.java new file mode 100644 index 0000000..69ac292 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/comm/CommCloseCommand.java @@ -0,0 +1,36 @@ +package io.github.spencerpark.jupyter.messages.comm; + +import com.google.gson.JsonObject; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.adapters.IdentityJsonElementAdapter; + +public class CommCloseCommand implements ContentType { + public static final MessageType MESSAGE_TYPE = MessageType.COMM_CLOSE_COMMAND; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @SerializedName("comm_id") + protected final String commId; + + @JsonAdapter(IdentityJsonElementAdapter.class) + protected final JsonObject data; + + public CommCloseCommand(String commId, JsonObject data) { + this.commId = commId; + this.data = data; + } + + public String getCommID() { + return commId; + } + + public JsonObject getData() { + return data; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/comm/CommMsgCommand.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/comm/CommMsgCommand.java new file mode 100644 index 0000000..235413a --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/comm/CommMsgCommand.java @@ -0,0 +1,36 @@ +package io.github.spencerpark.jupyter.messages.comm; + +import com.google.gson.JsonObject; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.adapters.IdentityJsonElementAdapter; + +public class CommMsgCommand implements ContentType { + public static final MessageType MESSAGE_TYPE = MessageType.COMM_MSG_COMMAND; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @SerializedName("comm_id") + protected final String commId; + + @JsonAdapter(IdentityJsonElementAdapter.class) + protected final JsonObject data; + + public CommMsgCommand(String commId, JsonObject data) { + this.commId = commId; + this.data = data; + } + + public String getCommID() { + return commId; + } + + public JsonObject getData() { + return data; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/comm/CommOpenCommand.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/comm/CommOpenCommand.java new file mode 100644 index 0000000..5a37acf --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/comm/CommOpenCommand.java @@ -0,0 +1,44 @@ +package io.github.spencerpark.jupyter.messages.comm; + +import com.google.gson.JsonObject; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.adapters.IdentityJsonElementAdapter; + +public class CommOpenCommand implements ContentType { + public static final MessageType MESSAGE_TYPE = MessageType.COMM_OPEN_COMMAND; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @SerializedName("comm_id") + protected final String commId; + + @SerializedName("target_name") + protected final String targetName; + + @JsonAdapter(IdentityJsonElementAdapter.class) + protected final JsonObject data; + + public CommOpenCommand(String commId, String targetName, JsonObject data) { + this.commId = commId; + this.targetName = targetName; + this.data = data; + } + + public String getCommID() { + return commId; + } + + public String getTargetName() { + return targetName; + } + + public JsonObject getData() { + return data; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/ErrorFormatter.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/ErrorFormatter.java new file mode 100644 index 0000000..2bf60c9 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/ErrorFormatter.java @@ -0,0 +1,8 @@ +package io.github.spencerpark.jupyter.messages.publish; + +import java.util.List; + +@FunctionalInterface +public interface ErrorFormatter { + List format(Exception e); +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishClearOutput.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishClearOutput.java new file mode 100644 index 0000000..508c244 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishClearOutput.java @@ -0,0 +1,29 @@ +package io.github.spencerpark.jupyter.messages.publish; + +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; + +public class PublishClearOutput implements ContentType { + public static final MessageType MESSAGE_TYPE = MessageType.PUBLISH_CLEAR_OUTPUT; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + public static final PublishClearOutput NOW = new PublishClearOutput(false); + public static final PublishClearOutput BEFORE_NEXT_OUTPUT = new PublishClearOutput(true); + + /** + * Wait to clear the output until the + */ + private final boolean wait; + + private PublishClearOutput(boolean wait) { + this.wait = wait; + } + + public boolean shouldWait() { + return wait; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishDisplayData.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishDisplayData.java new file mode 100644 index 0000000..1c48ed4 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishDisplayData.java @@ -0,0 +1,18 @@ +package io.github.spencerpark.jupyter.messages.publish; + +import io.github.spencerpark.jupyter.kernel.display.DisplayData; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; + +public class PublishDisplayData extends DisplayData implements ContentType { + public static final MessageType MESSAGE_TYPE = MessageType.PUBLISH_DISPLAY_DATA; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + public PublishDisplayData(DisplayData data) { + super(data); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishError.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishError.java new file mode 100644 index 0000000..6ff2303 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishError.java @@ -0,0 +1,55 @@ +package io.github.spencerpark.jupyter.messages.publish; + +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.reply.ErrorReply; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * See also {@link ErrorReply} + */ +public class PublishError implements ContentType { + public static final MessageType MESSAGE_TYPE = MessageType.PUBLISH_ERROR; + + public static PublishError of(Exception exception, ErrorFormatter formatter) { + String name = exception.getClass().getSimpleName(); + String msg = exception.getLocalizedMessage(); + List stacktrace = formatter.format(exception); + + return new PublishError(name, msg == null ? "" : msg, stacktrace); + } + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @SerializedName("ename") + protected final String errName; + @SerializedName("evalue") + protected final String errMsg; + @SerializedName("traceback") + protected final List stacktrace; + + public PublishError(String errName, String errMsg, List stacktrace) { + this.errName = errName; + this.errMsg = errMsg; + this.stacktrace = stacktrace; + } + + public String getErrorName() { + return errName; + } + + public String getErrorMessage() { + return errMsg; + } + + public List getStacktrace() { + return stacktrace; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishExecuteInput.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishExecuteInput.java new file mode 100644 index 0000000..0dc4bee --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishExecuteInput.java @@ -0,0 +1,38 @@ +package io.github.spencerpark.jupyter.messages.publish; + +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; + +public class PublishExecuteInput implements ContentType { + public static final MessageType MESSAGE_TYPE = MessageType.PUBLISH_EXECUTE_INPUT; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + /** + * The code that is currently being executed + */ + private final String code; + + /** + * The current execution count + */ + @SerializedName("execution_count") + private final int count; + + public PublishExecuteInput(String code, int count) { + this.code = code; + this.count = count; + } + + public String getCode() { + return code; + } + + public int getCount() { + return count; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishExecuteResult.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishExecuteResult.java new file mode 100644 index 0000000..d0b82fb --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishExecuteResult.java @@ -0,0 +1,27 @@ +package io.github.spencerpark.jupyter.messages.publish; + +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.kernel.display.DisplayData; +import io.github.spencerpark.jupyter.messages.MessageType; + +public class PublishExecuteResult extends DisplayData implements ContentType { + public static final MessageType MESSAGE_TYPE = MessageType.PUBLISH_EXECUTION_RESULT; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @SerializedName("execution_count") + private final int count; + + public PublishExecuteResult(int count, DisplayData data) { + super(data); + this.count = count; + } + + public int getCount() { + return count; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishStatus.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishStatus.java new file mode 100644 index 0000000..5ed1fc2 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishStatus.java @@ -0,0 +1,44 @@ +package io.github.spencerpark.jupyter.messages.publish; + +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; + +public class PublishStatus implements ContentType { + public static final MessageType MESSAGE_TYPE = MessageType.PUBLISH_STATUS; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + public static final PublishStatus BUSY = new PublishStatus(State.BUSY); + public static final PublishStatus IDLE = new PublishStatus(State.IDLE); + public static final PublishStatus STARTING = new PublishStatus(State.STARTING); + + public static PublishStatus forState(State state) { + switch (state) { + case BUSY: return BUSY; + case IDLE: return IDLE; + case STARTING: return STARTING; + default: return null; + } + } + + public enum State { + @SerializedName("busy") BUSY, + @SerializedName("idle") IDLE, + @SerializedName("starting") STARTING + } + + @SerializedName("execution_state") + private final State state; + + private PublishStatus(State state) { + this.state = state; + } + + public State getState() { + return state; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishStream.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishStream.java new file mode 100644 index 0000000..f1d11c6 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishStream.java @@ -0,0 +1,39 @@ +package io.github.spencerpark.jupyter.messages.publish; + +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; + +public class PublishStream implements ContentType { + public static final MessageType MESSAGE_TYPE = MessageType.PUBLISH_STREAM; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + public enum StreamType { + @SerializedName("stdout") OUT, + @SerializedName("stderr") ERR + } + + /** + * One of 'stdout' or 'stderr' + */ + @SerializedName("name") + private final StreamType type; + private final String text; + + public PublishStream(StreamType type, String text) { + this.type = type; + this.text = text; + } + + public StreamType getStreamType() { + return type; + } + + public String getText() { + return text; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishUpdateDisplayData.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishUpdateDisplayData.java new file mode 100644 index 0000000..ac719f9 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/publish/PublishUpdateDisplayData.java @@ -0,0 +1,21 @@ +package io.github.spencerpark.jupyter.messages.publish; + +import io.github.spencerpark.jupyter.kernel.display.DisplayData; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; + +public class PublishUpdateDisplayData extends DisplayData implements ContentType { + public static final MessageType MESSAGE_TYPE = MessageType.PUBLISH_UPDATE_DISPLAY_DATA; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + public PublishUpdateDisplayData(DisplayData data) { + super(data); + + if (!data.hasDisplayId()) + throw new IllegalArgumentException("In order to update a display, the data must have a display_id."); + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/CommInfoReply.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/CommInfoReply.java new file mode 100644 index 0000000..4bbe5d0 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/CommInfoReply.java @@ -0,0 +1,50 @@ +package io.github.spencerpark.jupyter.messages.reply; + +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.ReplyType; +import io.github.spencerpark.jupyter.messages.request.CommInfoRequest; + +import java.util.Map; + +public class CommInfoReply implements ContentType, ReplyType { + public static final MessageType MESSAGE_TYPE = MessageType.COMM_INFO_REPLY; + public static final MessageType REQUEST_MESSAGE_TYPE = MessageType.COMM_INFO_REQUEST; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getRequestType() { + return REQUEST_MESSAGE_TYPE; + } + + public static class CommInfo { + @SerializedName("target_name") + protected final String targetName; + + public CommInfo(String targetName) { + this.targetName = targetName; + } + + public String getTargetName() { + return targetName; + } + } + + /** + * A map of uuid to target_name for the comms + */ + protected final Map comms; + + public CommInfoReply(Map comms) { + this.comms = comms; + } + + public Map getComms() { + return comms; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/CompleteReply.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/CompleteReply.java new file mode 100644 index 0000000..7adcfa7 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/CompleteReply.java @@ -0,0 +1,70 @@ +package io.github.spencerpark.jupyter.messages.reply; + +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.ReplyType; +import io.github.spencerpark.jupyter.messages.request.CompleteRequest; + +import java.util.List; +import java.util.Map; + +public class CompleteReply implements ContentType, ReplyType { + public static final MessageType MESSAGE_TYPE = MessageType.COMPLETE_REPLY; + public static final MessageType REQUEST_MESSAGE_TYPE = MessageType.COMPLETE_REQUEST; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getRequestType() { + return REQUEST_MESSAGE_TYPE; + } + + protected final String status = "ok"; + + protected final List matches; + + /** + * The starting position in the request's code to replace with a match + */ + @SerializedName("cursor_start") + protected final int cursorStart; + + /** + * The end position in the request's code to replace with a match + */ + @SerializedName("cursor_end") + protected final int cursorEnd; + + protected final Map metadata; + + public CompleteReply(List matches, int cursorStart, int cursorEnd, Map metadata) { + this.matches = matches; + this.cursorStart = cursorStart; + this.cursorEnd = cursorEnd; + this.metadata = metadata; + } + + public String getStatus() { + return status; + } + + public List getMatches() { + return matches; + } + + public int getCursorStart() { + return cursorStart; + } + + public int getCursorEnd() { + return cursorEnd; + } + + public Map getMetadata() { + return metadata; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/ErrorReply.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/ErrorReply.java new file mode 100644 index 0000000..6251c7c --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/ErrorReply.java @@ -0,0 +1,64 @@ +package io.github.spencerpark.jupyter.messages.reply; + +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.ReplyType; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +public class ErrorReply implements ReplyType { + @Override + public MessageType getRequestType() { + return MessageType.UNKNOWN; + } + + public static ErrorReply of(Exception exception) { + String name = exception.getClass().getSimpleName(); + String msg = exception.getLocalizedMessage(); + List stacktrace = Arrays.stream(exception.getStackTrace()) + .map(StackTraceElement::toString) + .collect(Collectors.toList()); + + return new ErrorReply(name, msg == null ? "" : msg, stacktrace); + } + + protected final String status = "error"; + @SerializedName("ename") + protected final String errName; + @SerializedName("evalue") + protected final String errMsg; + @SerializedName("traceback") + protected final List stacktrace; + + //Present for the execute_reply in erroneous execution + @SerializedName("execution_count") + protected Integer count; + + public ErrorReply(String errName, String errMsg, List stacktrace) { + this.errName = errName; + this.errMsg = errMsg; + this.stacktrace = stacktrace; + } + + public void setExecutionCount(int count) { + this.count = count; + } + + public String getStatus() { + return status; + } + + public String getErrorName() { + return errName; + } + + public String getErrorMessage() { + return errMsg; + } + + public List getStacktrace() { + return stacktrace; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/ExecuteReply.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/ExecuteReply.java new file mode 100644 index 0000000..72f4f38 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/ExecuteReply.java @@ -0,0 +1,66 @@ +package io.github.spencerpark.jupyter.messages.reply; + +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.kernel.ExpressionValue; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.ReplyType; +import io.github.spencerpark.jupyter.messages.publish.PublishDisplayData; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.request.ExecuteRequest; + +import java.util.Map; + +public class ExecuteReply implements ContentType, ReplyType { + public static final MessageType MESSAGE_TYPE = MessageType.EXECUTE_REPLY; + public static final MessageType REQUEST_MESSAGE_TYPE = MessageType.EXECUTE_REQUEST; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getRequestType() { + return REQUEST_MESSAGE_TYPE; + } + + public enum Status { + @SerializedName("ok") OK, + @SerializedName("error") ERROR + } + + private final Status status; + + @SerializedName("execution_count") + protected final int executionCount; + + /** + * The values are either {@link ErrorReply} or {@link PublishDisplayData} + */ + @SerializedName("user_expressions") + protected final Map evaluatedUserExpr; + + public ExecuteReply(int executionCount, Map evaluatedUserExpr) { + this.status = Status.OK; + this.executionCount = executionCount; + this.evaluatedUserExpr = evaluatedUserExpr; + } + + public ExecuteReply(int executionCount) { + this.status = Status.ERROR; + this.executionCount = executionCount; + this.evaluatedUserExpr = null; + } + + public Status getStatus() { + return status; + } + + public int getExecutionCount() { + return executionCount; + } + + public Map getEvaluatedUserExpr() { + return evaluatedUserExpr; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/HistoryReply.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/HistoryReply.java new file mode 100644 index 0000000..3d8d84d --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/HistoryReply.java @@ -0,0 +1,34 @@ +package io.github.spencerpark.jupyter.messages.reply; + +import io.github.spencerpark.jupyter.kernel.history.HistoryEntry; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.ReplyType; +import io.github.spencerpark.jupyter.messages.request.HistoryRequest; + +import java.util.List; + +public class HistoryReply implements ContentType, ReplyType { + public static final MessageType MESSAGE_TYPE = MessageType.HISTORY_REPLY; + public static final MessageType REQUEST_MESSAGE_TYPE = MessageType.HISTORY_REQUEST; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getRequestType() { + return REQUEST_MESSAGE_TYPE; + } + + protected final List history; + + public HistoryReply(List history) { + this.history = history; + } + + public List getHistory() { + return history; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/InputReply.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/InputReply.java new file mode 100644 index 0000000..d12af3d --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/InputReply.java @@ -0,0 +1,31 @@ +package io.github.spencerpark.jupyter.messages.reply; + +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.ReplyType; +import io.github.spencerpark.jupyter.messages.request.InputRequest; + +public class InputReply implements ContentType, ReplyType { + public static final MessageType MESSAGE_TYPE = MessageType.INPUT_REPLY; + public static final MessageType REQUEST_MESSAGE_TYPE = MessageType.INPUT_REQUEST; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getRequestType() { + return REQUEST_MESSAGE_TYPE; + } + + protected String value; + + public InputReply(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/InspectReply.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/InspectReply.java new file mode 100644 index 0000000..cd8e824 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/InspectReply.java @@ -0,0 +1,38 @@ +package io.github.spencerpark.jupyter.messages.reply; + +import io.github.spencerpark.jupyter.kernel.display.DisplayData; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.ReplyType; +import io.github.spencerpark.jupyter.messages.request.InspectRequest; + +public class InspectReply extends DisplayData implements ContentType, ReplyType { + public static final MessageType MESSAGE_TYPE = MessageType.INSPECT_REPLY; + public static final MessageType REQUEST_MESSAGE_TYPE = MessageType.INSPECT_REQUEST; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getRequestType() { + return REQUEST_MESSAGE_TYPE; + } + + protected final String status = "ok"; + protected final boolean found; + + public InspectReply(boolean found, DisplayData data) { + super(data); + this.found = found; + } + + public String getStatus() { + return status; + } + + public boolean isFound() { + return found; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/InterruptReply.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/InterruptReply.java new file mode 100644 index 0000000..402a926 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/InterruptReply.java @@ -0,0 +1,21 @@ +package io.github.spencerpark.jupyter.messages.reply; + +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.ReplyType; +import io.github.spencerpark.jupyter.messages.request.InterruptRequest; + +public class InterruptReply implements ContentType, ReplyType { + public static final MessageType MESSAGE_TYPE = MessageType.INTERRUPT_REPLY; + public static final MessageType REQUEST_MESSAGE_TYPE = MessageType.INTERRUPT_REQUEST; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getRequestType() { + return REQUEST_MESSAGE_TYPE; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/IsCompleteReply.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/IsCompleteReply.java new file mode 100644 index 0000000..ea942e8 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/IsCompleteReply.java @@ -0,0 +1,112 @@ +package io.github.spencerpark.jupyter.messages.reply; + +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.ReplyType; +import io.github.spencerpark.jupyter.messages.request.IsCompleteRequest; + +public class IsCompleteReply implements ContentType, ReplyType { + public static final MessageType MESSAGE_TYPE = MessageType.IS_COMPLETE_REPLY; + public static final MessageType REQUEST_MESSAGE_TYPE = MessageType.IS_COMPLETE_REQUEST; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getRequestType() { + return REQUEST_MESSAGE_TYPE; + } + + public static final IsCompleteReply VALID_CODE = new IsCompleteReply(Status.VALID_CODE); + public static final IsCompleteReply INVALID_CODE = new IsCompleteReply(Status.INVALID_CODE); + public static final IsCompleteReply UNKNOWN = new IsCompleteReply(Status.UNKNOWN); + + private static final IsCompleteReply[] COMMON_INDENTS = { + new IsCompleteReply(Status.NOT_FINISHED, ""), + new IsCompleteReply(Status.NOT_FINISHED, " "), + new IsCompleteReply(Status.NOT_FINISHED, " "), + new IsCompleteReply(Status.NOT_FINISHED, " "), + new IsCompleteReply(Status.NOT_FINISHED, " "), + new IsCompleteReply(Status.NOT_FINISHED, " "), + new IsCompleteReply(Status.NOT_FINISHED, " "), + new IsCompleteReply(Status.NOT_FINISHED, " "), + new IsCompleteReply(Status.NOT_FINISHED, " "), + new IsCompleteReply(Status.NOT_FINISHED, "\t"), + new IsCompleteReply(Status.NOT_FINISHED, "\t\t") + }; + + /** + * Try to resolve the indent to a common, shared instance, otherwise + * create a new one. Since many indent replies will be a short sequence + * or whitespace or an empty string we can cache some of these. + * + * @param indent the indent to suggest the frontend prefixes the next + * line with + * + * @return a reply describing the indent suggestion + */ + public static IsCompleteReply getIncompleteReplyWithIndent(String indent) { + switch (indent) { + case "": + return COMMON_INDENTS[0]; + case " ": + return COMMON_INDENTS[1]; + case " ": + return COMMON_INDENTS[2]; + case " ": + return COMMON_INDENTS[3]; + case " ": + return COMMON_INDENTS[4]; + case " ": + return COMMON_INDENTS[5]; + case " ": + return COMMON_INDENTS[6]; + case " ": + return COMMON_INDENTS[7]; + case " ": + return COMMON_INDENTS[8]; + case "\t": + return COMMON_INDENTS[9]; + case "\t\t": + return COMMON_INDENTS[10]; + default: + return new IsCompleteReply(Status.NOT_FINISHED, indent); + } + } + + public enum Status { + @SerializedName("complete") VALID_CODE, + @SerializedName("incomplete") NOT_FINISHED, + @SerializedName("invalid") INVALID_CODE, + @SerializedName("unknown") UNKNOWN + } + + protected final Status status; + + /** + * If status is INVALID_CODE this is a hint for the front end on what + * to use for the indent on the next line. + */ + protected final String indent; + + private IsCompleteReply(Status status) { + this.status = status; + this.indent = ""; + } + + private IsCompleteReply(Status status, String indent) { + this.status = status; + this.indent = indent; + } + + public Status getStatus() { + return status; + } + + public String getIndent() { + return indent; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/KernelInfoReply.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/KernelInfoReply.java new file mode 100644 index 0000000..52526b7 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/KernelInfoReply.java @@ -0,0 +1,90 @@ +package io.github.spencerpark.jupyter.messages.reply; + +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.kernel.LanguageInfo; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.ReplyType; +import io.github.spencerpark.jupyter.messages.request.KernelInfoRequest; + +import java.util.List; + +public class KernelInfoReply implements ContentType, ReplyType { + public static final MessageType MESSAGE_TYPE = MessageType.KERNEL_INFO_REPLY; + public static final MessageType REQUEST_MESSAGE_TYPE = MessageType.KERNEL_INFO_REQUEST; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getRequestType() { + return REQUEST_MESSAGE_TYPE; + } + + /** + * Semantic version string. X.Y.Z + */ + @SerializedName("protocol_version") + protected String protocolVersion; + + /** + * Ex. 'ipython' for IPython + */ + @SerializedName("implementation") + protected String implementationName; + + /** + * Semantic version string for the kernel + */ + @SerializedName("implementation_version") + protected String implementationVersion; + + @SerializedName("language_info") + protected LanguageInfo langInfo; + + /** + * An optional banner text about the kernel. + */ + protected String banner; + + /** + * Optional help links about the kernel language + */ + @SerializedName("help_links") + protected List helpLinks; + + public KernelInfoReply(String protocolVersion, String implementationName, String implementationVersion, LanguageInfo langInfo, String banner, List helpLinks) { + this.protocolVersion = protocolVersion; + this.implementationName = implementationName; + this.implementationVersion = implementationVersion; + this.langInfo = langInfo; + this.banner = banner; + this.helpLinks = helpLinks; + } + + public String getProtocolVersion() { + return protocolVersion; + } + + public String getImplementationName() { + return implementationName; + } + + public String getImplementationVersion() { + return implementationVersion; + } + + public LanguageInfo getLangInfo() { + return langInfo; + } + + public String getBanner() { + return banner; + } + + public List getHelpLinks() { + return helpLinks; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/ShutdownReply.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/ShutdownReply.java new file mode 100644 index 0000000..f4780f1 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/reply/ShutdownReply.java @@ -0,0 +1,34 @@ +package io.github.spencerpark.jupyter.messages.reply; + +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.ReplyType; +import io.github.spencerpark.jupyter.messages.request.ShutdownRequest; + +public class ShutdownReply implements ContentType, ReplyType { + public static final MessageType MESSAGE_TYPE = MessageType.SHUTDOWN_REPLY; + public static final MessageType REQUEST_MESSAGE_TYPE = MessageType.SHUTDOWN_REQUEST; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getRequestType() { + return REQUEST_MESSAGE_TYPE; + } + + public static final ShutdownReply SHUTDOWN_AND_RESTART = new ShutdownReply(true); + public static final ShutdownReply SHUTDOWN = new ShutdownReply(false); + + protected boolean restart; + + private ShutdownReply(boolean restart) { + this.restart = restart; + } + + public boolean isRestart() { + return restart; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/CommInfoRequest.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/CommInfoRequest.java new file mode 100644 index 0000000..ee7baa9 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/CommInfoRequest.java @@ -0,0 +1,36 @@ +package io.github.spencerpark.jupyter.messages.request; + +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.RequestType; +import io.github.spencerpark.jupyter.messages.reply.CommInfoReply; + +public class CommInfoRequest implements ContentType, RequestType { + public static final MessageType MESSAGE_TYPE = MessageType.COMM_INFO_REQUEST; + public static final MessageType REPLY_MESSAGE_TYPE = MessageType.COMM_INFO_REPLY; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getReplyType() { + return REPLY_MESSAGE_TYPE; + } + + /** + * An optional target name + */ + @SerializedName("target_name") + protected final String targetName; + + public CommInfoRequest(String targetName) { + this.targetName = targetName; + } + + public String getTargetName() { + return targetName; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/CompleteRequest.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/CompleteRequest.java new file mode 100644 index 0000000..3dddc00 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/CompleteRequest.java @@ -0,0 +1,40 @@ +package io.github.spencerpark.jupyter.messages.request; + +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.RequestType; +import io.github.spencerpark.jupyter.messages.reply.CompleteReply; + +public class CompleteRequest implements ContentType, RequestType { + public static final MessageType MESSAGE_TYPE = MessageType.COMPLETE_REQUEST; + public static final MessageType REPLY_MESSAGE_TYPE = MessageType.COMPLETE_REPLY; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getReplyType() { + return REPLY_MESSAGE_TYPE; + } + + protected final String code; + + @SerializedName("cursor_pos") + protected final int cursorPos; + + public CompleteRequest(String code, int cursorPos) { + this.code = code; + this.cursorPos = cursorPos; + } + + public String getCode() { + return code; + } + + public int getCursorPos() { + return cursorPos; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/ExecuteRequest.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/ExecuteRequest.java new file mode 100644 index 0000000..6478513 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/ExecuteRequest.java @@ -0,0 +1,107 @@ +package io.github.spencerpark.jupyter.messages.request; + +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.RequestType; +import io.github.spencerpark.jupyter.messages.reply.ExecuteReply; + +import java.util.Map; + +public class ExecuteRequest implements ContentType, RequestType { + public static final MessageType MESSAGE_TYPE = MessageType.EXECUTE_REQUEST; + public static final MessageType REPLY_MESSAGE_TYPE = MessageType.EXECUTE_REPLY; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getReplyType() { + return REPLY_MESSAGE_TYPE; + } + + /** + * The source code to execute. May be a multiline string. + */ + protected final String code; + + /** + * silent -> !store_history + * + * if silent: + * - no broadcast on IOPUB channel + * - no execute_result reply + * + * Default: {@code false} + */ + protected final boolean silent; + + /** + * if storeHistory: + * - populate history + */ + @SerializedName("store_history") + protected final boolean storeHistory; + + /** + * A bank of {@code name -> code} that need to be evaluated. + * + * The idea behind it is that a front end may always want {@code path -> `pwd`} + * so that they can display where the kernel is. + */ + @SerializedName("user_expressions") + protected final Map userExpr; + + @SerializedName("allow_stdin") + protected final boolean stdinEnabled; + + @SerializedName("stop_on_error") + protected final boolean stopOnError; + + public ExecuteRequest(String code, boolean silent, boolean storeHistory, Map userExpr, boolean stdinEnabled, boolean stopOnError) { + this.code = code; + this.silent = silent; + this.storeHistory = storeHistory; + this.userExpr = userExpr; + this.stdinEnabled = stdinEnabled; + this.stopOnError = stopOnError; + } + + public String getCode() { + return code; + } + + public boolean isSilent() { + return silent; + } + + public boolean shouldStoreHistory() { + return storeHistory; + } + + public Map getUserExpr() { + return userExpr; + } + + public boolean isStdinEnabled() { + return stdinEnabled; + } + + public boolean shouldStopOnError() { + return stopOnError; + } + + @Override + public String toString() { + return "ExecuteRequest{" + + "code='" + code + '\'' + + ", silent=" + silent + + ", storeHistory=" + storeHistory + + ", userExpr=" + userExpr + + ", stdinEnabled=" + stdinEnabled + + ", stopOnError=" + stopOnError + + '}'; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/HistoryRequest.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/HistoryRequest.java new file mode 100644 index 0000000..9fd9d48 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/HistoryRequest.java @@ -0,0 +1,150 @@ +package io.github.spencerpark.jupyter.messages.request; + +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.RequestType; +import io.github.spencerpark.jupyter.messages.reply.HistoryReply; + +public class HistoryRequest implements ContentType, RequestType { + public static final MessageType MESSAGE_TYPE = MessageType.HISTORY_REQUEST; + public static final MessageType REPLY_MESSAGE_TYPE = MessageType.HISTORY_REPLY; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getReplyType() { + return REPLY_MESSAGE_TYPE; + } + + public enum AccessType { + @SerializedName("range") RANGE, + @SerializedName("tail") TAIL, + @SerializedName("search") SEARCH, + } + + /** + * If true, include the output associated with the inputs. + */ + protected final boolean output; + + /** + * If true, return the raw input history, else the transformed input. + */ + protected final boolean raw; + + @SerializedName("hist_access_type") + protected final AccessType accessType; + + private HistoryRequest(boolean output, boolean raw, AccessType accessType) { + this.output = output; + this.raw = raw; + this.accessType = accessType; + } + + public boolean includeOutput() { + return output; + } + + public boolean useRaw() { + return raw; + } + + public AccessType getAccessType() { + return accessType; + } + + public static class Range extends HistoryRequest { + /** + * A session index that counts up each time the kernel + * starts. If negative the number is counting back from + * the current session. + */ + protected final int session; + + /** + * Start cell (execution count number) within the session. + */ + protected final int start; + + /** + * Stop cell (execution count number) with the session. + */ + protected final int stop; + + public Range(boolean output, boolean raw, int session, int start, int stop) { + super(output, raw, AccessType.RANGE); + this.session = session; + this.start = start; + this.stop = stop; + } + + public int getSessionIndex() { + return session; + } + + public int getStart() { + return start; + } + + public int getStop() { + return stop; + } + } + + public static class Tail extends HistoryRequest { + /** + * Get the last n executions + */ + protected final int n; + + public Tail(boolean output, boolean raw, int n) { + super(output, raw, AccessType.TAIL); + this.n = n; + } + + public int getMaxReturnLength() { + return n; + } + } + + public static class Search extends HistoryRequest { + /** + * Get the last n executions + */ + protected final int n; + + /** + * Glob primary filter with '*' and '?'. Default to '*' + */ + protected final String pattern; + + /** + * If true, omit duplicate entries in the return. Defaults + * to false. + */ + protected final boolean unique; + + public Search(boolean output, boolean raw, int n, String pattern, boolean unique) { + super(output, raw, AccessType.SEARCH); + this.n = n; + this.pattern = pattern; + this.unique = unique; + } + + public int getMaxReturnLength() { + return n; + } + + public String getPattern() { + return pattern; + } + + public boolean filterUnique() { + return unique; + } + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/InputRequest.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/InputRequest.java new file mode 100644 index 0000000..328a309 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/InputRequest.java @@ -0,0 +1,37 @@ +package io.github.spencerpark.jupyter.messages.request; + +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.RequestType; +import io.github.spencerpark.jupyter.messages.reply.InputReply; + +public class InputRequest implements ContentType, RequestType { + public static final MessageType MESSAGE_TYPE = MessageType.INPUT_REQUEST; + public static final MessageType REPLY_MESSAGE_TYPE = MessageType.INPUT_REPLY; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getReplyType() { + return REPLY_MESSAGE_TYPE; + } + + protected String prompt; + protected boolean password; + + public InputRequest(String prompt, boolean password) { + this.prompt = prompt; + this.password = password; + } + + public String getPrompt() { + return prompt; + } + + public boolean isPassword() { + return password; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/InspectRequest.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/InspectRequest.java new file mode 100644 index 0000000..0ef44e2 --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/InspectRequest.java @@ -0,0 +1,59 @@ +package io.github.spencerpark.jupyter.messages.request; + +import com.google.gson.annotations.SerializedName; +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.RequestType; +import io.github.spencerpark.jupyter.messages.reply.InspectReply; + +public class InspectRequest implements ContentType, RequestType { + public static final MessageType MESSAGE_TYPE = MessageType.INSPECT_REQUEST; + public static final MessageType REPLY_MESSAGE_TYPE = MessageType.INSPECT_REPLY; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getReplyType() { + return REPLY_MESSAGE_TYPE; + } + + /** + * The code that the request wants inspected + */ + protected final String code; + + /** + * The character index within the code in which the cursor is + * at. This allows for an inspection + */ + @SerializedName("cursor_pos") + protected final int cursorPos; + + /** + * Either 0 or 1. 0 is the default and in IPython level 1 + * includes the source in the inspection. + */ + @SerializedName("detail_level") + protected final int detailLevel; + + public InspectRequest(String code, int cursorPos, int detailLevel) { + this.code = code; + this.cursorPos = cursorPos; + this.detailLevel = detailLevel; + } + + public String getCode() { + return code; + } + + public int getCursorPos() { + return cursorPos; + } + + public int getDetailLevel() { + return detailLevel; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/InterruptRequest.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/InterruptRequest.java new file mode 100644 index 0000000..39b119a --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/InterruptRequest.java @@ -0,0 +1,21 @@ +package io.github.spencerpark.jupyter.messages.request; + +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.RequestType; +import io.github.spencerpark.jupyter.messages.reply.InterruptReply; + +public class InterruptRequest implements ContentType, RequestType { + public static final MessageType MESSAGE_TYPE = MessageType.INTERRUPT_REQUEST; + public static final MessageType REPLY_MESSAGE_TYPE = MessageType.INTERRUPT_REPLY; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getReplyType() { + return REPLY_MESSAGE_TYPE; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/IsCompleteRequest.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/IsCompleteRequest.java new file mode 100644 index 0000000..44a682a --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/IsCompleteRequest.java @@ -0,0 +1,31 @@ +package io.github.spencerpark.jupyter.messages.request; + +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.RequestType; +import io.github.spencerpark.jupyter.messages.reply.IsCompleteReply; + +public class IsCompleteRequest implements ContentType, RequestType { + public static final MessageType MESSAGE_TYPE = MessageType.IS_COMPLETE_REQUEST; + public static final MessageType REPLY_MESSAGE_TYPE = MessageType.IS_COMPLETE_REPLY; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getReplyType() { + return REPLY_MESSAGE_TYPE; + } + + protected final String code; + + public IsCompleteRequest(String code) { + this.code = code; + } + + public String getCode() { + return code; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/KernelInfoRequest.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/KernelInfoRequest.java new file mode 100644 index 0000000..a41e04c --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/KernelInfoRequest.java @@ -0,0 +1,21 @@ +package io.github.spencerpark.jupyter.messages.request; + +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.RequestType; +import io.github.spencerpark.jupyter.messages.reply.KernelInfoReply; + +public class KernelInfoRequest implements ContentType, RequestType { + public static final MessageType MESSAGE_TYPE = MessageType.KERNEL_INFO_REQUEST; + public static final MessageType REPLY_MESSAGE_TYPE = MessageType.KERNEL_INFO_REPLY; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getReplyType() { + return REPLY_MESSAGE_TYPE; + } +} diff --git a/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/ShutdownRequest.java b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/ShutdownRequest.java new file mode 100644 index 0000000..25c9b2e --- /dev/null +++ b/basekernel/src/main/java/io/github/spencerpark/jupyter/messages/request/ShutdownRequest.java @@ -0,0 +1,34 @@ +package io.github.spencerpark.jupyter.messages.request; + +import io.github.spencerpark.jupyter.messages.ContentType; +import io.github.spencerpark.jupyter.messages.MessageType; +import io.github.spencerpark.jupyter.messages.RequestType; +import io.github.spencerpark.jupyter.messages.reply.ShutdownReply; + +public class ShutdownRequest implements ContentType, RequestType { + public static final MessageType MESSAGE_TYPE = MessageType.SHUTDOWN_REQUEST; + public static final MessageType REPLY_MESSAGE_TYPE = MessageType.SHUTDOWN_REPLY; + + @Override + public MessageType getType() { + return MESSAGE_TYPE; + } + + @Override + public MessageType getReplyType() { + return REPLY_MESSAGE_TYPE; + } + + public static final ShutdownRequest SHUTDOWN_AND_RESTART = new ShutdownRequest(true); + public static final ShutdownRequest SHUTDOWN = new ShutdownRequest(false); + + protected boolean restart; + + private ShutdownRequest(boolean restart) { + this.restart = restart; + } + + public boolean isRestart() { + return restart; + } +} diff --git a/basekernel/src/main/resources/kernel-metadata.json b/basekernel/src/main/resources/kernel-metadata.json new file mode 100644 index 0000000..f200f68 --- /dev/null +++ b/basekernel/src/main/resources/kernel-metadata.json @@ -0,0 +1,4 @@ +{ + "version": "@version@", + "project": "@project@" +} \ No newline at end of file diff --git a/basekernel/src/test/java/io/github/spencerpark/jupyter/kernel/display/RenderRequestTypesResolutionTest.java b/basekernel/src/test/java/io/github/spencerpark/jupyter/kernel/display/RenderRequestTypesResolutionTest.java new file mode 100644 index 0000000..f73374f --- /dev/null +++ b/basekernel/src/test/java/io/github/spencerpark/jupyter/kernel/display/RenderRequestTypesResolutionTest.java @@ -0,0 +1,66 @@ +package io.github.spencerpark.jupyter.kernel.display; + +import io.github.spencerpark.jupyter.kernel.display.mime.MIMEType; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertEquals; + +@RunWith(Parameterized.class) +public class RenderRequestTypesResolutionTest { + @Parameterized.Parameters + public static Collection data() { + return Arrays.asList(new Object[][]{ + { "image/svg+xml", "image/svg+xml", Collections.singletonList("image/svg+xml") }, + { "image/svg+xml", "image/svg", Collections.singletonList("image/svg") }, + { "image/svg+xml", "image/svg+xml", Collections.singletonList("image/*") }, + { "image/svg+xml", "image/svg+xml", Collections.singletonList("image") }, + { "image/svg+xml", "application/xml", Collections.singletonList("application/xml") }, + { "image/svg+xml", "application/xml", Collections.singletonList("application/*") }, + { "image/svg+xml", "application/xml", Collections.singletonList("application") }, + { "image/svg+xml", "image/svg+xml", Collections.singletonList("*") }, + + { "image/svg", "image/svg", Collections.singletonList("image/svg") }, + + { "image/svg", null, Collections.singletonList("application/xml") }, + { "image/svg+xml", null, Collections.singletonList("application/json") }, + }); + } + + private final MIMEType supported; + private final MIMEType expected; + private final RenderRequestTypes requestTypes; + + public RenderRequestTypesResolutionTest(String supported, String expected, List requestTypes) { + this.supported = supported == null ? null : MIMEType.parse(supported); + this.expected = expected == null ? null : MIMEType.parse(expected); + + RenderRequestTypes.Builder builder = new RenderRequestTypes.Builder(group -> { + switch (group) { + case "xml": + return MIMEType.APPLICATION_XML; + case "json": + return MIMEType.APPLICATION_JSON; + default: + return null; + } + }); + requestTypes.stream() + .map(MIMEType::parse) + .forEach(builder::withType); + this.requestTypes = builder.build(); + } + + @Test + public void test() { + MIMEType actual = this.requestTypes.resolveSupportedType(this.supported); + + assertEquals(expected, actual); + } +} \ No newline at end of file diff --git a/basekernel/src/test/java/io/github/spencerpark/jupyter/kernel/display/RendererTest.java b/basekernel/src/test/java/io/github/spencerpark/jupyter/kernel/display/RendererTest.java new file mode 100644 index 0000000..156b7c1 --- /dev/null +++ b/basekernel/src/test/java/io/github/spencerpark/jupyter/kernel/display/RendererTest.java @@ -0,0 +1,454 @@ +package io.github.spencerpark.jupyter.kernel.display; + +import io.github.spencerpark.jupyter.kernel.display.mime.MIMEType; +import org.junit.Before; +import org.junit.Test; + +import java.util.*; + +import static org.junit.Assert.*; + +public class RendererTest { + private Renderer renderer; + + @Before + public void setUp() throws Exception { + this.renderer = new Renderer(); + this.renderer.createRegistration(D.class) + .preferring(MIMEType.TEXT_HTML) + .supporting(MIMEType.TEXT_LATEX) + .register((D d, RenderContext ctx) -> { + ctx.renderIfRequested(MIMEType.TEXT_HTML, d::html); + ctx.renderIfRequested(MIMEType.TEXT_LATEX, () -> "\\d"); + }); + this.renderer.createRegistration(F.class) + .supporting(MIMEType.ANY) + .register((F f, RenderContext ctx) -> { + ctx.renderIfRequested(MIMEType.TEXT_HTML, f::html); + ctx.renderIfRequested(MIMEType.TEXT_CSS, f::css); + ctx.renderIfRequested(MIMEType.APPLICATION_JAVASCRIPT, f::js); + }); + this.renderer.createRegistration(H.class) + .supporting(MIMEType.parse("text/*")) + .supporting(MIMEType.APPLICATION_JAVASCRIPT) + .register((H h, RenderContext ctx) -> { + ctx.renderIfRequested(MIMEType.TEXT_HTML, h::html); + ctx.renderIfRequested(MIMEType.TEXT_CSS, h::css); + ctx.renderIfRequested(MIMEType.APPLICATION_JAVASCRIPT, h::js); + }); + this.renderer.createRegistration(J.class) + .supporting(MIMEType.TEXT_PLAIN) + .supporting(MIMEType.APPLICATION_JAVASCRIPT) + .register((J j, RenderContext ctx) -> { + ctx.renderIfRequested(MIMEType.APPLICATION_JAVASCRIPT, j::js); + ctx.renderIfRequested(MIMEType.TEXT_PLAIN, j::pretty); + }); + } + + class A { + @Override + public String toString() { + return "A"; + } + } + + class B implements DisplayDataRenderable { + @Override + public Set getSupportedRenderTypes() { + return Collections.singleton(MIMEType.TEXT_MARKDOWN); + } + + @Override + public Set getPreferredRenderTypes() { + return Collections.singleton(MIMEType.TEXT_MARKDOWN); + } + + @Override + public void render(RenderContext context) { + context.renderIfRequested(MIMEType.TEXT_MARKDOWN, () -> "**B**"); + } + + @Override + public String toString() { + return "B"; + } + } + + class C implements DisplayDataRenderable { + private final Set supported = new LinkedHashSet<>(); + + C() { + this.supported.add(MIMEType.TEXT_MARKDOWN); + this.supported.add(MIMEType.TEXT_CSS); + } + + @Override + public Set getSupportedRenderTypes() { + return supported; + } + + @Override + public Set getPreferredRenderTypes() { + return Collections.singleton(MIMEType.TEXT_CSS); + } + + @Override + public void render(RenderContext context) { + context.renderIfRequested(MIMEType.TEXT_MARKDOWN, () -> "**C**"); + context.renderIfRequested(MIMEType.TEXT_CSS, () -> ".c{}"); + } + + @Override + public String toString() { + return "C"; + } + } + + class D { + public String html() { + return ""; + } + + @Override + public String toString() { + return "D"; + } + } + + class E implements DisplayDataRenderable { + @Override + public Set getSupportedRenderTypes() { + return Collections.singleton(MIMEType.ANY); + } + + @Override + public void render(RenderContext context) { + context.renderIfRequested(MIMEType.TEXT_HTML, () -> ""); + context.renderIfRequested(MIMEType.TEXT_CSS, () -> ".e{}"); + context.renderIfRequested(MIMEType.APPLICATION_JAVASCRIPT, () -> "e();"); + } + + @Override + public String toString() { + return "E"; + } + } + + class F { + public String html() { + return ""; + } + + public String css() { + return ".f{}"; + } + + public String js() { + return "f();"; + } + + @Override + public String toString() { + return "F"; + } + } + + class G implements DisplayDataRenderable { + @Override + public Set getSupportedRenderTypes() { + return new LinkedHashSet<>(Arrays.asList(MIMEType.parse("text/*"), MIMEType.APPLICATION_JAVASCRIPT)); + } + + @Override + public void render(RenderContext context) { + context.renderIfRequested(MIMEType.TEXT_HTML, () -> ""); + context.renderIfRequested(MIMEType.TEXT_CSS, () -> ".g{}"); + context.renderIfRequested(MIMEType.TEXT_LATEX, () -> "\\g"); + context.renderIfRequested(MIMEType.APPLICATION_JAVASCRIPT, () -> "g();"); + } + + @Override + public String toString() { + return "G"; + } + } + + class H { + public String html() { + return ""; + } + + public String css() { + return ".h{}"; + } + + public String js() { + return "h();"; + } + + @Override + public String toString() { + return "H"; + } + } + + class I implements DisplayDataRenderable { + @Override + public Set getSupportedRenderTypes() { + return new LinkedHashSet<>(Arrays.asList(MIMEType.TEXT_PLAIN, MIMEType.APPLICATION_JAVASCRIPT)); + } + + @Override + public void render(RenderContext context) { + context.renderIfRequested(MIMEType.APPLICATION_JAVASCRIPT, () -> "i();"); + context.renderIfRequested(MIMEType.TEXT_PLAIN, () -> "I!"); + } + + @Override + public String toString() { + return "I"; + } + } + + class J { + public String js() { + return "j();"; + } + + public String pretty() { + return "J!"; + } + + @Override + public String toString() { + return "J"; + } + } + + @Test + public void rendersPlainText() { + DisplayData data = this.renderer.render(new A()); + + assertEquals("A", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void alwaysRendersPlainText() { + DisplayData data = this.renderer.render(new B()); + + assertEquals("B", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void rendersPreferred() { + DisplayData data = this.renderer.render(new B()); + + assertEquals("**B**", data.getData(MIMEType.TEXT_MARKDOWN)); + } + + @Test + public void rendersJustPreferred() { + DisplayData data = this.renderer.render(new C()); + + assertEquals(".c{}", data.getData(MIMEType.TEXT_CSS)); + assertEquals("C", data.getData(MIMEType.TEXT_PLAIN)); + assertNull(data.getData(MIMEType.TEXT_MARKDOWN)); + } + + @Test + public void rendersExternal() { + DisplayData data = this.renderer.render(new D()); + + assertEquals("", data.getData(MIMEType.TEXT_HTML)); + assertEquals("D", data.getData(MIMEType.TEXT_PLAIN)); + assertNull(data.getData(MIMEType.TEXT_LATEX)); + } + + @Test + public void rendersAs() { + DisplayData data = this.renderer.renderAs(new C(), "text/markdown"); + + assertEquals("**C**", data.getData(MIMEType.TEXT_MARKDOWN)); + assertEquals("C", data.getData(MIMEType.TEXT_PLAIN)); + assertNull(data.getData(MIMEType.TEXT_CSS)); + } + + @Test + public void rendersAsExternal() { + DisplayData data = this.renderer.renderAs(new D(), "text/latex"); + + assertEquals("\\d", data.getData(MIMEType.TEXT_LATEX)); + assertEquals("D", data.getData(MIMEType.TEXT_PLAIN)); + assertNull(data.getData(MIMEType.TEXT_HTML)); + } + + @Test + public void supportsPreferringAll() { + DisplayData data = this.renderer.render(new E()); + + assertEquals("", data.getData(MIMEType.TEXT_HTML)); + assertEquals(".e{}", data.getData(MIMEType.TEXT_CSS)); + assertEquals("e();", data.getData(MIMEType.APPLICATION_JAVASCRIPT)); + assertEquals("E", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void supportsPreferringAllExternal() { + DisplayData data = this.renderer.render(new F()); + + assertEquals("", data.getData(MIMEType.TEXT_HTML)); + assertEquals(".f{}", data.getData(MIMEType.TEXT_CSS)); + assertEquals("f();", data.getData(MIMEType.APPLICATION_JAVASCRIPT)); + assertEquals("F", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void supportsPreferringAllRequestingAll() { + DisplayData data = this.renderer.renderAs(new E(), "*"); + + assertEquals("", data.getData(MIMEType.TEXT_HTML)); + assertEquals(".e{}", data.getData(MIMEType.TEXT_CSS)); + assertEquals("e();", data.getData(MIMEType.APPLICATION_JAVASCRIPT)); + assertEquals("E", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void supportsPreferringAllRequestingAllExternal() { + DisplayData data = this.renderer.renderAs(new F(), "*"); + + assertEquals("", data.getData(MIMEType.TEXT_HTML)); + assertEquals(".f{}", data.getData(MIMEType.TEXT_CSS)); + assertEquals("f();", data.getData(MIMEType.APPLICATION_JAVASCRIPT)); + assertEquals("F", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void supportsPreferringAllRequestingSome() { + DisplayData data = this.renderer.renderAs(new E(), "text/html"); + + assertEquals("", data.getData(MIMEType.TEXT_HTML)); + assertNull(data.getData(MIMEType.TEXT_CSS)); + assertNull(data.getData(MIMEType.APPLICATION_JAVASCRIPT)); + assertEquals("E", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void supportsPreferringAllRequestingSomeExternal() { + DisplayData data = this.renderer.renderAs(new F(), "text/html"); + + assertEquals("", data.getData(MIMEType.TEXT_HTML)); + assertNull(data.getData(MIMEType.TEXT_CSS)); + assertNull(data.getData(MIMEType.APPLICATION_JAVASCRIPT)); + assertEquals("F", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void supportsPreferringAllRequestingGroup() { + DisplayData data = this.renderer.renderAs(new E(), "text/*"); + + assertEquals("", data.getData(MIMEType.TEXT_HTML)); + assertEquals(".e{}", data.getData(MIMEType.TEXT_CSS)); + assertNull(data.getData(MIMEType.APPLICATION_JAVASCRIPT)); + assertEquals("E", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void supportsPreferringAllRequestingGroupExternal() { + DisplayData data = this.renderer.renderAs(new F(), "text/*"); + + assertEquals("", data.getData(MIMEType.TEXT_HTML)); + assertEquals(".f{}", data.getData(MIMEType.TEXT_CSS)); + assertNull(data.getData(MIMEType.APPLICATION_JAVASCRIPT)); + assertEquals("F", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void supportsPreferringGroup() { + DisplayData data = this.renderer.render(new G()); + + assertEquals("", data.getData(MIMEType.TEXT_HTML)); + assertEquals(".g{}", data.getData(MIMEType.TEXT_CSS)); + assertEquals("g();", data.getData(MIMEType.APPLICATION_JAVASCRIPT)); + assertEquals("G", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void supportsPreferringGroupExternal() { + DisplayData data = this.renderer.render(new H()); + + assertEquals("", data.getData(MIMEType.TEXT_HTML)); + assertEquals(".h{}", data.getData(MIMEType.TEXT_CSS)); + assertEquals("h();", data.getData(MIMEType.APPLICATION_JAVASCRIPT)); + assertEquals("H", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void supportsPreferringGroupRequestingSome() { + DisplayData data = this.renderer.renderAs(new G(), "text/html"); + + assertEquals("", data.getData(MIMEType.TEXT_HTML)); + assertNull(data.getData(MIMEType.TEXT_CSS)); + assertNull(data.getData(MIMEType.APPLICATION_JAVASCRIPT)); + assertEquals("G", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void supportsPreferringGroupRequestingSomeExternal() { + DisplayData data = this.renderer.renderAs(new H(), "text/html"); + + assertEquals("", data.getData(MIMEType.TEXT_HTML)); + assertNull(data.getData(MIMEType.TEXT_CSS)); + assertNull(data.getData(MIMEType.APPLICATION_JAVASCRIPT)); + assertEquals("H", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void supportsPreferringGroupRequestingGroup() { + DisplayData data = this.renderer.renderAs(new G(), "text/*"); + + assertEquals("", data.getData(MIMEType.TEXT_HTML)); + assertEquals(".g{}", data.getData(MIMEType.TEXT_CSS)); + assertNull(data.getData(MIMEType.APPLICATION_JAVASCRIPT)); + assertEquals("G", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void supportsPreferringGroupRequestingGroupExternal() { + DisplayData data = this.renderer.renderAs(new H(), "text/*"); + + assertEquals("", data.getData(MIMEType.TEXT_HTML)); + assertEquals(".h{}", data.getData(MIMEType.TEXT_CSS)); + assertNull(data.getData(MIMEType.APPLICATION_JAVASCRIPT)); + assertEquals("H", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void supportsOverridingTextRepresentation() { + DisplayData data = this.renderer.render(new I()); + + assertEquals("I!", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void supportsOverridingTextRepresentationExternal() { + DisplayData data = this.renderer.render(new J()); + + assertEquals("J!", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void supportsOverridingTextRepresentationWhenNotRequested() { + DisplayData data = this.renderer.renderAs(new I(), "application/javascript"); + + assertEquals("i();", data.getData(MIMEType.APPLICATION_JAVASCRIPT)); + assertEquals("I!", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void supportsOverridingTextRepresentationWhenNotRequestedExternal() { + DisplayData data = this.renderer.renderAs(new J(), "application/javascript"); + + assertEquals("j();", data.getData(MIMEType.APPLICATION_JAVASCRIPT)); + assertEquals("J!", data.getData(MIMEType.TEXT_PLAIN)); + } +} \ No newline at end of file diff --git a/basekernel/src/test/java/io/github/spencerpark/jupyter/kernel/display/common/UrlTest.java b/basekernel/src/test/java/io/github/spencerpark/jupyter/kernel/display/common/UrlTest.java new file mode 100644 index 0000000..9363381 --- /dev/null +++ b/basekernel/src/test/java/io/github/spencerpark/jupyter/kernel/display/common/UrlTest.java @@ -0,0 +1,58 @@ +package io.github.spencerpark.jupyter.kernel.display.common; + +import io.github.spencerpark.jupyter.kernel.display.DisplayData; +import io.github.spencerpark.jupyter.kernel.display.Renderer; +import io.github.spencerpark.jupyter.kernel.display.mime.MIMEType; +import org.junit.Before; +import org.junit.Test; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class UrlTest { + + private Renderer renderer; + + @Before + public void setUp() { + renderer = new Renderer(); + Url.registerAll(renderer); + } + + @Test + public void rendersWellFormedAnchorByDefault() throws MalformedURLException { + URL url = URI.create("https://example.com/?a=1&b=2").toURL(); + DisplayData data = renderer.renderAs(url, "text/html"); + String html = (String) data.getData(MIMEType.TEXT_HTML); + assertNotNull(html); + assertEquals("https://example.com/?a=1&b=2", html); + } + + @Test + public void rendersPlainUrlAsText() throws MalformedURLException { + URL url = URI.create("https://example.com/?a=1&b=2").toURL(); + DisplayData data = renderer.renderAs(url, "text/plain"); + assertEquals("https://example.com/?a=1&b=2", data.getData(MIMEType.TEXT_PLAIN)); + } + + @Test + public void rendersCustomVoidTagWithoutClosingTag() throws MalformedURLException { + URL url = URI.create("https://example.com/image.png").toURL(); + Map params = Map.of(Url.HTML_TAG_KEY, "img", Url.HTML_SRC_ATTR_KEY, "src"); + DisplayData data = renderer.renderAs(url, params, "text/html"); + assertEquals("", (String) data.getData(MIMEType.TEXT_HTML)); + } + + @Test + public void sanitizesUnsafeHtmlNames() throws MalformedURLException { + URL url = URI.create("https://example.com/").toURL(); + Map params = Map.of(Url.HTML_TAG_KEY, "