├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .python-version ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── banner.txt ├── commands.toml ├── crates └── nu_plugin_plotters │ ├── Cargo.toml │ ├── README.md │ └── src │ ├── commands │ ├── chart_2d.rs │ ├── draw │ │ ├── mod.rs │ │ ├── svg.rs │ │ └── terminal.rs │ ├── mod.rs │ └── series │ │ ├── bar.rs │ │ ├── line.rs │ │ └── mod.rs │ ├── lib.rs │ ├── main.rs │ ├── plugin.rs │ └── value │ ├── chart_2d.rs │ ├── color.rs │ ├── coords │ ├── coord_1d.rs │ ├── coord_2d.rs │ ├── mod.rs │ └── range.rs │ ├── mod.rs │ └── series_2d.rs ├── examples ├── example.ipynb ├── polars-data.nu └── polars.ipynb ├── media ├── draw-terminal.png └── screenshot.png ├── pyproject.toml ├── rustfmt.toml ├── src ├── error.rs ├── handlers │ ├── control.rs │ ├── mod.rs │ ├── shell.rs │ └── stream.rs ├── jupyter │ ├── connection_file.rs │ ├── kernel_info.rs │ ├── messages │ │ ├── control.rs │ │ ├── iopub.rs │ │ ├── mod.rs │ │ ├── multipart.rs │ │ └── shell.rs │ ├── mod.rs │ └── register_kernel.rs ├── main.rs ├── nu │ ├── commands │ │ ├── command.rs │ │ ├── display.rs │ │ ├── external.rs │ │ ├── mod.rs │ │ └── print.rs │ ├── konst.rs │ ├── mod.rs │ ├── module.rs │ └── render.rs └── util.rs ├── tests └── test_kernel.py └── uv.lock /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: continuous-integration 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | env: 8 | RUSTFLAGS: "-D warnings" 9 | 10 | jobs: 11 | cargo: 12 | name: cargo ${{ matrix.job.name }} (${{ matrix.crate }}) 13 | 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | crate: 19 | - nu_plugin_plotters 20 | - nu-jupyter-kernel 21 | job: 22 | - name: check 23 | command: cargo check 24 | - name: build 25 | command: cargo build 26 | - name: doc 27 | command: cargo doc 28 | - name: test 29 | command: cargo test 30 | 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: ConorMacBride/install-package@v1 34 | with: 35 | apt: libfontconfig1-dev 36 | - uses: actions-rust-lang/setup-rust-toolchain@v1 37 | with: 38 | rustflags: $RUSTFLAGS 39 | - run: ${{ matrix.job.command }} -p ${{ matrix.crate }} 40 | 41 | integration-test: 42 | runs-on: ubuntu-latest 43 | 44 | steps: 45 | - uses: actions/checkout@v4 46 | - uses: ConorMacBride/install-package@v1 47 | with: 48 | apt: libfontconfig1-dev 49 | - uses: actions-rust-lang/setup-rust-toolchain@v1 50 | with: 51 | rustflags: $RUSTFLAGS 52 | - run: cargo run register --user 53 | - name: Read .python-version 54 | id: python-version 55 | run: echo "python-version=$(cat .python-version)" >> $GITHUB_OUTPUT 56 | - uses: actions/setup-python@v5 57 | with: 58 | python-version: ${{ steps.python-version.outputs.python-version }} 59 | - uses: yezz123/setup-uv@v4 60 | - run: uv sync 61 | - uses: actions/cache@v4 62 | with: 63 | path: .venv/ 64 | key: ${{ runner.os }}-uv-${{ hashFiles('**/uv.lock') }} 65 | - run: uv run pytest 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode 2 | /target 3 | /examples/.ipynb_checkpoints 4 | /examples/.jupyter 5 | /examples/data 6 | /.venv 7 | __pycache__ 8 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nu-jupyter-kernel" 3 | version = "0.1.9+0.104.0" 4 | edition.workspace = true 5 | description = "A jupyter raw kernel for nu" 6 | repository.workspace = true 7 | license.workspace = true 8 | keywords = ["jupyter-kernel", "jupyter", "nushell"] 9 | categories = ["science"] 10 | 11 | [workspace.package] 12 | edition = "2021" 13 | repository = "https://github.com/cptpiepmatz/nu-jupyter-kernel" 14 | license = "MIT" 15 | 16 | [package.metadata.jupyter] 17 | protocol_version = "5.4" 18 | 19 | [features] 20 | default = ["plotting"] 21 | plotting = ["nu-plotters"] 22 | 23 | [workspace] 24 | members = ["crates/nu_plugin_plotters"] 25 | 26 | [workspace.dependencies] 27 | nu-cmd-extra = "0.104.0" 28 | nu-cmd-lang = "0.104.0" 29 | nu-cmd-plugin = "0.104.0" 30 | nu-command = { version = "0.104.0", features = ["plugin"] } 31 | nu-engine = { version = "0.104.0" } 32 | nu-parser = { version = "0.104.0", features = ["plugin"] } 33 | nu-protocol = { version = "0.104.0", features = ["plugin"] } 34 | nu-plugin = "0.104.0" 35 | nuon = "0.104.0" 36 | 37 | # plotting 38 | [dependencies.nu-plotters] 39 | package = "nu_plugin_plotters" 40 | version = "0.1" 41 | path = "crates/nu_plugin_plotters" 42 | optional = true 43 | 44 | [dependencies] 45 | # nu 46 | nu-cmd-extra.workspace = true 47 | nu-cmd-lang.workspace = true 48 | nu-cmd-plugin.workspace = true 49 | nu-command.workspace = true 50 | nu-engine.version = "0.104.0" # cannot publish if this inherits from workspace 51 | nu-parser.workspace = true 52 | nu-protocol.workspace = true 53 | 54 | # Cryptography and Security 55 | hmac = "0.12.1" 56 | sha2 = "0.10.8" 57 | 58 | # Data Handling and Serialization 59 | bytes = "1.6.0" 60 | serde = { version = "1.0", features = ["derive"] } 61 | serde_json = "1.0" 62 | uuid = "1.8.0" 63 | 64 | # Date and Time 65 | chrono = "0.4.38" 66 | 67 | # Derive Macros 68 | clap = { version = "4.5.4", features = ["derive"] } 69 | derive_more = { version = "2", features = ["full"] } 70 | strum = { version = "0.27", features = ["derive"] } 71 | 72 | # Error Handling 73 | thiserror = "2.0.3" 74 | miette = { version = "7.2.0", features = ["fancy"] } 75 | 76 | # Filesystem 77 | dirs = "6" 78 | 79 | # Formatting and Utilities 80 | const_format = "0.2.32" 81 | hex = "0.4.3" 82 | indoc = "2" 83 | 84 | # Media and Configuration Types 85 | mime = "0.3.17" 86 | mime_guess = "2.0.4" 87 | static-toml = "1.2.0" 88 | 89 | # Networking and IPC 90 | os_pipe = { version = "1.1.5", features = ["io_safety"] } 91 | zeromq = "0.4.0" 92 | 93 | # Synchronization and Concurrency 94 | parking_lot = "0.12.2" 95 | tokio = { version = "1.39.2", features = ["rt", "macros", "parking_lot"] } 96 | 97 | # Miscellaneous 98 | atomic_enum = "0.3.0" 99 | 100 | [patch.crates-io] 101 | zip = { git = "https://github.com/zip-rs/zip2", tag = "v2.5.0" } 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Tim 'Piepmatz' Hesse 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

nu-jupyter-kernel

2 |

3 | 4 | A 5 | jupyter 6 | raw kernel 7 | for nu. 8 | 9 |

10 | 11 |
12 | 13 |
14 | 15 | [![crates.io Version](https://img.shields.io/crates/v/nu-jupyter-kernel?style=for-the-badge)](https://crates.io/crates/nu-jupyter-kernel) 16 | [![Nu Version](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fcptpiepmatz%2Fnu-jupyter-kernel%2Fmain%2FCargo.toml&query=workspace.dependencies%5B'nu-engine'%5D.version&prefix=v&style=for-the-badge&label=Nu%20Version&color=%234E9A06)](https://github.com/nushell/nushell) 17 | ![Jupyter Protocol Version](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fcptpiepmatz%2Fnu-jupyter-kernel%2Fmain%2FCargo.toml&query=package.metadata.jupyter.protocol_version&prefix=v&style=for-the-badge&label=Jupyter%20Protocol%20Version&color=%23F37726) 18 | [![License](https://img.shields.io/github/license/cptpiepmatz/nu-jupyter-kernel?style=for-the-badge)](https://github.com/cptpiepmatz/nu-jupyter-kernel/blob/main/LICENSE) 19 | 20 |
21 | 22 | 23 | ## About 24 | `nu-jupyter-kernel` is a [Jupyter](https://jupyter.org) kernel specifically for 25 | executing Nu pipelines. 26 | Unlike most Jupyter kernels that rely on Python, this raw kernel directly 27 | implements the Jupyter messaging protocol, allowing direct communication without 28 | Python intermediaries. 29 | It's designed to work seamlessly with Nu, the language utilized by 30 | [Nushell](https://github.com/nushell/nushell) — 31 | a modern shell that emphasizes structured data. 32 | 33 | ![screenshot](media/screenshot.png) 34 | 35 | ## Features 36 | The `nu-jupyter-kernel` has several features making it a useful kernel for 37 | Jupyter notebooks: 38 | 39 | - **Execution of Nu code:** 40 | Directly run Nu pipeplines within your Jupyter notebook. 41 | 42 | - **State sharing across cells:** 43 | Unlike isolated script execution, the kernel maintains state across different 44 | cells using the `nu-engine`. 45 | 46 | - **Rich Data Rendering:** 47 | Outputs are dynamically rendered in various data types wherever applicable. 48 | 49 | - **Inline Value Printing:** 50 | Easily print values at any point during cell execution. 51 | 52 | - **Controlled External Commands:** 53 | By default, external commands are disabled for reproducibility. 54 | You can enable them as needed, and they will function as they do in Nushell. 55 | 56 | - **Kernel Information:** 57 | Access kernel-specific information via the `$nuju` constant. 58 | 59 | - **Error representation:** 60 | Shell errors are beautifully rendered. 61 | 62 | - **Nushell Plugin Compatibility:** 63 | Supports Nushell plugins within notebooks, allowing them to be loaded and 64 | utilized as in a typical Nushell environment. 65 | 66 | - **Plotting Integration:** 67 | The kernel directly integrates the `nu_plugin_plotters`, making plots easily 68 | accessible. 69 | 70 | ## Examples 71 | In the "examples" directory are some notebooks that show how the kernel works. 72 | Opening the examples on Github also shows a preview of them. 73 | 74 | 75 | ## Design Goals 76 | The design of the `nu-jupyter-kernel` focuses on the following goals: 77 | 78 | - **Reproducibility:** 79 | Notebooks should be as reproducible as possible by default. 80 | 81 | - **Clarity in dependencies:** 82 | Make all dependencies clear and obvious to the user. 83 | 84 | - **Script-like behavior:** 85 | The kernel behaves largely like a regular Nu script to ensure familiarity. 86 | 87 | - **Clear Feature Distinctions:** 88 | Clearly indicating any deviations or limitations compared to standard Nu 89 | script capabilities to avoid confusion during notebook executions. 90 | 91 | ## Installation 92 | To build the kernel you need to have the rust toolchain installed, check the 93 | [installation guide on rust's official website](https://www.rust-lang.org/tools/install). 94 | 95 | Using `cargo install --locked nu-jupyter-kernel` you can install the latest release of 96 | the kernel. 97 | If you want to install the latest version on the git repo, you can install the 98 | kernel via `cargo install --locked nu-jupyter-kernel --git https://github.com/cptpiepmatz/nu-jupyter-kernel.git` 99 | 100 | ## Usage 101 | ### Registering the Kernel 102 | After installation, you must register the kernel to make it available within 103 | Jupyter environments. 104 | This can be done through the command: 105 | 106 | ```sh 107 | nu-jupyter-kernel register 108 | ``` 109 | 110 | You can specify the registration scope using `--user` for the current user 111 | (default) or `--system` for system-wide availability. 112 | 113 | ### Using the Kernel 114 | 115 | - **Jupyter Notebook:** 116 | Open Jupyter Notebook, create or open a notebook, and then select "Nushell" 117 | from the kernel options in the top right corner. 118 | 119 | - **Visual Studio Code:** 120 | Ensure you have the 121 | [Jupyter extension by Microsoft](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.jupyter) 122 | installed. 123 | Open a `.ipynb` file, click on "Select Kernel", choose "Jupyter Kernel", and 124 | you should see "Nushell" listed. 125 | 126 | Both options may require a restart after registering the kernel. 127 | 128 | ### Note on Updates 129 | Kernel binary updates do not require re-registration unless the binary's 130 | location changes. 131 | For developers, keep in mind that running `cargo run register` and 132 | `cargo run --release register` will result in different binary locations. 133 | 134 | ## Version Scheme 135 | This crate follows the semantic versioning scheme as required by the 136 | [Rust documentation](https://doc.rust-lang.org/cargo/reference/semver.html). 137 | The version number is represented as `x.y.z+a.b.c`, where `x.y.z` is the version 138 | of the crate and `a.b.c` is the version of the `nu-engine` that this crate is 139 | built with. 140 | The `+` symbol is used to separate the two version numbers. 141 | 142 | 143 | ## Contributing 144 | Contributions are welcome! 145 | If you're interested in contributing to the `nu-jupyter-kernel`, you can start 146 | by opening an issue or a pull request. 147 | If you'd like to discuss potential changes or get more involved, join the 148 | Nushell community on Discord. 149 | Invite links are available when you start Nushell or on their GitHub repository. 150 | 151 | ## Testing 152 | This project uses [`uv`](https://github.com/astral-sh/uv) for integration 153 | testing. 154 | Since tools for executing Jupyter notebooks are not currently available in Rust, 155 | the tests are handled via Python. 156 | 157 | To run the tests, follow these steps: 158 | 1. **Register the kernel**: 159 | ```nushell 160 | cargo run register 161 | ``` 162 | 2. **Sync Python dependencies:** 163 | ```nushell 164 | uv sync 165 | ``` 166 | 3. **Run the tests:** 167 | ```nushell 168 | uv run pytest 169 | ``` 170 | 171 | Make sure `uv` is installed before running the commands. 172 | -------------------------------------------------------------------------------- /banner.txt: -------------------------------------------------------------------------------- 1 | __ , 2 | .--()°'.' You're running `nu-jupyter-kernel`, 3 | '|, . ,' based on the `nu` language, 4 | !_-(_\ where all data is structured! 5 | -------------------------------------------------------------------------------- /commands.toml: -------------------------------------------------------------------------------- 1 | # this file holds all the texts that commands use 2 | 3 | # TODO: check out which more should be hidden 4 | incompatible_commands = ["input", "exit", "run-external"] 5 | 6 | [nuju] 7 | name = "nuju" 8 | description = "Control behavior of the kernel." 9 | extra_description = """ 10 | You must use one of the following subcommands. 11 | Using this command as-is will only produce this help message. 12 | """ 13 | 14 | [display] 15 | name = "nuju display" 16 | description = "Control the rendering of the current cell's output." 17 | extra_description = """ 18 | Applies a filter to control how the output of the current cell is displayed. 19 | This command can be positioned anywhere within the cell's code. It passes 20 | through the cell's data, allowing it to be used effectively as the final 21 | command without altering the output content. 22 | """ 23 | search_terms = ["jupyter", "display", "cell", "output"] 24 | 25 | [[display.examples]] 26 | example = "{a: 3, b: [1, 2, 2]} | nuju display md" 27 | description = "Force render output to be markdown" 28 | 29 | [[display.examples]] 30 | example = "{a: 3, b: [1, 2, 2]} | nuju display json" 31 | description = "Force render output to be json" 32 | 33 | [[display.examples]] 34 | example = "{a: 3, b: [1, 2, 2]} | table --expand | nuju display txt" 35 | description = "Force render output to be a classic nushell table" 36 | 37 | [external] 38 | name = "nuju external" 39 | description = "Enable external commands for subsequent cells." 40 | extra_description = """ 41 | Activates a setting that permits the use of external commands in all subsequent 42 | cell evaluations within the notebook. This irreversible change enhances 43 | flexibility for advanced tasks. By disabling external commands by default, 44 | notebooks become more portable and less likely to encounter failures when run 45 | on different machines." 46 | """ 47 | search_terms = ["jupyter", "external", "run"] 48 | 49 | [print] 50 | name = "nuju print" 51 | description = "Display data for this cell." 52 | search_terms = ["jupyter", "print", "display", "cell", "output"] 53 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nu_plugin_plotters" 3 | description = "A nushell plugin for for plotting charts" 4 | version = "0.1.6+0.104.0" 5 | edition.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | # nu 11 | nu-plugin.workspace = true 12 | nu-protocol.workspace = true 13 | nu-engine.workspace = true 14 | 15 | # plotting 16 | plotters = "0.3.6" 17 | 18 | # dates 19 | chrono = "0.4" 20 | 21 | # serialization, needed for custom values 22 | serde = { version = "1", features = ["derive"] } 23 | typetag = "0.2" 24 | 25 | # drawing to the terminal 26 | icy_sixel = "0.1" 27 | viuer = "0.9" 28 | image = "0.25" 29 | 30 | # misc 31 | indoc = "2" 32 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/README.md: -------------------------------------------------------------------------------- 1 |

nu_plugin_plotters

2 |

3 | 4 | A nushell 5 | plugin for plotting charts. 6 | 7 |

8 | 9 |
10 | 11 |

12 | 13 | Version 14 | 15 | 16 | Nu Version 17 | 18 | 19 | License 20 | 21 |

22 | 23 | ## About 24 | `nu_plugin_plotters` is a plugin for [Nushell](https://www.nushell.sh) that 25 | provides easy plotting of data using 26 | [`plotters`](https://github.com/plotters-rs/plotters). 27 | 28 | ![screenshot](../../media/draw-terminal.png) 29 | 30 | ## Usage 31 | The plugin provides three main commands: 32 | 33 | - **`series`**: 34 | Use this command to create a dataset from a list of data points. 35 | You can pass in: 36 | - A table with `x` and `y` columns. 37 | - A list of 2-element lists representing `x` and `y` coordinates. 38 | - A plain list of numbers, where the index of each value becomes the `x` value. 39 | 40 | You can also apply custom styling to the series. 41 | 42 | - **`chart`**: 43 | This command creates a chart from one or more series. 44 | You can either pipe the series into the command or pass them as arguments. 45 | Charts can also be extended by adding more series, and you have options to 46 | customize the chart's appearance. 47 | 48 | - **`draw`**: 49 | This renders the chart onto a canvas. 50 | You can output to an SVG file (using the `save` command) or display directly 51 | in the terminal (using iterm, kitty or sixel). 52 | 53 | These commands are modular, allowing you to build and inspect charts step by 54 | step. 55 | Each command's output is a custom value that can be converted into standard Nu 56 | values for further inspection or manipulation. 57 | 58 | ## `nu-jupyter-kernel` Integration 59 | This plugin is directly integrated into the 60 | [`nu-jupyter-kernel`](https://github.com/cptpiepmatz/nu-jupyter-kernel) and 61 | therefore doesn't need to installed separately in order to create charts for the 62 | notebook. 63 | 64 | Also charts are automatically "drawn" and don't need to be called via `draw svg`. 65 | Just output the chart and the kernel will execute the `draw svg` command 66 | automatically (you may need to enforce this using `nuju display svg`). 67 | 68 | 69 | This plugin is integrated directly into the 70 | [`nu-jupyter-kernel`](https://github.com/cptpiepmatz/nu-jupyter-kernel), so 71 | there's no need for separate installation to create charts within Jupyter 72 | notebooks. 73 | 74 | Charts are automatically rendered without the need to explicitly call `draw svg`. 75 | Simply output the chart, and the kernel will handle the `draw svg` command 76 | behind the scenes. 77 | If necessary, you can enforce this behavior by using the 78 | `nuju display svg` command. 79 | 80 | ## Version Scheme 81 | This crate follows the semantic versioning scheme as required by the 82 | [Rust documentation](https://doc.rust-lang.org/cargo/reference/semver.html). 83 | The version number is represented as `x.y.z+a.b.c`, where `x.y.z` is the version 84 | of the crate and `a.b.c` is the version of the `nu-plugin` that this crate is 85 | built with. 86 | The `+` symbol is used to separate the two version numbers. 87 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/commands/chart_2d.rs: -------------------------------------------------------------------------------- 1 | use nu_engine::command_prelude::*; 2 | use nu_plugin::{EngineInterface, EvaluatedCall, SimplePluginCommand}; 3 | use nu_protocol::{FromValue, LabeledError}; 4 | 5 | use crate::value; 6 | 7 | #[derive(Debug, Clone)] 8 | pub struct Chart2d; 9 | 10 | impl Command for Chart2d { 11 | fn name(&self) -> &str { 12 | "chart 2d" 13 | } 14 | 15 | fn signature(&self) -> Signature { 16 | Signature::new(Command::name(self)) 17 | .add_help() 18 | .description(Command::description(self)) 19 | .extra_description(Command::extra_description(self)) 20 | .search_terms( 21 | Command::search_terms(self) 22 | .into_iter() 23 | .map(ToOwned::to_owned) 24 | .collect(), 25 | ) 26 | .optional( 27 | "chart", 28 | value::Chart2d::ty().to_shape(), 29 | "Baseline chart to extend from.", 30 | ) 31 | .named( 32 | "width", 33 | SyntaxShape::Int, 34 | "Set the width of the chart in pixels.", 35 | Some('W'), 36 | ) 37 | .named( 38 | "height", 39 | SyntaxShape::Int, 40 | "Set the height of the chart in pixels.", 41 | Some('H'), 42 | ) 43 | .named( 44 | "background", 45 | SyntaxShape::Any, 46 | "Set the background color of the chart.", 47 | Some('b'), 48 | ) 49 | .named( 50 | "caption", 51 | SyntaxShape::String, 52 | "Set a caption for the chart.", 53 | Some('c'), 54 | ) 55 | .named( 56 | "margin", 57 | SyntaxShape::List(Box::new(SyntaxShape::Int)), 58 | "Set the margin for the chart, refer to css margin shorthands for setting values.", 59 | Some('m'), 60 | ) 61 | .named( 62 | "label-area", 63 | SyntaxShape::List(Box::new(SyntaxShape::Int)), 64 | "Set the area size for the chart, refer to css margin shorthands for setting \ 65 | values.", 66 | Some('l'), 67 | ) 68 | .named("x-range", SyntaxShape::Range, "Set the x range.", Some('x')) 69 | .named("y-range", SyntaxShape::Range, "Set the y range.", Some('y')) 70 | .switch("disable-mesh", "Disable the background mesh grid.", None) 71 | .switch( 72 | "disable-x-mesh", 73 | "Disable the background mesh for the x axis.", 74 | None, 75 | ) 76 | .switch( 77 | "disable-y-mesh", 78 | "Disable the background mesh for the y axis.", 79 | None, 80 | ) 81 | .input_output_type(Type::Nothing, value::Chart2d::ty()) 82 | .input_output_type(value::Series2d::ty(), value::Chart2d::ty()) 83 | .input_output_type(Type::list(value::Series2d::ty()), value::Chart2d::ty()) 84 | } 85 | 86 | fn description(&self) -> &str { 87 | "Construct a 2D chart." 88 | } 89 | 90 | fn extra_description(&self) -> &str { 91 | "A chart is a container for a list of series, any `plotters::series-2d` or \ 92 | `list` is collected into this container and may be rendered via `draw \ 93 | svg` or `draw png`." 94 | } 95 | 96 | fn search_terms(&self) -> Vec<&str> { 97 | vec!["plotters", "chart", "2d"] 98 | } 99 | 100 | fn run( 101 | &self, 102 | engine_state: &EngineState, 103 | stack: &mut Stack, 104 | call: &Call, 105 | input: PipelineData, 106 | ) -> Result { 107 | let span = input.span().unwrap_or(Span::unknown()); 108 | let input = input.into_value(span)?; 109 | let extend = call.opt(engine_state, stack, 0)?; 110 | let width = call.get_flag(engine_state, stack, "width")?; 111 | let height = call.get_flag(engine_state, stack, "height")?; 112 | let background = call.get_flag(engine_state, stack, "background")?; 113 | let caption = call.get_flag(engine_state, stack, "caption")?; 114 | let margin = call.get_flag(engine_state, stack, "margin")?; 115 | let label_area = call.get_flag(engine_state, stack, "label-area")?; 116 | let x_range = call.get_flag(engine_state, stack, "x-range")?; 117 | let y_range = call.get_flag(engine_state, stack, "y-range")?; 118 | let disable_mesh = call.get_flag(engine_state, stack, "disable-mesh")?; 119 | let disable_x_mesh = call.get_flag(engine_state, stack, "disable-x-mesh")?; 120 | let disable_y_mesh = call.get_flag(engine_state, stack, "disable-y-mesh")?; 121 | Chart2d::run(self, input, extend, Chart2dOptions { 122 | width, 123 | height, 124 | background, 125 | caption, 126 | margin, 127 | label_area, 128 | x_range, 129 | y_range, 130 | disable_mesh, 131 | disable_x_mesh, 132 | disable_y_mesh, 133 | }) 134 | .map(|v| PipelineData::Value(v, None)) 135 | } 136 | } 137 | 138 | impl SimplePluginCommand for Chart2d { 139 | type Plugin = crate::plugin::PlottersPlugin; 140 | 141 | fn name(&self) -> &str { 142 | Command::name(self) 143 | } 144 | 145 | fn signature(&self) -> Signature { 146 | Command::signature(self) 147 | } 148 | 149 | fn description(&self) -> &str { 150 | Command::description(self) 151 | } 152 | 153 | fn extra_description(&self) -> &str { 154 | Command::extra_description(self) 155 | } 156 | 157 | fn search_terms(&self) -> Vec<&str> { 158 | Command::search_terms(self) 159 | } 160 | 161 | fn run( 162 | &self, 163 | _: &Self::Plugin, 164 | _: &EngineInterface, 165 | call: &EvaluatedCall, 166 | input: &Value, 167 | ) -> Result { 168 | let input = input.clone(); 169 | let extend = call 170 | .positional 171 | .first() 172 | .map(|v| ::from_value(v.clone())) 173 | .transpose()?; 174 | 175 | let mut options = Chart2dOptions::default(); 176 | for (name, value) in call.named.clone() { 177 | // TODO: put this function somewhere reusable 178 | fn extract_named( 179 | name: impl ToString, 180 | value: Option, 181 | span: Span, 182 | ) -> Result { 183 | let value = value.ok_or_else(|| ShellError::MissingParameter { 184 | param_name: name.to_string(), 185 | span, 186 | })?; 187 | T::from_value(value) 188 | } 189 | 190 | fn extract_flag(value: Option) -> Result, ShellError> { 191 | Ok(Some(match value { 192 | None => true, 193 | Some(value) => bool::from_value(value)?, 194 | })) 195 | } 196 | 197 | match name.item.as_str() { 198 | "width" => options.width = extract_named("width", value, name.span)?, 199 | "height" => options.height = extract_named("height", value, name.span)?, 200 | "background" => options.background = extract_named("background", value, name.span)?, 201 | "caption" => options.caption = extract_named("caption", value, name.span)?, 202 | "margin" => options.margin = extract_named("margin", value, name.span)?, 203 | "label-area" => options.label_area = extract_named("label-area", value, name.span)?, 204 | "x-range" => options.x_range = extract_named("x-range", value, name.span)?, 205 | "y-range" => options.y_range = extract_named("y-range", value, name.span)?, 206 | "disable-mesh" => options.disable_mesh = extract_flag(value)?, 207 | "disable-x-mesh" => options.disable_x_mesh = extract_flag(value)?, 208 | "disable-y-mesh" => options.disable_y_mesh = extract_flag(value)?, 209 | _ => continue, 210 | } 211 | } 212 | 213 | Chart2d::run(self, input, extend, options).map_err(Into::into) 214 | } 215 | } 216 | 217 | #[derive(Debug, Default)] 218 | struct Chart2dOptions { 219 | pub width: Option, 220 | pub height: Option, 221 | pub background: Option, 222 | pub caption: Option, 223 | pub margin: Option>, 224 | pub label_area: Option>, 225 | pub x_range: Option, 226 | pub y_range: Option, 227 | pub disable_mesh: Option, 228 | pub disable_x_mesh: Option, 229 | pub disable_y_mesh: Option, 230 | } 231 | 232 | impl Chart2d { 233 | fn run( 234 | &self, 235 | input: Value, 236 | extend: Option, 237 | Chart2dOptions { 238 | // unroll here to ensure we use all of them 239 | width, 240 | height, 241 | background, 242 | caption, 243 | margin, 244 | label_area, 245 | x_range, 246 | y_range, 247 | disable_mesh, 248 | disable_x_mesh, 249 | disable_y_mesh, 250 | }: Chart2dOptions, 251 | ) -> Result { 252 | let span = input.span(); 253 | let mut input = match input { 254 | v @ Value::Custom { .. } => vec![value::Series2d::from_value(v)?], 255 | v @ Value::List { .. } => Vec::from_value(v)?, 256 | _ => todo!("handle invalid input"), 257 | }; 258 | 259 | let mut chart = extend.unwrap_or_default(); 260 | chart.series.append(&mut input); 261 | chart.width = width.unwrap_or(chart.width); 262 | chart.height = height.unwrap_or(chart.height); 263 | chart.background = background.or(chart.background); 264 | chart.caption = caption.or(chart.caption); 265 | chart.margin = margin 266 | .map(side_shorthand) 267 | .transpose()? 268 | .unwrap_or(chart.margin); 269 | chart.label_area = label_area 270 | .map(side_shorthand) 271 | .transpose()? 272 | .unwrap_or(chart.label_area); 273 | chart.x_range = x_range.or(chart.x_range); 274 | chart.y_range = y_range.or(chart.y_range); 275 | chart.x_mesh = !(disable_mesh.unwrap_or(false) || disable_x_mesh.unwrap_or(false)); 276 | chart.y_mesh = !(disable_mesh.unwrap_or(false) || disable_y_mesh.unwrap_or(false)); 277 | 278 | Ok(Value::custom(Box::new(chart), span)) 279 | } 280 | } 281 | 282 | fn side_shorthand(input: Vec) -> Result<[T; 4], ShellError> { 283 | let mut iter = input.into_iter(); 284 | Ok(match (iter.next(), iter.next(), iter.next(), iter.next()) { 285 | (Some(a), None, None, None) => [a, a, a, a], 286 | (Some(a), Some(b), None, None) => [a, b, a, b], 287 | (Some(a), Some(b), Some(c), None) => [a, b, b, c], 288 | (Some(a), Some(b), Some(c), Some(d)) => [a, b, c, d], 289 | (None, None, None, None) => todo!("throw error for empty list"), 290 | _ => unreachable!("all other variants are not possible with lists"), 291 | }) 292 | } 293 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/commands/draw/mod.rs: -------------------------------------------------------------------------------- 1 | use plotters::chart::{ChartBuilder, ChartContext, LabelAreaPosition}; 2 | use plotters::coord::Shift; 3 | use plotters::prelude::{Cartesian2d, DrawingArea, DrawingBackend, Rectangle}; 4 | use plotters::series::LineSeries; 5 | use plotters::style::{RGBAColor, ShapeStyle, BLACK}; 6 | 7 | use crate::value::{ 8 | self, Bar2dSeries, Coord1d, Coord2d, Line2dSeries, Range, RangeMetadata, Series2d, 9 | }; 10 | 11 | mod svg; 12 | pub use svg::*; 13 | 14 | mod terminal; 15 | pub use terminal::*; 16 | 17 | fn draw(chart: value::Chart2d, drawing_area: DrawingArea) { 18 | if chart.series.is_empty() { 19 | todo!("return some error that empty series do not work") 20 | } 21 | 22 | // TODO: make better error 23 | let mut x_range = chart.x_range().unwrap(); 24 | let y_range = chart.y_range().unwrap(); 25 | 26 | // bar charts typically want to display all the discrete points 27 | if chart 28 | .series 29 | .iter() 30 | .any(|series| matches!(series, Series2d::Bar(_))) 31 | { 32 | x_range.metadata = Some(RangeMetadata { 33 | discrete_key_points: true, 34 | }); 35 | } 36 | 37 | if let Some(color) = chart.background { 38 | let color: RGBAColor = color.into(); 39 | drawing_area.fill(&color).unwrap(); 40 | } 41 | 42 | let mut chart_builder = ChartBuilder::on(&drawing_area); 43 | 44 | let [top, right, bottom, left] = chart.margin; 45 | chart_builder.margin_top(top); 46 | chart_builder.margin_right(right); 47 | chart_builder.margin_bottom(bottom); 48 | chart_builder.margin_left(left); 49 | 50 | let [top, right, bottom, left] = chart.label_area; 51 | chart_builder.set_label_area_size(LabelAreaPosition::Top, top); 52 | chart_builder.set_label_area_size(LabelAreaPosition::Right, right); 53 | chart_builder.set_label_area_size(LabelAreaPosition::Bottom, bottom); 54 | chart_builder.set_label_area_size(LabelAreaPosition::Left, left); 55 | 56 | if let Some(caption) = chart.caption { 57 | chart_builder.caption(caption, &BLACK); 58 | } 59 | 60 | let mut chart_context = chart_builder.build_cartesian_2d(x_range, y_range).unwrap(); 61 | 62 | let mut mesh = chart_context.configure_mesh(); 63 | if !chart.x_mesh { 64 | mesh.disable_x_mesh(); 65 | }; 66 | if !chart.y_mesh { 67 | mesh.disable_y_mesh(); 68 | }; 69 | mesh.draw().unwrap(); 70 | 71 | for series in chart.series { 72 | match series { 73 | value::Series2d::Line(series) => draw_line(&mut chart_context, series), 74 | value::Series2d::Bar(series) => draw_vertical_bar(&mut chart_context, series), 75 | } 76 | } 77 | } 78 | 79 | fn draw_line( 80 | chart_context: &mut ChartContext>, 81 | series: Line2dSeries, 82 | ) { 83 | let Line2dSeries { 84 | series, 85 | color, 86 | filled, 87 | stroke_width, 88 | point_size, 89 | } = series; 90 | let shape_style = ShapeStyle { 91 | color: color.into(), 92 | filled, 93 | stroke_width, 94 | }; 95 | let series = LineSeries::new( 96 | series.into_iter().map(|value::Coord2d { x, y }| (x, y)), 97 | shape_style, 98 | ) 99 | .point_size(point_size); 100 | chart_context.draw_series(series).unwrap(); 101 | } 102 | 103 | fn draw_vertical_bar( 104 | chart_context: &mut ChartContext>, 105 | series: Bar2dSeries, 106 | ) { 107 | let Bar2dSeries { 108 | series, 109 | color, 110 | filled, 111 | stroke_width, 112 | } = series; 113 | let shape_style = ShapeStyle { 114 | color: color.into(), 115 | filled, 116 | stroke_width, 117 | }; 118 | let rect_iter = series.into_iter().map(|Coord2d { x, y }| { 119 | let half_width = Coord1d::Float(0.8) / Coord1d::Int(2); 120 | let value_point = (x - half_width, y); 121 | let base_point = (x + half_width, Coord1d::Int(0)); 122 | Rectangle::new([value_point, base_point], shape_style) 123 | }); 124 | chart_context.draw_series(rect_iter).unwrap(); 125 | } 126 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/commands/draw/svg.rs: -------------------------------------------------------------------------------- 1 | use nu_engine::command_prelude::*; 2 | use nu_plugin::{EngineInterface, EvaluatedCall, SimplePluginCommand}; 3 | use nu_protocol::{FromValue, LabeledError}; 4 | use plotters::prelude::{IntoDrawingArea, SVGBackend}; 5 | 6 | use crate::value; 7 | 8 | #[derive(Debug, Clone)] 9 | pub struct DrawSvg; 10 | 11 | impl Command for DrawSvg { 12 | fn name(&self) -> &str { 13 | "draw svg" 14 | } 15 | 16 | fn signature(&self) -> Signature { 17 | Signature::new(Command::name(self)) 18 | .add_help() 19 | .description(Command::description(self)) 20 | .search_terms( 21 | Command::search_terms(self) 22 | .into_iter() 23 | .map(ToOwned::to_owned) 24 | .collect(), 25 | ) 26 | .input_output_type(value::Chart2d::ty(), Type::String) 27 | } 28 | 29 | fn description(&self) -> &str { 30 | "Draws a chart on a SVG string." 31 | } 32 | 33 | fn search_terms(&self) -> Vec<&str> { 34 | vec!["plotters", "chart", "2d", "draw", "svg"] 35 | } 36 | 37 | fn run( 38 | &self, 39 | _: &EngineState, 40 | _: &mut Stack, 41 | _: &Call, 42 | input: PipelineData, 43 | ) -> Result { 44 | let span = input.span().unwrap_or(Span::unknown()); 45 | let input = input.into_value(span)?; 46 | DrawSvg::run(self, input).map(|v| PipelineData::Value(v, None)) 47 | } 48 | } 49 | 50 | impl SimplePluginCommand for DrawSvg { 51 | type Plugin = crate::plugin::PlottersPlugin; 52 | 53 | fn name(&self) -> &str { 54 | Command::name(self) 55 | } 56 | 57 | fn signature(&self) -> Signature { 58 | Command::signature(self) 59 | } 60 | 61 | fn description(&self) -> &str { 62 | Command::description(self) 63 | } 64 | 65 | fn search_terms(&self) -> Vec<&str> { 66 | Command::search_terms(self) 67 | } 68 | 69 | fn run( 70 | &self, 71 | _: &Self::Plugin, 72 | _: &EngineInterface, 73 | _: &EvaluatedCall, 74 | input: &Value, 75 | ) -> Result { 76 | let input = input.clone(); 77 | DrawSvg::run(self, input).map_err(Into::into) 78 | } 79 | } 80 | 81 | impl DrawSvg { 82 | fn run(&self, input: Value) -> Result { 83 | let span = input.span(); 84 | let chart = value::Chart2d::from_value(input)?; 85 | 86 | let mut output = String::new(); 87 | let drawing_backend = SVGBackend::with_string(&mut output, (chart.width, chart.height)); 88 | super::draw(chart, drawing_backend.into_drawing_area()); 89 | 90 | Ok(Value::string(output, span)) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/commands/draw/terminal.rs: -------------------------------------------------------------------------------- 1 | use icy_sixel::{DiffusionMethod, MethodForLargest, MethodForRep, PixelFormat, Quality}; 2 | use image::{DynamicImage, ImageBuffer, RgbImage}; 3 | use nu_engine::command_prelude::*; 4 | use nu_plugin::{EngineInterface, EvaluatedCall, SimplePluginCommand}; 5 | use nu_protocol::{FromValue, LabeledError}; 6 | use plotters::prelude::{BitMapBackend, IntoDrawingArea}; 7 | use plotters::style::WHITE; 8 | use viuer::KittySupport; 9 | 10 | use crate::value; 11 | 12 | #[derive(Debug, Clone)] 13 | pub struct DrawTerminal; 14 | 15 | impl Command for DrawTerminal { 16 | fn name(&self) -> &str { 17 | "draw terminal" 18 | } 19 | 20 | fn signature(&self) -> Signature { 21 | Signature::new(Command::name(self)) 22 | .add_help() 23 | .description(Command::description(self)) 24 | .search_terms( 25 | Command::search_terms(self) 26 | .into_iter() 27 | .map(ToOwned::to_owned) 28 | .collect(), 29 | ) 30 | .input_output_type(value::Chart2d::ty(), Type::Nothing) 31 | } 32 | 33 | fn description(&self) -> &str { 34 | "Draws a chart to a sixel string. Compatible terminal emulators may render that." 35 | } 36 | 37 | fn search_terms(&self) -> Vec<&str> { 38 | vec!["plotters", "chart", "2d", "draw", "terminal"] 39 | } 40 | 41 | fn run( 42 | &self, 43 | _: &EngineState, 44 | _: &mut Stack, 45 | _: &Call, 46 | input: PipelineData, 47 | ) -> Result { 48 | let span = input.span().unwrap_or(Span::unknown()); 49 | let input = input.into_value(span)?; 50 | DrawTerminal::run(self, input).map(|v| PipelineData::Value(v, None)) 51 | } 52 | } 53 | 54 | impl SimplePluginCommand for DrawTerminal { 55 | type Plugin = crate::plugin::PlottersPlugin; 56 | 57 | fn name(&self) -> &str { 58 | Command::name(self) 59 | } 60 | 61 | fn signature(&self) -> Signature { 62 | Command::signature(self) 63 | } 64 | 65 | fn description(&self) -> &str { 66 | Command::description(self) 67 | } 68 | 69 | fn search_terms(&self) -> Vec<&str> { 70 | Command::search_terms(self) 71 | } 72 | 73 | fn run( 74 | &self, 75 | _: &Self::Plugin, 76 | _: &EngineInterface, 77 | _: &EvaluatedCall, 78 | input: &Value, 79 | ) -> Result { 80 | let input = input.clone(); 81 | DrawTerminal::run(self, input).map_err(Into::into) 82 | } 83 | } 84 | 85 | impl DrawTerminal { 86 | fn run(&self, input: Value) -> Result { 87 | let span = input.span(); 88 | let mut chart = value::Chart2d::from_value(input)?; 89 | 90 | const BYTES_PER_PIXEL: u32 = 3; 91 | let bytes = chart.height * chart.width * BYTES_PER_PIXEL; 92 | let mut buf: Vec = vec![0; bytes as usize]; 93 | 94 | let size = (chart.width, chart.height); 95 | let drawing_backend = BitMapBackend::with_buffer(&mut buf, size); 96 | 97 | if chart.background.is_none() { 98 | chart.background = Some(WHITE.into()); 99 | } 100 | 101 | super::draw(chart, drawing_backend.into_drawing_area()); 102 | 103 | // TODO: convert these errors somehow into shell errors 104 | if viuer::get_kitty_support() != KittySupport::None || viuer::is_iterm_supported() { 105 | let img: RgbImage = ImageBuffer::from_raw(size.0, size.1, buf).unwrap(); 106 | let img = DynamicImage::ImageRgb8(img); 107 | viuer::print(&img, &viuer::Config { 108 | absolute_offset: false, 109 | ..Default::default() 110 | }).unwrap(); 111 | } 112 | else { 113 | let sixel = icy_sixel::sixel_string( 114 | &buf, 115 | size.0 as i32, 116 | size.1 as i32, 117 | PixelFormat::RGB888, 118 | DiffusionMethod::Stucki, 119 | MethodForLargest::Auto, 120 | MethodForRep::Auto, 121 | Quality::HIGH, 122 | ) 123 | .unwrap(); 124 | println!("{sixel}"); 125 | } 126 | 127 | Ok(Value::nothing(span)) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod series; 2 | 3 | mod chart_2d; 4 | pub use chart_2d::Chart2d; 5 | 6 | pub mod draw; 7 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/commands/series/bar.rs: -------------------------------------------------------------------------------- 1 | use nu_engine::command_prelude::*; 2 | use nu_plugin::{EngineInterface, EvaluatedCall}; 3 | use nu_protocol::{FromValue, IntoValue, LabeledError}; 4 | use plotters::style::BLUE; 5 | 6 | use crate::value::{Bar2dSeries, Color, Series2d}; 7 | 8 | #[derive(Debug, Clone)] 9 | pub struct BarSeries; 10 | 11 | impl Command for BarSeries { 12 | fn name(&self) -> &str { 13 | "series bar" 14 | } 15 | 16 | fn signature(&self) -> Signature { 17 | Signature::new(Command::name(self)) 18 | .add_help() 19 | .description(Command::description(self)) 20 | .extra_description(Command::extra_description(self)) 21 | .search_terms( 22 | Command::search_terms(self) 23 | .into_iter() 24 | .map(ToOwned::to_owned) 25 | .collect(), 26 | ) 27 | .named( 28 | "color", 29 | SyntaxShape::Any, 30 | "Define the color of the points and the line. For valid color inputs, refer to \ 31 | `plotters colors --help`.", 32 | Some('c'), 33 | ) 34 | .named( 35 | "filled", 36 | SyntaxShape::Boolean, 37 | "Define whether the points in the series should be filled.", 38 | Some('f'), 39 | ) 40 | .named( 41 | "stroke-width", 42 | SyntaxShape::Int, 43 | "Define the width of the stroke.", 44 | Some('s'), 45 | ) 46 | .named( 47 | "horizontal", 48 | SyntaxShape::Boolean, 49 | "Define whether the bars should be horizontal.", 50 | Some('H'), 51 | ) 52 | .input_output_type(Type::list(Type::Number), Series2d::ty()) 53 | .input_output_type(Type::list(Type::list(Type::Number)), Series2d::ty()) 54 | .input_output_type( 55 | Type::list(Type::Record( 56 | vec![ 57 | ("x".to_string(), Type::Number), 58 | ("y".to_string(), Type::Number), 59 | ] 60 | .into_boxed_slice(), 61 | )), 62 | Series2d::ty(), 63 | ) 64 | } 65 | 66 | fn description(&self) -> &str { 67 | "Create a bar series." 68 | } 69 | 70 | fn extra_description(&self) -> &str { 71 | "This series requires as input a list or stream of value pairs for the x and y axis." 72 | } 73 | 74 | fn search_terms(&self) -> Vec<&str> { 75 | vec!["plotters", "series", "bar", "chart"] 76 | } 77 | 78 | fn run( 79 | &self, 80 | engine_state: &EngineState, 81 | stack: &mut Stack, 82 | call: &Call, 83 | input: PipelineData, 84 | ) -> Result { 85 | let span = input.span().unwrap_or(Span::unknown()); 86 | let input = input.into_value(span)?; 87 | let color = call.get_flag(engine_state, stack, "color")?; 88 | let filled = call.get_flag(engine_state, stack, "filled")?; 89 | let stroke_width = call.get_flag(engine_state, stack, "stroke-width")?; 90 | BarSeries::run(self, input, color, filled, stroke_width) 91 | .map(|v| PipelineData::Value(v, None)) 92 | } 93 | } 94 | 95 | impl nu_plugin::SimplePluginCommand for BarSeries { 96 | type Plugin = crate::plugin::PlottersPlugin; 97 | 98 | fn name(&self) -> &str { 99 | Command::name(self) 100 | } 101 | 102 | fn signature(&self) -> Signature { 103 | Command::signature(self) 104 | } 105 | 106 | fn description(&self) -> &str { 107 | Command::description(self) 108 | } 109 | 110 | fn extra_description(&self) -> &str { 111 | Command::extra_description(self) 112 | } 113 | 114 | fn search_terms(&self) -> Vec<&str> { 115 | Command::search_terms(self) 116 | } 117 | 118 | fn run( 119 | &self, 120 | _: &Self::Plugin, 121 | _: &EngineInterface, 122 | call: &EvaluatedCall, 123 | input: &Value, 124 | ) -> Result { 125 | let input = input.clone(); 126 | let (mut color, mut filled, mut stroke_width) = Default::default(); 127 | for (name, value) in call.named.clone() { 128 | fn extract_named( 129 | name: impl ToString, 130 | value: Option, 131 | span: Span, 132 | ) -> Result { 133 | let value = value.ok_or_else(|| ShellError::MissingParameter { 134 | param_name: name.to_string(), 135 | span, 136 | })?; 137 | T::from_value(value) 138 | } 139 | 140 | match name.item.as_str() { 141 | "color" => color = extract_named("color", value, name.span)?, 142 | "filled" => filled = extract_named("filled", value, name.span)?, 143 | "stroke-width" => stroke_width = extract_named("stroke-width", value, name.span)?, 144 | _ => continue, 145 | } 146 | } 147 | 148 | BarSeries::run(self, input, color, filled, stroke_width).map_err(Into::into) 149 | } 150 | } 151 | 152 | impl BarSeries { 153 | fn run( 154 | &self, 155 | input: Value, 156 | color: Option, 157 | filled: Option, 158 | stroke_width: Option, 159 | ) -> Result { 160 | let input_span = input.span(); 161 | let series = super::series_from_value(input)?; 162 | let series = Bar2dSeries { 163 | series, 164 | color: color.unwrap_or(BLUE.into()), 165 | filled: filled.unwrap_or(true), 166 | stroke_width: stroke_width.unwrap_or(1), 167 | }; 168 | 169 | Ok(Series2d::Bar(series).into_value(input_span)) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/commands/series/line.rs: -------------------------------------------------------------------------------- 1 | use nu_engine::command_prelude::*; 2 | use nu_plugin::{EngineInterface, EvaluatedCall}; 3 | use nu_protocol::{FromValue, IntoValue, LabeledError}; 4 | use plotters::style::BLUE; 5 | 6 | use crate::value::{Color, Line2dSeries, Series2d}; 7 | 8 | #[derive(Debug, Clone)] 9 | pub struct LineSeries; 10 | 11 | impl Command for LineSeries { 12 | fn name(&self) -> &str { 13 | "series line" 14 | } 15 | 16 | fn signature(&self) -> Signature { 17 | Signature::new(Command::name(self)) 18 | .add_help() 19 | .description(Command::description(self)) 20 | .extra_description(Command::extra_description(self)) 21 | .search_terms( 22 | Command::search_terms(self) 23 | .into_iter() 24 | .map(ToOwned::to_owned) 25 | .collect(), 26 | ) 27 | .named( 28 | "color", 29 | SyntaxShape::Any, 30 | "Define the color of the points and the line. For valid color inputs, refer to \ 31 | `plotters colors --help`.", 32 | Some('c'), 33 | ) 34 | .named( 35 | "filled", 36 | SyntaxShape::Boolean, 37 | "Define whether the points in the series should be filled.", 38 | Some('f'), 39 | ) 40 | .named( 41 | "stroke-width", 42 | SyntaxShape::Int, 43 | "Define the width of the stroke.", 44 | Some('s'), 45 | ) 46 | .named( 47 | "point-size", 48 | SyntaxShape::Int, 49 | "Define the size of the points in pixels.", 50 | Some('p'), 51 | ) 52 | .input_output_type(Type::list(Type::Number), Series2d::ty()) 53 | .input_output_type(Type::list(Type::list(Type::Number)), Series2d::ty()) 54 | .input_output_type( 55 | Type::list(Type::Record( 56 | vec![ 57 | ("x".to_string(), Type::Number), 58 | ("y".to_string(), Type::Number), 59 | ] 60 | .into_boxed_slice(), 61 | )), 62 | Series2d::ty(), 63 | ) 64 | } 65 | 66 | fn description(&self) -> &str { 67 | "Create a line series." 68 | } 69 | 70 | fn extra_description(&self) -> &str { 71 | "This series requires as input a list or stream of value pairs for the x and y axis." 72 | } 73 | 74 | fn search_terms(&self) -> Vec<&str> { 75 | vec!["plotters", "series", "line", "chart"] 76 | } 77 | 78 | fn run( 79 | &self, 80 | engine_state: &EngineState, 81 | stack: &mut Stack, 82 | call: &Call, 83 | input: PipelineData, 84 | ) -> Result { 85 | let span = input.span().unwrap_or(Span::unknown()); 86 | let input = input.into_value(span)?; 87 | let color = call.get_flag(engine_state, stack, "color")?; 88 | let filled = call.get_flag(engine_state, stack, "filled")?; 89 | let stroke_width = call.get_flag(engine_state, stack, "stroke-width")?; 90 | let point_size = call.get_flag(engine_state, stack, "point-size")?; 91 | LineSeries::run(self, input, color, filled, stroke_width, point_size) 92 | .map(|v| PipelineData::Value(v, None)) 93 | } 94 | } 95 | 96 | impl nu_plugin::SimplePluginCommand for LineSeries { 97 | type Plugin = crate::plugin::PlottersPlugin; 98 | 99 | fn name(&self) -> &str { 100 | Command::name(self) 101 | } 102 | 103 | fn signature(&self) -> Signature { 104 | Command::signature(self) 105 | } 106 | 107 | fn description(&self) -> &str { 108 | Command::description(self) 109 | } 110 | 111 | fn extra_description(&self) -> &str { 112 | Command::extra_description(self) 113 | } 114 | 115 | fn search_terms(&self) -> Vec<&str> { 116 | Command::search_terms(self) 117 | } 118 | 119 | fn run( 120 | &self, 121 | _: &Self::Plugin, 122 | _: &EngineInterface, 123 | call: &EvaluatedCall, 124 | input: &Value, 125 | ) -> Result { 126 | let input = input.clone(); 127 | let (mut color, mut filled, mut stroke_width, mut point_size) = Default::default(); 128 | for (name, value) in call.named.clone() { 129 | fn extract_named( 130 | name: impl ToString, 131 | value: Option, 132 | span: Span, 133 | ) -> Result { 134 | let value = value.ok_or_else(|| ShellError::MissingParameter { 135 | param_name: name.to_string(), 136 | span, 137 | })?; 138 | T::from_value(value) 139 | } 140 | 141 | match name.item.as_str() { 142 | "color" => color = extract_named("color", value, name.span)?, 143 | "filled" => filled = extract_named("filled", value, name.span)?, 144 | "stroke-width" => stroke_width = extract_named("stroke-width", value, name.span)?, 145 | "point-size" => point_size = extract_named("point-size", value, name.span)?, 146 | _ => continue, 147 | } 148 | } 149 | 150 | LineSeries::run(self, input, color, filled, stroke_width, point_size).map_err(Into::into) 151 | } 152 | } 153 | 154 | impl LineSeries { 155 | fn run( 156 | &self, 157 | input: Value, 158 | color: Option, 159 | filled: Option, 160 | stroke_width: Option, 161 | point_size: Option, 162 | ) -> Result { 163 | let input_span = input.span(); 164 | let series = super::series_from_value(input)?; 165 | let series = Line2dSeries { 166 | series, 167 | color: color.unwrap_or(BLUE.into()), 168 | filled: filled.unwrap_or(false), 169 | stroke_width: stroke_width.unwrap_or(1), 170 | point_size: point_size.unwrap_or(0), 171 | }; 172 | 173 | Ok(Series2d::Line(series).into_value(input_span)) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/commands/series/mod.rs: -------------------------------------------------------------------------------- 1 | use nu_protocol::{FromValue, ShellError, Value}; 2 | 3 | use crate::value::{Coord1d, Coord2d}; 4 | 5 | mod line; 6 | pub use line::*; 7 | 8 | mod bar; 9 | pub use bar::*; 10 | 11 | fn series_from_value(input: Value) -> Result, ShellError> { 12 | let input_span = input.span(); 13 | let input = input.into_list()?; 14 | let first = input 15 | .first() 16 | .ok_or_else(|| ShellError::PipelineEmpty { 17 | dst_span: input_span, 18 | })? 19 | .clone(); 20 | 21 | let number = Coord1d::from_value(first.clone()); 22 | let tuple = <(Coord1d, Coord1d)>::from_value(first.clone()); 23 | let coord = Coord2d::from_value(first); 24 | 25 | let mut series: Vec = Vec::with_capacity(input.len()); 26 | match (number, tuple, coord) { 27 | (Ok(_), _, _) => { 28 | // input: list 29 | for (i, val) in input.into_iter().enumerate() { 30 | let val = Coord1d::from_value(val)?; 31 | let val = Coord2d { 32 | x: Coord1d::from_int(i as i64), 33 | y: val, 34 | }; 35 | series.push(val); 36 | } 37 | } 38 | 39 | (_, Ok(_), _) => { 40 | // input: list> 41 | for val in input { 42 | let (x, y) = <(Coord1d, Coord1d)>::from_value(val)?; 43 | let val = Coord2d { x, y }; 44 | series.push(val); 45 | } 46 | } 47 | 48 | (_, _, Ok(_)) => { 49 | // input: list> 50 | for val in input { 51 | let val = Coord2d::from_value(val)?; 52 | series.push(val); 53 | } 54 | } 55 | 56 | (Err(_), Err(_), Err(_)) => todo!("throw explaining error"), 57 | } 58 | 59 | Ok(series) 60 | } 61 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/lib.rs: -------------------------------------------------------------------------------- 1 | use nu_protocol::engine::{EngineState, StateWorkingSet}; 2 | 3 | pub mod commands; 4 | pub mod plugin; 5 | pub mod value; 6 | 7 | pub fn add_plotters_command_context(mut engine_state: EngineState) -> EngineState { 8 | let delta = { 9 | let mut working_set = StateWorkingSet::new(&engine_state); 10 | 11 | macro_rules! bind_command { 12 | ( $( $command:expr ),* $(,)? ) => { 13 | $( working_set.add_decl(Box::new($command)); )* 14 | }; 15 | } 16 | 17 | bind_command!( 18 | commands::series::BarSeries, 19 | commands::series::LineSeries, 20 | commands::Chart2d, 21 | commands::draw::DrawSvg, 22 | commands::draw::DrawTerminal, 23 | ); 24 | 25 | working_set.render() 26 | }; 27 | 28 | if let Err(err) = engine_state.merge_delta(delta) { 29 | eprintln!("Error creating default context: {err:?}"); 30 | } 31 | 32 | engine_state 33 | } 34 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | nu_plugin::serve_plugin( 3 | &nu_plugin_plotters::plugin::PlottersPlugin, 4 | nu_plugin::MsgPackSerializer, 5 | ) 6 | } 7 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/plugin.rs: -------------------------------------------------------------------------------- 1 | use nu_plugin::{Plugin, PluginCommand}; 2 | 3 | use crate::commands; 4 | 5 | pub struct PlottersPlugin; 6 | 7 | impl Plugin for PlottersPlugin { 8 | fn version(&self) -> String { 9 | env!("CARGO_PKG_VERSION").into() 10 | } 11 | 12 | fn commands(&self) -> Vec>> { 13 | vec![ 14 | Box::new(commands::series::BarSeries), 15 | Box::new(commands::series::LineSeries), 16 | Box::new(commands::Chart2d), 17 | Box::new(commands::draw::DrawSvg), 18 | Box::new(commands::draw::DrawTerminal), 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/value/chart_2d.rs: -------------------------------------------------------------------------------- 1 | use std::any::Any; 2 | 3 | use nu_protocol::{CustomValue, FromValue, IntoValue, ShellError, Span, Type, Value}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use super::color::Color; 7 | use super::series_2d::Series2d; 8 | use super::Range; 9 | 10 | #[derive(Debug, Clone, IntoValue, Serialize, Deserialize)] 11 | pub struct Chart2d { 12 | pub series: Vec, 13 | pub width: u32, 14 | pub height: u32, 15 | pub background: Option, 16 | pub caption: Option, 17 | pub margin: [u32; 4], // use css shorthand rotation [top, right, bottom, left] 18 | pub label_area: [u32; 4], 19 | pub x_range: Option, 20 | pub y_range: Option, 21 | pub x_mesh: bool, 22 | pub y_mesh: bool, 23 | } 24 | 25 | impl Default for Chart2d { 26 | fn default() -> Self { 27 | Self { 28 | series: Vec::new(), 29 | width: 600, 30 | height: 400, 31 | background: None, 32 | caption: None, 33 | margin: [5, 5, 5, 5], 34 | label_area: [0, 0, 35, 35], 35 | x_range: None, 36 | y_range: None, 37 | x_mesh: true, 38 | y_mesh: true, 39 | } 40 | } 41 | } 42 | 43 | impl FromValue for Chart2d { 44 | fn from_value(v: Value) -> Result { 45 | let span = v.span(); 46 | let v = v.into_custom_value()?; 47 | match v.as_any().downcast_ref::() { 48 | Some(v) => Ok(v.clone()), 49 | None => Err(ShellError::CantConvert { 50 | to_type: Self::ty().to_string(), 51 | from_type: v.type_name(), 52 | span, 53 | help: None, 54 | }), 55 | } 56 | } 57 | 58 | fn expected_type() -> Type { 59 | Self::ty() 60 | } 61 | } 62 | 63 | #[typetag::serde] 64 | impl CustomValue for Chart2d { 65 | fn clone_value(&self, span: Span) -> Value { 66 | Value::custom(Box::new(self.clone()), span) 67 | } 68 | 69 | fn type_name(&self) -> String { 70 | Self::ty().to_string() 71 | } 72 | 73 | fn to_base_value(&self, span: Span) -> Result { 74 | Ok(self.clone().into_value(span)) 75 | } 76 | 77 | fn as_any(&self) -> &dyn Any { 78 | self 79 | } 80 | 81 | fn as_mut_any(&mut self) -> &mut dyn Any { 82 | self 83 | } 84 | } 85 | 86 | macro_rules! xy_range { 87 | ($fn_name:ident) => { 88 | pub fn $fn_name(&self) -> Option { 89 | if let Some(range) = self.$fn_name { 90 | return Some(range); 91 | } 92 | 93 | let first = self.series.first()?; 94 | let Range { 95 | mut min, mut max, .. 96 | } = first.$fn_name()?; 97 | for Range { 98 | min: s_min, 99 | max: s_max, 100 | .. 101 | } in self.series.iter().filter_map(|s| s.$fn_name()) 102 | { 103 | if s_min < min { 104 | min = s_min 105 | } 106 | if s_max > max { 107 | max = s_max 108 | } 109 | } 110 | 111 | Some(Range { 112 | min, 113 | max, 114 | metadata: None, 115 | }) 116 | } 117 | }; 118 | } 119 | 120 | impl Chart2d { 121 | xy_range!(x_range); 122 | 123 | xy_range!(y_range); 124 | 125 | pub fn ty() -> Type { 126 | Type::Custom("plotters::chart-2d".to_string().into_boxed_str()) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/value/color.rs: -------------------------------------------------------------------------------- 1 | use nu_protocol::{FromValue, IntoValue, ShellError, Span, Type, Value}; 2 | use plotters::style::*; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Clone, IntoValue, Serialize, Deserialize)] 6 | pub struct Color { 7 | pub r: ColorChannel, 8 | pub g: ColorChannel, 9 | pub b: ColorChannel, 10 | pub a: AlphaChannel, 11 | } 12 | 13 | impl Default for Color { 14 | fn default() -> Self { 15 | Self { 16 | r: ColorChannel(0), 17 | g: ColorChannel(0), 18 | b: ColorChannel(0), 19 | a: AlphaChannel(1.0), 20 | } 21 | } 22 | } 23 | 24 | impl FromValue for Color { 25 | fn from_value(v: Value) -> Result { 26 | match v { 27 | val @ Value::Record { .. } => { 28 | #[derive(FromValue)] 29 | struct ColorDTO { 30 | r: ColorChannel, 31 | g: ColorChannel, 32 | b: ColorChannel, 33 | a: Option, 34 | } 35 | 36 | let color = ColorDTO::from_value(val)?; 37 | Ok(Color { 38 | r: color.r, 39 | g: color.g, 40 | b: color.b, 41 | a: color.a.unwrap_or_default(), 42 | }) 43 | } 44 | 45 | list @ Value::List { .. } => { 46 | // TODO: check list length to avoid cloning 47 | let rgba = <(ColorChannel, ColorChannel, ColorChannel, AlphaChannel)>::from_value( 48 | list.clone(), 49 | ); 50 | let rgb = <(ColorChannel, ColorChannel, ColorChannel)>::from_value(list); 51 | match (rgba, rgb) { 52 | (Ok((r, g, b, a)), _) => Ok(Color { r, g, b, a }), 53 | (Err(ShellError::CantFindColumn { .. }), Ok((r, g, b))) => Ok(Color { 54 | r, 55 | g, 56 | b, 57 | a: Default::default(), 58 | }), 59 | (Err(e), Ok(_)) => Err(e), 60 | (Err(_), Err(e)) => Err(e), 61 | } 62 | } 63 | 64 | ref v @ Value::String { 65 | ref val, 66 | internal_span: span, 67 | } => match val.to_lowercase().as_str() { 68 | "black" => Ok(BLACK.into()), 69 | "blue" => Ok(BLUE.into()), 70 | "cyan" => Ok(CYAN.into()), 71 | "green" => Ok(GREEN.into()), 72 | "magenta" => Ok(MAGENTA.into()), 73 | "red" => Ok(RED.into()), 74 | "white" => Ok(WHITE.into()), 75 | "yellow" => Ok(YELLOW.into()), 76 | val => { 77 | if let Some(val) = val.strip_prefix("#") { 78 | match val.len() { 79 | 6 => { 80 | let span = |offset| { 81 | Span::new(span.start + 2 + offset, span.start + 4 + offset) 82 | }; 83 | let mut color = Color::default(); 84 | color.r.0 = u8_from_hex(&val[0..2], span(0))?; 85 | color.g.0 = u8_from_hex(&val[2..4], span(2))?; 86 | color.b.0 = u8_from_hex(&val[4..6], span(4))?; 87 | return Ok(color); 88 | } 89 | 3 => { 90 | let span = |offset| { 91 | Span::new(span.start + 2 + offset, span.start + 3 + offset) 92 | }; 93 | let mut color = Color::default(); 94 | color.r.0 = u8_from_hex(&val[0..1].repeat(2), span(0))?; 95 | color.g.0 = u8_from_hex(&val[1..2].repeat(2), span(1))?; 96 | color.b.0 = u8_from_hex(&val[2..3].repeat(2), span(2))?; 97 | return Ok(color); 98 | } 99 | _ => (), 100 | } 101 | } 102 | 103 | Err(ShellError::CantConvert { 104 | to_type: Self::expected_type().to_string(), 105 | from_type: v.get_type().to_string(), 106 | span: v.span(), 107 | help: None, 108 | }) 109 | } 110 | }, 111 | 112 | v => Err(ShellError::CantConvert { 113 | to_type: Self::expected_type().to_string(), 114 | from_type: v.get_type().to_string(), 115 | span: v.span(), 116 | help: None, 117 | }), 118 | } 119 | } 120 | 121 | fn expected_type() -> Type { 122 | Type::Custom("plotters::color".to_string().into_boxed_str()) 123 | } 124 | } 125 | 126 | fn u8_from_hex(hex: &str, span: Span) -> Result { 127 | u8::from_str_radix(hex, 16).map_err(|_| ShellError::CantConvert { 128 | to_type: Type::Int.to_string(), 129 | from_type: Type::String.to_string(), 130 | span, 131 | help: None, 132 | }) 133 | } 134 | 135 | impl From for Color { 136 | fn from(value: RGBColor) -> Self { 137 | Self { 138 | r: ColorChannel(value.0), 139 | g: ColorChannel(value.1), 140 | b: ColorChannel(value.2), 141 | a: Default::default(), 142 | } 143 | } 144 | } 145 | 146 | impl From for plotters::style::RGBAColor { 147 | fn from(value: Color) -> Self { 148 | Self(value.r.0, value.g.0, value.b.0, value.a.0) 149 | } 150 | } 151 | 152 | #[derive(Debug, Clone, Serialize, Deserialize)] 153 | pub struct ColorChannel(u8); 154 | 155 | impl IntoValue for ColorChannel { 156 | fn into_value(self, span: Span) -> Value { 157 | Value::int(self.0 as i64, span) 158 | } 159 | } 160 | 161 | impl FromValue for ColorChannel { 162 | fn from_value(value: Value) -> Result { 163 | let span = value.span(); 164 | let value = i64::from_value(value)?; 165 | const U8MIN: i64 = u8::MIN as i64; 166 | const U8MAX: i64 = u8::MAX as i64; 167 | #[allow(overlapping_range_endpoints)] 168 | #[allow(clippy::match_overlapping_arm)] 169 | match value { 170 | U8MIN..=U8MAX => Ok(ColorChannel(value as u8)), 171 | i64::MIN..U8MIN => Err(ShellError::GenericError { 172 | error: "Integer too small".to_string(), 173 | msg: format!("{value} is smaller than {U8MIN}"), 174 | span: Some(span), 175 | help: None, 176 | inner: vec![], 177 | }), 178 | U8MAX..=i64::MAX => Err(ShellError::GenericError { 179 | error: "Integer too large".to_string(), 180 | msg: format!("{value} is larger than {U8MAX}"), 181 | span: Some(span), 182 | help: None, 183 | inner: vec![], 184 | }), 185 | } 186 | } 187 | 188 | fn expected_type() -> Type { 189 | Type::Int 190 | } 191 | } 192 | 193 | #[derive(Debug, Clone, Serialize, Deserialize)] 194 | pub struct AlphaChannel(f64); 195 | 196 | impl IntoValue for AlphaChannel { 197 | fn into_value(self, span: Span) -> Value { 198 | Value::float(self.0, span) 199 | } 200 | } 201 | 202 | impl FromValue for AlphaChannel { 203 | fn from_value(v: Value) -> Result { 204 | let span = v.span(); 205 | let v = f64::from_value(v)?; 206 | match v { 207 | 0.0..=1.0 => Ok(Self(v)), 208 | _ => Err(ShellError::GenericError { 209 | error: "Invalid alpha channel value".to_string(), 210 | msg: format!("{v} is not in range between 0.0 and 1.0"), 211 | span: Some(span), 212 | help: None, 213 | inner: vec![], 214 | }), 215 | } 216 | } 217 | 218 | fn expected_type() -> nu_protocol::Type { 219 | f64::expected_type() 220 | } 221 | } 222 | 223 | impl Default for AlphaChannel { 224 | fn default() -> Self { 225 | Self(1.0) 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/value/coords/coord_1d.rs: -------------------------------------------------------------------------------- 1 | use std::cmp; 2 | use std::ops::{Add, AddAssign, Div, Sub}; 3 | 4 | use nu_protocol::{FromValue, IntoValue, ShellError, Span, Value}; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Debug, Clone, Copy, Serialize, Deserialize)] 8 | pub enum Coord1d { 9 | Int(i64), 10 | Float(f64), // we ensure that this is valid number 11 | } 12 | 13 | #[derive(Debug, Clone, Copy)] 14 | pub enum Coord1dFromFloatError { 15 | Nan, 16 | Infinity, 17 | } 18 | 19 | impl Coord1d { 20 | pub fn from_int(int: i64) -> Self { 21 | Coord1d::Int(int) 22 | } 23 | 24 | pub fn from_float(float: f64) -> Result { 25 | match float.is_finite() { 26 | true => Ok(Self::Float(float)), 27 | false => match float.is_nan() { 28 | true => Err(Coord1dFromFloatError::Nan), 29 | false => Err(Coord1dFromFloatError::Infinity), 30 | }, 31 | } 32 | } 33 | 34 | pub fn as_float(self) -> f64 { 35 | match self { 36 | Coord1d::Int(int) => int as f64, 37 | Coord1d::Float(float) => float, 38 | } 39 | } 40 | 41 | pub fn floor(self) -> i64 { 42 | match self { 43 | Coord1d::Int(int) => int, 44 | Coord1d::Float(float) => { 45 | let float = float.floor(); 46 | debug_assert!( 47 | float < i64::MAX as f64, 48 | "Coord1d::Float was larger than i64::MAX" 49 | ); 50 | debug_assert!( 51 | float > i64::MIN as f64, 52 | "Coord1d::Float was smaller than i64::MIN" 53 | ); 54 | float as i64 55 | } 56 | } 57 | } 58 | 59 | pub fn round(self) -> i64 { 60 | match self { 61 | Coord1d::Int(int) => int, 62 | Coord1d::Float(float) => { 63 | let float = float.round(); 64 | debug_assert!( 65 | float < i64::MAX as f64, 66 | "Coord1d::Float was larger than i64::MAX" 67 | ); 68 | debug_assert!( 69 | float > i64::MIN as f64, 70 | "Coord1d::Float was smaller than i64::MIN" 71 | ); 72 | float as i64 73 | } 74 | } 75 | } 76 | 77 | pub fn ceil(self) -> i64 { 78 | match self { 79 | Coord1d::Int(int) => int, 80 | Coord1d::Float(float) => { 81 | let float = float.ceil(); 82 | debug_assert!( 83 | float < i64::MAX as f64, 84 | "Coord1d::Float was larger than i64::MAX" 85 | ); 86 | debug_assert!( 87 | float > i64::MIN as f64, 88 | "Coord1d::Float was smaller than i64::MIN" 89 | ); 90 | float as i64 91 | } 92 | } 93 | } 94 | } 95 | 96 | impl Eq for Coord1d {} 97 | 98 | impl PartialEq for Coord1d { 99 | fn eq(&self, other: &Self) -> bool { 100 | let this = (*self).as_float(); 101 | let that = (*other).as_float(); 102 | this == that 103 | } 104 | } 105 | 106 | impl Ord for Coord1d { 107 | fn cmp(&self, other: &Self) -> cmp::Ordering { 108 | let this = (*self).as_float(); 109 | let that = (*other).as_float(); 110 | match PartialOrd::partial_cmp(&this, &that) { 111 | Some(ord) => ord, 112 | // we ensure that float is always a valid value 113 | None => panic!("self was NaN"), 114 | } 115 | } 116 | } 117 | 118 | impl PartialOrd for Coord1d { 119 | fn partial_cmp(&self, other: &Self) -> Option { 120 | Some(self.cmp(other)) 121 | } 122 | } 123 | 124 | impl IntoValue for Coord1d { 125 | fn into_value(self, span: Span) -> Value { 126 | match self { 127 | Coord1d::Int(int) => Value::int(int, span), 128 | Coord1d::Float(float) => Value::float(float, span), 129 | } 130 | } 131 | } 132 | 133 | impl FromValue for Coord1d { 134 | fn from_value(v: Value) -> Result { 135 | match v { 136 | Value::Int { val, .. } => Ok(Self::Int(val)), 137 | Value::Float { val, internal_span } => Self::from_float(val).map_err(|e| { 138 | let error = match e { 139 | Coord1dFromFloatError::Nan => "Number is not a number", 140 | Coord1dFromFloatError::Infinity => "Number is not finite", 141 | } 142 | .to_string(); 143 | 144 | ShellError::GenericError { 145 | error, 146 | msg: "Coordinates need to be a valid number.".to_string(), 147 | span: Some(internal_span), 148 | help: None, 149 | inner: vec![], 150 | } 151 | }), 152 | _ => Err(ShellError::CantConvert { 153 | to_type: Self::expected_type().to_string(), 154 | from_type: v.get_type().to_string(), 155 | span: v.span(), 156 | help: None, 157 | }), 158 | } 159 | } 160 | } 161 | 162 | impl Add for Coord1d { 163 | type Output = Self; 164 | 165 | fn add(self, rhs: Self) -> Self::Output { 166 | match (self, rhs) { 167 | (Coord1d::Int(lhs), Coord1d::Int(rhs)) => Coord1d::Int(lhs + rhs), 168 | (Coord1d::Int(lhs), Coord1d::Float(rhs)) => Coord1d::Float(lhs as f64 + rhs), 169 | (Coord1d::Float(lhs), Coord1d::Int(rhs)) => Coord1d::Float(lhs + rhs as f64), 170 | (Coord1d::Float(lhs), Coord1d::Float(rhs)) => Coord1d::Float(lhs + rhs), 171 | } 172 | } 173 | } 174 | 175 | impl AddAssign for Coord1d { 176 | fn add_assign(&mut self, rhs: Self) { 177 | *self = self.add(rhs); 178 | } 179 | } 180 | 181 | impl Sub for Coord1d { 182 | type Output = Self; 183 | 184 | fn sub(self, rhs: Self) -> Self::Output { 185 | match (self, rhs) { 186 | (Coord1d::Int(lhs), Coord1d::Int(rhs)) => Coord1d::Int(lhs - rhs), 187 | (Coord1d::Int(lhs), Coord1d::Float(rhs)) => Coord1d::Float(lhs as f64 - rhs), 188 | (Coord1d::Float(lhs), Coord1d::Int(rhs)) => Coord1d::Float(lhs - rhs as f64), 189 | (Coord1d::Float(lhs), Coord1d::Float(rhs)) => Coord1d::Float(lhs - rhs), 190 | } 191 | } 192 | } 193 | 194 | impl Div for Coord1d { 195 | type Output = Self; 196 | 197 | fn div(self, rhs: Self) -> Self::Output { 198 | let lhs = self.as_float(); 199 | let rhs = rhs.as_float(); 200 | Coord1d::Float(lhs / rhs) 201 | } 202 | } 203 | 204 | impl Default for Coord1d { 205 | fn default() -> Self { 206 | Self::Int(0) 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/value/coords/coord_2d.rs: -------------------------------------------------------------------------------- 1 | use nu_protocol::{FromValue, IntoValue, ShellError, Value}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::Coord1d; 5 | 6 | #[derive(Debug, Clone, Copy, IntoValue, PartialEq, Eq, Serialize, Deserialize)] 7 | pub struct Coord2d { 8 | pub x: Coord1d, 9 | pub y: Coord1d, 10 | } 11 | 12 | impl FromValue for Coord2d { 13 | fn from_value(v: Value) -> Result { 14 | #[derive(FromValue)] 15 | struct Coord2dDTO { 16 | x: Coord1d, 17 | y: Coord1d, 18 | } 19 | 20 | let tuple = <(Coord1d, Coord1d)>::from_value(v.clone()); 21 | let record = Coord2dDTO::from_value(v); 22 | match (tuple, record) { 23 | (Ok((x, y)), _) => Ok(Self { x, y }), 24 | (_, Ok(Coord2dDTO { x, y })) => Ok(Self { x, y }), 25 | (Err(tuple_e), Err(record_e)) => Err(ShellError::GenericError { 26 | error: "Invalid 2D coordinate.".to_string(), 27 | msg: "A 2D coordinate needs to be either pair or a x-y-record of numbers." 28 | .to_string(), 29 | span: None, 30 | help: None, 31 | inner: vec![tuple_e, record_e], 32 | }), 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/value/coords/mod.rs: -------------------------------------------------------------------------------- 1 | mod coord_1d; 2 | pub use coord_1d::*; 3 | 4 | mod coord_2d; 5 | pub use coord_2d::*; 6 | 7 | mod range; 8 | pub use range::*; 9 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/value/coords/range.rs: -------------------------------------------------------------------------------- 1 | use std::ops::{self, Bound}; 2 | 3 | use nu_protocol::{FloatRange, FromValue, IntoValue, ShellError, Type, Value}; 4 | use plotters::coord::ranged1d::{KeyPointHint, NoDefaultFormatting, ValueFormatter}; 5 | use plotters::coord::types::{RangedCoordf64, RangedCoordi64}; 6 | use plotters::prelude::{DiscreteRanged, Ranged}; 7 | use serde::{Deserialize, Serialize}; 8 | 9 | use super::Coord1d; 10 | 11 | // TODO: ensure that ranges are always min to max 12 | #[derive(Debug, Clone, Copy, IntoValue, Serialize, Deserialize)] 13 | pub struct Range { 14 | pub min: Coord1d, 15 | pub max: Coord1d, 16 | pub metadata: Option, 17 | } 18 | 19 | #[derive(Debug, Clone, Copy, IntoValue, Serialize, Deserialize)] 20 | pub struct RangeMetadata { 21 | pub discrete_key_points: bool, 22 | } 23 | 24 | impl FromValue for Range { 25 | fn from_value(v: Value) -> Result { 26 | match v { 27 | Value::Range { val, internal_span } => { 28 | // TODO: try IntRange here first 29 | let range = FloatRange::from(*val); 30 | let min = range.start(); 31 | let max = match range.end() { 32 | Bound::Included(max) => max, 33 | Bound::Excluded(max) => max, 34 | Bound::Unbounded => { 35 | return Err(ShellError::CantConvert { 36 | to_type: Self::expected_type().to_string(), 37 | from_type: Type::Range.to_string(), 38 | span: internal_span, 39 | help: Some("Try a bounded range instead.".to_string()), 40 | }) 41 | } 42 | }; 43 | 44 | let min = Coord1d::from_value(min.into_value(internal_span))?; 45 | let max = Coord1d::from_value(max.into_value(internal_span))?; 46 | 47 | Ok(Self { 48 | min, 49 | max, 50 | metadata: None, 51 | }) 52 | } 53 | 54 | v @ Value::List { .. } => { 55 | let [min, max] = <[Coord1d; 2]>::from_value(v)?; 56 | Ok(Self { 57 | min, 58 | max, 59 | metadata: None, 60 | }) 61 | } 62 | 63 | v @ Value::Record { .. } => { 64 | #[derive(Debug, FromValue)] 65 | struct RangeDTO { 66 | min: Coord1d, 67 | max: Coord1d, 68 | } 69 | 70 | let RangeDTO { min, max } = RangeDTO::from_value(v)?; 71 | Ok(Self { 72 | min, 73 | max, 74 | metadata: None, 75 | }) 76 | } 77 | 78 | v => Err(ShellError::CantConvert { 79 | to_type: Self::expected_type().to_string(), 80 | from_type: v.get_type().to_string(), 81 | span: v.span(), 82 | help: None, 83 | }), 84 | } 85 | } 86 | 87 | fn expected_type() -> Type { 88 | Type::List(Box::new(Coord1d::expected_type())) 89 | } 90 | } 91 | 92 | impl From for RangedCoordf64 { 93 | fn from(value: Range) -> Self { 94 | RangedCoordf64::from(value.min.as_float()..value.max.as_float()) 95 | } 96 | } 97 | 98 | impl Ranged for Range { 99 | type FormatOption = NoDefaultFormatting; 100 | type ValueType = Coord1d; 101 | 102 | fn map(&self, value: &Self::ValueType, limit: (i32, i32)) -> i32 { 103 | RangedCoordf64::from(*self).map(&value.as_float(), limit) 104 | } 105 | 106 | fn key_points(&self, hint: Hint) -> Vec { 107 | if let Some(RangeMetadata { 108 | discrete_key_points: true, 109 | }) = self.metadata 110 | { 111 | let lower = self.min.ceil(); 112 | let upper = self.max.floor(); 113 | return (lower..=upper).map(Coord1d::Int).collect(); 114 | } 115 | 116 | match (self.min, self.max) { 117 | (Coord1d::Int(_), Coord1d::Int(_)) => RangedCoordi64::from(*self) 118 | .key_points(hint) 119 | .into_iter() 120 | .map(Coord1d::from_int) 121 | .collect(), 122 | 123 | (Coord1d::Int(_), Coord1d::Float(_)) | 124 | (Coord1d::Float(_), Coord1d::Int(_)) | 125 | (Coord1d::Float(_), Coord1d::Float(_)) => { 126 | // TODO: check here if we want f64 key points and if from_float may expects None 127 | // values 128 | RangedCoordf64::from(*self) 129 | .key_points(hint) 130 | .into_iter() 131 | .map(|float| Coord1d::from_float(float).unwrap()) 132 | .collect() 133 | } 134 | } 135 | } 136 | 137 | fn range(&self) -> ops::Range { 138 | self.min..self.max 139 | } 140 | } 141 | 142 | impl From for RangedCoordi64 { 143 | fn from(value: Range) -> Self { 144 | RangedCoordi64::from(value.min.floor()..value.max.ceil()) 145 | } 146 | } 147 | 148 | impl DiscreteRanged for Range { 149 | fn size(&self) -> usize { 150 | RangedCoordi64::from(*self).size() 151 | } 152 | 153 | fn index_of(&self, value: &Self::ValueType) -> Option { 154 | RangedCoordi64::from(*self).index_of(&value.round()) 155 | } 156 | 157 | fn from_index(&self, index: usize) -> Option { 158 | RangedCoordi64::from(*self) 159 | .from_index(index) 160 | .map(Coord1d::from_int) 161 | } 162 | } 163 | 164 | impl ValueFormatter for Range { 165 | fn format(value: &Coord1d) -> String { 166 | match value { 167 | Coord1d::Int(value) => RangedCoordi64::format(value), 168 | Coord1d::Float(value) => RangedCoordf64::format(value), 169 | } 170 | } 171 | 172 | fn format_ext(&self, value: &Coord1d) -> String { 173 | Self::format(value) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/value/mod.rs: -------------------------------------------------------------------------------- 1 | mod chart_2d; 2 | pub use chart_2d::*; 3 | 4 | mod color; 5 | pub use color::*; 6 | 7 | mod series_2d; 8 | pub use series_2d::*; 9 | 10 | mod coords; 11 | pub use coords::*; 12 | -------------------------------------------------------------------------------- /crates/nu_plugin_plotters/src/value/series_2d.rs: -------------------------------------------------------------------------------- 1 | use std::any::Any; 2 | use std::{cmp, iter}; 3 | 4 | use nu_protocol::{CustomValue, FromValue, IntoValue, Record, ShellError, Span, Type, Value}; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use super::color::Color; 8 | use super::{Coord1d, Coord2d, Range}; 9 | 10 | #[derive(Debug, Clone, Serialize, Deserialize)] 11 | pub enum Series2d { 12 | Line(Line2dSeries), 13 | Bar(Bar2dSeries), 14 | } 15 | 16 | #[derive(Debug, Clone, Serialize, Deserialize, IntoValue)] 17 | pub struct Line2dSeries { 18 | pub series: Vec, 19 | pub color: Color, 20 | pub filled: bool, 21 | pub stroke_width: u32, 22 | pub point_size: u32, 23 | } 24 | 25 | #[derive(Debug, Clone, Serialize, Deserialize, IntoValue)] 26 | pub struct Bar2dSeries { 27 | pub series: Vec, 28 | pub color: Color, 29 | pub filled: bool, 30 | pub stroke_width: u32, 31 | } 32 | 33 | impl Series2d { 34 | fn into_base_value(self, span: Span) -> Value { 35 | let kind = match &self { 36 | Series2d::Line(_) => "line", 37 | Series2d::Bar(_) => "bar", 38 | }; 39 | let kind = ("kind".to_string(), Value::string(kind, span)); 40 | 41 | let record = match self { 42 | Series2d::Line(line) => line.into_value(span), 43 | Series2d::Bar(bar) => bar.into_value(span), 44 | }; 45 | let record = record 46 | .into_record() 47 | .expect("structs derive IntoValue via Value::Record"); 48 | 49 | let iter = iter::once(kind).chain(record); 50 | Value::record(Record::from_iter(iter), span) 51 | } 52 | } 53 | 54 | impl IntoValue for Series2d { 55 | fn into_value(self, span: Span) -> Value { 56 | Value::custom(Box::new(self), span) 57 | } 58 | } 59 | 60 | impl FromValue for Series2d { 61 | fn from_value(v: Value) -> Result { 62 | let span = v.span(); 63 | let v = v.into_custom_value()?; 64 | match v.as_any().downcast_ref::() { 65 | Some(v) => Ok(v.clone()), 66 | None => Err(ShellError::CantConvert { 67 | to_type: Self::ty().to_string(), 68 | from_type: v.type_name(), 69 | span, 70 | help: None, 71 | }), 72 | } 73 | } 74 | 75 | fn expected_type() -> Type { 76 | Self::ty() 77 | } 78 | } 79 | 80 | #[typetag::serde] 81 | impl CustomValue for Series2d { 82 | fn clone_value(&self, span: Span) -> Value { 83 | Value::custom(Box::new(self.clone()), span) 84 | } 85 | 86 | fn type_name(&self) -> String { 87 | Self::ty().to_string() 88 | } 89 | 90 | fn to_base_value(&self, span: Span) -> Result { 91 | Ok(Series2d::into_base_value(self.clone(), span)) 92 | } 93 | 94 | fn as_any(&self) -> &dyn Any { 95 | self 96 | } 97 | 98 | fn as_mut_any(&mut self) -> &mut dyn Any { 99 | self 100 | } 101 | } 102 | 103 | impl Series2d { 104 | pub fn series(&self) -> &[Coord2d] { 105 | match self { 106 | Series2d::Line(line) => &line.series, 107 | Series2d::Bar(bar) => &bar.series, 108 | } 109 | } 110 | 111 | // FIXME: maybe we need to rethink this range function 112 | fn range(&self, axis: A, map: M) -> Option 113 | where 114 | A: Fn(&Coord2d) -> Coord1d, 115 | M: Fn(Coord1d) -> (Coord1d, Coord1d), 116 | { 117 | let first = self.series().first()?; 118 | let (mut min, mut max) = (axis(first), axis(first)); 119 | for (lower, upper) in self.series().iter().map(axis).map(map) { 120 | if lower < min { 121 | min = lower; 122 | } 123 | 124 | if upper > max { 125 | max = upper; 126 | } 127 | } 128 | 129 | Some(Range { 130 | min, 131 | max, 132 | metadata: None, 133 | }) 134 | } 135 | 136 | pub fn x_range(&self) -> Option { 137 | self.range( 138 | |c| c.x, 139 | |c| match self { 140 | Series2d::Line(_) => (c, c), 141 | Series2d::Bar(_) => (c - Coord1d::Float(0.5), c + Coord1d::Float(0.5)), 142 | }, 143 | ) 144 | } 145 | 146 | pub fn y_range(&self) -> Option { 147 | self.range( 148 | |c| c.y, 149 | |c| match self { 150 | Series2d::Line(_) => (c, c), 151 | Series2d::Bar(_) => (cmp::min(Coord1d::Int(0), c), cmp::max(Coord1d::Int(0), c)), 152 | }, 153 | ) 154 | } 155 | 156 | pub fn ty() -> Type { 157 | Type::Custom("plotters::series-2d".to_string().into_boxed_str()) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /examples/example.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "e372f727", 6 | "metadata": {}, 7 | "source": [ 8 | "This is running `nu` code." 9 | ] 10 | }, 11 | { 12 | "cell_type": "code", 13 | "execution_count": 1, 14 | "id": "fb2910e8", 15 | "metadata": {}, 16 | "outputs": [ 17 | { 18 | "data": { 19 | "text/plain": [ 20 | " __ ,\r\n", 21 | " .--()°'.' You're running `nu-jupyter-kernel`,\r\n", 22 | "'|, . ,' based on the `nu` language,\r\n", 23 | " !_-(_\\ where all data is structured!" 24 | ] 25 | }, 26 | "metadata": {}, 27 | "output_type": "display_data" 28 | }, 29 | { 30 | "data": { 31 | "application/json": "Welcome to Nushell.", 32 | "text/html": [ 33 | "
Welcome to Nushell.
" 34 | ], 35 | "text/markdown": [ 36 | "Welcome to Nushell.\n" 37 | ], 38 | "text/plain": [ 39 | "Welcome to Nushell." 40 | ] 41 | }, 42 | "execution_count": 1, 43 | "metadata": {}, 44 | "output_type": "execute_result" 45 | } 46 | ], 47 | "source": [ 48 | "open ../banner.txt | nuju print -f txt\n", 49 | "help | lines | first" 50 | ] 51 | } 52 | ], 53 | "metadata": { 54 | "kernelspec": { 55 | "display_name": "Nushell", 56 | "language": "nushell", 57 | "name": "nu" 58 | }, 59 | "language_info": { 60 | "file_extension": ".nu", 61 | "mimetype": "text/x-nushell", 62 | "name": "nushell", 63 | "version": "0.98.0" 64 | } 65 | }, 66 | "nbformat": 4, 67 | "nbformat_minor": 5 68 | } 69 | -------------------------------------------------------------------------------- /examples/polars-data.nu: -------------------------------------------------------------------------------- 1 | let data_dir = ($env.FILE_PWD | path join data) 2 | mkdir $data_dir 3 | cd $data_dir 4 | 5 | print "Downloading zip..." 6 | http get "https://www.stats.govt.nz/assets/Uploads/New-Zealand-business-demography-statistics/New-Zealand-business-demography-statistics-At-February-2022/Download-data/CSV-data-load_data_metadata.zip" 7 | | save nz-stats.zip 8 | 9 | print "Extracting zip..." 10 | match $nu.os-info.name { 11 | "windows" => {tar -xf nz-stats.zip}, 12 | _ => {unzip nz-stats.zip} 13 | } 14 | 15 | print "Done." 16 | -------------------------------------------------------------------------------- /media/draw-terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cptpiepmatz/nu-jupyter-kernel/30a5cf4d3ea11b698697a6caf945f8caa60f3f26/media/draw-terminal.png -------------------------------------------------------------------------------- /media/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cptpiepmatz/nu-jupyter-kernel/30a5cf4d3ea11b698697a6caf945f8caa60f3f26/media/screenshot.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "nu-jupyter-kernel-test" 3 | version = "0.0.0" 4 | description = "Tests for the nu-jupyter-kernel" 5 | requires-python = ">=3.12" 6 | dependencies = [ 7 | "jupyter-client>=8.6.2", 8 | "pytest-timeout>=2.3.1", 9 | "pytest>=8.3.3", 10 | ] 11 | 12 | [tool.pytest.ini_options] 13 | testpaths = ["tests"] 14 | timeout = 30 15 | timeout_method = "thread" 16 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | unstable_features = true 2 | edition = "2021" 3 | binop_separator = "Back" 4 | control_brace_style = "ClosingNextLine" 5 | format_strings = true 6 | hex_literal_case = "Upper" 7 | imports_granularity = "Module" 8 | overflow_delimited_expr = true 9 | reorder_impl_items = true 10 | reorder_imports = true 11 | group_imports = "StdExternalCrate" 12 | use_field_init_shorthand = true 13 | wrap_comments = true -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug)] 2 | pub enum KernelError { 3 | MissingFormatDecls { missing: Vec<&'static str> }, 4 | } 5 | -------------------------------------------------------------------------------- /src/handlers/control.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | use std::sync::atomic::{AtomicBool, Ordering}; 3 | use std::sync::Arc; 4 | use std::time::Duration; 5 | 6 | use tokio::sync::broadcast; 7 | 8 | use crate::jupyter::kernel_info::KernelInfo; 9 | use crate::jupyter::messages::control::{ControlReply, ControlReplyOk, ControlRequest}; 10 | use crate::jupyter::messages::{Header, Message, Metadata}; 11 | use crate::jupyter::Shutdown; 12 | use crate::ControlSocket; 13 | 14 | pub async fn handle( 15 | mut socket: ControlSocket, 16 | shutdown_sender: broadcast::Sender, 17 | interrupt_signal: Arc, 18 | ) { 19 | loop { 20 | let message = Message::::recv(&mut socket).await.unwrap(); 21 | match &message.content { 22 | ControlRequest::KernelInfo => handle_kernel_info_request(&mut socket, &message).await, 23 | ControlRequest::Shutdown(shutdown) => { 24 | handle_shutdown_request(&mut socket, &message, *shutdown, &shutdown_sender).await; 25 | match shutdown.restart { 26 | true => continue, 27 | false => break, 28 | } 29 | } 30 | ControlRequest::Interrupt => { 31 | handle_interrupt_request(&mut socket, &message, interrupt_signal.deref()).await 32 | } 33 | ControlRequest::Debug => todo!(), 34 | } 35 | } 36 | } 37 | 38 | async fn handle_kernel_info_request(socket: &mut ControlSocket, message: &Message) { 39 | let kernel_info = KernelInfo::get(); 40 | let reply = ControlReply::Ok(ControlReplyOk::KernelInfo(Box::new(kernel_info))); 41 | let msg_type = ControlReply::msg_type(&message.header.msg_type).unwrap(); 42 | let reply = Message { 43 | zmq_identities: message.zmq_identities.clone(), 44 | header: Header::new(msg_type), 45 | parent_header: Some(message.header.clone()), 46 | metadata: Metadata::empty(), 47 | content: reply, 48 | buffers: vec![], 49 | }; 50 | reply.into_multipart().unwrap().send(socket).await.unwrap(); 51 | } 52 | 53 | async fn handle_shutdown_request( 54 | socket: &mut ControlSocket, 55 | message: &Message, 56 | shutdown: Shutdown, 57 | sender: &broadcast::Sender, 58 | ) { 59 | // according to docs, we first shut our kernel and then reply to the client 60 | sender.send(shutdown).unwrap(); 61 | 62 | // TODO: check if application terminated, maybe with broadcast that ensures that 63 | // every subscriber received something 64 | let reply = ControlReply::Ok(ControlReplyOk::Shutdown(shutdown)); 65 | let msg_type = ControlReply::msg_type(&message.header.msg_type).unwrap(); 66 | let reply = Message { 67 | zmq_identities: message.zmq_identities.clone(), 68 | header: Header::new(msg_type), 69 | parent_header: Some(message.header.clone()), 70 | metadata: Metadata::empty(), 71 | content: reply, 72 | buffers: vec![], 73 | }; 74 | reply.into_multipart().unwrap().send(socket).await.unwrap(); 75 | } 76 | 77 | async fn handle_interrupt_request( 78 | socket: &mut ControlSocket, 79 | message: &Message, 80 | interrupt_signal: &AtomicBool, 81 | ) { 82 | interrupt_signal.store(true, Ordering::Relaxed); 83 | 84 | while interrupt_signal.load(Ordering::Relaxed) { 85 | // poll the interrupt signal to check when the engine is successfully 86 | // interrupted 87 | tokio::time::sleep(Duration::from_millis(100)).await; 88 | } 89 | 90 | let reply = ControlReply::Ok(ControlReplyOk::Interrupt); 91 | let msg_type = ControlReply::msg_type(&message.header.msg_type).unwrap(); 92 | let reply = Message { 93 | zmq_identities: message.zmq_identities.clone(), 94 | header: Header::new(msg_type), 95 | parent_header: Some(message.header.clone()), 96 | metadata: Metadata::empty(), 97 | content: reply, 98 | buffers: vec![], 99 | }; 100 | reply.into_multipart().unwrap().send(socket).await.unwrap(); 101 | } 102 | -------------------------------------------------------------------------------- /src/handlers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod iopub { 2 | use tokio::sync::{broadcast, mpsc}; 3 | 4 | use crate::jupyter::messages::multipart::Multipart; 5 | use crate::jupyter::Shutdown; 6 | use crate::util::Select; 7 | use crate::IopubSocket; 8 | 9 | pub async fn handle( 10 | mut socket: IopubSocket, 11 | mut shutdown: broadcast::Receiver, 12 | mut iopub_rx: mpsc::Receiver, 13 | ) { 14 | loop { 15 | let next = tokio::select! { 16 | biased; 17 | v = shutdown.recv() => Select::Left(v), 18 | v = iopub_rx.recv() => Select::Right(v.unwrap()), 19 | }; 20 | 21 | let multipart = match next { 22 | Select::Left(Ok(Shutdown { restart: false })) => break, 23 | Select::Left(Ok(Shutdown { restart: true })) => continue, 24 | Select::Left(Err(_)) => break, 25 | Select::Right(multipart) => multipart, 26 | }; 27 | multipart.send(&mut socket).await.unwrap(); 28 | } 29 | } 30 | } 31 | 32 | pub mod heartbeat { 33 | use tokio::sync::broadcast; 34 | use zeromq::{SocketRecv, SocketSend}; 35 | 36 | use crate::jupyter::Shutdown; 37 | use crate::util::Select; 38 | use crate::HeartbeatSocket; 39 | 40 | pub async fn handle(mut socket: HeartbeatSocket, mut shutdown: broadcast::Receiver) { 41 | loop { 42 | let next = tokio::select! { 43 | biased; 44 | v = shutdown.recv() => Select::Left(v), 45 | v = socket.recv() => Select::Right(v.unwrap()), 46 | }; 47 | 48 | let msg = match next { 49 | Select::Left(Ok(Shutdown { restart: false })) => break, 50 | Select::Left(Ok(Shutdown { restart: true })) => continue, 51 | Select::Left(Err(_)) => break, 52 | Select::Right(msg) => msg, 53 | }; 54 | socket.send(msg).await.unwrap(); 55 | } 56 | } 57 | } 58 | 59 | pub mod control; 60 | pub mod shell; 61 | pub mod stream; 62 | -------------------------------------------------------------------------------- /src/handlers/shell.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use mime::Mime; 4 | use nu_protocol::engine::{EngineState, Stack, StateWorkingSet}; 5 | use nu_protocol::PipelineData; 6 | use parking_lot::Mutex; 7 | use serde_json::json; 8 | use tokio::sync::{broadcast, mpsc}; 9 | 10 | use super::stream::StreamHandler; 11 | use crate::jupyter::kernel_info::KernelInfo; 12 | use crate::jupyter::messages::iopub::{self, ExecuteResult, IopubBroacast, Status}; 13 | use crate::jupyter::messages::multipart::Multipart; 14 | use crate::jupyter::messages::shell::{ 15 | ExecuteReply, ExecuteRequest, IsCompleteReply, IsCompleteRequest, ShellReply, ShellReplyOk, 16 | ShellRequest, 17 | }; 18 | use crate::jupyter::messages::{Header, Message, Metadata}; 19 | use crate::jupyter::Shutdown; 20 | use crate::nu::commands::external::External; 21 | use crate::nu::konst::Konst; 22 | use crate::nu::module::KernelInternalSpans; 23 | use crate::nu::render::{FormatDeclIds, PipelineRender, StringifiedPipelineRender}; 24 | use crate::nu::{self, ExecuteError, ReportExecuteError}; 25 | use crate::util::Select; 26 | use crate::ShellSocket; 27 | 28 | // TODO: get rid of this static by passing this into the display command 29 | pub static RENDER_FILTER: Mutex> = Mutex::new(Option::None); 30 | 31 | pub struct HandlerContext { 32 | pub socket: ShellSocket, 33 | pub iopub: mpsc::Sender, 34 | pub stdout_handler: StreamHandler, 35 | pub stderr_handler: StreamHandler, 36 | pub engine_state: EngineState, 37 | pub format_decl_ids: FormatDeclIds, 38 | pub konst: Konst, 39 | pub spans: KernelInternalSpans, 40 | pub stack: Stack, 41 | pub cell: Cell, 42 | } 43 | 44 | pub async fn handle(mut ctx: HandlerContext, mut shutdown: broadcast::Receiver) { 45 | let initial_engine_state = ctx.engine_state.clone(); 46 | let initial_stack = ctx.stack.clone(); 47 | 48 | loop { 49 | let next = tokio::select! { 50 | biased; 51 | v = shutdown.recv() => Select::Left(v), 52 | v = Message::::recv(&mut ctx.socket) => Select::Right(v), 53 | }; 54 | 55 | let message = match next { 56 | Select::Left(Ok(Shutdown { restart: false })) => break, 57 | Select::Left(Ok(Shutdown { restart: true })) => { 58 | ctx.engine_state = initial_engine_state.clone(); 59 | ctx.stack = initial_stack.clone(); 60 | // TODO: check if cell counter should get a reset too 61 | continue; 62 | } 63 | Select::Left(Err(_)) => break, 64 | Select::Right(Ok(msg)) => msg, 65 | Select::Right(Err(_)) => { 66 | eprintln!("could not recv message"); 67 | continue; 68 | } 69 | }; 70 | 71 | send_status(&mut ctx, &message, Status::Busy).await; 72 | 73 | match &message.content { 74 | ShellRequest::KernelInfo => handle_kernel_info_request(&mut ctx, &message).await, 75 | ShellRequest::Execute(request) => { 76 | // take the context out temporarily to allow execution on another thread 77 | ctx = handle_execute_request(ctx, &message, request).await; 78 | } 79 | ShellRequest::IsComplete(request) => { 80 | handle_is_complete_request(&mut ctx, &message, request).await 81 | } 82 | } 83 | 84 | send_status(&mut ctx, &message, Status::Idle).await; 85 | } 86 | } 87 | 88 | async fn send_status(ctx: &mut HandlerContext, message: &Message, status: Status) { 89 | ctx.iopub 90 | .send( 91 | status 92 | .into_message(message.header.clone()) 93 | .into_multipart() 94 | .unwrap(), 95 | ) 96 | .await 97 | .unwrap(); 98 | } 99 | 100 | /// Representation of cell execution in Jupyter. 101 | /// 102 | /// Used to keep track of the execution counter and retry attempts for the same 103 | /// cell. 104 | pub struct Cell { 105 | execution_counter: usize, 106 | retry_counter: usize, 107 | } 108 | 109 | impl Cell { 110 | /// Construct a new Cell. 111 | pub const fn new() -> Self { 112 | Cell { 113 | execution_counter: 1, 114 | retry_counter: 1, 115 | } 116 | } 117 | 118 | /// Generate a name for the next retry of the current cell. 119 | /// 120 | /// This method increases the retry counter each time it is called, 121 | /// indicating a new attempt on the same cell. 122 | pub fn next_name(&mut self) -> String { 123 | let name = format!("cell[{}]#{}", self.execution_counter, self.retry_counter); 124 | self.retry_counter += 1; 125 | name 126 | } 127 | 128 | /// Increment the execution counter after a successful execution. 129 | /// 130 | /// Jupyter demands that the execution counter only increases after a 131 | /// successful execution. This function increments the counter and resets 132 | /// the retry counter, indicating a new cell execution. It returns the 133 | /// previous execution counter. 134 | pub fn success(&mut self) -> usize { 135 | let current_execution_counter = self.execution_counter; 136 | self.execution_counter += 1; 137 | self.retry_counter = 1; 138 | current_execution_counter 139 | } 140 | } 141 | 142 | async fn handle_kernel_info_request(ctx: &mut HandlerContext, message: &Message) { 143 | let kernel_info = KernelInfo::get(); 144 | let reply = ShellReply::Ok(ShellReplyOk::KernelInfo(kernel_info)); 145 | let msg_type = ShellReply::msg_type(&message.header.msg_type).unwrap(); 146 | let reply = Message { 147 | zmq_identities: message.zmq_identities.clone(), 148 | header: Header::new(msg_type), 149 | parent_header: Some(message.header.clone()), 150 | metadata: Metadata::empty(), 151 | content: reply, 152 | buffers: vec![], 153 | }; 154 | reply 155 | .into_multipart() 156 | .unwrap() 157 | .send(&mut ctx.socket) 158 | .await 159 | .unwrap(); 160 | } 161 | 162 | async fn handle_execute_request( 163 | mut ctx: HandlerContext, 164 | message: &Message, 165 | request: &ExecuteRequest, 166 | ) -> HandlerContext { 167 | let ExecuteRequest { 168 | code, 169 | silent, 170 | store_history, 171 | user_expressions, 172 | allow_stdin, 173 | stop_on_error, 174 | } = request; 175 | let msg_type = ShellReply::msg_type(&message.header.msg_type).unwrap(); 176 | External::apply(&mut ctx.engine_state).unwrap(); 177 | 178 | let cell_name = ctx.cell.next_name(); 179 | ctx.konst 180 | .update(&mut ctx.stack, cell_name.clone(), message.clone()); 181 | ctx.stdout_handler 182 | .update_reply(message.zmq_identities.clone(), message.header.clone()); 183 | ctx.stderr_handler 184 | .update_reply(message.zmq_identities.clone(), message.header.clone()); 185 | 186 | // TODO: place coll in cell, then just pass the cell 187 | let code = code.to_owned(); 188 | let (executed, mut ctx) = tokio::task::spawn_blocking(move || { 189 | ( 190 | nu::execute(&code, &mut ctx.engine_state, &mut ctx.stack, &cell_name), 191 | ctx, 192 | ) 193 | }) 194 | .await 195 | .unwrap(); 196 | match executed { 197 | Ok(data) => handle_execute_results(&mut ctx, message, msg_type, data).await, 198 | Err(error) => handle_execute_error(&mut ctx, message, msg_type, error).await, 199 | }; 200 | 201 | // reset interrupt signal after every execution, this also notifies the control 202 | // handler 203 | ctx.engine_state.reset_signals(); 204 | 205 | ctx 206 | } 207 | 208 | async fn handle_execute_error( 209 | ctx: &mut HandlerContext, 210 | message: &Message, 211 | msg_type: &str, 212 | error: ExecuteError, 213 | ) { 214 | let mut working_set = StateWorkingSet::new(&ctx.engine_state); 215 | let (name, value) = { 216 | // keeping the report makes the following part not Send 217 | let report = ReportExecuteError::new(error, &mut working_set); 218 | let name = report.code().to_string(); 219 | let value = report.fmt(); 220 | (name, value) 221 | }; 222 | // TODO: for traceback use error source 223 | let traceback = vec![]; 224 | 225 | // we send display data to have control over the rendering of the output 226 | let broadcast = IopubBroacast::DisplayData(iopub::DisplayData { 227 | data: HashMap::from([(mime::TEXT_PLAIN.to_string(), value.clone())]), 228 | metadata: HashMap::new(), 229 | transient: HashMap::new(), 230 | }); 231 | let broadcast = Message { 232 | zmq_identities: message.zmq_identities.clone(), 233 | header: Header::new(broadcast.msg_type()), 234 | parent_header: Some(message.header.clone()), 235 | metadata: Metadata::empty(), 236 | content: broadcast, 237 | buffers: vec![], 238 | }; 239 | ctx.iopub 240 | .send(broadcast.into_multipart().unwrap()) 241 | .await 242 | .unwrap(); 243 | 244 | let reply = ShellReply::Error { 245 | name, 246 | value, 247 | traceback, 248 | }; 249 | let reply = Message { 250 | zmq_identities: message.zmq_identities.clone(), 251 | header: Header::new(msg_type), 252 | parent_header: Some(message.header.clone()), 253 | metadata: Metadata::empty(), 254 | content: reply, 255 | buffers: vec![], 256 | }; 257 | reply 258 | .into_multipart() 259 | .unwrap() 260 | .send(&mut ctx.socket) 261 | .await 262 | .unwrap(); 263 | } 264 | 265 | async fn handle_execute_results( 266 | ctx: &mut HandlerContext, 267 | message: &Message, 268 | msg_type: &str, 269 | pipeline_data: PipelineData, 270 | ) { 271 | let execution_count = ctx.cell.success(); 272 | 273 | if !pipeline_data.is_nothing() { 274 | let render: StringifiedPipelineRender = { 275 | // render filter needs to be dropped until next async yield 276 | let mut render_filter = RENDER_FILTER.lock(); 277 | PipelineRender::render( 278 | pipeline_data, 279 | &ctx.engine_state, 280 | &mut ctx.stack, 281 | &ctx.spans, 282 | ctx.format_decl_ids, 283 | render_filter.take(), 284 | ) 285 | .unwrap() // TODO: replace this with some actual handling 286 | .into() 287 | }; 288 | 289 | let execute_result = ExecuteResult { 290 | execution_count, 291 | data: render.data, 292 | metadata: render.metadata, 293 | }; 294 | let broadcast = IopubBroacast::from(execute_result); 295 | let broadcast = Message { 296 | zmq_identities: message.zmq_identities.clone(), 297 | header: Header::new(broadcast.msg_type()), 298 | parent_header: Some(message.header.clone()), 299 | metadata: Metadata::empty(), 300 | content: broadcast, 301 | buffers: vec![], 302 | }; 303 | ctx.iopub 304 | .send(broadcast.into_multipart().unwrap()) 305 | .await 306 | .unwrap(); 307 | } 308 | 309 | let reply = ExecuteReply { 310 | execution_count, 311 | user_expressions: json!({}), 312 | }; 313 | let reply = ShellReply::Ok(ShellReplyOk::Execute(reply)); 314 | let reply = Message { 315 | zmq_identities: message.zmq_identities.clone(), 316 | header: Header::new(msg_type), 317 | parent_header: Some(message.header.clone()), 318 | metadata: Metadata::empty(), 319 | content: reply, 320 | buffers: vec![], 321 | }; 322 | reply 323 | .into_multipart() 324 | .unwrap() 325 | .send(&mut ctx.socket) 326 | .await 327 | .unwrap(); 328 | } 329 | 330 | async fn handle_is_complete_request( 331 | ctx: &mut HandlerContext, 332 | message: &Message, 333 | request: &IsCompleteRequest, 334 | ) { 335 | let reply = IsCompleteReply::Unknown; 336 | let reply = ShellReply::Ok(ShellReplyOk::IsComplete(reply)); 337 | let msg_type = ShellReply::msg_type(&message.header.msg_type).unwrap(); 338 | let reply = Message { 339 | zmq_identities: message.zmq_identities.clone(), 340 | header: Header::new(msg_type), 341 | parent_header: Some(message.header.clone()), 342 | metadata: Metadata::empty(), 343 | content: reply, 344 | buffers: vec![], 345 | }; 346 | reply 347 | .into_multipart() 348 | .unwrap() 349 | .send(&mut ctx.socket) 350 | .await 351 | .unwrap(); 352 | } 353 | -------------------------------------------------------------------------------- /src/handlers/stream.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::{self, Read}; 3 | use std::os; 4 | use std::sync::Arc; 5 | use std::thread::{self}; 6 | 7 | use bytes::Bytes; 8 | use parking_lot::Mutex; 9 | use tokio::sync::mpsc; 10 | 11 | use crate::jupyter::messages::iopub::{self, IopubBroacast}; 12 | use crate::jupyter::messages::multipart::Multipart; 13 | use crate::jupyter::messages::{Header, Message, Metadata}; 14 | 15 | const BUFFER_SIZE: usize = 8 * 1024; 16 | 17 | pub struct StreamHandler { 18 | message_data: Arc, Option
)>>, 19 | stream_name: iopub::StreamName, // iopub_tx: Sender, // moved into the thread 20 | } 21 | 22 | impl StreamHandler { 23 | pub fn start( 24 | stream_name: iopub::StreamName, 25 | iopub_tx: mpsc::Sender, 26 | ) -> io::Result<(Self, File)> { 27 | // TODO: construct a Self, create a pipe, start a reader thread, return the 28 | // writer as a file 29 | let message_data = Arc::new(Mutex::new((vec![], None))); 30 | 31 | let (mut pipe_reader, pipe_writer) = os_pipe::pipe()?; 32 | let t_message_data = message_data.clone(); 33 | thread::Builder::new() 34 | .name(format!("{} reader", stream_name.as_ref())) 35 | .spawn(move || { 36 | let mut read_buf = [0u8; BUFFER_SIZE]; 37 | loop { 38 | let mut s_buf: Vec = Vec::new(); 39 | loop { 40 | match pipe_reader.read(&mut read_buf) { 41 | Err(err) => todo!("handle that error"), 42 | Ok(0) if s_buf.is_empty() => todo!("stream is dead"), 43 | Ok(0) => break, 44 | Ok(BUFFER_SIZE) => s_buf.extend_from_slice(&read_buf), 45 | Ok(n) => { 46 | s_buf.extend_from_slice(&read_buf[..n]); 47 | break; 48 | } 49 | } 50 | } 51 | // TODO: handle this somehow 52 | let s = String::from_utf8(s_buf).unwrap(); 53 | let broadcast = IopubBroacast::Stream(iopub::Stream { 54 | name: stream_name, 55 | text: s, 56 | }); 57 | let (zmq_identities, parent_header) = t_message_data.lock().clone(); 58 | let message = Message { 59 | zmq_identities, 60 | header: Header::new(broadcast.msg_type()), 61 | parent_header, 62 | metadata: Metadata::empty(), 63 | content: broadcast, 64 | buffers: vec![], 65 | }; 66 | // TODO: handle this better, also does this need to be blocking? 67 | iopub_tx 68 | .blocking_send(message.into_multipart().unwrap()) 69 | .unwrap(); 70 | } 71 | })?; 72 | 73 | #[cfg(windows)] 74 | let file: File = os::windows::io::OwnedHandle::from(pipe_writer).into(); 75 | #[cfg(unix)] 76 | let file: File = os::unix::io::OwnedFd::from(pipe_writer).into(); 77 | 78 | Ok(( 79 | Self { 80 | message_data, 81 | stream_name, 82 | }, 83 | file, 84 | )) 85 | } 86 | 87 | pub fn update_reply(&mut self, zmq_identities: Vec, parent_header: Header) { 88 | let mut message_data = self.message_data.lock(); 89 | message_data.0 = zmq_identities; 90 | message_data.1 = Some(parent_header); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/jupyter/connection_file.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | use std::net::Ipv4Addr; 3 | use std::path::Path; 4 | use std::{fs, io}; 5 | 6 | use serde::{Deserialize, Deserializer}; 7 | use thiserror::Error; 8 | 9 | #[derive(Debug, Deserialize)] 10 | pub struct ConnectionFile { 11 | pub control_port: PortAddr, 12 | pub shell_port: PortAddr, 13 | pub transport: Transport, 14 | pub signature_scheme: SignatureScheme, 15 | pub stdin_port: PortAddr, 16 | #[serde(alias = "hb_port")] 17 | pub heartbeat_port: PortAddr, 18 | pub ip: Ipv4Addr, 19 | pub iopub_port: PortAddr, 20 | #[serde(deserialize_with = "deserialize_key")] 21 | pub key: Vec, 22 | } 23 | 24 | #[derive(Debug, Error)] 25 | pub enum ReadConnectionFileError { 26 | #[error("could not read connection file")] 27 | ReadFile(#[from] io::Error), 28 | #[error("could not parse connection file")] 29 | Parse(#[from] serde_json::Error), 30 | } 31 | 32 | impl ConnectionFile { 33 | pub fn from_path(path: impl AsRef) -> Result { 34 | let contents = fs::read_to_string(path)?; 35 | let connection_file: ConnectionFile = serde_json::from_str(&contents)?; 36 | Ok(connection_file) 37 | } 38 | } 39 | 40 | #[derive(Debug, Deserialize)] 41 | pub struct PortAddr(u16); 42 | 43 | impl Display for PortAddr { 44 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 45 | write!(f, "{}", self.0) 46 | } 47 | } 48 | 49 | #[derive(Debug)] 50 | pub struct SignatureScheme { 51 | pub algorithm: SupportedSignatureAlgorithm, 52 | pub hash_fn: SupportedSignatureHashFunction, 53 | } 54 | 55 | impl<'de> Deserialize<'de> for SignatureScheme { 56 | fn deserialize(deserializer: D) -> Result 57 | where 58 | D: serde::Deserializer<'de>, 59 | { 60 | let as_str = String::deserialize(deserializer)?; 61 | let mut split = as_str.split('-'); 62 | let algorithm = split 63 | .next() 64 | .ok_or_else(|| serde::de::Error::missing_field("algorithm"))?; 65 | let hash_fn = split 66 | .next() 67 | .ok_or_else(|| serde::de::Error::missing_field("hash_fn"))?; 68 | 69 | let algorithm = match algorithm { 70 | "hmac" => SupportedSignatureAlgorithm::Hmac, 71 | other => return Err(serde::de::Error::unknown_variant(other, &["hmac"])), 72 | }; 73 | 74 | let hash_fn = match hash_fn { 75 | "sha256" => SupportedSignatureHashFunction::Sha256, 76 | other => return Err(serde::de::Error::unknown_variant(other, &["sha256"])), 77 | }; 78 | 79 | Ok(SignatureScheme { algorithm, hash_fn }) 80 | } 81 | } 82 | 83 | #[non_exhaustive] 84 | #[derive(Debug, Clone, Copy)] 85 | pub enum SupportedSignatureAlgorithm { 86 | Hmac, 87 | } 88 | 89 | #[non_exhaustive] 90 | #[derive(Debug, Clone, Copy)] 91 | pub enum SupportedSignatureHashFunction { 92 | Sha256, 93 | } 94 | 95 | #[derive(Debug, Clone, Copy, Deserialize)] 96 | #[serde(rename_all = "snake_case")] 97 | pub enum Transport { 98 | Tcp, 99 | } 100 | 101 | impl Display for Transport { 102 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 103 | match self { 104 | Transport::Tcp => write!(f, "tcp"), 105 | } 106 | } 107 | } 108 | 109 | fn deserialize_key<'de, D>(deserializer: D) -> Result, D::Error> 110 | where 111 | D: Deserializer<'de>, 112 | { 113 | let as_str = String::deserialize(deserializer)?; 114 | Ok(as_str.into_bytes()) 115 | } 116 | -------------------------------------------------------------------------------- /src/jupyter/kernel_info.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | 3 | use crate::CARGO_TOML; 4 | 5 | #[derive(Debug, Serialize, Clone)] 6 | pub struct KernelInfo { 7 | pub protocol_version: String, 8 | pub implementation: String, 9 | pub implementation_version: String, 10 | pub language_info: LanguageInfo, 11 | pub banner: String, 12 | pub debugger: bool, 13 | pub help_links: Vec, 14 | } 15 | 16 | #[derive(Debug, Serialize, Clone)] 17 | pub struct LanguageInfo { 18 | pub name: String, 19 | pub version: String, 20 | pub mimetype: String, 21 | pub file_extension: String, 22 | } 23 | 24 | #[derive(Debug, Serialize, Clone)] 25 | pub struct HelpLink { 26 | pub text: String, 27 | pub url: String, 28 | } 29 | 30 | impl From<(T, U)> for HelpLink 31 | where 32 | T: Into, 33 | U: Into, 34 | { 35 | fn from(value: (T, U)) -> Self { 36 | HelpLink { 37 | text: value.0.into(), 38 | url: value.1.into(), 39 | } 40 | } 41 | } 42 | 43 | impl KernelInfo { 44 | pub fn get() -> Self { 45 | KernelInfo { 46 | protocol_version: CARGO_TOML 47 | .package 48 | .metadata 49 | .jupyter 50 | .protocol_version 51 | .to_owned(), 52 | implementation: CARGO_TOML.package.name.to_owned(), 53 | implementation_version: CARGO_TOML.package.version.to_owned(), 54 | language_info: LanguageInfo { 55 | name: "nushell".to_owned(), 56 | version: CARGO_TOML.dependencies.nu_engine.version.to_owned(), 57 | mimetype: "text/x-nushell".to_owned(), 58 | file_extension: ".nu".to_owned(), 59 | }, 60 | banner: include_str!("../../banner.txt").to_owned(), 61 | debugger: false, 62 | help_links: [ 63 | ("Discord", "https://discord.gg/NtAbbGn"), 64 | ("GitHub", "https://github.com/nushell/nushell"), 65 | ] 66 | .into_iter() 67 | .map(|pair| pair.into()) 68 | .collect(), 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/jupyter/messages/control.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::jupyter::kernel_info::KernelInfo; 4 | use crate::jupyter::Shutdown; 5 | 6 | #[derive(Debug, Deserialize, Clone)] 7 | pub enum ControlRequest { 8 | KernelInfo, 9 | Shutdown(Shutdown), 10 | Interrupt, 11 | Debug, // TODO: check if this is reasonable 12 | } 13 | 14 | impl ControlRequest { 15 | pub fn parse_variant(variant: &str, body: &str) -> Result { 16 | match variant { 17 | "kernel_info_request" => Ok(Self::KernelInfo), 18 | "shutdown_request" => Ok(Self::Shutdown(serde_json::from_str(body).unwrap())), 19 | "interrupt_request" => Ok(Self::Interrupt), 20 | "debug_request" => todo!(), 21 | _ => { 22 | eprintln!("found it here: {variant}"); 23 | 24 | eprintln!("unknown request {variant}"); 25 | Err(()) 26 | } 27 | } 28 | } 29 | } 30 | 31 | #[derive(Debug, Serialize, Clone)] 32 | #[serde(rename_all = "snake_case", tag = "status")] 33 | pub enum ControlReply { 34 | Ok(ControlReplyOk), 35 | Error { 36 | #[serde(rename = "ename")] 37 | name: String, 38 | #[serde(rename = "evalue")] 39 | value: String, 40 | traceback: Vec, 41 | }, 42 | } 43 | 44 | impl ControlReply { 45 | pub fn msg_type(request_type: &'_ str) -> Result<&'static str, ()> { 46 | Ok(match request_type { 47 | "kernel_info_request" => "kernel_info_reply", 48 | "shutdown_request" => "shutdown_reply", 49 | "interrupt_request" => "interrupt_reply", 50 | "debug_request" => "debug_reply", 51 | _ => todo!("handle unknown requests"), 52 | }) 53 | } 54 | } 55 | 56 | #[derive(Debug, Serialize, Clone)] 57 | #[serde(untagged)] 58 | pub enum ControlReplyOk { 59 | KernelInfo(Box), 60 | Shutdown(Shutdown), 61 | Interrupt, 62 | Debug, 63 | } 64 | -------------------------------------------------------------------------------- /src/jupyter/messages/iopub.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use derive_more::From; 4 | use serde::Serialize; 5 | use strum::AsRefStr; 6 | 7 | use super::Header; 8 | use crate::jupyter::messages::{Message, Metadata}; 9 | 10 | #[derive(Debug, Serialize, From, Clone)] 11 | #[serde(untagged)] 12 | pub enum IopubBroacast { 13 | Stream(Stream), 14 | DisplayData(DisplayData), 15 | UpdateDisplayData, 16 | ExecuteInput, 17 | ExecuteResult(ExecuteResult), 18 | Error(Error), 19 | Status(Status), 20 | ClearOutput, 21 | DebugEvent, 22 | } 23 | 24 | impl IopubBroacast { 25 | pub fn msg_type(&self) -> &'static str { 26 | match self { 27 | IopubBroacast::Stream(_) => "stream", 28 | IopubBroacast::DisplayData(_) => "display_data", 29 | IopubBroacast::UpdateDisplayData => "update_display_data", 30 | IopubBroacast::ExecuteInput => "execute_input", 31 | IopubBroacast::ExecuteResult(_) => "execute_result", 32 | IopubBroacast::Error(_) => "error", 33 | IopubBroacast::Status(_) => "status", 34 | IopubBroacast::ClearOutput => "clear_output", 35 | IopubBroacast::DebugEvent => "debug_event", 36 | } 37 | } 38 | } 39 | 40 | #[derive(Debug, Serialize, Clone, Copy, AsRefStr)] 41 | #[serde(rename_all = "snake_case")] 42 | pub enum StreamName { 43 | Stdout, 44 | Stderr, 45 | } 46 | 47 | #[derive(Debug, Serialize, Clone)] 48 | pub struct Stream { 49 | pub name: StreamName, 50 | pub text: String, 51 | } 52 | 53 | #[derive(Debug, Serialize, Clone)] 54 | pub struct DisplayData { 55 | pub data: HashMap, 56 | pub metadata: HashMap, 57 | pub transient: HashMap, 58 | } 59 | 60 | #[derive(Debug, Serialize, Clone)] 61 | pub struct ExecuteResult { 62 | pub execution_count: usize, 63 | pub data: HashMap, 64 | pub metadata: HashMap, 65 | } 66 | 67 | #[derive(Debug, Serialize, Clone)] 68 | pub struct Error { 69 | #[serde(rename = "ename")] 70 | pub name: String, 71 | 72 | #[serde(rename = "evalue")] 73 | pub value: String, 74 | 75 | pub traceback: Vec, 76 | } 77 | 78 | #[derive(Debug, Serialize, Clone, Copy)] 79 | #[serde(tag = "execution_state", rename_all = "snake_case")] 80 | pub enum Status { 81 | Busy, 82 | Idle, 83 | } 84 | 85 | impl Status { 86 | pub fn into_message(self, parent_header: impl Into>) -> Message { 87 | let broadcast = IopubBroacast::Status(self); 88 | let msg_type = broadcast.msg_type(); 89 | Message { 90 | zmq_identities: vec![], 91 | header: Header::new(msg_type), 92 | parent_header: parent_header.into(), 93 | metadata: Metadata::empty(), 94 | content: broadcast, 95 | buffers: vec![], 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/jupyter/messages/mod.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | use std::sync::atomic::{AtomicUsize, Ordering}; 3 | use std::sync::OnceLock; 4 | 5 | use bytes::Bytes; 6 | use chrono::Utc; 7 | use control::ControlRequest; 8 | use derive_more::From; 9 | use hmac::{Hmac, Mac}; 10 | use nu_protocol::{FromValue, IntoValue}; 11 | use serde::{Deserialize, Serialize}; 12 | use serde_json::json; 13 | use sha2::digest::InvalidLength; 14 | use sha2::Sha256; 15 | use uuid::Uuid; 16 | use zeromq::SocketRecv; 17 | 18 | use self::shell::ShellRequest; 19 | use crate::{Channel, CARGO_TOML}; 20 | 21 | pub mod control; 22 | pub mod iopub; 23 | pub mod multipart; 24 | pub mod shell; 25 | 26 | pub static KERNEL_SESSION: KernelSession = KernelSession::new(); 27 | pub static MESSAGE_COUNTER: AtomicUsize = AtomicUsize::new(0); 28 | pub static DIGESTER: Digester = Digester::new(); 29 | 30 | pub struct KernelSession(OnceLock); 31 | 32 | impl KernelSession { 33 | pub const fn new() -> Self { 34 | KernelSession(OnceLock::new()) 35 | } 36 | 37 | pub fn get(&self) -> &str { 38 | self.0.get_or_init(|| Uuid::new_v4().to_string()) 39 | } 40 | } 41 | 42 | pub struct Digester(OnceLock>); 43 | 44 | impl Digester { 45 | pub const fn new() -> Self { 46 | Digester(OnceLock::new()) 47 | } 48 | 49 | pub fn key_init(&self, key: &[u8]) -> Result<(), InvalidLength> { 50 | self.0.set(Hmac::new_from_slice(key)?).expect("already set"); 51 | Ok(()) 52 | } 53 | 54 | pub fn get(&self) -> &Hmac { 55 | match self.0.get() { 56 | None => panic!("hmac not initialized"), 57 | Some(hmac) => hmac, 58 | } 59 | } 60 | } 61 | 62 | #[derive(Debug, Deserialize, Serialize, Clone, IntoValue, FromValue)] 63 | pub struct Header { 64 | pub msg_id: String, 65 | pub session: String, 66 | pub username: String, 67 | pub date: String, 68 | pub msg_type: String, // TODO: make this an enum 69 | pub version: String, 70 | } 71 | 72 | impl Header { 73 | pub fn new(msg_type: impl Into) -> Self { 74 | let session = KERNEL_SESSION.get(); 75 | let msg_counter = MESSAGE_COUNTER.fetch_add(1, Ordering::SeqCst); 76 | 77 | Header { 78 | msg_id: format!("{session}:{msg_counter}"), 79 | session: session.to_owned(), 80 | username: "nu_kernel".to_owned(), 81 | date: Utc::now().to_rfc3339(), 82 | msg_type: msg_type.into(), 83 | version: CARGO_TOML 84 | .package 85 | .metadata 86 | .jupyter 87 | .protocol_version 88 | .to_owned(), 89 | } 90 | } 91 | } 92 | 93 | #[derive(Debug, Deserialize, Serialize, Clone)] 94 | pub struct Metadata(serde_json::Value); 95 | 96 | impl Metadata { 97 | pub fn empty() -> Self { 98 | Metadata(json!({})) 99 | } 100 | } 101 | 102 | #[derive(Debug, Deserialize, Clone)] 103 | pub enum IncomingContent { 104 | Shell(shell::ShellRequest), 105 | Control(control::ControlRequest), 106 | } 107 | 108 | #[derive(Debug, Serialize, From, Clone)] 109 | pub enum OutgoingContent { 110 | Shell(shell::ShellReply), 111 | Iopub(iopub::IopubBroacast), 112 | Control(control::ControlReply), 113 | } 114 | 115 | #[derive(Debug, Clone)] 116 | pub struct Message { 117 | pub zmq_identities: Vec, 118 | pub header: Header, 119 | pub parent_header: Option
, 120 | pub metadata: Metadata, 121 | pub content: C, 122 | pub buffers: Vec, 123 | } 124 | 125 | static ZMQ_WAIT: i32 = 0; 126 | 127 | impl Message { 128 | // TODO: add a real error type here 129 | async fn recv(socket: &mut S, source: Channel) -> Result { 130 | let mut zmq_message = socket.recv().await.unwrap().into_vec().into_iter(); 131 | let zmq_message = &mut zmq_message; 132 | 133 | let mut zmq_identities = Vec::new(); 134 | for bytes in zmq_message.by_ref() { 135 | if bytes.deref() == b"" { 136 | break; 137 | } 138 | zmq_identities.push(bytes.to_owned()); 139 | } 140 | 141 | // TODO: add error handling for this here 142 | fn next_string(byte_iter: &mut impl Iterator) -> String { 143 | String::from_utf8(byte_iter.next().unwrap().to_vec()).unwrap() 144 | } 145 | 146 | let signature = next_string(zmq_message); 147 | 148 | let header = next_string(zmq_message); 149 | let header: Header = serde_json::from_str(&header).unwrap(); 150 | 151 | let parent_header = next_string(zmq_message); 152 | let parent_header: Option
= match parent_header.as_str() { 153 | "{}" => None, 154 | ph => serde_json::from_str(ph).unwrap(), 155 | }; 156 | 157 | let metadata = next_string(zmq_message); 158 | let metadata: Metadata = serde_json::from_str(&metadata).unwrap(); 159 | 160 | let content = next_string(zmq_message); 161 | // FIXME: this is a annoying solution, should be handled somehow by the type 162 | // system better 163 | let content = match source { 164 | Channel::Shell => { 165 | IncomingContent::Shell(ShellRequest::parse_variant(&header.msg_type, &content)?) 166 | } 167 | Channel::Stdin => todo!(), 168 | Channel::Control => { 169 | IncomingContent::Control(ControlRequest::parse_variant(&header.msg_type, &content)?) 170 | } 171 | }; 172 | 173 | let buffers: Vec = zmq_message.collect(); 174 | 175 | Ok(Message { 176 | zmq_identities, 177 | header, 178 | parent_header, 179 | metadata, 180 | content, 181 | buffers, 182 | }) 183 | } 184 | } 185 | 186 | impl Message { 187 | pub async fn recv(socket: &mut S) -> Result { 188 | let msg = Message::::recv(socket, Channel::Shell).await?; 189 | let Message { 190 | zmq_identities, 191 | header, 192 | parent_header, 193 | metadata, 194 | content, 195 | buffers, 196 | } = msg; 197 | let IncomingContent::Shell(content) = content 198 | else { 199 | panic!("unexpected content"); 200 | }; 201 | Ok(Message { 202 | zmq_identities, 203 | header, 204 | parent_header, 205 | metadata, 206 | content, 207 | buffers, 208 | }) 209 | } 210 | } 211 | 212 | impl Message { 213 | pub async fn recv(socket: &mut S) -> Result { 214 | let msg = Message::::recv(socket, Channel::Control).await?; 215 | let Message { 216 | zmq_identities, 217 | header, 218 | parent_header, 219 | metadata, 220 | content, 221 | buffers, 222 | } = msg; 223 | let IncomingContent::Control(content) = content 224 | else { 225 | panic!("unexpected content"); 226 | }; 227 | Ok(Message { 228 | zmq_identities, 229 | header, 230 | parent_header, 231 | metadata, 232 | content, 233 | buffers, 234 | }) 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/jupyter/messages/multipart.rs: -------------------------------------------------------------------------------- 1 | use std::iter; 2 | 3 | use bytes::Bytes; 4 | use hmac::Mac; 5 | use zeromq::{SocketSend, ZmqMessage}; 6 | 7 | use super::{Message, OutgoingContent, DIGESTER}; 8 | 9 | pub struct Multipart(ZmqMessage); 10 | 11 | impl Multipart { 12 | pub async fn send(self, socket: &mut S) -> Result<(), ()> { 13 | socket.send(self.0).await.unwrap(); 14 | Ok(()) 15 | } 16 | } 17 | 18 | impl Message { 19 | fn into_multipart_impl(self) -> Result { 20 | let zmq_identities = self.zmq_identities; 21 | let header = serde_json::to_string(&self.header).unwrap(); 22 | let parent_header = match self.parent_header { 23 | Some(ref parent_header) => serde_json::to_string(parent_header).unwrap(), 24 | None => "{}".to_owned(), 25 | }; 26 | let metadata = serde_json::to_string(&self.metadata).unwrap(); 27 | let content = match self.content { 28 | OutgoingContent::Shell(ref content) => serde_json::to_string(content).unwrap(), 29 | OutgoingContent::Iopub(ref content) => serde_json::to_string(content).unwrap(), 30 | OutgoingContent::Control(ref content) => serde_json::to_string(content).unwrap(), 31 | }; 32 | let buffers = self.buffers; 33 | 34 | let mut digester = DIGESTER.get().clone(); 35 | digester.update(header.as_bytes()); 36 | digester.update(parent_header.as_bytes()); 37 | digester.update(metadata.as_bytes()); 38 | digester.update(content.as_bytes()); 39 | let signature = digester.finalize().into_bytes(); 40 | let signature = hex::encode(signature); 41 | 42 | let frames: Vec = zmq_identities 43 | .into_iter() 44 | .chain(iter::once(Bytes::from_static(b""))) 45 | .chain( 46 | [signature, header, parent_header, metadata, content] 47 | .into_iter() 48 | .map(Bytes::from), 49 | ) 50 | .chain(buffers) 51 | .collect(); 52 | 53 | Ok(Multipart(ZmqMessage::try_from(frames).unwrap())) 54 | } 55 | } 56 | 57 | impl Message 58 | where 59 | C: Into, 60 | { 61 | pub fn into_multipart(self) -> Result { 62 | let Message { 63 | zmq_identities, 64 | header, 65 | parent_header, 66 | metadata, 67 | content, 68 | buffers, 69 | } = self; 70 | let msg = Message { 71 | zmq_identities, 72 | header, 73 | parent_header, 74 | metadata, 75 | content: content.into(), 76 | buffers, 77 | }; 78 | msg.into_multipart_impl() 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/jupyter/messages/shell.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::jupyter::kernel_info::KernelInfo; 4 | 5 | #[derive(Debug, Deserialize, Clone)] 6 | pub enum ShellRequest { 7 | Execute(ExecuteRequest), 8 | IsComplete(IsCompleteRequest), 9 | KernelInfo, 10 | } 11 | 12 | impl ShellRequest { 13 | pub fn parse_variant(variant: &str, body: &str) -> Result { 14 | match variant { 15 | "execute_request" => Ok(Self::Execute(serde_json::from_str(body).unwrap())), 16 | "is_complete_request" => Ok(Self::IsComplete(serde_json::from_str(body).unwrap())), 17 | "kernel_info_request" => Ok(Self::KernelInfo), 18 | _ => { 19 | eprintln!("unknown request {variant}"); 20 | Err(()) 21 | } 22 | } 23 | } 24 | } 25 | 26 | #[derive(Debug, Serialize, Clone)] 27 | #[serde(rename_all = "snake_case", tag = "status")] 28 | pub enum ShellReply { 29 | Ok(ShellReplyOk), 30 | Error { 31 | #[serde(rename = "ename")] 32 | name: String, 33 | #[serde(rename = "evalue")] 34 | value: String, 35 | traceback: Vec, 36 | }, 37 | } 38 | 39 | impl ShellReply { 40 | pub fn msg_type(request_type: &'_ str) -> Result<&'static str, ()> { 41 | Ok(match request_type { 42 | "kernel_info_request" => "kernel_info_reply", 43 | "execute_request" => "execute_reply", 44 | "is_complete_request" => "is_complete_reply", 45 | _ => todo!("handle unknown requests"), 46 | }) 47 | } 48 | } 49 | 50 | #[derive(Debug, Serialize, Clone)] 51 | #[serde(untagged)] 52 | pub enum ShellReplyOk { 53 | KernelInfo(KernelInfo), 54 | Execute(ExecuteReply), 55 | IsComplete(IsCompleteReply), 56 | } 57 | 58 | #[derive(Debug, Deserialize, Clone)] 59 | pub struct ExecuteRequest { 60 | pub code: String, 61 | #[serde(default)] 62 | pub silent: bool, 63 | // TODO: check if this assertion can still be unhold or should be 64 | pub store_history: bool, 65 | // TODO: figure out what to do with this 66 | pub user_expressions: serde_json::Value, 67 | pub allow_stdin: bool, 68 | pub stop_on_error: bool, 69 | } 70 | 71 | #[derive(Debug, Serialize, Clone)] 72 | pub struct ExecuteReply { 73 | pub execution_count: usize, 74 | pub user_expressions: serde_json::Value, 75 | } 76 | 77 | #[derive(Debug, Deserialize, Clone)] 78 | pub struct IsCompleteRequest { 79 | pub code: String, 80 | } 81 | 82 | #[derive(Debug, Serialize, Clone)] 83 | #[serde(tag = "status", rename_all = "snake_case")] 84 | pub enum IsCompleteReply { 85 | Complete, 86 | Incomplete { indent: String }, 87 | Invalid, 88 | Unknown, 89 | } 90 | -------------------------------------------------------------------------------- /src/jupyter/mod.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | pub mod connection_file; 4 | pub mod kernel_info; 5 | pub mod messages; 6 | pub mod register_kernel; 7 | 8 | #[derive(Debug, Clone, Copy, Serialize, Deserialize)] 9 | pub struct Shutdown { 10 | pub restart: bool, 11 | } 12 | -------------------------------------------------------------------------------- /src/jupyter/register_kernel.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | use std::{env, fs, io}; 3 | 4 | use clap::ValueEnum; 5 | use miette::Diagnostic; 6 | use serde_json::json; 7 | use thiserror::Error; 8 | 9 | #[derive(Debug, ValueEnum, Clone, Copy)] 10 | pub enum RegisterLocation { 11 | User, 12 | System, 13 | } 14 | 15 | #[derive(Debug, Error, Diagnostic)] 16 | pub enum RegisterKernelError { 17 | #[error(transparent)] 18 | Io(#[from] io::Error), 19 | 20 | #[error("could not format kernel manifest")] 21 | Format(#[from] serde_json::Error), 22 | } 23 | 24 | pub fn register_kernel(location: RegisterLocation) -> Result { 25 | let path = kernel_path(location); 26 | let path = path.as_ref(); 27 | fs::create_dir_all(path)?; 28 | let mut file_path = PathBuf::from(path); 29 | file_path.push("kernel.json"); 30 | let manifest = serde_json::to_string_pretty(&kernel_manifest())?; 31 | fs::write(&file_path, manifest)?; 32 | Ok(file_path) 33 | } 34 | 35 | fn kernel_path(location: RegisterLocation) -> impl AsRef { 36 | let mut path = PathBuf::new(); 37 | 38 | #[cfg(target_os = "windows")] 39 | match location { 40 | RegisterLocation::User => { 41 | let appdata = env::var("APPDATA").expect("%APPDATA% not found"); 42 | path.push(appdata); 43 | path.push(r"jupyter\kernels"); 44 | } 45 | RegisterLocation::System => { 46 | let programdata = env::var("PROGRAMDATA").expect("%PROGRAMDATA% not found"); 47 | path = PathBuf::from(programdata); 48 | path.push(r"jupyter\kernels"); 49 | } 50 | } 51 | 52 | #[cfg(any(target_os = "linux", target_os = "android"))] 53 | match location { 54 | RegisterLocation::User => { 55 | path.push(dirs::home_dir().expect("defined on linux")); 56 | path.push(".local/share/jupyter/kernels") 57 | } 58 | RegisterLocation::System => path.push("/usr/local/share/jupyter/kernels"), 59 | } 60 | 61 | #[cfg(target_os = "macos")] 62 | match location { 63 | RegisterLocation::User => { 64 | path.push(dirs::home_dir().expect("defined on macos")); 65 | path.push("Library/Jupyter/kernels") 66 | } 67 | RegisterLocation::System => path.push("/usr/local/share/jupyter/kernels"), 68 | } 69 | 70 | if path.to_string_lossy() == "" { 71 | panic!( 72 | "Your target os ({}) doesn't support `register`", 73 | env::consts::OS 74 | ); 75 | } 76 | 77 | path.push("nu"); 78 | 79 | path 80 | } 81 | 82 | fn kernel_manifest() -> serde_json::Value { 83 | let this_exec = env::current_exe().unwrap(); 84 | json!({ 85 | "argv": [this_exec, "start", "{connection_file}"], 86 | "display_name": "Nushell", 87 | "language": "nushell", 88 | "interrupt_mode": "message", 89 | "env": {}, 90 | "metadata": {} 91 | }) 92 | } 93 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // for now 2 | #![allow(dead_code)] 3 | #![allow(unused_variables)] 4 | 5 | use std::path::{Path, PathBuf}; 6 | use std::{panic, process}; 7 | 8 | use clap::{Parser, Subcommand}; 9 | use const_format::formatcp; 10 | use handlers::shell::Cell; 11 | use handlers::stream::StreamHandler; 12 | use jupyter::connection_file::ConnectionFile; 13 | use jupyter::messages::iopub; 14 | use jupyter::register_kernel::{register_kernel, RegisterLocation}; 15 | use nu::commands::{add_jupyter_command_context, JupyterCommandContext}; 16 | use nu::konst::Konst; 17 | use nu::render::FormatDeclIds; 18 | use nu_protocol::engine::Stack; 19 | use tokio::sync::{broadcast, mpsc}; 20 | use zeromq::{PubSocket, RepSocket, RouterSocket, Socket, ZmqResult}; 21 | 22 | use crate::jupyter::messages::DIGESTER; 23 | 24 | mod error; 25 | mod handlers; 26 | mod jupyter; 27 | mod nu; 28 | mod util; 29 | 30 | static_toml::static_toml! { 31 | const CARGO_TOML = include_toml!("Cargo.toml"); 32 | } 33 | 34 | #[derive(Debug, Parser)] 35 | #[command(version, long_version = formatcp!( 36 | "{}\nnu-engine {}", 37 | CARGO_TOML.package.version, 38 | CARGO_TOML.dependencies.nu_engine.version 39 | ))] 40 | struct Cli { 41 | #[command(subcommand)] 42 | command: Command, 43 | } 44 | 45 | #[derive(Debug, Subcommand)] 46 | enum Command { 47 | #[command(alias = "install")] 48 | Register { 49 | #[clap(long, group = "location")] 50 | user: bool, 51 | 52 | #[clap(long, group = "location")] 53 | system: bool, 54 | }, 55 | 56 | Start { 57 | connection_file_path: PathBuf, 58 | }, 59 | } 60 | 61 | type ShellSocket = RouterSocket; 62 | type IopubSocket = PubSocket; 63 | type StdinSocket = RouterSocket; 64 | type ControlSocket = RouterSocket; 65 | type HeartbeatSocket = RepSocket; 66 | 67 | struct Sockets { 68 | pub shell: ShellSocket, 69 | pub iopub: IopubSocket, 70 | pub stdin: StdinSocket, 71 | pub control: ControlSocket, 72 | pub heartbeat: HeartbeatSocket, 73 | } 74 | 75 | impl Sockets { 76 | async fn start(connection_file: &ConnectionFile) -> ZmqResult { 77 | let endpoint = |port| { 78 | format!( 79 | "{}://{}:{}", 80 | connection_file.transport, connection_file.ip, port 81 | ) 82 | }; 83 | 84 | let mut shell = ShellSocket::new(); 85 | shell.bind(&endpoint(&connection_file.shell_port)).await?; 86 | 87 | let mut iopub = IopubSocket::new(); 88 | iopub.bind(&endpoint(&connection_file.iopub_port)).await?; 89 | 90 | let mut stdin = StdinSocket::new(); 91 | stdin.bind(&endpoint(&connection_file.stdin_port)).await?; 92 | 93 | let mut control = ControlSocket::new(); 94 | control 95 | .bind(&endpoint(&connection_file.control_port)) 96 | .await?; 97 | 98 | let mut heartbeat = HeartbeatSocket::new(); 99 | heartbeat 100 | .bind(&endpoint(&connection_file.heartbeat_port)) 101 | .await?; 102 | 103 | Ok(Sockets { 104 | shell, 105 | iopub, 106 | stdin, 107 | control, 108 | heartbeat, 109 | }) 110 | } 111 | } 112 | 113 | #[tokio::main] 114 | async fn main() -> miette::Result<()> { 115 | let args = Cli::parse(); 116 | match args.command { 117 | Command::Register { user, system } => { 118 | let location = match (user, system) { 119 | (true, true) => unreachable!("handled by clap"), 120 | (false, true) => RegisterLocation::System, 121 | (true, false) => RegisterLocation::User, 122 | (false, false) => RegisterLocation::User, // default case 123 | }; 124 | let path = register_kernel(location)?; 125 | println!("Registered kernel to {}", path.display()); 126 | } 127 | Command::Start { 128 | connection_file_path, 129 | } => start_kernel(connection_file_path).await, 130 | } 131 | Ok(()) 132 | } 133 | 134 | async fn start_kernel(connection_file_path: impl AsRef) { 135 | set_avalanche_panic_hook(); 136 | 137 | let connection_file = ConnectionFile::from_path(connection_file_path).unwrap(); 138 | let sockets = Sockets::start(&connection_file).await.unwrap(); 139 | DIGESTER.key_init(&connection_file.key).unwrap(); 140 | 141 | let mut engine_state = nu::initial_engine_state(); 142 | let format_decl_ids = FormatDeclIds::find(&engine_state).unwrap(); 143 | let spans = nu::module::create_nuju_module(&mut engine_state); 144 | nu::commands::hide_incompatible_commands(&mut engine_state).unwrap(); 145 | let konst = Konst::register(&mut engine_state).unwrap(); 146 | let (engine_state, interrupt_signal) = nu::add_interrupt_signal(engine_state); 147 | 148 | let (iopub_tx, iopub_rx) = mpsc::channel(1); 149 | let (shutdown_tx, shutdown_rx) = broadcast::channel(1); 150 | 151 | let ctx = JupyterCommandContext { 152 | iopub: iopub_tx.clone(), 153 | format_decl_ids, 154 | konst, 155 | spans: spans.clone(), 156 | }; 157 | let engine_state = add_jupyter_command_context(engine_state, ctx); 158 | 159 | let (stdout_handler, stdout_file) = 160 | StreamHandler::start(iopub::StreamName::Stdout, iopub_tx.clone()).unwrap(); 161 | let (stderr_handler, stderr_file) = 162 | StreamHandler::start(iopub::StreamName::Stderr, iopub_tx.clone()).unwrap(); 163 | let stack = Stack::new() 164 | .stdout_file(stdout_file) 165 | .stderr_file(stderr_file); 166 | 167 | let cell = Cell::new(); 168 | 169 | let heartbeat_task = tokio::spawn(handlers::heartbeat::handle( 170 | sockets.heartbeat, 171 | shutdown_rx.resubscribe(), 172 | )); 173 | 174 | let iopub_task = tokio::spawn(handlers::iopub::handle( 175 | sockets.iopub, 176 | shutdown_rx.resubscribe(), 177 | iopub_rx, 178 | )); 179 | 180 | let shell_ctx = handlers::shell::HandlerContext { 181 | socket: sockets.shell, 182 | iopub: iopub_tx, 183 | stdout_handler, 184 | stderr_handler, 185 | engine_state, 186 | format_decl_ids, 187 | konst, 188 | spans, 189 | stack, 190 | cell, 191 | }; 192 | let shell_task = tokio::spawn(handlers::shell::handle( 193 | shell_ctx, 194 | shutdown_rx.resubscribe(), 195 | )); 196 | 197 | let control_task = tokio::spawn(handlers::control::handle( 198 | sockets.control, 199 | shutdown_tx, 200 | interrupt_signal, 201 | )); 202 | 203 | heartbeat_task.await.unwrap(); 204 | iopub_task.await.unwrap(); 205 | shell_task.await.unwrap(); 206 | control_task.await.unwrap(); 207 | } 208 | 209 | // no heartbeat nor iopub as they are handled differently 210 | #[derive(Debug, Clone, Copy)] 211 | enum Channel { 212 | Shell, 213 | Stdin, 214 | Control, 215 | } 216 | 217 | fn set_avalanche_panic_hook() { 218 | let old_hook = panic::take_hook(); 219 | panic::set_hook(Box::new(move |panic_info| { 220 | old_hook(panic_info); 221 | process::exit(1); 222 | })); 223 | } 224 | -------------------------------------------------------------------------------- /src/nu/commands/command.rs: -------------------------------------------------------------------------------- 1 | use nu_engine::command_prelude::*; 2 | use nu_engine::get_full_help; 3 | 4 | use super::COMMANDS_TOML; 5 | 6 | #[derive(Clone)] 7 | pub struct Nuju; 8 | 9 | impl Command for Nuju { 10 | fn name(&self) -> &str { 11 | COMMANDS_TOML.nuju.name 12 | } 13 | 14 | fn description(&self) -> &str { 15 | COMMANDS_TOML.nuju.description 16 | } 17 | 18 | fn signature(&self) -> nu_protocol::Signature { 19 | Signature::build(Self.name()) 20 | .category(super::category()) 21 | .input_output_types(vec![(Type::Nothing, Type::String)]) 22 | } 23 | 24 | fn extra_description(&self) -> &str { 25 | COMMANDS_TOML.nuju.extra_description 26 | } 27 | 28 | fn run( 29 | &self, 30 | engine_state: &EngineState, 31 | stack: &mut Stack, 32 | call: &Call, 33 | _input: PipelineData, 34 | ) -> Result { 35 | Ok( 36 | Value::string(get_full_help(&Nuju, engine_state, stack), call.head) 37 | .into_pipeline_data(), 38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/nu/commands/display.rs: -------------------------------------------------------------------------------- 1 | use mime_guess::MimeGuess; 2 | use nu_engine::CallExt; 3 | use nu_protocol::engine::Command; 4 | use nu_protocol::{Example, ShellError, Signature, Spanned, SyntaxShape, Type}; 5 | 6 | use super::COMMANDS_TOML; 7 | use crate::handlers::shell::RENDER_FILTER; 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct Display; 11 | 12 | impl Command for Display { 13 | fn name(&self) -> &str { 14 | COMMANDS_TOML.display.name 15 | } 16 | 17 | fn description(&self) -> &str { 18 | COMMANDS_TOML.display.description 19 | } 20 | 21 | fn extra_description(&self) -> &str { 22 | COMMANDS_TOML.display.extra_description 23 | } 24 | 25 | fn search_terms(&self) -> Vec<&str> { 26 | COMMANDS_TOML.display.search_terms.into() 27 | } 28 | 29 | fn signature(&self) -> Signature { 30 | Signature::build(self.name()) 31 | .required("format", SyntaxShape::String, "Format to filter for") 32 | .input_output_types(vec![(Type::Any, Type::Any)]) 33 | .category(super::category()) 34 | } 35 | 36 | fn examples(&self) -> Vec { 37 | COMMANDS_TOML 38 | .display 39 | .examples 40 | .iter() 41 | .map(|eg| Example { 42 | example: eg.example, 43 | description: eg.description, 44 | result: None, 45 | }) 46 | .collect() 47 | } 48 | 49 | fn run( 50 | &self, 51 | engine_state: &nu_protocol::engine::EngineState, 52 | stack: &mut nu_protocol::engine::Stack, 53 | call: &nu_protocol::engine::Call, 54 | input: nu_protocol::PipelineData, 55 | ) -> Result { 56 | let format: Spanned = call.req(engine_state, stack, 0)?; 57 | 58 | let mime = MimeGuess::from_ext(&format.item).first().ok_or_else(|| { 59 | ShellError::IncorrectValue { 60 | msg: "cannot guess a mime type".to_owned(), 61 | val_span: format.span, 62 | call_span: call.head, 63 | } 64 | })?; 65 | 66 | RENDER_FILTER.lock().replace(mime); 67 | Ok(input) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/nu/commands/external.rs: -------------------------------------------------------------------------------- 1 | use std::sync::atomic::Ordering; 2 | 3 | use atomic_enum::atomic_enum; 4 | use nu_protocol::engine::{Call, Command, EngineState, Stack, StateWorkingSet}; 5 | use nu_protocol::{PipelineData, ShellError, Signature, Span, Type, Value}; 6 | 7 | use super::COMMANDS_TOML; 8 | 9 | #[atomic_enum] 10 | #[derive(PartialEq)] 11 | enum ExternalState { 12 | Disabled = 0, 13 | JustEnabled, 14 | AlreadyEnabled, 15 | } 16 | 17 | impl AtomicExternalState { 18 | pub fn fetch_max(&self, val: ExternalState, order: Ordering) -> ExternalState { 19 | match self.0.fetch_max(val as usize, order) { 20 | 0 => ExternalState::Disabled, 21 | 1 => ExternalState::JustEnabled, 22 | 2 => ExternalState::AlreadyEnabled, 23 | _ => unreachable!("ExternalState represents at max 2"), 24 | } 25 | } 26 | } 27 | 28 | static EXTERNAL_STATE: AtomicExternalState = AtomicExternalState::new(ExternalState::Disabled); 29 | 30 | #[derive(Debug, Clone)] 31 | pub struct External; 32 | 33 | impl Command for External { 34 | fn name(&self) -> &str { 35 | COMMANDS_TOML.external.name 36 | } 37 | 38 | fn description(&self) -> &str { 39 | COMMANDS_TOML.external.description 40 | } 41 | 42 | fn extra_description(&self) -> &str { 43 | COMMANDS_TOML.external.extra_description 44 | } 45 | 46 | fn search_terms(&self) -> Vec<&str> { 47 | COMMANDS_TOML.external.search_terms.into() 48 | } 49 | 50 | fn signature(&self) -> Signature { 51 | Signature::build(self.name()) 52 | .input_output_types(vec![(Type::Any, Type::Nothing)]) 53 | .category(super::category()) 54 | } 55 | 56 | fn run( 57 | &self, 58 | _engine_state: &EngineState, 59 | _stack: &mut Stack, 60 | _call: &Call, 61 | _input: PipelineData, 62 | ) -> Result { 63 | // TODO: add some display data iopub here 64 | // update the value to at least `JustEnabled` 65 | EXTERNAL_STATE.fetch_max(ExternalState::JustEnabled, Ordering::SeqCst); 66 | Ok(PipelineData::Value(Value::nothing(Span::unknown()), None)) 67 | } 68 | } 69 | 70 | impl External { 71 | /// Apply the `run-external` command to the engine if external commands were 72 | /// just enabled. 73 | pub fn apply(engine_state: &mut EngineState) -> Result<(), ShellError> { 74 | if let ExternalState::JustEnabled = EXTERNAL_STATE.load(Ordering::SeqCst) { 75 | let mut working_set = StateWorkingSet::new(engine_state); 76 | // TODO: add a command that controls the output of external calls 77 | working_set.add_decl(Box::new(nu_command::External)); 78 | engine_state.merge_delta(working_set.render())?; 79 | EXTERNAL_STATE.swap(ExternalState::AlreadyEnabled, Ordering::SeqCst); 80 | } 81 | Ok(()) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/nu/commands/mod.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write; 2 | 3 | use nu_protocol::engine::{EngineState, Stack, StateWorkingSet}; 4 | use nu_protocol::Category; 5 | use tokio::sync::mpsc; 6 | 7 | use super::konst::Konst; 8 | use super::module::KernelInternalSpans; 9 | use super::render::FormatDeclIds; 10 | use crate::jupyter::messages::multipart::Multipart; 11 | 12 | pub mod command; 13 | pub mod display; 14 | pub mod external; 15 | pub mod print; 16 | 17 | static_toml::static_toml! { 18 | const COMMANDS_TOML = include_toml!("commands.toml"); 19 | } 20 | 21 | /// Hide incompatible commands so that users don't accidentally call them. 22 | pub fn hide_incompatible_commands( 23 | engine_state: &mut EngineState, 24 | ) -> Result<(), super::ExecuteError> { 25 | let mut code = String::new(); 26 | for command in COMMANDS_TOML.incompatible_commands { 27 | writeln!(code, "hide {command}").expect("String::write is infallible"); 28 | } 29 | 30 | let mut stack = Stack::new(); 31 | super::execute(&code, engine_state, &mut stack, "hide-initial-commands")?; 32 | Ok(()) 33 | } 34 | 35 | pub fn category() -> Category { 36 | Category::Custom("jupyter".to_owned()) 37 | } 38 | 39 | #[derive(Debug, Clone)] 40 | pub struct JupyterCommandContext { 41 | pub iopub: mpsc::Sender, 42 | pub format_decl_ids: FormatDeclIds, 43 | pub konst: Konst, 44 | pub spans: KernelInternalSpans, 45 | } 46 | 47 | pub fn add_jupyter_command_context( 48 | mut engine_state: EngineState, 49 | ctx: JupyterCommandContext, 50 | ) -> EngineState { 51 | let delta = { 52 | let mut working_set = StateWorkingSet::new(&engine_state); 53 | 54 | macro_rules! bind_command { 55 | ( $( $command:expr ),* $(,)? ) => { 56 | $( working_set.add_decl(Box::new($command)); )* 57 | }; 58 | } 59 | 60 | bind_command! { 61 | command::Nuju, 62 | external::External, 63 | display::Display, 64 | print::Print::new(ctx) 65 | } 66 | 67 | working_set.render() 68 | }; 69 | 70 | if let Err(err) = engine_state.merge_delta(delta) { 71 | eprintln!("Error creating jupyter context: {err:?}"); 72 | } 73 | 74 | engine_state 75 | } 76 | -------------------------------------------------------------------------------- /src/nu/commands/print.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use mime_guess::MimeGuess; 4 | use nu_engine::CallExt; 5 | use nu_protocol::engine::Command; 6 | use nu_protocol::{FromValue, PipelineData, ShellError, Signature, Span, SyntaxShape, Type, Value}; 7 | 8 | use super::{JupyterCommandContext, COMMANDS_TOML}; 9 | use crate::jupyter::messages::iopub::{DisplayData, IopubBroacast}; 10 | use crate::jupyter::messages::{Header, Message, Metadata}; 11 | use crate::nu::render::{PipelineRender, StringifiedPipelineRender}; 12 | 13 | #[derive(Debug, Clone)] 14 | pub struct Print(JupyterCommandContext); 15 | 16 | impl Print { 17 | pub fn new(ctx: JupyterCommandContext) -> Self { 18 | Self(ctx) 19 | } 20 | } 21 | 22 | impl Command for Print { 23 | fn name(&self) -> &str { 24 | COMMANDS_TOML.print.name 25 | } 26 | 27 | fn description(&self) -> &str { 28 | COMMANDS_TOML.print.description 29 | } 30 | 31 | fn search_terms(&self) -> Vec<&str> { 32 | COMMANDS_TOML.print.search_terms.into() 33 | } 34 | 35 | fn signature(&self) -> Signature { 36 | Signature::build(self.name()) 37 | .optional("input", SyntaxShape::Any, "Value to print") 38 | .named( 39 | "format", 40 | SyntaxShape::String, 41 | "Format to filter for", 42 | Some('f'), 43 | ) 44 | .input_output_types(vec![ 45 | (Type::Any, Type::Nothing), 46 | (Type::Nothing, Type::Nothing), 47 | ]) 48 | .category(super::category()) 49 | } 50 | 51 | // TODO: split this into multiple parts, this is too much 52 | fn run( 53 | &self, 54 | engine_state: &nu_protocol::engine::EngineState, 55 | stack: &mut nu_protocol::engine::Stack, 56 | call: &nu_protocol::engine::Call, 57 | input: nu_protocol::PipelineData, 58 | ) -> Result { 59 | let arg: Option = call.opt(engine_state, stack, 0)?; 60 | let arg: Option = arg.map(|v| PipelineData::Value(v, None)); 61 | let input_span = input.span(); // maybe needed for an error 62 | let to_render = match (input, arg) { 63 | // no data provided, throw error 64 | (PipelineData::Empty, None) => Err(ShellError::GenericError { 65 | error: "No input data".to_string(), 66 | msg: "No data was piped or passed as an argument to the command.".to_string(), 67 | span: Some(call.span()), 68 | help: Some( 69 | "Please provide data through the pipeline or as an argument.".to_string(), 70 | ), 71 | inner: vec![], 72 | }), 73 | 74 | // passed arg has no data, throw error 75 | (_, Some(PipelineData::Empty)) => Err(ShellError::TypeMismatch { 76 | err_message: "Expected non-empty data, but found empty".to_string(), 77 | span: call.arguments_span(), 78 | }), 79 | 80 | // render passed arg 81 | (PipelineData::Empty, Some(data)) => Ok(data), 82 | 83 | // too many inputs, throw error 84 | (_, Some(_)) => Err(ShellError::IncompatibleParameters { 85 | left_message: "Either pass data via pipe".to_string(), 86 | left_span: input_span.unwrap_or(call.head), 87 | right_message: "Or pass data via an argument".to_string(), 88 | right_span: call.arguments_span(), 89 | }), 90 | 91 | // render piped arg 92 | (data, None) => Ok(data), 93 | }?; 94 | 95 | let format: Option = call.get_flag(engine_state, stack, "format")?; 96 | let spanned_format: Option<(Span, Value)> = format.map(|v| (v.span(), v)); 97 | let spanned_format: Option<(Span, String)> = spanned_format 98 | .map(|(span, v)| String::from_value(v).map(|s| (span, s))) 99 | .transpose()?; 100 | let mime = spanned_format 101 | .map(|(span, s)| { 102 | MimeGuess::from_ext(&s) 103 | .first() 104 | .ok_or_else(|| ShellError::IncorrectValue { 105 | msg: "Cannot guess a mime type".to_owned(), 106 | val_span: span, 107 | call_span: call.head, 108 | }) 109 | }) 110 | .transpose()?; 111 | 112 | let render: StringifiedPipelineRender = PipelineRender::render( 113 | to_render, 114 | engine_state, 115 | stack, 116 | &self.0.spans, 117 | self.0.format_decl_ids, 118 | mime, 119 | ) 120 | .unwrap() // TODO: handle this better 121 | .into(); 122 | 123 | let display_data = DisplayData { 124 | data: render.data, 125 | metadata: render.metadata, 126 | transient: HashMap::new(), 127 | }; 128 | let broadcast = IopubBroacast::DisplayData(display_data); 129 | 130 | let konst = self.0.konst.data(stack, call.head)?; 131 | let message = Message { 132 | zmq_identities: konst.message.zmq_identities, 133 | header: Header::new(broadcast.msg_type()), 134 | parent_header: Some(konst.message.header), 135 | metadata: Metadata::empty(), 136 | content: broadcast, 137 | buffers: vec![], 138 | }; 139 | 140 | self.0 141 | .iopub 142 | .blocking_send(message.into_multipart().unwrap()) 143 | .unwrap(); 144 | 145 | Ok(PipelineData::Empty) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/nu/konst.rs: -------------------------------------------------------------------------------- 1 | use bytes::Bytes; 2 | use nu_protocol::engine::{EngineState, Stack, StateWorkingSet}; 3 | use nu_protocol::{FromValue, IntoValue, ShellError, Span, Type, VarId}; 4 | 5 | use crate::jupyter::messages::{Header, Message}; 6 | use crate::CARGO_TOML; 7 | 8 | #[derive(Debug, Clone, Copy)] 9 | pub struct Konst { 10 | var_id: VarId, 11 | } 12 | 13 | impl Konst { 14 | pub const VAR_NAME: &'static str = "nuju"; 15 | 16 | pub fn register(engine_state: &mut EngineState) -> Result { 17 | let mut working_set = StateWorkingSet::new(engine_state); 18 | let var_id = working_set.add_variable( 19 | Self::VAR_NAME.as_bytes().to_vec(), 20 | Span::unknown(), 21 | Type::Any, 22 | false, 23 | ); 24 | engine_state.merge_delta(working_set.render())?; 25 | Ok(Self { var_id }) 26 | } 27 | 28 | pub fn var_id(&self) -> VarId { 29 | self.var_id 30 | } 31 | 32 | pub fn update(&self, stack: &mut Stack, cell_name: String, message: Message) { 33 | let data = KonstData { 34 | version: KonstDataVersion { 35 | kernel: CARGO_TOML.package.version.to_owned(), 36 | nu: CARGO_TOML.dependencies.nu_engine.version.to_owned(), 37 | }, 38 | cell: cell_name, 39 | message: KonstDataMessage { 40 | zmq_identities: message.zmq_identities, 41 | header: message.header, 42 | parent_header: message.parent_header, 43 | }, 44 | }; 45 | stack.add_var(self.var_id, data.into_value(Span::unknown())) 46 | } 47 | 48 | pub fn data(&self, stack: &Stack, span: Span) -> Result { 49 | let value = stack 50 | .get_var(self.var_id, span) 51 | .map_err(|_| ShellError::VariableNotFoundAtRuntime { span })?; 52 | KonstData::from_value(value) 53 | } 54 | } 55 | 56 | #[derive(Debug, Clone, IntoValue, FromValue)] 57 | pub struct KonstData { 58 | pub version: KonstDataVersion, 59 | pub cell: String, 60 | pub message: KonstDataMessage, 61 | } 62 | 63 | #[derive(Debug, Clone, IntoValue, FromValue)] 64 | pub struct KonstDataVersion { 65 | pub kernel: String, 66 | pub nu: String, 67 | } 68 | 69 | #[derive(Debug, Clone, IntoValue, FromValue)] 70 | pub struct KonstDataMessage { 71 | pub zmq_identities: Vec, 72 | pub header: Header, 73 | pub parent_header: Option
, 74 | } 75 | -------------------------------------------------------------------------------- /src/nu/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::env; 3 | use std::fmt::{Debug, Write}; 4 | use std::path::PathBuf; 5 | use std::sync::atomic::AtomicBool; 6 | use std::sync::Arc; 7 | 8 | use nu_protocol::debugger::WithoutDebug; 9 | use nu_protocol::engine::{EngineState, Stack, StateDelta, StateWorkingSet}; 10 | use nu_protocol::{ 11 | CompileError, ParseError, PipelineData, ShellError, Signals, Span, UseAnsiColoring, Value, 12 | NU_VARIABLE_ID, 13 | }; 14 | use thiserror::Error; 15 | 16 | pub mod commands; 17 | pub mod konst; 18 | pub mod module; 19 | pub mod render; 20 | 21 | #[allow(clippy::let_and_return)] // i like it here 22 | pub fn initial_engine_state() -> EngineState { 23 | // TODO: compare with nu_cli::get_engine_state for other contexts 24 | let engine_state = nu_cmd_lang::create_default_context(); 25 | let engine_state = configure_engine_state(engine_state); 26 | let engine_state = add_env_context(engine_state); 27 | 28 | let engine_state = nu_command::add_shell_command_context(engine_state); 29 | let engine_state = nu_cmd_extra::add_extra_command_context(engine_state); 30 | let engine_state = nu_cmd_plugin::add_plugin_command_context(engine_state); 31 | 32 | #[cfg(feature = "nu-plotters")] 33 | let engine_state = nu_plotters::add_plotters_command_context(engine_state); 34 | 35 | // this doesn't add the jupyter context, as they need more context 36 | 37 | engine_state 38 | } 39 | 40 | fn add_env_context(mut engine_state: EngineState) -> EngineState { 41 | let mut env_map = HashMap::new(); 42 | 43 | for (key, value) in env::vars() { 44 | env_map.insert(key, value); 45 | } 46 | 47 | if let Ok(current_dir) = env::current_dir() { 48 | env_map.insert("PWD".into(), current_dir.to_string_lossy().into_owned()); 49 | } 50 | 51 | let mut toml = String::new(); 52 | let mut values = Vec::new(); 53 | let mut line_offset = 0; 54 | for (key, value) in env_map { 55 | let line = format!("{key} = {value:?}"); 56 | let start = key.len() + " = ".len() + line_offset; 57 | let end = line_offset + line.len(); 58 | let span = Span::new(start, end); 59 | line_offset += line.len() + 1; 60 | writeln!(toml, "{line}").expect("infallible"); 61 | values.push((key, Value::string(value, span))); 62 | } 63 | 64 | let span_offset = engine_state.next_span_start(); 65 | engine_state.add_file( 66 | "Host Environment Variables".into(), 67 | toml.into_bytes().into(), 68 | ); 69 | for (key, value) in values { 70 | let span = value.span(); 71 | let span = Span::new(span.start + span_offset, span.end + span_offset); 72 | engine_state.add_env_var(key, value.with_span(span)); 73 | } 74 | 75 | engine_state 76 | } 77 | 78 | fn configure_engine_state(mut engine_state: EngineState) -> EngineState { 79 | engine_state.history_enabled = false; 80 | engine_state.is_interactive = false; 81 | engine_state.is_login = false; 82 | 83 | // if we cannot access the current dir, we probably also cannot access the 84 | // subdirectories 85 | if let Ok(mut config_dir) = env::current_dir() { 86 | config_dir.push(".nu"); 87 | engine_state.set_config_path("config-path", config_dir.join("config.nu")); 88 | engine_state.set_config_path("env-path", config_dir.join("env.nu")); 89 | } 90 | 91 | engine_state.generate_nu_constant(); 92 | 93 | if let Some(ref v) = engine_state.get_var(NU_VARIABLE_ID).const_val { 94 | engine_state.plugin_path = v 95 | .get_data_by_key("plugin-path") 96 | .and_then(|v| v.as_str().ok().map(PathBuf::from)); 97 | } 98 | 99 | engine_state 100 | } 101 | 102 | pub fn add_interrupt_signal(mut engine_state: EngineState) -> (EngineState, Arc) { 103 | let signal = Arc::new(AtomicBool::new(false)); 104 | let signals = Signals::new(signal.clone()); 105 | engine_state.set_signals(signals); 106 | (engine_state, signal) 107 | } 108 | 109 | pub fn execute( 110 | code: &str, 111 | engine_state: &mut EngineState, 112 | stack: &mut Stack, 113 | name: &str, 114 | ) -> Result { 115 | let code = code.as_bytes(); 116 | let mut working_set = StateWorkingSet::new(engine_state); 117 | let block = nu_parser::parse(&mut working_set, Some(name), code, false); 118 | 119 | // TODO: report parse warnings 120 | 121 | if let Some(error) = working_set.parse_errors.into_iter().next() { 122 | return Err(ExecuteError::Parse { 123 | error, 124 | delta: working_set.delta, 125 | }); 126 | } 127 | 128 | if let Some(error) = working_set.compile_errors.into_iter().next() { 129 | return Err(ExecuteError::Compile { 130 | error, 131 | delta: working_set.delta, 132 | }); 133 | } 134 | 135 | engine_state.merge_delta(working_set.delta)?; 136 | let res = 137 | nu_engine::eval_block::(engine_state, stack, &block, PipelineData::Empty)?; 138 | Ok(res) 139 | } 140 | 141 | #[derive(Error)] 142 | pub enum ExecuteError { 143 | #[error("{error}")] 144 | Parse { 145 | #[source] 146 | error: ParseError, 147 | /// Delta of the working set. 148 | /// 149 | /// By keeping this delta around we later can update another working 150 | /// set with and with that correctly source the parsing issues. 151 | delta: StateDelta, 152 | }, 153 | 154 | #[error("{error}")] 155 | Compile { 156 | #[source] 157 | error: CompileError, 158 | /// Delta of the working set. 159 | /// 160 | /// By keeping this delta around we later can update another working 161 | /// set with and with that correctly source the parsing issues. 162 | delta: StateDelta, 163 | }, 164 | 165 | #[error(transparent)] 166 | Shell(#[from] ShellError), 167 | } 168 | 169 | impl Debug for ExecuteError { 170 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 171 | match self { 172 | Self::Parse { error, delta } => f 173 | .debug_struct("Parse") 174 | .field("error", error) 175 | .field("delta", &format_args!("[StateDelta]")) 176 | .finish(), 177 | Self::Compile { error, delta } => f 178 | .debug_struct("Compile") 179 | .field("error", error) 180 | .field("delta", &format_args!("[StateDelta]")) 181 | .finish(), 182 | Self::Shell(arg0) => f.debug_tuple("Shell").field(arg0).finish(), 183 | } 184 | } 185 | } 186 | 187 | #[derive(Error)] 188 | #[error("{diagnostic}")] 189 | pub struct ReportExecuteError<'s> { 190 | diagnostic: Box, 191 | working_set: &'s StateWorkingSet<'s>, 192 | } 193 | 194 | impl Debug for ReportExecuteError<'_> { 195 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 196 | // This code is stolen from nu_protocol::errors::cli_error::CliError::Debug impl 197 | 198 | let config = self.working_set.get_config(); 199 | 200 | let ansi_support = match config.use_ansi_coloring { 201 | // TODO: design a better auto determination 202 | UseAnsiColoring::Auto => true, 203 | UseAnsiColoring::True => true, 204 | UseAnsiColoring::False => false, 205 | }; 206 | 207 | let error_style = &config.error_style; 208 | 209 | let miette_handler: Box = match error_style { 210 | nu_protocol::ErrorStyle::Plain => Box::new(miette::NarratableReportHandler::new()), 211 | nu_protocol::ErrorStyle::Fancy => Box::new( 212 | miette::MietteHandlerOpts::new() 213 | // For better support of terminal themes use the ANSI coloring 214 | .rgb_colors(miette::RgbColors::Never) 215 | // If ansi support is disabled in the config disable the eye-candy 216 | .color(ansi_support) 217 | .unicode(ansi_support) 218 | .terminal_links(ansi_support) 219 | .build(), 220 | ), 221 | }; 222 | 223 | // Ignore error to prevent format! panics. This can happen if span points at 224 | // some inaccessible location, for example by calling `report_error()` 225 | // with wrong working set. 226 | let _ = miette_handler.debug(self, f); 227 | 228 | Ok(()) 229 | } 230 | } 231 | 232 | impl<'s> ReportExecuteError<'s> { 233 | pub fn new(error: ExecuteError, working_set: &'s mut StateWorkingSet<'s>) -> Self { 234 | let diagnostic = match error { 235 | ExecuteError::Parse { error, delta } => { 236 | working_set.delta = delta; 237 | Box::new(error) as Box 238 | } 239 | ExecuteError::Compile { error, delta } => { 240 | working_set.delta = delta; 241 | Box::new(error) as Box 242 | } 243 | ExecuteError::Shell(error) => Box::new(error) as Box, 244 | }; 245 | Self { 246 | diagnostic, 247 | working_set, 248 | } 249 | } 250 | 251 | pub fn code<'a>(&'a self) -> Box { 252 | miette::Diagnostic::code(self) 253 | .unwrap_or_else(|| Box::new(format_args!("nu-jupyter-kernel::unknown-error"))) 254 | } 255 | 256 | pub fn fmt(&self) -> String { 257 | format!("Error: {:?}", self) 258 | } 259 | } 260 | 261 | impl<'s> miette::Diagnostic for ReportExecuteError<'s> { 262 | fn code<'a>(&'a self) -> Option> { 263 | self.diagnostic.code() 264 | } 265 | 266 | fn severity(&self) -> Option { 267 | self.diagnostic.severity() 268 | } 269 | 270 | fn help<'a>(&'a self) -> Option> { 271 | self.diagnostic.help() 272 | } 273 | 274 | fn url<'a>(&'a self) -> Option> { 275 | self.diagnostic.url() 276 | } 277 | 278 | fn source_code(&self) -> Option<&dyn miette::SourceCode> { 279 | match self.diagnostic.source_code() { 280 | None => Some(&self.working_set as &dyn miette::SourceCode), 281 | Some(source_code) => Some(source_code), 282 | } 283 | } 284 | 285 | fn labels(&self) -> Option + '_>> { 286 | self.diagnostic.labels() 287 | } 288 | 289 | fn related<'a>(&'a self) -> Option + 'a>> { 290 | self.diagnostic.related() 291 | } 292 | 293 | fn diagnostic_source(&self) -> Option<&dyn miette::Diagnostic> { 294 | self.diagnostic.diagnostic_source() 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /src/nu/module.rs: -------------------------------------------------------------------------------- 1 | use indoc::indoc; 2 | use nu_protocol::ast::{Argument, Block, Expr}; 3 | use nu_protocol::engine::{EngineState, StateWorkingSet}; 4 | use nu_protocol::{BlockId, DeclId, Span}; 5 | 6 | const MODULE: &'static str = indoc! {r#" 7 | module nuju { 8 | module render { 9 | @kernel-internal 10 | def "text" []: any -> string {...} 11 | 12 | @kernel-internal 13 | def "csv" []: any -> string {...} 14 | 15 | @kernel-internal 16 | def "json" []: any -> string {...} 17 | 18 | @kernel-internal 19 | def "html" []: any -> string {...} 20 | 21 | @kernel-internal 22 | def "md" []: any -> string {...} 23 | 24 | @kernel-internal 25 | def "svg" []: any -> string {...} 26 | } 27 | } 28 | "#}; 29 | 30 | #[derive(Debug, Clone)] 31 | pub struct KernelInternalSpans { 32 | pub render: KernelInternalRenderSpans, 33 | } 34 | 35 | #[derive(Debug, Clone)] 36 | pub struct KernelInternalRenderSpans { 37 | pub text: Span, 38 | pub csv: Span, 39 | pub json: Span, 40 | pub html: Span, 41 | pub md: Span, 42 | pub svg: Span, 43 | } 44 | 45 | pub fn create_nuju_module(engine_state: &mut EngineState) -> KernelInternalSpans { 46 | let mut working_set = StateWorkingSet::new(&engine_state); 47 | 48 | let file_id = working_set.add_file("nuju internals".to_string(), MODULE.as_bytes()); 49 | let file_span = working_set.get_span_for_file(file_id); 50 | 51 | let (outer_block, ..) = nu_parser::parse_module_block(&mut working_set, file_span, b"nuju"); 52 | let nuju_block_id = find_inner_block(&outer_block, "nuju").expect("find nuju block"); 53 | let nuju_block = working_set.get_block(nuju_block_id); 54 | let render_block_id = find_inner_block(&nuju_block, "render").expect("find nuju/render block"); 55 | let render_block = working_set.get_block(render_block_id); 56 | 57 | let (_, render_text_span) = find_decl(&render_block, "text").expect("find render/text decl"); 58 | let (_, render_csv_span) = find_decl(&render_block, "csv").expect("find render/csv decl"); 59 | let (_, render_json_span) = find_decl(&render_block, "json").expect("find render/json decl"); 60 | let (_, render_html_span) = find_decl(&render_block, "html").expect("find render/html decl"); 61 | let (_, render_md_span) = find_decl(&render_block, "md").expect("find render/md decl"); 62 | let (_, render_svg_span) = find_decl(&render_block, "svg").expect("find render/svg decl"); 63 | 64 | engine_state 65 | .merge_delta(working_set.delta) 66 | .expect("merge nuju module delta"); 67 | 68 | KernelInternalSpans { 69 | render: KernelInternalRenderSpans { 70 | text: render_text_span, 71 | csv: render_csv_span, 72 | json: render_json_span, 73 | html: render_html_span, 74 | md: render_md_span, 75 | svg: render_svg_span, 76 | }, 77 | } 78 | } 79 | 80 | fn find_inner_block(block: &Block, name: &str) -> Option { 81 | for pipeline in block.pipelines.iter() { 82 | for element in pipeline.elements.iter() { 83 | if let Expr::Call(call) = &element.expr.expr { 84 | if let Some(Argument::Positional(expr)) = call.arguments.get(0) { 85 | if let Expr::String(positional_name) = &expr.expr { 86 | if positional_name == name { 87 | if let Some(Argument::Positional(expr)) = call.arguments.get(1) { 88 | if let Expr::Block(block_id) = &expr.expr { 89 | return Some(*block_id); 90 | } 91 | } 92 | } 93 | } 94 | } 95 | } 96 | } 97 | } 98 | 99 | None 100 | } 101 | 102 | fn find_decl(block: &Block, name: &str) -> Option<(DeclId, Span)> { 103 | for pipeline in block.pipelines.iter() { 104 | for element in pipeline.elements.iter() { 105 | if let Expr::AttributeBlock(attribute_block) = &element.expr.expr { 106 | if let Expr::Call(call) = &attribute_block.item.expr { 107 | if let Some(Argument::Positional(expr)) = call.arguments.get(0) { 108 | if let Expr::String(positional_name) = &expr.expr { 109 | if positional_name == name { 110 | // TODO: return expression span if it includes the `@` 111 | return Some((call.decl_id, attribute_block.item.span)); 112 | } 113 | } 114 | } 115 | } 116 | } 117 | } 118 | } 119 | 120 | None 121 | } 122 | 123 | #[cfg(test)] 124 | mod tests { 125 | use super::*; 126 | 127 | #[test] 128 | fn adding_nuju_module_works() { 129 | let engine_state = EngineState::default(); 130 | let mut engine_state = nu_cmd_lang::create_default_context(); 131 | let spans = create_nuju_module(&mut engine_state); 132 | assert_eq!( 133 | engine_state.get_span_contents(spans.render.json), 134 | br#"def "json" []: any -> string {...}"# 135 | ) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/nu/render.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use mime::Mime; 4 | use nu_command::{ToCsv, ToJson, ToMd}; 5 | use nu_plotters::commands::draw::DrawSvg; 6 | use nu_protocol::ast::{Argument, Call}; 7 | use nu_protocol::debugger::WithoutDebug; 8 | use nu_protocol::engine::{Command, EngineState, Stack}; 9 | use nu_protocol::{DeclId, PipelineData, ShellError, Span, Spanned, Value}; 10 | use thiserror::Error; 11 | 12 | use super::module::KernelInternalSpans; 13 | use crate::error::KernelError; 14 | 15 | macro_rules! create_format_decl_ids { 16 | ($($field:ident : $search_str:expr),+ $(,)?) => { 17 | #[derive(Debug, Clone, Copy)] 18 | pub struct FormatDeclIds { 19 | $(pub $field: DeclId,)+ 20 | } 21 | 22 | impl FormatDeclIds { 23 | pub fn find(engine_state: &EngineState) -> Result { 24 | $(let mut $field = None;)+ 25 | 26 | for (str_bytes, decl_id) in engine_state.get_decls_sorted(false) { 27 | let Ok(s) = String::from_utf8(str_bytes) else { continue }; 28 | match s.as_str() { 29 | $($search_str => $field = Some(decl_id),)+ 30 | _ => (), 31 | } 32 | } 33 | 34 | if let ($(Some($field),)+) = ($($field,)+) { 35 | return Ok(FormatDeclIds { 36 | $($field,)+ 37 | }); 38 | } 39 | 40 | let mut missing = Vec::new(); 41 | $(if $field.is_none() { missing.push($search_str) })+ 42 | Err(KernelError::MissingFormatDecls {missing}) 43 | } 44 | } 45 | }; 46 | } 47 | 48 | create_format_decl_ids!( 49 | to_text: "to text", 50 | to_csv: "to csv", 51 | to_json: "to json", 52 | to_html: "to html", 53 | to_md: "to md", 54 | table: "table", 55 | // TODO: make this feature flagged 56 | draw_svg: "draw svg" 57 | ); 58 | 59 | fn flag(flag: impl Into, span: Span) -> Argument { 60 | Argument::Named(( 61 | Spanned { 62 | item: flag.into(), 63 | span, 64 | }, 65 | None, 66 | None, 67 | )) 68 | } 69 | 70 | #[derive(Debug, Error)] 71 | pub enum RenderError { 72 | #[error("could not convert pipeline data into value: {0}")] 73 | IntoValue(#[source] ShellError), 74 | 75 | #[error("could not render plain text output: {0}")] 76 | NoText(#[source] ShellError), 77 | } 78 | 79 | #[derive(Debug)] 80 | enum InternalRenderError { 81 | Eval(ShellError), 82 | IntoValue(ShellError), 83 | NoString(ShellError), 84 | } 85 | 86 | #[derive(Debug)] 87 | pub struct PipelineRender { 88 | pub data: HashMap, 89 | pub metadata: HashMap, 90 | } 91 | 92 | impl PipelineRender { 93 | fn render_via_cmd( 94 | value: &Value, 95 | to_cmd: impl Command, 96 | decl_id: DeclId, 97 | engine_state: &EngineState, 98 | span: Span, 99 | stack: &mut Stack, 100 | ) -> Result, InternalRenderError> { 101 | let ty = value.get_type(); 102 | let may = to_cmd 103 | .signature() 104 | .input_output_types 105 | .iter() 106 | .map(|(input, _)| input) 107 | .any(|input| ty.is_subtype_of(input)); 108 | 109 | match may { 110 | false => Ok(None), 111 | true => { 112 | Self::render_via_call(value.clone(), decl_id, engine_state, stack, span, vec![]) 113 | .map(Option::Some) 114 | } 115 | } 116 | } 117 | 118 | fn render_via_call( 119 | value: Value, 120 | decl_id: DeclId, 121 | engine_state: &EngineState, 122 | stack: &mut Stack, 123 | span: Span, 124 | arguments: Vec, 125 | ) -> Result { 126 | let pipeline_data = PipelineData::Value(value, None); 127 | let call = Call { 128 | decl_id, 129 | head: span, 130 | arguments, 131 | parser_info: HashMap::new(), 132 | }; 133 | let formatted = 134 | nu_engine::eval_call::(engine_state, stack, &call, pipeline_data) 135 | .map_err(InternalRenderError::Eval)?; 136 | let formatted = formatted 137 | .into_value(Span::unknown()) 138 | .map_err(InternalRenderError::IntoValue)? 139 | .into_string() 140 | .map_err(InternalRenderError::NoString)?; 141 | Ok(formatted) 142 | } 143 | 144 | pub fn render( 145 | pipeline_data: PipelineData, 146 | engine_state: &EngineState, 147 | stack: &mut Stack, 148 | spans: &KernelInternalSpans, 149 | format_decl_ids: FormatDeclIds, 150 | filter: Option, 151 | ) -> Result { 152 | let mut data = HashMap::new(); 153 | let metadata = HashMap::new(); 154 | let value = pipeline_data 155 | .into_value(Span::unknown()) 156 | .map_err(RenderError::IntoValue)?; 157 | let ty = value.get_type(); 158 | 159 | // `to text` has any input type, no need to check 160 | // also we always need to provide plain text output 161 | match Self::render_via_call( 162 | value.clone(), 163 | format_decl_ids.to_text, 164 | engine_state, 165 | stack, 166 | spans.render.text, 167 | vec![], 168 | ) { 169 | Ok(s) => data.insert(mime::TEXT_PLAIN, s), 170 | Err( 171 | InternalRenderError::Eval(e) | 172 | InternalRenderError::IntoValue(e) | 173 | InternalRenderError::NoString(e), 174 | ) => return Err(RenderError::NoText(e)), 175 | }; 176 | 177 | let match_filter = |mime| filter.is_none() || filter == Some(mime); 178 | 179 | // call directly as `ToHtml` is private 180 | if match_filter(mime::TEXT_HTML) { 181 | let span = spans.render.html; 182 | match Self::render_via_call( 183 | value.clone(), 184 | format_decl_ids.to_html, 185 | engine_state, 186 | stack, 187 | span, 188 | vec![flag("partial", span), flag("html-color", span)], 189 | ) { 190 | Ok(s) => data.insert(mime::TEXT_HTML, s), 191 | Err(InternalRenderError::Eval(_)) => None, 192 | Err(_) => None, // TODO: print the error 193 | }; 194 | } 195 | 196 | if match_filter(mime::TEXT_CSV) { 197 | match Self::render_via_cmd( 198 | &value, 199 | ToCsv, 200 | format_decl_ids.to_csv, 201 | engine_state, 202 | spans.render.csv, 203 | stack, 204 | ) { 205 | Ok(Some(s)) => data.insert(mime::TEXT_CSV, s), 206 | Ok(None) | Err(InternalRenderError::Eval(_)) => None, 207 | Err(_) => None, // TODO: print the error 208 | }; 209 | } 210 | 211 | if match_filter(mime::APPLICATION_JSON) { 212 | match Self::render_via_cmd( 213 | &value, 214 | ToJson, 215 | format_decl_ids.to_json, 216 | engine_state, 217 | spans.render.json, 218 | stack, 219 | ) { 220 | Ok(Some(s)) => data.insert(mime::APPLICATION_JSON, s), 221 | Ok(None) | Err(InternalRenderError::Eval(_)) => None, 222 | Err(_) => None, // TODO: print the error 223 | }; 224 | } 225 | 226 | let md_mime: mime::Mime = "text/markdown" 227 | .parse() 228 | .expect("'text/markdown' is valid mime type"); 229 | if match_filter(md_mime.clone()) { 230 | match Self::render_via_cmd( 231 | &value, 232 | ToMd, 233 | format_decl_ids.to_md, 234 | engine_state, 235 | spans.render.md, 236 | stack, 237 | ) { 238 | Ok(Some(s)) => data.insert(md_mime, s), 239 | Ok(None) | Err(InternalRenderError::Eval(_)) => None, 240 | Err(_) => None, // TODO: print the error 241 | }; 242 | } 243 | 244 | // TODO: feature flag this 245 | if match_filter(mime::IMAGE_SVG) { 246 | match Self::render_via_cmd( 247 | &value, 248 | DrawSvg, 249 | format_decl_ids.draw_svg, 250 | engine_state, 251 | spans.render.svg, 252 | stack, 253 | ) { 254 | Ok(Some(s)) => data.insert(mime::IMAGE_SVG, s), 255 | Ok(None) | Err(InternalRenderError::Eval(_)) => None, 256 | Err(_) => None, // TODO: print the error 257 | }; 258 | } 259 | 260 | Ok(PipelineRender { data, metadata }) 261 | } 262 | } 263 | 264 | #[derive(Debug)] 265 | pub struct StringifiedPipelineRender { 266 | pub data: HashMap, 267 | pub metadata: HashMap, 268 | } 269 | 270 | impl From for StringifiedPipelineRender { 271 | fn from(render: PipelineRender) -> Self { 272 | Self { 273 | data: render 274 | .data 275 | .into_iter() 276 | .map(|(k, v)| (k.to_string(), v)) 277 | .collect(), 278 | metadata: render 279 | .metadata 280 | .into_iter() 281 | .map(|(k, v)| (k.to_string(), v)) 282 | .collect(), 283 | } 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Clone)] 2 | pub enum Select { 3 | Left(L), 4 | Right(R), 5 | } 6 | -------------------------------------------------------------------------------- /tests/test_kernel.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import tomllib 5 | from jupyter_client import BlockingKernelClient, KernelManager 6 | 7 | 8 | TIMEOUT = 10 9 | 10 | 11 | @pytest.fixture 12 | def kernel(): 13 | km = KernelManager(kernel_name="nu") 14 | km.start_kernel() 15 | yield km.client() 16 | km.shutdown_kernel() 17 | 18 | 19 | def ok(client: BlockingKernelClient, code: str) -> list[dict]: 20 | # wait until client is ready, then send some code 21 | client.wait_for_ready(timeout=TIMEOUT) 22 | client.execute(code) 23 | 24 | # kernel should instantly reply with busy message 25 | busy_status = client.get_iopub_msg(timeout=TIMEOUT) 26 | assert busy_status["content"]["execution_state"] == "busy" 27 | 28 | # check on the iopub channel until we receive an idle message 29 | contents = [] 30 | while True: 31 | iopub_reply = client.get_iopub_msg(timeout=TIMEOUT) 32 | if iopub_reply["content"].get("execution_state") == "idle": 33 | break 34 | contents.append(iopub_reply["content"]) 35 | 36 | # we should get a ok on the shell channel 37 | shell_reply = client.get_shell_msg(timeout=TIMEOUT) 38 | assert shell_reply["content"]["status"] == "ok" 39 | 40 | return contents 41 | 42 | 43 | def test_kernel_info(kernel: BlockingKernelClient): 44 | kernel.wait_for_ready(timeout=TIMEOUT) 45 | kernel.kernel_info() 46 | 47 | # control channel not used in jupyter_client 48 | # control_kernel_info = kernel.get_control_msg(timeout=TIMEOUT) 49 | shell_kernel_info = kernel.get_shell_msg(timeout=TIMEOUT) 50 | 51 | # assert control_kernel_info["content"] == shell_kernel_info["content"] 52 | kernel_info = shell_kernel_info["content"] 53 | 54 | with open("Cargo.toml", "rb") as cargo_toml_file: 55 | cargo_toml = tomllib.load(cargo_toml_file) 56 | package = cargo_toml["package"] 57 | metadata = package["metadata"]["jupyter"] 58 | nu_engine = cargo_toml["workspace"]["dependencies"]["nu-engine"] 59 | assert kernel_info["protocol_version"] == metadata["protocol_version"] 60 | assert kernel_info["implementation"] == package["name"] 61 | assert kernel_info["implementation_version"] == package["version"] 62 | assert kernel_info["language_info"]["name"] == "nushell" 63 | assert kernel_info["language_info"]["version"] == nu_engine["version"] 64 | assert kernel_info["language_info"]["file_extension"] == ".nu" 65 | 66 | 67 | def test_basic_rendering(kernel: BlockingKernelClient): 68 | contents = ok(kernel, "$nuju") 69 | assert len(contents) == 1 70 | data = contents[0]["data"] 71 | assert "application/json" in data 72 | assert "text/plain" in data 73 | assert "text/html" in data 74 | assert "text/markdown" in data 75 | 76 | 77 | def test_nuju_content(kernel: BlockingKernelClient): 78 | contents = ok(kernel, "$nuju") 79 | assert len(contents) == 1 80 | data = contents[0]["data"] 81 | nuju_constant = json.loads(data["application/json"]) 82 | with open("Cargo.toml", "rb") as cargo_toml_file: 83 | cargo_toml = tomllib.load(cargo_toml_file) 84 | assert nuju_constant["version"]["kernel"] == cargo_toml["package"]["version"] 85 | 86 | nu_version = cargo_toml["workspace"]["dependencies"]["nu-engine"]["version"] 87 | assert nuju_constant["version"]["nu"] == nu_version 88 | 89 | 90 | def test_persistence(kernel: BlockingKernelClient): 91 | set_value = ok(kernel, "let foo = 'bar'") 92 | assert len(set_value) == 0 93 | 94 | get_value = ok(kernel, "$foo") 95 | assert len(get_value) == 1 96 | 97 | assert get_value[0]["data"]["text/plain"] == "bar" 98 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.12" 3 | 4 | [[package]] 5 | name = "cffi" 6 | version = "1.17.1" 7 | source = { registry = "https://pypi.org/simple" } 8 | dependencies = [ 9 | { name = "pycparser" }, 10 | ] 11 | sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } 12 | wheels = [ 13 | { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, 14 | { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, 15 | { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, 16 | { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, 17 | { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, 18 | { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, 19 | { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, 20 | { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, 21 | { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, 22 | { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, 23 | { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, 24 | { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, 25 | { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, 26 | { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, 27 | { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, 28 | { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, 29 | { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, 30 | { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, 31 | { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, 32 | { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, 33 | { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, 34 | { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, 35 | ] 36 | 37 | [[package]] 38 | name = "colorama" 39 | version = "0.4.6" 40 | source = { registry = "https://pypi.org/simple" } 41 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 42 | wheels = [ 43 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 44 | ] 45 | 46 | [[package]] 47 | name = "iniconfig" 48 | version = "2.0.0" 49 | source = { registry = "https://pypi.org/simple" } 50 | sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } 51 | wheels = [ 52 | { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, 53 | ] 54 | 55 | [[package]] 56 | name = "jupyter-client" 57 | version = "8.6.2" 58 | source = { registry = "https://pypi.org/simple" } 59 | dependencies = [ 60 | { name = "jupyter-core" }, 61 | { name = "python-dateutil" }, 62 | { name = "pyzmq" }, 63 | { name = "tornado" }, 64 | { name = "traitlets" }, 65 | ] 66 | sdist = { url = "https://files.pythonhosted.org/packages/ff/61/3cd51dea7878691919adc34ff6ad180f13bfe25fb8c7662a9ee6dc64e643/jupyter_client-8.6.2.tar.gz", hash = "sha256:2bda14d55ee5ba58552a8c53ae43d215ad9868853489213f37da060ced54d8df", size = 341102 } 67 | wheels = [ 68 | { url = "https://files.pythonhosted.org/packages/cf/d3/c4bb02580bc0db807edb9a29b2d0c56031be1ef0d804336deb2699a470f6/jupyter_client-8.6.2-py3-none-any.whl", hash = "sha256:50cbc5c66fd1b8f65ecb66bc490ab73217993632809b6e505687de18e9dea39f", size = 105901 }, 69 | ] 70 | 71 | [[package]] 72 | name = "jupyter-core" 73 | version = "5.7.2" 74 | source = { registry = "https://pypi.org/simple" } 75 | dependencies = [ 76 | { name = "platformdirs" }, 77 | { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, 78 | { name = "traitlets" }, 79 | ] 80 | sdist = { url = "https://files.pythonhosted.org/packages/00/11/b56381fa6c3f4cc5d2cf54a7dbf98ad9aa0b339ef7a601d6053538b079a7/jupyter_core-5.7.2.tar.gz", hash = "sha256:aa5f8d32bbf6b431ac830496da7392035d6f61b4f54872f15c4bd2a9c3f536d9", size = 87629 } 81 | wheels = [ 82 | { url = "https://files.pythonhosted.org/packages/c9/fb/108ecd1fe961941959ad0ee4e12ee7b8b1477247f30b1fdfd83ceaf017f0/jupyter_core-5.7.2-py3-none-any.whl", hash = "sha256:4f7315d2f6b4bcf2e3e7cb6e46772eba760ae459cd1f59d29eb57b0a01bd7409", size = 28965 }, 83 | ] 84 | 85 | [[package]] 86 | name = "nu-jupyter-kernel-test" 87 | version = "0.0.0" 88 | source = { virtual = "." } 89 | dependencies = [ 90 | { name = "jupyter-client" }, 91 | { name = "pytest" }, 92 | { name = "pytest-timeout" }, 93 | ] 94 | 95 | [package.metadata] 96 | requires-dist = [ 97 | { name = "jupyter-client", specifier = ">=8.6.2" }, 98 | { name = "pytest", specifier = ">=8.3.3" }, 99 | { name = "pytest-timeout", specifier = ">=2.3.1" }, 100 | ] 101 | 102 | [[package]] 103 | name = "packaging" 104 | version = "24.1" 105 | source = { registry = "https://pypi.org/simple" } 106 | sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 } 107 | wheels = [ 108 | { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 }, 109 | ] 110 | 111 | [[package]] 112 | name = "platformdirs" 113 | version = "4.3.3" 114 | source = { registry = "https://pypi.org/simple" } 115 | sdist = { url = "https://files.pythonhosted.org/packages/f5/19/f7bee3a71decedd8d7bc4d3edb7970b8e899f3caef257b0f0d623f2f7b11/platformdirs-4.3.3.tar.gz", hash = "sha256:d4e0b7d8ec176b341fb03cb11ca12d0276faa8c485f9cd218f613840463fc2c0", size = 21304 } 116 | wheels = [ 117 | { url = "https://files.pythonhosted.org/packages/69/e6/7c8e8c326903bd97c6c0c47e0a3c5de815faaae986cab7defdeddf5fddcd/platformdirs-4.3.3-py3-none-any.whl", hash = "sha256:50a5450e2e84f44539718293cbb1da0a0885c9d14adf21b77bae4e66fc99d9b5", size = 18437 }, 118 | ] 119 | 120 | [[package]] 121 | name = "pluggy" 122 | version = "1.5.0" 123 | source = { registry = "https://pypi.org/simple" } 124 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } 125 | wheels = [ 126 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, 127 | ] 128 | 129 | [[package]] 130 | name = "pycparser" 131 | version = "2.22" 132 | source = { registry = "https://pypi.org/simple" } 133 | sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } 134 | wheels = [ 135 | { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, 136 | ] 137 | 138 | [[package]] 139 | name = "pytest" 140 | version = "8.3.3" 141 | source = { registry = "https://pypi.org/simple" } 142 | dependencies = [ 143 | { name = "colorama", marker = "sys_platform == 'win32'" }, 144 | { name = "iniconfig" }, 145 | { name = "packaging" }, 146 | { name = "pluggy" }, 147 | ] 148 | sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } 149 | wheels = [ 150 | { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, 151 | ] 152 | 153 | [[package]] 154 | name = "pytest-timeout" 155 | version = "2.3.1" 156 | source = { registry = "https://pypi.org/simple" } 157 | dependencies = [ 158 | { name = "pytest" }, 159 | ] 160 | sdist = { url = "https://files.pythonhosted.org/packages/93/0d/04719abc7a4bdb3a7a1f968f24b0f5253d698c9cc94975330e9d3145befb/pytest-timeout-2.3.1.tar.gz", hash = "sha256:12397729125c6ecbdaca01035b9e5239d4db97352320af155b3f5de1ba5165d9", size = 17697 } 161 | wheels = [ 162 | { url = "https://files.pythonhosted.org/packages/03/27/14af9ef8321f5edc7527e47def2a21d8118c6f329a9342cc61387a0c0599/pytest_timeout-2.3.1-py3-none-any.whl", hash = "sha256:68188cb703edfc6a18fad98dc25a3c61e9f24d644b0b70f33af545219fc7813e", size = 14148 }, 163 | ] 164 | 165 | [[package]] 166 | name = "python-dateutil" 167 | version = "2.9.0.post0" 168 | source = { registry = "https://pypi.org/simple" } 169 | dependencies = [ 170 | { name = "six" }, 171 | ] 172 | sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } 173 | wheels = [ 174 | { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, 175 | ] 176 | 177 | [[package]] 178 | name = "pywin32" 179 | version = "306" 180 | source = { registry = "https://pypi.org/simple" } 181 | wheels = [ 182 | { url = "https://files.pythonhosted.org/packages/14/91/17e016d5923e178346aabda3dfec6629d1a26efe587d19667542105cf0a6/pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b", size = 8507705 }, 183 | { url = "https://files.pythonhosted.org/packages/83/1c/25b79fc3ec99b19b0a0730cc47356f7e2959863bf9f3cd314332bddb4f68/pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e", size = 9227429 }, 184 | { url = "https://files.pythonhosted.org/packages/1c/43/e3444dc9a12f8365d9603c2145d16bf0a2f8180f343cf87be47f5579e547/pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040", size = 10388145 }, 185 | ] 186 | 187 | [[package]] 188 | name = "pyzmq" 189 | version = "26.2.0" 190 | source = { registry = "https://pypi.org/simple" } 191 | dependencies = [ 192 | { name = "cffi", marker = "implementation_name == 'pypy'" }, 193 | ] 194 | sdist = { url = "https://files.pythonhosted.org/packages/fd/05/bed626b9f7bb2322cdbbf7b4bd8f54b1b617b0d2ab2d3547d6e39428a48e/pyzmq-26.2.0.tar.gz", hash = "sha256:070672c258581c8e4f640b5159297580a9974b026043bd4ab0470be9ed324f1f", size = 271975 } 195 | wheels = [ 196 | { url = "https://files.pythonhosted.org/packages/28/2f/78a766c8913ad62b28581777ac4ede50c6d9f249d39c2963e279524a1bbe/pyzmq-26.2.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:ded0fc7d90fe93ae0b18059930086c51e640cdd3baebdc783a695c77f123dcd9", size = 1343105 }, 197 | { url = "https://files.pythonhosted.org/packages/b7/9c/4b1e2d3d4065be715e007fe063ec7885978fad285f87eae1436e6c3201f4/pyzmq-26.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:17bf5a931c7f6618023cdacc7081f3f266aecb68ca692adac015c383a134ca52", size = 1008365 }, 198 | { url = "https://files.pythonhosted.org/packages/4f/ef/5a23ec689ff36d7625b38d121ef15abfc3631a9aecb417baf7a4245e4124/pyzmq-26.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55cf66647e49d4621a7e20c8d13511ef1fe1efbbccf670811864452487007e08", size = 665923 }, 199 | { url = "https://files.pythonhosted.org/packages/ae/61/d436461a47437d63c6302c90724cf0981883ec57ceb6073873f32172d676/pyzmq-26.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4661c88db4a9e0f958c8abc2b97472e23061f0bc737f6f6179d7a27024e1faa5", size = 903400 }, 200 | { url = "https://files.pythonhosted.org/packages/47/42/fc6d35ecefe1739a819afaf6f8e686f7f02a4dd241c78972d316f403474c/pyzmq-26.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea7f69de383cb47522c9c208aec6dd17697db7875a4674c4af3f8cfdac0bdeae", size = 860034 }, 201 | { url = "https://files.pythonhosted.org/packages/07/3b/44ea6266a6761e9eefaa37d98fabefa112328808ac41aa87b4bbb668af30/pyzmq-26.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7f98f6dfa8b8ccaf39163ce872bddacca38f6a67289116c8937a02e30bbe9711", size = 860579 }, 202 | { url = "https://files.pythonhosted.org/packages/38/6f/4df2014ab553a6052b0e551b37da55166991510f9e1002c89cab7ce3b3f2/pyzmq-26.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e3e0210287329272539eea617830a6a28161fbbd8a3271bf4150ae3e58c5d0e6", size = 1196246 }, 203 | { url = "https://files.pythonhosted.org/packages/38/9d/ee240fc0c9fe9817f0c9127a43238a3e28048795483c403cc10720ddef22/pyzmq-26.2.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6b274e0762c33c7471f1a7471d1a2085b1a35eba5cdc48d2ae319f28b6fc4de3", size = 1507441 }, 204 | { url = "https://files.pythonhosted.org/packages/85/4f/01711edaa58d535eac4a26c294c617c9a01f09857c0ce191fd574d06f359/pyzmq-26.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:29c6a4635eef69d68a00321e12a7d2559fe2dfccfa8efae3ffb8e91cd0b36a8b", size = 1406498 }, 205 | { url = "https://files.pythonhosted.org/packages/07/18/907134c85c7152f679ed744e73e645b365f3ad571f38bdb62e36f347699a/pyzmq-26.2.0-cp312-cp312-win32.whl", hash = "sha256:989d842dc06dc59feea09e58c74ca3e1678c812a4a8a2a419046d711031f69c7", size = 575533 }, 206 | { url = "https://files.pythonhosted.org/packages/ce/2c/a6f4a20202a4d3c582ad93f95ee78d79bbdc26803495aec2912b17dbbb6c/pyzmq-26.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:2a50625acdc7801bc6f74698c5c583a491c61d73c6b7ea4dee3901bb99adb27a", size = 637768 }, 207 | { url = "https://files.pythonhosted.org/packages/5f/0e/eb16ff731632d30554bf5af4dbba3ffcd04518219d82028aea4ae1b02ca5/pyzmq-26.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:4d29ab8592b6ad12ebbf92ac2ed2bedcfd1cec192d8e559e2e099f648570e19b", size = 540675 }, 208 | { url = "https://files.pythonhosted.org/packages/04/a7/0f7e2f6c126fe6e62dbae0bc93b1bd3f1099cf7fea47a5468defebe3f39d/pyzmq-26.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9dd8cd1aeb00775f527ec60022004d030ddc51d783d056e3e23e74e623e33726", size = 1006564 }, 209 | { url = "https://files.pythonhosted.org/packages/31/b6/a187165c852c5d49f826a690857684333a6a4a065af0a6015572d2284f6a/pyzmq-26.2.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:28c812d9757fe8acecc910c9ac9dafd2ce968c00f9e619db09e9f8f54c3a68a3", size = 1340447 }, 210 | { url = "https://files.pythonhosted.org/packages/68/ba/f4280c58ff71f321602a6e24fd19879b7e79793fb8ab14027027c0fb58ef/pyzmq-26.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d80b1dd99c1942f74ed608ddb38b181b87476c6a966a88a950c7dee118fdf50", size = 665485 }, 211 | { url = "https://files.pythonhosted.org/packages/77/b5/c987a5c53c7d8704216f29fc3d810b32f156bcea488a940e330e1bcbb88d/pyzmq-26.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c997098cc65e3208eca09303630e84d42718620e83b733d0fd69543a9cab9cb", size = 903484 }, 212 | { url = "https://files.pythonhosted.org/packages/29/c9/07da157d2db18c72a7eccef8e684cefc155b712a88e3d479d930aa9eceba/pyzmq-26.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ad1bc8d1b7a18497dda9600b12dc193c577beb391beae5cd2349184db40f187", size = 859981 }, 213 | { url = "https://files.pythonhosted.org/packages/43/09/e12501bd0b8394b7d02c41efd35c537a1988da67fc9c745cae9c6c776d31/pyzmq-26.2.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:bea2acdd8ea4275e1278350ced63da0b166421928276c7c8e3f9729d7402a57b", size = 860334 }, 214 | { url = "https://files.pythonhosted.org/packages/eb/ff/f5ec1d455f8f7385cc0a8b2acd8c807d7fade875c14c44b85c1bddabae21/pyzmq-26.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:23f4aad749d13698f3f7b64aad34f5fc02d6f20f05999eebc96b89b01262fb18", size = 1196179 }, 215 | { url = "https://files.pythonhosted.org/packages/ec/8a/bb2ac43295b1950fe436a81fc5b298be0b96ac76fb029b514d3ed58f7b27/pyzmq-26.2.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a4f96f0d88accc3dbe4a9025f785ba830f968e21e3e2c6321ccdfc9aef755115", size = 1507668 }, 216 | { url = "https://files.pythonhosted.org/packages/a9/49/dbc284ebcfd2dca23f6349227ff1616a7ee2c4a35fe0a5d6c3deff2b4fed/pyzmq-26.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ced65e5a985398827cc9276b93ef6dfabe0273c23de8c7931339d7e141c2818e", size = 1406539 }, 217 | { url = "https://files.pythonhosted.org/packages/00/68/093cdce3fe31e30a341d8e52a1ad86392e13c57970d722c1f62a1d1a54b6/pyzmq-26.2.0-cp313-cp313-win32.whl", hash = "sha256:31507f7b47cc1ead1f6e86927f8ebb196a0bab043f6345ce070f412a59bf87b5", size = 575567 }, 218 | { url = "https://files.pythonhosted.org/packages/92/ae/6cc4657148143412b5819b05e362ae7dd09fb9fe76e2a539dcff3d0386bc/pyzmq-26.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:70fc7fcf0410d16ebdda9b26cbd8bf8d803d220a7f3522e060a69a9c87bf7bad", size = 637551 }, 219 | { url = "https://files.pythonhosted.org/packages/6c/67/fbff102e201688f97c8092e4c3445d1c1068c2f27bbd45a578df97ed5f94/pyzmq-26.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:c3789bd5768ab5618ebf09cef6ec2b35fed88709b104351748a63045f0ff9797", size = 540378 }, 220 | { url = "https://files.pythonhosted.org/packages/3f/fe/2d998380b6e0122c6c4bdf9b6caf490831e5f5e2d08a203b5adff060c226/pyzmq-26.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:034da5fc55d9f8da09015d368f519478a52675e558c989bfcb5cf6d4e16a7d2a", size = 1007378 }, 221 | { url = "https://files.pythonhosted.org/packages/4a/f4/30d6e7157f12b3a0390bde94d6a8567cdb88846ed068a6e17238a4ccf600/pyzmq-26.2.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:c92d73464b886931308ccc45b2744e5968cbaade0b1d6aeb40d8ab537765f5bc", size = 1329532 }, 222 | { url = "https://files.pythonhosted.org/packages/82/86/3fe917870e15ee1c3ad48229a2a64458e36036e64b4afa9659045d82bfa8/pyzmq-26.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:794a4562dcb374f7dbbfb3f51d28fb40123b5a2abadee7b4091f93054909add5", size = 653242 }, 223 | { url = "https://files.pythonhosted.org/packages/50/2d/242e7e6ef6c8c19e6cb52d095834508cd581ffb925699fd3c640cdc758f1/pyzmq-26.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aee22939bb6075e7afededabad1a56a905da0b3c4e3e0c45e75810ebe3a52672", size = 888404 }, 224 | { url = "https://files.pythonhosted.org/packages/ac/11/7270566e1f31e4ea73c81ec821a4b1688fd551009a3d2bab11ec66cb1e8f/pyzmq-26.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ae90ff9dad33a1cfe947d2c40cb9cb5e600d759ac4f0fd22616ce6540f72797", size = 845858 }, 225 | { url = "https://files.pythonhosted.org/packages/91/d5/72b38fbc69867795c8711bdd735312f9fef1e3d9204e2f63ab57085434b9/pyzmq-26.2.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:43a47408ac52647dfabbc66a25b05b6a61700b5165807e3fbd40063fcaf46386", size = 847375 }, 226 | { url = "https://files.pythonhosted.org/packages/dd/9a/10ed3c7f72b4c24e719c59359fbadd1a27556a28b36cdf1cd9e4fb7845d5/pyzmq-26.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:25bf2374a2a8433633c65ccb9553350d5e17e60c8eb4de4d92cc6bd60f01d306", size = 1183489 }, 227 | { url = "https://files.pythonhosted.org/packages/72/2d/8660892543fabf1fe41861efa222455811adac9f3c0818d6c3170a1153e3/pyzmq-26.2.0-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:007137c9ac9ad5ea21e6ad97d3489af654381324d5d3ba614c323f60dab8fae6", size = 1492932 }, 228 | { url = "https://files.pythonhosted.org/packages/7b/d6/32fd69744afb53995619bc5effa2a405ae0d343cd3e747d0fbc43fe894ee/pyzmq-26.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:470d4a4f6d48fb34e92d768b4e8a5cc3780db0d69107abf1cd7ff734b9766eb0", size = 1392485 }, 229 | ] 230 | 231 | [[package]] 232 | name = "six" 233 | version = "1.16.0" 234 | source = { registry = "https://pypi.org/simple" } 235 | sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 } 236 | wheels = [ 237 | { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, 238 | ] 239 | 240 | [[package]] 241 | name = "tornado" 242 | version = "6.4.1" 243 | source = { registry = "https://pypi.org/simple" } 244 | sdist = { url = "https://files.pythonhosted.org/packages/ee/66/398ac7167f1c7835406888a386f6d0d26ee5dbf197d8a571300be57662d3/tornado-6.4.1.tar.gz", hash = "sha256:92d3ab53183d8c50f8204a51e6f91d18a15d5ef261e84d452800d4ff6fc504e9", size = 500623 } 245 | wheels = [ 246 | { url = "https://files.pythonhosted.org/packages/00/d9/c33be3c1a7564f7d42d87a8d186371a75fd142097076767a5c27da941fef/tornado-6.4.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:163b0aafc8e23d8cdc3c9dfb24c5368af84a81e3364745ccb4427669bf84aec8", size = 435924 }, 247 | { url = "https://files.pythonhosted.org/packages/2e/0f/721e113a2fac2f1d7d124b3279a1da4c77622e104084f56119875019ffab/tornado-6.4.1-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6d5ce3437e18a2b66fbadb183c1d3364fb03f2be71299e7d10dbeeb69f4b2a14", size = 433883 }, 248 | { url = "https://files.pythonhosted.org/packages/13/cf/786b8f1e6fe1c7c675e79657448178ad65e41c1c9765ef82e7f6f765c4c5/tornado-6.4.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e20b9113cd7293f164dc46fffb13535266e713cdb87bd2d15ddb336e96cfc4", size = 437224 }, 249 | { url = "https://files.pythonhosted.org/packages/e4/8e/a6ce4b8d5935558828b0f30f3afcb2d980566718837b3365d98e34f6067e/tornado-6.4.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ae50a504a740365267b2a8d1a90c9fbc86b780a39170feca9bcc1787ff80842", size = 436597 }, 250 | { url = "https://files.pythonhosted.org/packages/22/d4/54f9d12668b58336bd30defe0307e6c61589a3e687b05c366f804b7faaf0/tornado-6.4.1-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613bf4ddf5c7a95509218b149b555621497a6cc0d46ac341b30bd9ec19eac7f3", size = 436797 }, 251 | { url = "https://files.pythonhosted.org/packages/cf/3f/2c792e7afa7dd8b24fad7a2ed3c2f24a5ec5110c7b43a64cb6095cc106b8/tornado-6.4.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25486eb223babe3eed4b8aecbac33b37e3dd6d776bc730ca14e1bf93888b979f", size = 437516 }, 252 | { url = "https://files.pythonhosted.org/packages/71/63/c8fc62745e669ac9009044b889fc531b6f88ac0f5f183cac79eaa950bb23/tornado-6.4.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:454db8a7ecfcf2ff6042dde58404164d969b6f5d58b926da15e6b23817950fc4", size = 436958 }, 253 | { url = "https://files.pythonhosted.org/packages/94/d4/f8ac1f5bd22c15fad3b527e025ce219bd526acdbd903f52053df2baecc8b/tornado-6.4.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a02a08cc7a9314b006f653ce40483b9b3c12cda222d6a46d4ac63bb6c9057698", size = 436882 }, 254 | { url = "https://files.pythonhosted.org/packages/4b/3e/a8124c21cc0bbf144d7903d2a0cadab15cadaf683fa39a0f92bc567f0d4d/tornado-6.4.1-cp38-abi3-win32.whl", hash = "sha256:d9a566c40b89757c9aa8e6f032bcdb8ca8795d7c1a9762910c722b1635c9de4d", size = 438092 }, 255 | { url = "https://files.pythonhosted.org/packages/d9/2f/3f2f05e84a7aff787a96d5fb06821323feb370fe0baed4db6ea7b1088f32/tornado-6.4.1-cp38-abi3-win_amd64.whl", hash = "sha256:b24b8982ed444378d7f21d563f4180a2de31ced9d8d84443907a0a64da2072e7", size = 438532 }, 256 | ] 257 | 258 | [[package]] 259 | name = "traitlets" 260 | version = "5.14.3" 261 | source = { registry = "https://pypi.org/simple" } 262 | sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621 } 263 | wheels = [ 264 | { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 }, 265 | ] 266 | --------------------------------------------------------------------------------