From 6cd3df53119c2864ea37871c9fcec9fc82693185 Mon Sep 17 00:00:00 2001 From: lda Date: Sun, 30 Aug 2026 18:34:12 +0700 Subject: [PATCH 01/12] build: add Jupyter kernel transport dependencies --- pyproject.toml | 2 + uv.lock | 161 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 10c1fdd..0f12d5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,9 @@ requires-python = ">=3.14" dependencies = [ "fastapi>=0.141.1", "fastmcp>=3.4.7", + "ipykernel>=7.3.0", "ipython>=9.16.1", + "jupyter-client>=8.10.0", "langchain>=1.3.18", "langchain-openai>=1.6.0", "matplotlib>=3.11.1", diff --git a/uv.lock b/uv.lock index 98bb1d4..0568941 100644 --- a/uv.lock +++ b/uv.lock @@ -44,6 +44,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "appnope" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/f7/a82489c2b6ebe32d3e2831895ae19c77861f0eadf3bb16034484d965dbb2/appnope-1.0.0.tar.gz", hash = "sha256:685db59cb6043c3c2e528adc0b3bce3a5f8d09bcf7492c6ea650d1b7421f3c49", size = 5454, upload-time = "2026-08-20T21:36:13.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/c7/6b687cb0f83d2a51017d47953f2ee430ffad6fec2a8ebc964e0718a33eb1/appnope-1.0.0-py3-none-any.whl", hash = "sha256:6fe0c04218aab65c54c4ff81638cdbf848d89f5653b74d68638a137f200dd16e", size = 4158, upload-time = "2026-08-20T21:36:12.444Z" }, +] + [[package]] name = "asttokens" version = "3.0.2" @@ -292,6 +301,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + [[package]] name = "contourpy" version = "1.3.3" @@ -399,6 +417,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/d0/247938ef46dd81ab293efcac06ac2fee922db5bede9039d059912081db51/cyclopts-4.23.3-py3-none-any.whl", hash = "sha256:b3a65872942afb08f3ab5ca3d65b0b3ecfc872c9ccbea9d6a74ec11aa8a0215e", size = 237180, upload-time = "2026-08-26T18:16:34.128Z" }, ] +[[package]] +name = "debugpy" +version = "1.8.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -663,6 +694,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] +[[package]] +name = "ipykernel" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio2" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" }, +] + [[package]] name = "ipython" version = "9.17.0" @@ -691,7 +746,9 @@ source = { editable = "." } dependencies = [ { name = "fastapi" }, { name = "fastmcp" }, + { name = "ipykernel" }, { name = "ipython" }, + { name = "jupyter-client" }, { name = "langchain" }, { name = "langchain-openai" }, { name = "matplotlib" }, @@ -704,7 +761,9 @@ dependencies = [ requires-dist = [ { name = "fastapi", specifier = ">=0.141.1" }, { name = "fastmcp", specifier = ">=3.4.7" }, + { name = "ipykernel", specifier = ">=7.3.0" }, { name = "ipython", specifier = ">=9.16.1" }, + { name = "jupyter-client", specifier = ">=8.10.0" }, { name = "langchain", specifier = ">=1.3.18" }, { name = "langchain-openai", specifier = ">=1.6.0" }, { name = "matplotlib", specifier = ">=3.11.1" }, @@ -899,6 +958,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "jupyter-client" +version = "8.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/2a/906772148a06e48885039e0250c340b770a55bbf37b08d6ee0449df369c3/jupyter_client-8.10.0.tar.gz", hash = "sha256:9f7116294dca55f1785be880057d44544db9b1567718d92cb33c58886afb9497", size = 360653, upload-time = "2026-08-28T12:17:10.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/88/7c548de1f6c2ade7c931a3282da73f9274fa6a1531091be682f89c85efb9/jupyter_client-8.10.0-py3-none-any.whl", hash = "sha256:5f73f24f22fa25192cfff6b23c051932a2473a797b05734aff495b392103e14e", size = 110184, upload-time = "2026-08-28T12:17:09.028Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + [[package]] name = "keyring" version = "25.7.0" @@ -1236,6 +1325,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, ] +[[package]] +name = "nest-asyncio2" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" }, +] + [[package]] name = "numpy" version = "2.5.2" @@ -1757,6 +1855,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "pyzmq" +version = "27.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/8d/5b3d5631c2f4b4b8862f64cd0c9eb777b5710eeb5125b4be8dd0a200a4c0/pyzmq-27.2.0.tar.gz", hash = "sha256:54d4259d1bfae24ecdb5ca79f7acc2eac6c286a02d6a0ae617797cb45f0726d3", size = 292316, upload-time = "2026-08-20T19:08:21.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/8a/153532fa53db30e116118164f3af269a1f3966b3e2ba32c89b12fe864bd8/pyzmq-27.2.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:591c8de5851c5ea372194469fe97587b97c3b641e9a70f31bb3474acbfde0241", size = 1431074, upload-time = "2026-08-20T19:06:40.601Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ef/c08b91248bb90a9efa81fa00ba81b69c157c74d0c5efbb2c319d91babb62/pyzmq-27.2.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:00e73942ef12cecbc7951c4a9104bb8ffaed742abb13af2da6833d90dd368cef", size = 973915, upload-time = "2026-08-20T19:06:42.037Z" }, + { url = "https://files.pythonhosted.org/packages/b4/78/a3a3a86c2b00fadb92ece1ca4f8f028d62b2ce9ac3526097239ab2d6fba9/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f8079d0521fe94bbb401fe9407578b28f3701627c8be2c9f7e0c5b77dcb0109", size = 697722, upload-time = "2026-08-20T19:06:43.325Z" }, + { url = "https://files.pythonhosted.org/packages/62/2c/d5828306f795e8d34676d266823b74e2101e0ad3760d12083de3e02abbb2/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dea74fd65f1fc5f7fe167916a473ebe6ed6174e5e5d9de11ea6583661be6cf43", size = 872258, upload-time = "2026-08-20T19:06:44.627Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/51253b78fd8739293e283407eeecb14215c02c71b6519af21f6eed8e69cd/pyzmq-27.2.0-cp312-abi3-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcc99ca132b667a4ed750afd42db4ea73288f18425a9b2e3c0af095665c491f5", size = 739591, upload-time = "2026-08-20T19:06:46.214Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3e/142c85b67a4c9678629b0cf6d5125b29663d75be69bfaa57a3cac344d780/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b8d5f66e4a8246cf77f7b8f7902af64f00553368fa0373c89d99b78f0ad79394", size = 1689031, upload-time = "2026-08-20T19:06:47.612Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ee/0776fb0f98ed1eb74d77240087fef0ab045b6ad15cb09555c6c5134c98ad/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:d1526b42a2e725b84ed226f37becedc250c6347594e5ed304e4e9aff68c9aec3", size = 2059547, upload-time = "2026-08-20T19:06:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/ec77f691a4aebe29ab6329f996fb0e0270c876a3016086e3ca6ef733bcae/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f707bcf2c1d007d14d70531d4dd7b41060881c73efa845580bf6faaf9ea24d42", size = 1910457, upload-time = "2026-08-20T19:06:50.783Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/1f5530ff4fc271b4597048371d5af972c2baab51be132ba15874e0327a6a/pyzmq-27.2.0-cp312-abi3-win32.whl", hash = "sha256:fdaaa4ea3242f6ad298eb5177eb042aea5c73c30e76d20caee7b15af20d24ec2", size = 563450, upload-time = "2026-08-20T19:06:52.307Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b83f7780dad22e0878e4c7bd9158ebd24ed12bc3d5e3a471cd0576f77ded/pyzmq-27.2.0-cp312-abi3-win_amd64.whl", hash = "sha256:2c218c6ab8bc447ba62054b581fd30209689d199c6ecb253f79615ca74a38e12", size = 628633, upload-time = "2026-08-20T19:06:53.809Z" }, + { url = "https://files.pythonhosted.org/packages/52/aa/3918b5ac7f9987bd9c421b065074fd7409ded88f856f2c704a24341877ec/pyzmq-27.2.0-cp312-abi3-win_arm64.whl", hash = "sha256:348d6fd3e4b81ae4580622ea8c2ea60224e84b2ac1b3be4482e6edc7de06e7a3", size = 556006, upload-time = "2026-08-20T19:06:55.242Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/a849161ff88b2de9b991cc8ab332218824741122fdc4fdf222a5b822ac8c/pyzmq-27.2.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:3d45189c0c3c99f817b7fefff0d32eeef684cf33e1e3c0fc4281515357c54702", size = 1134452, upload-time = "2026-08-20T19:06:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/3c/34/ff4aaff0cfba2a4d7ad1a16ffedc52c6deb89fcf673d455085446b23f215/pyzmq-27.2.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d61910b52be5b2cd8b248dbcbe3a1b0275556a7d99fb613fc43323b546e273b8", size = 1167520, upload-time = "2026-08-20T19:07:01.283Z" }, + { url = "https://files.pythonhosted.org/packages/b6/07/42111e9dc1041d78b4443d6eb1b82b027f1a58178dc8a38385effbc72ad5/pyzmq-27.2.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3ab6eb88590e510ab16715c32dbba12000da9bee989fdadd9ee19a234c492eb7", size = 1466289, upload-time = "2026-08-20T19:07:02.738Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b4/def7a478458da78665840564161772e7e938600c32a89f28e8b221b54d2d/pyzmq-27.2.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecbdd131b9669f62d3a45afee5527c7ae9f141e4301267f21714c90bd21725f", size = 975868, upload-time = "2026-08-20T19:07:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/38/d5/e3e85f7fea37153097aaff49db9e33093909cc2a7b22c1ac4ebe546600fc/pyzmq-27.2.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3146385b94a760236c5eceff468a66a296a716ca98a2e0f9217b1518118466b1", size = 706054, upload-time = "2026-08-20T19:07:05.623Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ef/3b7d9449b223183222bf517245e1e53d5f1ab8c10be8b45f6a301b2f994a/pyzmq-27.2.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9846e881620dd62566ca76a53e384c3f37490faf4b9240aebc7498810dfca853", size = 878984, upload-time = "2026-08-20T19:07:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/be/a5/8b49dbd494f6dcfda69dc4cade322a4b02706ef4e3d30cc366d4e369899f/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d9527e3dbaef1edaeeb2446fa7379446814a43ade8adc7c4a5ebe69437815ddd", size = 1697489, upload-time = "2026-08-20T19:07:08.945Z" }, + { url = "https://files.pythonhosted.org/packages/da/5a/4bb8280901130c26ea25f0cbb4a6d39d94250860c6b3dbd912f1cf48fca7/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:56b48fa9d478a3af7254f397697a62f5ad3e1bb677e200b2701f0c290d97e5af", size = 2064236, upload-time = "2026-08-20T19:07:10.384Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/f433af66922554adb2b5f79e897018c8e19a90b9eaeb49c4814f8355ebe4/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bf0b6e4ce1bb089751c504c5493d6b0557eabd02dd21b76e9086cf964234b103", size = 1917424, upload-time = "2026-08-20T19:07:11.909Z" }, + { url = "https://files.pythonhosted.org/packages/36/81/ea1c1ae3f801d96ba2c269e056761ebcfe023476e651d3af2a7817962051/pyzmq-27.2.0-cp314-cp314t-win32.whl", hash = "sha256:fba8afcf265c6e9fbe1594cb045d4765c6c9a7d607653a8196067ef23566b843", size = 591103, upload-time = "2026-08-20T19:07:13.451Z" }, + { url = "https://files.pythonhosted.org/packages/8a/04/149a627707e780fa9f2c1ede3590c14fa6b18b5576d15744342622299a50/pyzmq-27.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d1bc1d380a91d954ed5fc9f12915dba014eed0978d2de05ee7ca688bdaac144a", size = 670215, upload-time = "2026-08-20T19:07:15.069Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/f9c3c1536c41ef3dbf765ea04218990e2056e558f98184ecd883767fc501/pyzmq-27.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c7cfb75caa83f5153c687e9d2107f64b5ef0ef0d6edd260d3ff920baaaa69101", size = 582252, upload-time = "2026-08-20T19:07:16.582Z" }, + { url = "https://files.pythonhosted.org/packages/fa/00/78fe097a304a408275747ce43f20428789130b059c5649956277c20f30cf/pyzmq-27.2.0-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:c5129a8fe43ecc49b99eb75616603d483a3c2fcaef504988fafe8ea392aea98b", size = 1134295, upload-time = "2026-08-20T19:07:17.94Z" }, + { url = "https://files.pythonhosted.org/packages/f2/83/1c36270658d2ee56e23a3f9ef5fbcb94cbd2f9fe966a6641f2f38e697162/pyzmq-27.2.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:baa2ce3485145653194d6c8c5beedd1e9f0bf46a0919c9fa2fe2204fc35b74d9", size = 1167492, upload-time = "2026-08-20T19:07:19.476Z" }, + { url = "https://files.pythonhosted.org/packages/58/b2/f0ae223438d7faa991f6feefdc823815f11cc604f898738376b59fd96515/pyzmq-27.2.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:e1ed46048d1920cabc96d952a0d5cfe4127ad8db572c335aae4e3c57b9278d7f", size = 1465992, upload-time = "2026-08-20T19:07:20.941Z" }, + { url = "https://files.pythonhosted.org/packages/59/46/fb56f3f37a6a0937b0e1d2885e808b5eedc171320bac85573cfae78fa9bc/pyzmq-27.2.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e0fa0bc6b1a184aee59b32efcd1b7f0e6d5b8f9387799e4c16a4cb66a86747d6", size = 976118, upload-time = "2026-08-20T19:07:22.577Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/a2c9bfd7c4d34eea1278493cd041bc000d41acb4463c89ceaad29dc813b6/pyzmq-27.2.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4bd6743e8bf854c3bfce892dd6578a514aabf128e37a4b2eafcf01856f7e44", size = 705968, upload-time = "2026-08-20T19:07:24.019Z" }, + { url = "https://files.pythonhosted.org/packages/d6/12/b906b269116b6591dc15c0acc5d04c043957c8a531d336999731f4b1d899/pyzmq-27.2.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95369ed6626afcfe2ac89832fb1b917c077fbeb905fbbe5d918349ce0222b89b", size = 879011, upload-time = "2026-08-20T19:07:25.428Z" }, + { url = "https://files.pythonhosted.org/packages/12/13/f96359534bfb77651c15f1fbfc4bfdd7ec3489d23f434706d39598dd0dcd/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40124779c3a56ad5d91902df1ff89159cb414b6c1a0ee697abcc66cf5e6db62d", size = 1697496, upload-time = "2026-08-20T19:07:26.821Z" }, + { url = "https://files.pythonhosted.org/packages/21/b4/2c007ae5f2fe5eca86cbfbc874ed86b5135f2f7812615dfd78606d3c93f6/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:ec8a318dfc27c7d946651b3d9e8025d5734f30c168a822195601827207bac09b", size = 2064347, upload-time = "2026-08-20T19:07:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/9b/88/767af3a6630c15215f3a66700ec79598a375edd1fdc9d75a3ad522178c01/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:88c0fac061bac269076edeb3a209acefc96cd6167c239daf1c2b404ac48d7012", size = 1917360, upload-time = "2026-08-20T19:07:29.693Z" }, + { url = "https://files.pythonhosted.org/packages/35/c1/80dd2d20d6e57bc68e1dce1e84bf3e76c9577c1bf728199985c8b4ea0fd1/pyzmq-27.2.0-cp315-cp315t-win32.whl", hash = "sha256:ac126d48cf18aa955daabef43bf0009ff76ad4deee437d09ecf15388214b5beb", size = 591073, upload-time = "2026-08-20T19:07:31.341Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b5/33b781666f3f52ae834bc9c8e38f4f0483a826c5a91cccc993292007bf10/pyzmq-27.2.0-cp315-cp315t-win_amd64.whl", hash = "sha256:edce90a1e588ec63adbf612cc0ad582de4169cd216c7ae53c15f42a2ee902f35", size = 670701, upload-time = "2026-08-20T19:07:32.895Z" }, + { url = "https://files.pythonhosted.org/packages/6e/97/bc4f0edefb992df4fdebcf9f0cc40f631cd4ed277e1ed59ef2cd99a5c8c5/pyzmq-27.2.0-cp315-cp315t-win_arm64.whl", hash = "sha256:a843094b4d3d633bc3623e47a2ff50742d6af02bc1f7606aa2e67e971e21878d", size = 581985, upload-time = "2026-08-20T19:07:34.19Z" }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -2048,6 +2192,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/5e/9b01afd037bfa22a0033963fa091e0f75b6fb15cd85bffb42ff86e697323/tiktoken-0.14.0-cp315-cp315t-win_amd64.whl", hash = "sha256:561e7580f84a79859af1ef6f676968e9030fcc3fe195700b15235bca64f009c9", size = 987929, upload-time = "2026-08-17T19:49:38.947Z" }, ] +[[package]] +name = "tornado" +version = "6.5.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/d3/343e5bb989d6515b1646cf3d40135d73f3d5e45339bded401b56cdac24dd/tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f", size = 520493, upload-time = "2026-08-07T02:12:42.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/d5/007086fd8df5489338e204f65adce33fd4f21a4999dbb2b9cff2f897b5f4/tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403", size = 449487, upload-time = "2026-08-07T02:12:28.682Z" }, + { url = "https://files.pythonhosted.org/packages/70/c8/5a24a99495903f594f6a199dd7beead1cbc0a13e2cb9102727bcaaf2a997/tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb", size = 447649, upload-time = "2026-08-07T02:12:30.306Z" }, + { url = "https://files.pythonhosted.org/packages/6e/de/f2e733f386b85962d1b1dc82cd63d169b5b4580062b35397eac9244a41fe/tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58", size = 450707, upload-time = "2026-08-07T02:12:31.95Z" }, + { url = "https://files.pythonhosted.org/packages/0b/94/20efeee9a01c141e9ac47c397f81679dfda24b32768fc4fff24e76d36c2c/tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808", size = 451677, upload-time = "2026-08-07T02:12:33.512Z" }, + { url = "https://files.pythonhosted.org/packages/42/ec/a96ccb8ccf0de2b7bc2c5fa1608a4803735018242e90c4882365a9fd418f/tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22", size = 451510, upload-time = "2026-08-07T02:12:35.346Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/93185859245ad3f00e62175f29607346788b696369347f0146e0421286bb/tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195", size = 450917, upload-time = "2026-08-07T02:12:36.963Z" }, + { url = "https://files.pythonhosted.org/packages/97/cf/fe33cf062834487d34d1559746a4a12521033c22645b6d74d4bca702e018/tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836", size = 451952, upload-time = "2026-08-07T02:12:38.512Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e1/468ad54333e92ccb62627e62cb88e5fc14a2171daa67ed47b1b8542d5b86/tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee", size = 452391, upload-time = "2026-08-07T02:12:39.971Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3e/cd5e4f06e34cde33b8ef66cf36aa2b5ad46354cc1af7d2136bbe365fee1d/tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26", size = 451411, upload-time = "2026-08-07T02:12:41.469Z" }, +] + [[package]] name = "traitlets" version = "5.16.1" From 27a12921083b5ea276ddc994869784a6c55ff149 Mon Sep 17 00:00:00 2001 From: lda Date: Sun, 30 Aug 2026 18:34:53 +0700 Subject: [PATCH 02/12] feat: define Jupyter transport message boundary --- src/ipython_shell/transport.py | 20 ++++++++++++++++++++ tests/test_transport.py | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 src/ipython_shell/transport.py create mode 100644 tests/test_transport.py diff --git a/src/ipython_shell/transport.py b/src/ipython_shell/transport.py new file mode 100644 index 0000000..f069d95 --- /dev/null +++ b/src/ipython_shell/transport.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + + +@dataclass +class KernelMessage: + """A decoded Jupyter message kept at the transport boundary.""" + + msg_type: str + parent_id: str | None + content: dict[str, object] + buffers: list[bytes] + + +@dataclass +class InputRequest: + """A request for user input emitted by a running kernel call.""" + + call_id: str + prompt: str + password: bool diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..8c4b7f7 --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,21 @@ +import unittest + +from ipython_shell.transport import KernelMessage + + +class TransportTests(unittest.TestCase): + def test_kernel_message_keeps_content_and_buffers(self): + message = KernelMessage( + msg_type="display_data", + parent_id="call-1", + content={"data": {"text/plain": "42"}}, + buffers=[b"binary"], + ) + + self.assertEqual(message.parent_id, "call-1") + self.assertEqual(message.content["data"], {"text/plain": "42"}) + self.assertEqual(message.buffers, [b"binary"]) + + +if __name__ == "__main__": + unittest.main() From 01c06218fbf7eeea861701f8f4ae7cf658153c5e Mon Sep 17 00:00:00 2001 From: lda Date: Sun, 30 Aug 2026 18:37:06 +0700 Subject: [PATCH 03/12] feat: execute cells through persistent Jupyter kernel --- src/ipython_shell/transport.py | 92 ++++++++++++++++++++++++++++++++++ tests/test_transport.py | 21 +++++++- 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/src/ipython_shell/transport.py b/src/ipython_shell/transport.py index f069d95..b1106ad 100644 --- a/src/ipython_shell/transport.py +++ b/src/ipython_shell/transport.py @@ -1,5 +1,9 @@ +import asyncio +from collections.abc import AsyncIterator from dataclasses import dataclass +from jupyter_client import AsyncKernelManager + @dataclass class KernelMessage: @@ -18,3 +22,91 @@ class InputRequest: call_id: str prompt: str password: bool + + +class JupyterTransport: + """Own one persistent ipykernel subprocess and its message channels.""" + + def __init__(self) -> None: + self.manager = AsyncKernelManager() + self.client = None + self._active_call: str | None = None + self._call_lock = asyncio.Lock() + + async def start(self) -> None: + """Start the kernel process and wait until it accepts requests.""" + await self.manager.start_kernel() + self.client = self.manager.client() + self.client.start_channels() + await self.client.wait_for_ready() + + async def execute(self, code: str) -> str: + """Submit one cell and return its Jupyter message ID.""" + if self.client is None: + raise RuntimeError("JupyterTransport has not been started") + + async with self._call_lock: + if self._active_call is not None: + raise RuntimeError("JupyterTransport already has an active call") + self._active_call = self.client.execute(code, allow_stdin=True) + return self._active_call + + async def messages_for(self, call_id: str) -> AsyncIterator[KernelMessage]: + """Yield decoded IOPub and shell messages for one execution.""" + if self.client is None: + raise RuntimeError("JupyterTransport has not been started") + if self._active_call != call_id: + raise RuntimeError(f"Unknown or inactive call: {call_id}") + + reply: KernelMessage | None = None + idle = False + try: + while True: + iopub_task = asyncio.create_task(self.client.get_iopub_msg()) + shell_task = asyncio.create_task(self.client.get_shell_msg()) + done, pending = await asyncio.wait( + {iopub_task, shell_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + + for task in done: + message = task.result() + parent_id = message.get("parent_header", {}).get("msg_id") + if parent_id != call_id: + continue + + decoded = KernelMessage( + msg_type=message["msg_type"], + parent_id=parent_id, + content=dict(message.get("content", {})), + buffers=[bytes(buffer) for buffer in message.get("buffers", [])], + ) + if decoded.msg_type == "execute_reply": + # The shell reply and IOPub messages use different + # channels. Buffer the reply so it remains terminal + # even when both channels become ready together. + reply = decoded + else: + yield decoded + if ( + decoded.msg_type == "status" + and decoded.content.get("execution_state") == "idle" + ): + idle = True + + if reply is not None and idle: + yield reply + return + finally: + if self._active_call == call_id: + self._active_call = None + + async def shutdown(self) -> None: + """Stop channels and terminate the kernel process.""" + if self.client is not None: + self.client.stop_channels() + self.client = None + await self.manager.shutdown_kernel(now=True) diff --git a/tests/test_transport.py b/tests/test_transport.py index 8c4b7f7..4d5c8b4 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1,6 +1,6 @@ import unittest -from ipython_shell.transport import KernelMessage +from ipython_shell.transport import JupyterTransport, KernelMessage class TransportTests(unittest.TestCase): @@ -17,5 +17,24 @@ class TransportTests(unittest.TestCase): self.assertEqual(message.buffers, [b"binary"]) +class AsyncTransportTests(unittest.IsolatedAsyncioTestCase): + async def test_transport_executes_and_returns_execute_reply(self): + transport = JupyterTransport() + await transport.start() + try: + call_id = await transport.execute("2 + 2") + messages = [ + message async for message in transport.messages_for(call_id) + ] + finally: + await transport.shutdown() + + self.assertTrue( + any(message.msg_type == "execute_result" for message in messages) + ) + self.assertEqual(messages[-1].msg_type, "execute_reply") + self.assertEqual(messages[-1].content["status"], "ok") + + if __name__ == "__main__": unittest.main() From 85d894531395aca5a47a13e5d3f44076cb2a1816 Mon Sep 17 00:00:00 2001 From: lda Date: Sun, 30 Aug 2026 18:44:26 +0700 Subject: [PATCH 04/12] feat: support Jupyter kernel transport input --- src/ipython_shell/transport.py | 33 ++++++++++++++++++-- tests/test_transport.py | 57 +++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/ipython_shell/transport.py b/src/ipython_shell/transport.py index b1106ad..3745479 100644 --- a/src/ipython_shell/transport.py +++ b/src/ipython_shell/transport.py @@ -32,6 +32,7 @@ class JupyterTransport: self.client = None self._active_call: str | None = None self._call_lock = asyncio.Lock() + self._waiting_for_input: str | None = None async def start(self) -> None: """Start the kernel process and wait until it accepts requests.""" @@ -51,8 +52,10 @@ class JupyterTransport: self._active_call = self.client.execute(code, allow_stdin=True) return self._active_call - async def messages_for(self, call_id: str) -> AsyncIterator[KernelMessage]: - """Yield decoded IOPub and shell messages for one execution.""" + async def messages_for( + self, call_id: str + ) -> AsyncIterator[KernelMessage | InputRequest]: + """Yield decoded output, input, and completion messages for one call.""" if self.client is None: raise RuntimeError("JupyterTransport has not been started") if self._active_call != call_id: @@ -64,8 +67,9 @@ class JupyterTransport: while True: iopub_task = asyncio.create_task(self.client.get_iopub_msg()) shell_task = asyncio.create_task(self.client.get_shell_msg()) + stdin_task = asyncio.create_task(self.client.get_stdin_msg()) done, pending = await asyncio.wait( - {iopub_task, shell_task}, + {iopub_task, shell_task, stdin_task}, return_when=asyncio.FIRST_COMPLETED, ) for task in pending: @@ -78,6 +82,17 @@ class JupyterTransport: if parent_id != call_id: continue + if message["msg_type"] == "input_request": + self._waiting_for_input = call_id + yield InputRequest( + call_id=call_id, + prompt=str(message.get("content", {}).get("prompt", "")), + password=bool( + message.get("content", {}).get("password", False) + ), + ) + continue + decoded = KernelMessage( msg_type=message["msg_type"], parent_id=parent_id, @@ -101,9 +116,21 @@ class JupyterTransport: yield reply return finally: + if self._waiting_for_input == call_id: + self._waiting_for_input = None if self._active_call == call_id: self._active_call = None + async def reply_to_input(self, value: str) -> None: + """Reply to the active kernel input request without using process stdin.""" + if self.client is None: + raise RuntimeError("JupyterTransport has not been started") + if self._waiting_for_input is None: + raise RuntimeError("JupyterTransport is not waiting for input") + + self.client.input(value) + self._waiting_for_input = None + async def shutdown(self) -> None: """Stop channels and terminate the kernel process.""" if self.client is not None: diff --git a/tests/test_transport.py b/tests/test_transport.py index 4d5c8b4..78d1b23 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1,6 +1,7 @@ +import asyncio import unittest -from ipython_shell.transport import JupyterTransport, KernelMessage +from ipython_shell.transport import InputRequest, JupyterTransport, KernelMessage class TransportTests(unittest.TestCase): @@ -35,6 +36,60 @@ class AsyncTransportTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(messages[-1].msg_type, "execute_reply") self.assertEqual(messages[-1].content["status"], "ok") + async def test_transport_preserves_mime_and_errors(self): + transport = JupyterTransport() + await transport.start() + try: + display_id = await transport.execute( + "from IPython.display import display\n" + "display({'text/plain': 'hello'}, raw=True)" + ) + display_messages = [ + message async for message in transport.messages_for(display_id) + ] + + error_id = await transport.execute("raise ValueError('boom')") + error_messages = [ + message async for message in transport.messages_for(error_id) + ] + finally: + await transport.shutdown() + + display = next( + message + for message in display_messages + if message.msg_type == "display_data" + ) + self.assertEqual(display.content["data"]["text/plain"], "hello") + + error = next( + message for message in error_messages if message.msg_type == "error" + ) + self.assertEqual(error.content["ename"], "ValueError") + + async def test_transport_routes_input_reply_without_parent_stdin(self): + transport = JupyterTransport() + await transport.start() + try: + call_id = await transport.execute("answer = input('name? '); answer") + stream = transport.messages_for(call_id) + + while True: + event = await asyncio.wait_for(anext(stream), timeout=5) + if isinstance(event, InputRequest): + input_request = event + break + + self.assertEqual(input_request.prompt, "name? ") + await transport.reply_to_input("Ada") + messages = [message async for message in stream] + finally: + await transport.shutdown() + + self.assertTrue( + any(message.msg_type == "execute_result" for message in messages) + ) + if __name__ == "__main__": unittest.main() From a77f146a720d58d9db7b2b4362a4e6d151959428 Mon Sep 17 00:00:00 2001 From: lda Date: Sun, 30 Aug 2026 18:59:41 +0700 Subject: [PATCH 05/12] feat: add Jupyter-backed shell app --- src/ipython_shell/__init__.py | 8 +++ src/ipython_shell/jupyter_app.py | 71 ++++++++++++++++++++++++ src/ipython_shell/jupyter_shell.py | 49 +++++++++++++++++ tests/test_jupyter_app.py | 87 ++++++++++++++++++++++++++++++ 4 files changed, 215 insertions(+) create mode 100644 src/ipython_shell/jupyter_app.py create mode 100644 src/ipython_shell/jupyter_shell.py create mode 100644 tests/test_jupyter_app.py diff --git a/src/ipython_shell/__init__.py b/src/ipython_shell/__init__.py index 9137e59..8cf4763 100644 --- a/src/ipython_shell/__init__.py +++ b/src/ipython_shell/__init__.py @@ -1,6 +1,8 @@ """Small, side-effect-free API for the in-process IPython runner.""" from .app import App +from .jupyter_app import JupyterApp +from .jupyter_shell import JupyterShell from .events import ( error_from_execution_result, event_from_execution_result, @@ -26,10 +28,16 @@ from .shell import ( run_cell_and_collect, setup_shell, ) +from .transport import InputRequest, JupyterTransport, KernelMessage from .utils import generate_good_names __all__ = [ "App", + "JupyterApp", + "JupyterShell", + "JupyterTransport", + "InputRequest", + "KernelMessage", "AppInfo", "CallError", "CallEvent", diff --git a/src/ipython_shell/jupyter_app.py b/src/ipython_shell/jupyter_app.py new file mode 100644 index 0000000..35e3caf --- /dev/null +++ b/src/ipython_shell/jupyter_app.py @@ -0,0 +1,71 @@ +from collections.abc import AsyncIterator +from uuid import uuid4 + +from .jupyter_shell import JupyterShell +from .transport import InputRequest, KernelMessage +from .utils import generate_good_names + + +class JupyterApp: + """Coordinate named persistent shells backed by Jupyter kernels.""" + + def __init__(self) -> None: + self.shells: dict[str, JupyterShell] = {} + self.last_shell: str | None = None + self._calls: dict[str, JupyterShell] = {} + + def _new_shell(self) -> tuple[str, JupyterShell]: + name = generate_good_names() + while name in self.shells: + name = generate_good_names() + shell = JupyterShell(name) + self.shells[name] = shell + self.last_shell = name + return name, shell + + def _select_shell(self, shell_name: str) -> tuple[str, JupyterShell]: + if shell_name == "new": + return self._new_shell() + if shell_name == "last": + if self.last_shell is None: + return self._new_shell() + return self.last_shell, self.shells[self.last_shell] + if shell_name not in self.shells: + self.shells[shell_name] = JupyterShell(shell_name) + self.last_shell = shell_name + return shell_name, self.shells[shell_name] + + def get_shell(self, shell_name: str) -> JupyterShell: + """Return a named Jupyter shell.""" + if shell_name not in self.shells: + raise KeyError(f"Shell {shell_name} does not exist") + return self.shells[shell_name] + + async def run_code_stream( + self, + code: str, + shell_name: str = "last", + ) -> AsyncIterator[KernelMessage | InputRequest]: + """Execute code and yield its subprocess messages.""" + _, shell = self._select_shell(shell_name) + call_id = uuid4().hex + self._calls[call_id] = shell + try: + async for message in shell.run_cell_stream(code, call_id=call_id): + yield message + finally: + self._calls.pop(call_id, None) + + async def reply_to_input(self, call_id: str, value: str) -> None: + """Reply to an input request emitted by a running call.""" + try: + shell = self._calls[call_id] + except KeyError as error: + raise KeyError(f"Call {call_id} does not exist") from error + await shell.reply_to_input(call_id, value) + + async def shutdown(self) -> None: + """Stop all kernel processes owned by this app.""" + awaitables = [shell.shutdown() for shell in self.shells.values()] + for awaitable in awaitables: + await awaitable diff --git a/src/ipython_shell/jupyter_shell.py b/src/ipython_shell/jupyter_shell.py new file mode 100644 index 0000000..ac2a100 --- /dev/null +++ b/src/ipython_shell/jupyter_shell.py @@ -0,0 +1,49 @@ +from collections.abc import AsyncIterator +from dataclasses import replace + +from .transport import InputRequest, JupyterTransport, KernelMessage + + +class JupyterShell: + """One persistent, subprocess-backed IPython shell.""" + + def __init__(self, name: str, transport: JupyterTransport | None = None) -> None: + self.name = name + self.transport = transport or JupyterTransport() + self._active_call_id: str | None = None + + async def start(self) -> None: + """Start this shell's kernel if it is not already running.""" + if self.transport.client is None: + await self.transport.start() + + async def run_cell_stream( + self, + code: str, + *, + call_id: str, + ) -> AsyncIterator[KernelMessage | InputRequest]: + """Yield kernel messages while one cell executes.""" + await self.start() + self._active_call_id = call_id + try: + kernel_call_id = await self.transport.execute(code) + async for message in self.transport.messages_for(kernel_call_id): + if isinstance(message, InputRequest): + yield replace(message, call_id=call_id) + else: + # Keep the kernel's execution ID private to the transport. + yield replace(message, parent_id=call_id) + finally: + if self._active_call_id == call_id: + self._active_call_id = None + + async def reply_to_input(self, call_id: str, value: str) -> None: + """Send input to the cell currently waiting in this shell.""" + if self._active_call_id != call_id: + raise RuntimeError(f"No active input request for call: {call_id}") + await self.transport.reply_to_input(value) + + async def shutdown(self) -> None: + """Stop this shell's kernel process.""" + await self.transport.shutdown() diff --git a/tests/test_jupyter_app.py b/tests/test_jupyter_app.py new file mode 100644 index 0000000..f54f2d9 --- /dev/null +++ b/tests/test_jupyter_app.py @@ -0,0 +1,87 @@ +import asyncio +import unittest + +from ipython_shell.jupyter_app import JupyterApp +from ipython_shell.jupyter_shell import JupyterShell +from ipython_shell.transport import InputRequest, KernelMessage + + +class JupyterShellTests(unittest.IsolatedAsyncioTestCase): + async def test_shell_keeps_namespace_between_cells(self): + shell = JupyterShell("persistent") + try: + first = [ + message + async for message in shell.run_cell_stream( + "answer = 41; answer", + call_id="call-1", + ) + ] + second = [ + message + async for message in shell.run_cell_stream( + "answer + 1", + call_id="call-2", + ) + ] + finally: + await shell.shutdown() + + self.assertEqual( + next( + message.content["data"]["text/plain"] + for message in first + if isinstance(message, KernelMessage) + and message.msg_type == "execute_result" + ), + "41", + ) + self.assertEqual( + next( + message.content["data"]["text/plain"] + for message in second + if isinstance(message, KernelMessage) + and message.msg_type == "execute_result" + ), + "42", + ) + + +class JupyterAppTests(unittest.IsolatedAsyncioTestCase): + async def test_app_routes_input_reply_to_the_call(self): + app = JupyterApp() + stream = app.run_code_stream( + "answer = input('name? '); answer", + shell_name="new", + ) + + try: + while True: + event = await asyncio.wait_for(anext(stream), timeout=5) + if isinstance(event, InputRequest): + input_request = event + break + + self.assertIsInstance(input_request, InputRequest) + assert isinstance(input_request, InputRequest) + self.assertTrue(input_request.call_id) + self.assertEqual(input_request.prompt, "name? ") + + await app.reply_to_input(input_request.call_id, "Ada") + messages = [message async for message in stream] + finally: + await app.shutdown() + + self.assertEqual( + next( + message.content["data"]["text/plain"] + for message in messages + if isinstance(message, KernelMessage) + and message.msg_type == "execute_result" + ), + "'Ada'", + ) + + +if __name__ == "__main__": + unittest.main() From 6315235e1652c0b5a88660c0deb7270e5c745cb8 Mon Sep 17 00:00:00 2001 From: lda Date: Sun, 30 Aug 2026 19:19:49 +0700 Subject: [PATCH 06/12] feat: expose Jupyter shell through web app --- pyproject.toml | 1 + src/ipython_shell/__init__.py | 6 +- src/ipython_shell/jupyter/__init__.py | 11 +++ .../{jupyter_app.py => jupyter/app.py} | 4 +- .../{jupyter_shell.py => jupyter/shell.py} | 0 src/ipython_shell/{ => jupyter}/transport.py | 0 src/ipython_webapp/jupyter/__init__.py | 5 ++ src/ipython_webapp/jupyter/app.py | 87 +++++++++++++++++++ tests/test_jupyter_app.py | 52 ++++++++++- tests/test_jupyter_webapp.py | 61 +++++++++++++ tests/test_transport.py | 2 +- 11 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 src/ipython_shell/jupyter/__init__.py rename src/ipython_shell/{jupyter_app.py => jupyter/app.py} (96%) rename src/ipython_shell/{jupyter_shell.py => jupyter/shell.py} (100%) rename src/ipython_shell/{ => jupyter}/transport.py (100%) create mode 100644 src/ipython_webapp/jupyter/__init__.py create mode 100644 src/ipython_webapp/jupyter/app.py create mode 100644 tests/test_jupyter_webapp.py diff --git a/pyproject.toml b/pyproject.toml index 0f12d5d..09cb244 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ ipython-demo = "ipython_shell.app:main" ipython-shell = "ipython_shell.app:main" ipython-webapp = "ipython_webapp.app:main" +ipython-jupyter-webapp = "ipython_webapp.jupyter.app:main" ipython-mcp = "ipython_mcp.app:main" [build-system] diff --git a/src/ipython_shell/__init__.py b/src/ipython_shell/__init__.py index 8cf4763..5b9c675 100644 --- a/src/ipython_shell/__init__.py +++ b/src/ipython_shell/__init__.py @@ -1,8 +1,8 @@ """Small, side-effect-free API for the in-process IPython runner.""" from .app import App -from .jupyter_app import JupyterApp -from .jupyter_shell import JupyterShell +from .jupyter.app import JupyterApp +from .jupyter.shell import JupyterShell from .events import ( error_from_execution_result, event_from_execution_result, @@ -28,7 +28,7 @@ from .shell import ( run_cell_and_collect, setup_shell, ) -from .transport import InputRequest, JupyterTransport, KernelMessage +from .jupyter.transport import InputRequest, JupyterTransport, KernelMessage from .utils import generate_good_names __all__ = [ diff --git a/src/ipython_shell/jupyter/__init__.py b/src/ipython_shell/jupyter/__init__.py new file mode 100644 index 0000000..3fe577c --- /dev/null +++ b/src/ipython_shell/jupyter/__init__.py @@ -0,0 +1,11 @@ +from .app import JupyterApp +from .shell import JupyterShell +from .transport import InputRequest, JupyterTransport, KernelMessage + +__all__ = [ + "InputRequest", + "JupyterApp", + "JupyterShell", + "JupyterTransport", + "KernelMessage", +] diff --git a/src/ipython_shell/jupyter_app.py b/src/ipython_shell/jupyter/app.py similarity index 96% rename from src/ipython_shell/jupyter_app.py rename to src/ipython_shell/jupyter/app.py index 35e3caf..9f0471a 100644 --- a/src/ipython_shell/jupyter_app.py +++ b/src/ipython_shell/jupyter/app.py @@ -1,9 +1,9 @@ from collections.abc import AsyncIterator from uuid import uuid4 -from .jupyter_shell import JupyterShell +from .shell import JupyterShell from .transport import InputRequest, KernelMessage -from .utils import generate_good_names +from ..utils import generate_good_names class JupyterApp: diff --git a/src/ipython_shell/jupyter_shell.py b/src/ipython_shell/jupyter/shell.py similarity index 100% rename from src/ipython_shell/jupyter_shell.py rename to src/ipython_shell/jupyter/shell.py diff --git a/src/ipython_shell/transport.py b/src/ipython_shell/jupyter/transport.py similarity index 100% rename from src/ipython_shell/transport.py rename to src/ipython_shell/jupyter/transport.py diff --git a/src/ipython_webapp/jupyter/__init__.py b/src/ipython_webapp/jupyter/__init__.py new file mode 100644 index 0000000..5aabd2c --- /dev/null +++ b/src/ipython_webapp/jupyter/__init__.py @@ -0,0 +1,5 @@ +"""FastAPI adapter for the subprocess-backed Jupyter shell app.""" + +from .app import InputReply, app, serialize_event, shell_app + +__all__ = ["InputReply", "app", "serialize_event", "shell_app"] diff --git a/src/ipython_webapp/jupyter/app.py b/src/ipython_webapp/jupyter/app.py new file mode 100644 index 0000000..a0c07d4 --- /dev/null +++ b/src/ipython_webapp/jupyter/app.py @@ -0,0 +1,87 @@ +import base64 +import json +from collections.abc import AsyncIterator +from typing import Annotated + +from fastapi import Body, FastAPI, HTTPException, Path, Response +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from ipython_shell.jupyter import InputRequest, JupyterApp, KernelMessage + +app = FastAPI(title="IPython Jupyter Web App", version="0.1.0") +shell_app = JupyterApp() + + +class InputReply(BaseModel): + """Value supplied by a client answering a kernel input request.""" + + value: str + + +def serialize_event(event: KernelMessage | InputRequest) -> dict[str, object]: + """Turn a shell event into JSON-safe data for a web client.""" + if isinstance(event, InputRequest): + return { + "type": "input_request", + "call_id": event.call_id, + "prompt": event.prompt, + "password": event.password, + } + + # Jupyter keeps binary MIME payloads in message buffers. Base64 makes + # those buffers safe to carry in the same NDJSON stream as text events. + return { + "type": "kernel_message", + "msg_type": event.msg_type, + "parent_id": event.parent_id, + "content": event.content, + "buffers": [base64.b64encode(buffer).decode("ascii") for buffer in event.buffers], + } + + +async def _stream_events(code: str, shell_name: str) -> AsyncIterator[str]: + """Encode one execution's events as newline-delimited JSON.""" + async for event in shell_app.run_code_stream(code, shell_name): + yield json.dumps(serialize_event(event), ensure_ascii=False) + "\n" + + +@app.post("/shells/{shell_name}/run") +async def run_code( + shell_name: Annotated[ + str, + Path(..., description="The Jupyter shell to run code in"), + ], + code: Annotated[ + str, + Body(..., description="The code to execute", media_type="text/plain"), + ], +) -> StreamingResponse: + """Run code and stream each Jupyter event as one JSON line.""" + return StreamingResponse( + _stream_events(code, shell_name), + media_type="application/x-ndjson", + ) + + +@app.post("/calls/{call_id}/input", status_code=204) +async def reply_to_input(call_id: str, reply: InputReply) -> Response: + """Answer an input request belonging to a currently running call.""" + try: + await shell_app.reply_to_input(call_id, reply.value) + except KeyError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + except RuntimeError as error: + raise HTTPException(status_code=409, detail=str(error)) from error + return Response(status_code=204) + + +def main() -> None: + """Run the Jupyter-backed HTTP API with Uvicorn.""" + import uvicorn + + uvicorn.run(app, host="::", port=8001, log_level="info") + + +if __name__ == "__main__": + main() diff --git a/tests/test_jupyter_app.py b/tests/test_jupyter_app.py index f54f2d9..5379d3c 100644 --- a/tests/test_jupyter_app.py +++ b/tests/test_jupyter_app.py @@ -1,9 +1,9 @@ import asyncio import unittest -from ipython_shell.jupyter_app import JupyterApp -from ipython_shell.jupyter_shell import JupyterShell -from ipython_shell.transport import InputRequest, KernelMessage +from ipython_shell.jupyter.app import JupyterApp +from ipython_shell.jupyter.shell import JupyterShell +from ipython_shell.jupyter.transport import InputRequest, KernelMessage class JupyterShellTests(unittest.IsolatedAsyncioTestCase): @@ -48,6 +48,52 @@ class JupyterShellTests(unittest.IsolatedAsyncioTestCase): class JupyterAppTests(unittest.IsolatedAsyncioTestCase): + async def test_named_shells_have_isolated_namespaces(self): + app = JupyterApp() + try: + _alpha_setup = [ + message + async for message in app.run_code_stream( + "alpha_only = 'alpha'", + shell_name="alpha", + ) + ] + beta_view = [ + message + async for message in app.run_code_stream( + "'alpha_only' in globals()", + shell_name="beta", + ) + ] + alpha_view = [ + message + async for message in app.run_code_stream( + "alpha_only", + shell_name="alpha", + ) + ] + finally: + await app.shutdown() + + self.assertEqual( + next( + message.content["data"]["text/plain"] + for message in beta_view + if isinstance(message, KernelMessage) + and message.msg_type == "execute_result" + ), + "False", + ) + self.assertEqual( + next( + message.content["data"]["text/plain"] + for message in alpha_view + if isinstance(message, KernelMessage) + and message.msg_type == "execute_result" + ), + "'alpha'", + ) + async def test_app_routes_input_reply_to_the_call(self): app = JupyterApp() stream = app.run_code_stream( diff --git a/tests/test_jupyter_webapp.py b/tests/test_jupyter_webapp.py new file mode 100644 index 0000000..5d4520d --- /dev/null +++ b/tests/test_jupyter_webapp.py @@ -0,0 +1,61 @@ +import json +import unittest + +import httpx + +from ipython_webapp.jupyter.app import ( + InputReply, + app, + reply_to_input, + run_code, + shell_app, +) + + +class JupyterWebAppTests(unittest.IsolatedAsyncioTestCase): + async def test_run_endpoint_streams_json_events_with_mime_data(self): + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + try: + response = await client.post( + "/shells/new/run", + content="{'answer': 42}", + headers={"content-type": "text/plain"}, + ) + finally: + await shell_app.shutdown() + + self.assertEqual(response.status_code, 200) + self.assertTrue( + response.headers["content-type"].startswith("application/x-ndjson") + ) + events = [json.loads(line) for line in response.text.splitlines()] + result = next(event for event in events if event["msg_type"] == "execute_result") + self.assertEqual(result["content"]["data"]["text/plain"], "{'answer': 42}") + + async def test_input_request_can_be_replied_to_while_run_streams(self): + try: + response = await run_code("new", "answer = input('name? '); answer") + self.assertEqual(response.media_type, "application/x-ndjson") + events = [] + async for line in response.body_iterator: + event = json.loads(line) + events.append(event) + if event["type"] == "input_request": + reply = await reply_to_input( + event["call_id"], + InputReply(value="Ada"), + ) + self.assertEqual(reply.status_code, 204) + finally: + await shell_app.shutdown() + + result = next(event for event in events if event.get("msg_type") == "execute_result") + self.assertEqual(result["content"]["data"]["text/plain"], "'Ada'") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_transport.py b/tests/test_transport.py index 78d1b23..64ce8e8 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1,7 +1,7 @@ import asyncio import unittest -from ipython_shell.transport import InputRequest, JupyterTransport, KernelMessage +from ipython_shell.jupyter.transport import InputRequest, JupyterTransport, KernelMessage class TransportTests(unittest.TestCase): From 56e1b96077ea3d7742201c4dfade28ba1b75deb6 Mon Sep 17 00:00:00 2001 From: lda Date: Sun, 30 Aug 2026 19:35:27 +0700 Subject: [PATCH 07/12] test: cover Jupyter MIME output paths --- src/ipython_webapp/jupyter/app.py | 4 ++-- tests/test_jupyter_webapp.py | 14 ++++++++++++++ tests/test_transport.py | 21 +++++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/ipython_webapp/jupyter/app.py b/src/ipython_webapp/jupyter/app.py index a0c07d4..09a8d9b 100644 --- a/src/ipython_webapp/jupyter/app.py +++ b/src/ipython_webapp/jupyter/app.py @@ -29,8 +29,8 @@ def serialize_event(event: KernelMessage | InputRequest) -> dict[str, object]: "password": event.password, } - # Jupyter keeps binary MIME payloads in message buffers. Base64 makes - # those buffers safe to carry in the same NDJSON stream as text events. + # Some Jupyter messages keep binary payloads in message buffers. Base64 + # makes those buffers safe to carry beside JSON MIME data in the stream. return { "type": "kernel_message", "msg_type": event.msg_type, diff --git a/tests/test_jupyter_webapp.py b/tests/test_jupyter_webapp.py index 5d4520d..927a140 100644 --- a/tests/test_jupyter_webapp.py +++ b/tests/test_jupyter_webapp.py @@ -3,16 +3,30 @@ import unittest import httpx +from ipython_shell.jupyter.transport import KernelMessage from ipython_webapp.jupyter.app import ( InputReply, app, reply_to_input, run_code, + serialize_event, shell_app, ) class JupyterWebAppTests(unittest.IsolatedAsyncioTestCase): + def test_serialize_event_encodes_binary_buffers(self): + event = KernelMessage( + msg_type="display_data", + parent_id="call-1", + content={"data": {"application/octet-stream": "present"}}, + buffers=[b"\x00\xff"], + ) + + serialized = serialize_event(event) + + self.assertEqual(serialized["buffers"], ["AP8="]) + async def test_run_endpoint_streams_json_events_with_mime_data(self): transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient( diff --git a/tests/test_transport.py b/tests/test_transport.py index 64ce8e8..c2494e3 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -67,6 +67,27 @@ class AsyncTransportTests(unittest.IsolatedAsyncioTestCase): ) self.assertEqual(error.content["ename"], "ValueError") + async def test_transport_preserves_matplotlib_mime_output(self): + transport = JupyterTransport() + await transport.start() + try: + call_id = await transport.execute( + "import matplotlib.pyplot as plt\n" + "plt.plot([1, 2, 3], [4, 5, 6])" + ) + messages = [ + message async for message in transport.messages_for(call_id) + ] + finally: + await transport.shutdown() + + display = next( + message + for message in messages + if message.msg_type == "display_data" + ) + self.assertIn("image/png", display.content["data"]) + async def test_transport_routes_input_reply_without_parent_stdin(self): transport = JupyterTransport() await transport.start() From b48443cdc37dd3c5f24762aac4d6b1fcdd939385 Mon Sep 17 00:00:00 2001 From: lda Date: Sun, 30 Aug 2026 21:20:37 +0700 Subject: [PATCH 08/12] fmt --- src/ipython_shell/__init__.py | 16 ++++++++-------- src/ipython_shell/jupyter/app.py | 2 +- src/ipython_shell/jupyter/transport.py | 4 +++- src/ipython_webapp/jupyter/app.py | 4 +++- tests/test_jupyter_webapp.py | 8 ++++++-- tests/test_transport.py | 21 +++++++++------------ 6 files changed, 30 insertions(+), 25 deletions(-) diff --git a/src/ipython_shell/__init__.py b/src/ipython_shell/__init__.py index 5b9c675..26bdcb2 100644 --- a/src/ipython_shell/__init__.py +++ b/src/ipython_shell/__init__.py @@ -1,13 +1,14 @@ """Small, side-effect-free API for the in-process IPython runner.""" from .app import App -from .jupyter.app import JupyterApp -from .jupyter.shell import JupyterShell from .events import ( error_from_execution_result, event_from_execution_result, event_from_history_output, ) +from .jupyter.app import JupyterApp +from .jupyter.shell import JupyterShell +from .jupyter.transport import InputRequest, JupyterTransport, KernelMessage from .models import ( AppInfo, CallError, @@ -28,16 +29,10 @@ from .shell import ( run_cell_and_collect, setup_shell, ) -from .jupyter.transport import InputRequest, JupyterTransport, KernelMessage from .utils import generate_good_names __all__ = [ "App", - "JupyterApp", - "JupyterShell", - "JupyterTransport", - "InputRequest", - "KernelMessage", "AppInfo", "CallError", "CallEvent", @@ -46,7 +41,12 @@ __all__ = [ "CallResponse", "CallResult", "CallResultResponse", + "InputRequest", "JSONValue", + "JupyterApp", + "JupyterShell", + "JupyterTransport", + "KernelMessage", "Shell", "ShellInfo", "ShellStatus", diff --git a/src/ipython_shell/jupyter/app.py b/src/ipython_shell/jupyter/app.py index 9f0471a..a0f20d8 100644 --- a/src/ipython_shell/jupyter/app.py +++ b/src/ipython_shell/jupyter/app.py @@ -1,9 +1,9 @@ from collections.abc import AsyncIterator from uuid import uuid4 +from ..utils import generate_good_names from .shell import JupyterShell from .transport import InputRequest, KernelMessage -from ..utils import generate_good_names class JupyterApp: diff --git a/src/ipython_shell/jupyter/transport.py b/src/ipython_shell/jupyter/transport.py index 3745479..320b332 100644 --- a/src/ipython_shell/jupyter/transport.py +++ b/src/ipython_shell/jupyter/transport.py @@ -97,7 +97,9 @@ class JupyterTransport: msg_type=message["msg_type"], parent_id=parent_id, content=dict(message.get("content", {})), - buffers=[bytes(buffer) for buffer in message.get("buffers", [])], + buffers=[ + bytes(buffer) for buffer in message.get("buffers", []) + ], ) if decoded.msg_type == "execute_reply": # The shell reply and IOPub messages use different diff --git a/src/ipython_webapp/jupyter/app.py b/src/ipython_webapp/jupyter/app.py index 09a8d9b..795f343 100644 --- a/src/ipython_webapp/jupyter/app.py +++ b/src/ipython_webapp/jupyter/app.py @@ -36,7 +36,9 @@ def serialize_event(event: KernelMessage | InputRequest) -> dict[str, object]: "msg_type": event.msg_type, "parent_id": event.parent_id, "content": event.content, - "buffers": [base64.b64encode(buffer).decode("ascii") for buffer in event.buffers], + "buffers": [ + base64.b64encode(buffer).decode("ascii") for buffer in event.buffers + ], } diff --git a/tests/test_jupyter_webapp.py b/tests/test_jupyter_webapp.py index 927a140..5cf0575 100644 --- a/tests/test_jupyter_webapp.py +++ b/tests/test_jupyter_webapp.py @@ -47,7 +47,9 @@ class JupyterWebAppTests(unittest.IsolatedAsyncioTestCase): response.headers["content-type"].startswith("application/x-ndjson") ) events = [json.loads(line) for line in response.text.splitlines()] - result = next(event for event in events if event["msg_type"] == "execute_result") + result = next( + event for event in events if event["msg_type"] == "execute_result" + ) self.assertEqual(result["content"]["data"]["text/plain"], "{'answer': 42}") async def test_input_request_can_be_replied_to_while_run_streams(self): @@ -67,7 +69,9 @@ class JupyterWebAppTests(unittest.IsolatedAsyncioTestCase): finally: await shell_app.shutdown() - result = next(event for event in events if event.get("msg_type") == "execute_result") + result = next( + event for event in events if event.get("msg_type") == "execute_result" + ) self.assertEqual(result["content"]["data"]["text/plain"], "'Ada'") diff --git a/tests/test_transport.py b/tests/test_transport.py index c2494e3..5939bc2 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1,7 +1,11 @@ import asyncio import unittest -from ipython_shell.jupyter.transport import InputRequest, JupyterTransport, KernelMessage +from ipython_shell.jupyter.transport import ( + InputRequest, + JupyterTransport, + KernelMessage, +) class TransportTests(unittest.TestCase): @@ -24,9 +28,7 @@ class AsyncTransportTests(unittest.IsolatedAsyncioTestCase): await transport.start() try: call_id = await transport.execute("2 + 2") - messages = [ - message async for message in transport.messages_for(call_id) - ] + messages = [message async for message in transport.messages_for(call_id)] finally: await transport.shutdown() @@ -72,19 +74,14 @@ class AsyncTransportTests(unittest.IsolatedAsyncioTestCase): await transport.start() try: call_id = await transport.execute( - "import matplotlib.pyplot as plt\n" - "plt.plot([1, 2, 3], [4, 5, 6])" + "import matplotlib.pyplot as plt\nplt.plot([1, 2, 3], [4, 5, 6])" ) - messages = [ - message async for message in transport.messages_for(call_id) - ] + messages = [message async for message in transport.messages_for(call_id)] finally: await transport.shutdown() display = next( - message - for message in messages - if message.msg_type == "display_data" + message for message in messages if message.msg_type == "display_data" ) self.assertIn("image/png", display.content["data"]) From e2958ea91adab73d5c2d6b3a687c7d683d54e7ed Mon Sep 17 00:00:00 2001 From: lda Date: Sun, 30 Aug 2026 21:43:51 +0700 Subject: [PATCH 09/12] fix: keep Jupyter channel readers alive --- src/ipython_shell/jupyter/transport.py | 145 ++++++++++++++++--------- tests/test_transport.py | 20 ++++ 2 files changed, 116 insertions(+), 49 deletions(-) diff --git a/src/ipython_shell/jupyter/transport.py b/src/ipython_shell/jupyter/transport.py index 320b332..fa3f809 100644 --- a/src/ipython_shell/jupyter/transport.py +++ b/src/ipython_shell/jupyter/transport.py @@ -1,6 +1,7 @@ import asyncio -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Awaitable, Callable from dataclasses import dataclass +from typing import Any from jupyter_client import AsyncKernelManager @@ -33,14 +34,65 @@ class JupyterTransport: self._active_call: str | None = None self._call_lock = asyncio.Lock() self._waiting_for_input: str | None = None + self._message_queue: asyncio.Queue[ + tuple[str, dict[str, Any] | BaseException] + ] | None = None + self._reader_tasks: set[asyncio.Task[None]] = set() async def start(self) -> None: - """Start the kernel process and wait until it accepts requests.""" + """Start the kernel and its persistent channel readers.""" + if self.client is not None: + return + await self.manager.start_kernel() self.client = self.manager.client() self.client.start_channels() await self.client.wait_for_ready() + self._message_queue = asyncio.Queue() + self._reader_tasks = { + asyncio.create_task( + self._read_channel("iopub", self.client.get_iopub_msg), + name="jupyter-iopub-reader", + ), + asyncio.create_task( + self._read_channel("shell", self.client.get_shell_msg), + name="jupyter-shell-reader", + ), + asyncio.create_task( + self._read_channel("stdin", self.client.get_stdin_msg), + name="jupyter-stdin-reader", + ), + } + + async def _read_channel( + self, + channel: str, + get_message: Callable[[], Awaitable[dict[str, Any]]], + ) -> None: + """Read one ZMQ channel continuously into the transport queue.""" + if self._message_queue is None: + raise RuntimeError("JupyterTransport has not been started") + + try: + while True: + message = await get_message() + await self._message_queue.put((channel, message)) + except asyncio.CancelledError: + raise + except BaseException as error: + # Surface reader failures instead of leaving messages_for() stuck. + await self._message_queue.put((channel, error)) + + async def _stop_readers(self) -> None: + """Cancel channel readers once, during transport shutdown.""" + readers = self._reader_tasks + self._reader_tasks = set() + for reader in readers: + reader.cancel() + await asyncio.gather(*readers, return_exceptions=True) + self._message_queue = None + async def execute(self, code: str) -> str: """Submit one cell and return its Jupyter message ID.""" if self.client is None: @@ -60,59 +112,53 @@ class JupyterTransport: raise RuntimeError("JupyterTransport has not been started") if self._active_call != call_id: raise RuntimeError(f"Unknown or inactive call: {call_id}") + if self._message_queue is None: + raise RuntimeError("JupyterTransport message readers are not running") reply: KernelMessage | None = None idle = False try: while True: - iopub_task = asyncio.create_task(self.client.get_iopub_msg()) - shell_task = asyncio.create_task(self.client.get_shell_msg()) - stdin_task = asyncio.create_task(self.client.get_stdin_msg()) - done, pending = await asyncio.wait( - {iopub_task, shell_task, stdin_task}, - return_when=asyncio.FIRST_COMPLETED, - ) - for task in pending: - task.cancel() - await asyncio.gather(*pending, return_exceptions=True) + _, queued_message = await self._message_queue.get() + if isinstance(queued_message, BaseException): + raise queued_message - for task in done: - message = task.result() - parent_id = message.get("parent_header", {}).get("msg_id") - if parent_id != call_id: - continue + message = queued_message + parent_id = message.get("parent_header", {}).get("msg_id") + if parent_id != call_id: + continue - if message["msg_type"] == "input_request": - self._waiting_for_input = call_id - yield InputRequest( - call_id=call_id, - prompt=str(message.get("content", {}).get("prompt", "")), - password=bool( - message.get("content", {}).get("password", False) - ), - ) - continue - - decoded = KernelMessage( - msg_type=message["msg_type"], - parent_id=parent_id, - content=dict(message.get("content", {})), - buffers=[ - bytes(buffer) for buffer in message.get("buffers", []) - ], + if message["msg_type"] == "input_request": + self._waiting_for_input = call_id + yield InputRequest( + call_id=call_id, + prompt=str(message.get("content", {}).get("prompt", "")), + password=bool( + message.get("content", {}).get("password", False) + ), ) - if decoded.msg_type == "execute_reply": - # The shell reply and IOPub messages use different - # channels. Buffer the reply so it remains terminal - # even when both channels become ready together. - reply = decoded - else: - yield decoded - if ( - decoded.msg_type == "status" - and decoded.content.get("execution_state") == "idle" - ): - idle = True + continue + + decoded = KernelMessage( + msg_type=message["msg_type"], + parent_id=parent_id, + content=dict(message.get("content", {})), + buffers=[ + bytes(buffer) for buffer in message.get("buffers", []) + ], + ) + if decoded.msg_type == "execute_reply": + # The shell reply and IOPub messages use different + # channels. Buffer the reply so it remains terminal + # even when both channels arrive close together. + reply = decoded + else: + yield decoded + if ( + decoded.msg_type == "status" + and decoded.content.get("execution_state") == "idle" + ): + idle = True if reply is not None and idle: yield reply @@ -124,7 +170,7 @@ class JupyterTransport: self._active_call = None async def reply_to_input(self, value: str) -> None: - """Reply to the active kernel input request without using process stdin.""" + """Reply to the active kernel input request without process stdin.""" if self.client is None: raise RuntimeError("JupyterTransport has not been started") if self._waiting_for_input is None: @@ -134,7 +180,8 @@ class JupyterTransport: self._waiting_for_input = None async def shutdown(self) -> None: - """Stop channels and terminate the kernel process.""" + """Stop readers, channels, and the kernel process.""" + await self._stop_readers() if self.client is not None: self.client.stop_channels() self.client = None diff --git a/tests/test_transport.py b/tests/test_transport.py index 5939bc2..586fc88 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -23,6 +23,26 @@ class TransportTests(unittest.TestCase): class AsyncTransportTests(unittest.IsolatedAsyncioTestCase): + async def test_transport_keeps_channel_readers_alive_during_a_call(self): + transport = JupyterTransport() + await transport.start() + try: + readers = set(transport._reader_tasks) + call_id = await transport.execute("2 + 2") + messages = [ + message async for message in transport.messages_for(call_id) + ] + + self.assertEqual(len(readers), 3) + self.assertEqual(transport._reader_tasks, readers) + self.assertTrue(all(not reader.done() for reader in readers)) + finally: + await transport.shutdown() + + self.assertTrue( + any(message.msg_type == "execute_result" for message in messages) + ) + async def test_transport_executes_and_returns_execute_reply(self): transport = JupyterTransport() await transport.start() From 0b87620852b8ecadf476e7c0f09388fe16962bc2 Mon Sep 17 00:00:00 2001 From: lda Date: Sun, 30 Aug 2026 22:31:12 +0700 Subject: [PATCH 10/12] fmt, stuff --- CONTEXT.md | 29 ++++++++++++++++++++++++++ pyproject.toml | 2 +- src/ipython_shell/jupyter/transport.py | 14 ++++++------- src/ipython_shell/utils.py | 21 +++++++++++++++++++ src/ipython_webapp/__init__.py | 25 ++++++++++++++++++++++ src/ipython_webapp/jupyter/app.py | 5 ++++- tests/test_transport.py | 4 +--- 7 files changed, 87 insertions(+), 13 deletions(-) create mode 100644 CONTEXT.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..956514b --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,29 @@ +# IPython Shell Execution + +This project exposes persistent Python environments through web and model-facing adapters. The glossary keeps the long-lived execution environment distinct from each individual code execution. + +## Execution + +**Shell**: +A persistent, isolated Python environment that retains its namespace and execution state across calls. +_Avoid_: Session, request, call + +**Call**: +One request to execute code in one shell. A call has its own identity and lifecycle, can emit ordered events, can pause for input, and eventually produces a result or error. +_Avoid_: Shell, session, task + +**Call ID**: +The application-facing identity of one call, used to associate its events, input requests, and final result. Any execution ID used internally by a transport is not part of this identity. +_Avoid_: Execution count, kernel ID + +**Call Event**: +An ordered observable part of a call, such as standard output, standard error, display data, an input request, or an execution error. +_Avoid_: Log line, transport message + +**App**: +The owner of the available shells and calls. It resolves shell selectors such as `new`, `last`, or a named shell and routes each call to its target shell. +_Avoid_: Server, session + +**Shell Name**: +A user-facing selector for a shell. `new` requests a fresh shell, `last` selects the most recently selected shell, and any other name identifies a reusable named shell. +_Avoid_: Shell ID, kernel name diff --git a/pyproject.toml b/pyproject.toml index 09cb244..e62dab2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ [project.scripts] ipython-demo = "ipython_shell.app:main" ipython-shell = "ipython_shell.app:main" -ipython-webapp = "ipython_webapp.app:main" +ipython-webapp = "ipython_webapp:main" ipython-jupyter-webapp = "ipython_webapp.jupyter.app:main" ipython-mcp = "ipython_mcp.app:main" diff --git a/src/ipython_shell/jupyter/transport.py b/src/ipython_shell/jupyter/transport.py index fa3f809..891b673 100644 --- a/src/ipython_shell/jupyter/transport.py +++ b/src/ipython_shell/jupyter/transport.py @@ -3,7 +3,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable from dataclasses import dataclass from typing import Any -from jupyter_client import AsyncKernelManager +from jupyter_client.manager import AsyncKernelManager @dataclass @@ -34,9 +34,9 @@ class JupyterTransport: self._active_call: str | None = None self._call_lock = asyncio.Lock() self._waiting_for_input: str | None = None - self._message_queue: asyncio.Queue[ - tuple[str, dict[str, Any] | BaseException] - ] | None = None + self._message_queue: ( + asyncio.Queue[tuple[str, dict[str, Any] | BaseException]] | None + ) = None self._reader_tasks: set[asyncio.Task[None]] = set() async def start(self) -> None: @@ -68,7 +68,7 @@ class JupyterTransport: async def _read_channel( self, channel: str, - get_message: Callable[[], Awaitable[dict[str, Any]]], + get_message: Callable[..., Awaitable[dict[str, Any]]], ) -> None: """Read one ZMQ channel continuously into the transport queue.""" if self._message_queue is None: @@ -143,9 +143,7 @@ class JupyterTransport: msg_type=message["msg_type"], parent_id=parent_id, content=dict(message.get("content", {})), - buffers=[ - bytes(buffer) for buffer in message.get("buffers", []) - ], + buffers=[bytes(buffer) for buffer in message.get("buffers", [])], ) if decoded.msg_type == "execute_reply": # The shell reply and IOPub messages use different diff --git a/src/ipython_shell/utils.py b/src/ipython_shell/utils.py index 3ee3dbc..a54b9a8 100644 --- a/src/ipython_shell/utils.py +++ b/src/ipython_shell/utils.py @@ -1,4 +1,7 @@ import inspect +import secrets +from collections.abc import Sequence +from typing import Literal from uuid import uuid4 from wonderwords import RandomWord @@ -22,3 +25,21 @@ def generate_good_names() -> str: adjective = _WORD_GENERATOR.word(include_categories=["adjectives"]) noun = _WORD_GENERATOR.word(include_categories=["nouns"]) return f"{adjective}-{noun}-{uuid4().hex[:4]}" + + +type WordCategory = Literal["adjectives", "nouns", "verbs"] +type WordCategory1 = Literal["adjective", "noun", "verb"] + + +def generate_omfg_names(uhh: Sequence[WordCategory | WordCategory1 | int]) -> str: + """Generate a readable, collision-resistant shell name. + + Example: generate_omfg_names(["adjectives", "nouns", 4]) -> "happy-dog-1a2b" + """ + words: list[str] = [] + for category in uhh: + if isinstance(category, int): + words.append(secrets.token_hex((category + 1) // 2)[:category]) # what? + else: + words.append(_WORD_GENERATOR.word(include_categories=[category])) + return "-".join(words) diff --git a/src/ipython_webapp/__init__.py b/src/ipython_webapp/__init__.py index e69de29..1e1c283 100644 --- a/src/ipython_webapp/__init__.py +++ b/src/ipython_webapp/__init__.py @@ -0,0 +1,25 @@ +import argparse + + +def main() -> None: + from .app import app as interactive_shell_app + from .jupyter import app as jupyter_app + + parser = argparse.ArgumentParser(description="Run the Jupyter shell web app.") + + parser.add_argument( + "--jupyter", + action="store_true", + help="Run the Jupyter shell web app.", + ) + args = parser.parse_args() + import uvicorn + + if args.jupyter: + uvicorn.run(jupyter_app, host="::", port=8000, log_level="info") + else: + uvicorn.run(interactive_shell_app, host="::", port=8000, log_level="info") + + +if __name__ == "__main__": + main() diff --git a/src/ipython_webapp/jupyter/app.py b/src/ipython_webapp/jupyter/app.py index 795f343..96aad78 100644 --- a/src/ipython_webapp/jupyter/app.py +++ b/src/ipython_webapp/jupyter/app.py @@ -52,7 +52,10 @@ async def _stream_events(code: str, shell_name: str) -> AsyncIterator[str]: async def run_code( shell_name: Annotated[ str, - Path(..., description="The Jupyter shell to run code in"), + Path( + ..., + description="The Jupyter shell to run code in. Existing shell guess. 'new' creates a new shell, 'last' uses the most recent shell.", + ), ], code: Annotated[ str, diff --git a/tests/test_transport.py b/tests/test_transport.py index 586fc88..cc2a540 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -29,9 +29,7 @@ class AsyncTransportTests(unittest.IsolatedAsyncioTestCase): try: readers = set(transport._reader_tasks) call_id = await transport.execute("2 + 2") - messages = [ - message async for message in transport.messages_for(call_id) - ] + messages = [message async for message in transport.messages_for(call_id)] self.assertEqual(len(readers), 3) self.assertEqual(transport._reader_tasks, readers) From 6a8546cb0f9438398cba223bb060f02d71bf8778 Mon Sep 17 00:00:00 2001 From: lda Date: Sun, 30 Aug 2026 22:49:34 +0700 Subject: [PATCH 11/12] shared shell registry --- src/ipython_shell/__init__.py | 6 ++- src/ipython_shell/app.py | 60 ++++++++++----------- src/ipython_shell/jupyter/app.py | 84 ++++++++++++++++++++---------- src/ipython_shell/jupyter/shell.py | 46 +++++++++++++++- src/ipython_shell/registry.py | 66 +++++++++++++++++++++++ src/ipython_shell/utils.py | 35 +++++++++++-- tests/test_app.py | 8 +++ tests/test_jupyter_app.py | 25 ++++++++- 8 files changed, 262 insertions(+), 68 deletions(-) create mode 100644 src/ipython_shell/registry.py diff --git a/src/ipython_shell/__init__.py b/src/ipython_shell/__init__.py index 26bdcb2..8d666b4 100644 --- a/src/ipython_shell/__init__.py +++ b/src/ipython_shell/__init__.py @@ -22,6 +22,7 @@ from .models import ( ShellInfo, ShellStatus, ) +from .registry import ShellRegistry from .shell import ( Shell, carefully_setup_shell, @@ -29,7 +30,7 @@ from .shell import ( run_cell_and_collect, setup_shell, ) -from .utils import generate_good_names +from .utils import generate_call_id, generate_good_names, generate_omfg_names __all__ = [ "App", @@ -49,12 +50,15 @@ __all__ = [ "KernelMessage", "Shell", "ShellInfo", + "ShellRegistry", "ShellStatus", "carefully_setup_shell", "error_from_execution_result", "event_from_execution_result", "event_from_history_output", + "generate_call_id", "generate_good_names", + "generate_omfg_names", "isolate_output_history", "run_cell_and_collect", "setup_shell", diff --git a/src/ipython_shell/app.py b/src/ipython_shell/app.py index 26a383c..3801fc0 100644 --- a/src/ipython_shell/app.py +++ b/src/ipython_shell/app.py @@ -7,9 +7,10 @@ from time import monotonic from typing import Literal from uuid import uuid4 -from ipython_shell.utils import generate_good_names +from ipython_shell.utils import generate_call_id from .models import AppInfo, CallOptions, CallResponse, ShellInfo +from .registry import ShellRegistry from .shell import Shell @@ -21,52 +22,45 @@ class App: self.created_at = datetime.now(UTC).isoformat() self._started_at = monotonic() self._total_calls = 0 - self.shells: dict[str, Shell] = {} - self.last_shell: str | None = None + self._shell_registry = ShellRegistry(Shell) + self._issued_call_ids: set[str] = set() - def _new_shell(self) -> tuple[str, Shell]: - name = generate_good_names() - while name in self.shells: - name = generate_good_names() - shell = Shell(name) - self.shells[name] = shell - self.last_shell = name - return name, shell + @property + def shells(self) -> dict[str, Shell]: + """Expose the managed shells for compatibility with the old API.""" + return self._shell_registry.shells + + @property + def last_shell(self) -> str | None: + """Return the name of the most recently selected shell.""" + return self._shell_registry.last_shell def _select_shell(self, shell_name: str) -> tuple[str, Shell]: - if shell_name == "new": - return self._new_shell() - - if shell_name == "last": - if self.last_shell is None: - return self._new_shell() - return self.last_shell, self.shells[self.last_shell] - - if shell_name not in self.shells: - self.shells[shell_name] = Shell(shell_name) - self.last_shell = shell_name - return shell_name, self.shells[shell_name] + return self._shell_registry.select(shell_name) def list_shells(self) -> list[ShellInfo]: """Return frontend-safe metadata for all known shells.""" - return [shell.describe() for shell in self.shells.values()] + return self._shell_registry.list_shells() def create_shell(self) -> ShellInfo: """Create a fresh named shell and return its metadata.""" - _, shell = self._new_shell() - return shell.describe() + return self._shell_registry.create_shell() def get_shell(self, shell_name: str) -> ShellInfo: """Return frontend-safe metadata for a single shell.""" - if shell_name not in self.shells: - raise KeyError(f"Shell {shell_name} does not exist") - return self.shells[shell_name].describe() + return self._shell_registry.get_shell(shell_name) def get_last_shell(self) -> ShellInfo: """Return metadata for the most recently selected shell.""" - if self.last_shell is None: - raise KeyError("No shell exists yet") - return self.get_shell(self.last_shell) + return self._shell_registry.get_last_shell() + + def _new_call_id(self) -> str: + """Generate a readable call ID that is unique for this app.""" + call_id = generate_call_id() + while call_id in self._issued_call_ids: + call_id = generate_call_id() + self._issued_call_ids.add(call_id) + return call_id def info(self) -> AppInfo: """Return runtime metadata without initializing or inspecting shells.""" @@ -96,7 +90,7 @@ class App: if options is None: options = CallOptions() selected_name, shell = self._select_shell(shell_name) - call_id = uuid4().hex + call_id = self._new_call_id() self._total_calls += 1 execution, events = shell.run_cell(code, call_id=call_id) return CallResponse( diff --git a/src/ipython_shell/jupyter/app.py b/src/ipython_shell/jupyter/app.py index a0f20d8..ad888f4 100644 --- a/src/ipython_shell/jupyter/app.py +++ b/src/ipython_shell/jupyter/app.py @@ -1,7 +1,11 @@ from collections.abc import AsyncIterator +from datetime import UTC, datetime +from time import monotonic from uuid import uuid4 -from ..utils import generate_good_names +from ..models import AppInfo, ShellInfo +from ..registry import ShellRegistry +from ..utils import generate_call_id from .shell import JupyterShell from .transport import InputRequest, KernelMessage @@ -10,36 +14,61 @@ class JupyterApp: """Coordinate named persistent shells backed by Jupyter kernels.""" def __init__(self) -> None: - self.shells: dict[str, JupyterShell] = {} - self.last_shell: str | None = None + self.app_id = uuid4().hex + self.created_at = datetime.now(UTC).isoformat() + self._started_at = monotonic() + self._total_calls = 0 + self._shell_registry = ShellRegistry(JupyterShell) self._calls: dict[str, JupyterShell] = {} + self._issued_call_ids: set[str] = set() - def _new_shell(self) -> tuple[str, JupyterShell]: - name = generate_good_names() - while name in self.shells: - name = generate_good_names() - shell = JupyterShell(name) - self.shells[name] = shell - self.last_shell = name - return name, shell + @property + def shells(self) -> dict[str, JupyterShell]: + """Expose the managed shells for compatibility with the sync app.""" + return self._shell_registry.shells + + @property + def last_shell(self) -> str | None: + """Return the name of the most recently selected shell.""" + return self._shell_registry.last_shell def _select_shell(self, shell_name: str) -> tuple[str, JupyterShell]: - if shell_name == "new": - return self._new_shell() - if shell_name == "last": - if self.last_shell is None: - return self._new_shell() - return self.last_shell, self.shells[self.last_shell] - if shell_name not in self.shells: - self.shells[shell_name] = JupyterShell(shell_name) - self.last_shell = shell_name - return shell_name, self.shells[shell_name] + return self._shell_registry.select(shell_name) - def get_shell(self, shell_name: str) -> JupyterShell: - """Return a named Jupyter shell.""" - if shell_name not in self.shells: - raise KeyError(f"Shell {shell_name} does not exist") - return self.shells[shell_name] + def list_shells(self) -> list[ShellInfo]: + """Return metadata for every known Jupyter shell.""" + return self._shell_registry.list_shells() + + def create_shell(self) -> ShellInfo: + """Create a fresh Jupyter shell and return its metadata.""" + return self._shell_registry.create_shell() + + def get_shell(self, shell_name: str) -> ShellInfo: + """Return frontend-safe metadata for one Jupyter shell.""" + return self._shell_registry.get_shell(shell_name) + + def get_last_shell(self) -> ShellInfo: + """Return metadata for the most recently selected Jupyter shell.""" + return self._shell_registry.get_last_shell() + + def info(self) -> AppInfo: + """Return runtime metadata without starting any kernels.""" + return AppInfo( + app_id=self.app_id, + created_at=self.created_at, + uptime_seconds=monotonic() - self._started_at, + shell_count=len(self.shells), + last_shell=self.last_shell, + total_calls=self._total_calls, + ) + + def _new_call_id(self) -> str: + """Generate a readable call ID that is unique for this app.""" + call_id = generate_call_id() + while call_id in self._issued_call_ids: + call_id = generate_call_id() + self._issued_call_ids.add(call_id) + return call_id async def run_code_stream( self, @@ -48,7 +77,8 @@ class JupyterApp: ) -> AsyncIterator[KernelMessage | InputRequest]: """Execute code and yield its subprocess messages.""" _, shell = self._select_shell(shell_name) - call_id = uuid4().hex + call_id = self._new_call_id() + self._total_calls += 1 self._calls[call_id] = shell try: async for message in shell.run_cell_stream(code, call_id=call_id): diff --git a/src/ipython_shell/jupyter/shell.py b/src/ipython_shell/jupyter/shell.py index ac2a100..20cc4f9 100644 --- a/src/ipython_shell/jupyter/shell.py +++ b/src/ipython_shell/jupyter/shell.py @@ -1,6 +1,9 @@ from collections.abc import AsyncIterator from dataclasses import replace +from datetime import UTC, datetime +from uuid import uuid4 +from ..models import ShellInfo, ShellStatus from .transport import InputRequest, JupyterTransport, KernelMessage @@ -9,6 +12,11 @@ class JupyterShell: def __init__(self, name: str, transport: JupyterTransport | None = None) -> None: self.name = name + self.shell_id = uuid4().hex + self.created_at = datetime.now(UTC).isoformat() + self.last_used_at: str | None = None + self.status: ShellStatus = "ready" + self._last_execution_count = 0 self.transport = transport or JupyterTransport() self._active_call_id: str | None = None @@ -26,6 +34,7 @@ class JupyterShell: """Yield kernel messages while one cell executes.""" await self.start() self._active_call_id = call_id + self.status = "running" try: kernel_call_id = await self.transport.execute(code) async for message in self.transport.messages_for(kernel_call_id): @@ -33,10 +42,25 @@ class JupyterShell: yield replace(message, call_id=call_id) else: # Keep the kernel's execution ID private to the transport. - yield replace(message, parent_id=call_id) + public_message = replace(message, parent_id=call_id) + if public_message.msg_type == "execute_reply": + self._last_execution_count = int( + public_message.content.get( + "execution_count", self._last_execution_count + ) + ) + self.status = ( + "error" + if public_message.content.get("status") == "error" + else "ready" + ) + self.last_used_at = datetime.now(UTC).isoformat() + yield public_message finally: if self._active_call_id == call_id: self._active_call_id = None + if self.status == "running" and self._active_call_id is None: + self.status = "ready" async def reply_to_input(self, call_id: str, value: str) -> None: """Send input to the cell currently waiting in this shell.""" @@ -47,3 +71,23 @@ class JupyterShell: async def shutdown(self) -> None: """Stop this shell's kernel process.""" await self.transport.shutdown() + + def describe(self) -> ShellInfo: + """Return metadata without starting an unused kernel.""" + return ShellInfo( + shell_id=self.shell_id, + name=self.name, + status=self.status, + execution_count=self._last_execution_count, + created_at=self.created_at, + last_used_at=self.last_used_at, + capabilities=( + "stdout", + "stderr", + "display_data", + "execute_result", + "error", + "matplotlib-inline", + "input", + ), + ) diff --git a/src/ipython_shell/registry.py b/src/ipython_shell/registry.py new file mode 100644 index 0000000..8d2607b --- /dev/null +++ b/src/ipython_shell/registry.py @@ -0,0 +1,66 @@ +from collections.abc import Callable +from typing import Protocol + +from .models import ShellInfo +from .utils import generate_good_names + + +class DescribableShell(Protocol): + """The small shell interface needed by the shared registry.""" + + def describe(self) -> ShellInfo: ... + + +class ShellRegistry[ShellT: DescribableShell]: + """Own named shells and resolve ``new``, ``last``, and explicit names.""" + + def __init__(self, factory: Callable[[str], ShellT]) -> None: + self._factory = factory + self.shells: dict[str, ShellT] = {} + self.last_shell: str | None = None + + def new_shell(self) -> tuple[str, ShellT]: + """Create a fresh generated shell and select it.""" + name = generate_good_names() + while name in self.shells: + name = generate_good_names() + shell = self._factory(name) + self.shells[name] = shell + self.last_shell = name + return name, shell + + def select(self, shell_name: str) -> tuple[str, ShellT]: + """Resolve a selector, creating explicit names when necessary.""" + if shell_name == "new": + return self.new_shell() + if shell_name == "last": + if self.last_shell is None: + return self.new_shell() + return self.last_shell, self.shells[self.last_shell] + if shell_name not in self.shells: + self.shells[shell_name] = self._factory(shell_name) + self.last_shell = shell_name + return shell_name, self.shells[shell_name] + + def list_shells(self) -> list[ShellInfo]: + """Return metadata for every known shell.""" + return [shell.describe() for shell in self.shells.values()] + + def create_shell(self) -> ShellInfo: + """Create a fresh generated shell and return its metadata.""" + _, shell = self.new_shell() + return shell.describe() + + def get_shell(self, shell_name: str) -> ShellInfo: + """Return metadata for one existing shell.""" + try: + shell = self.shells[shell_name] + except KeyError as error: + raise KeyError(f"Shell {shell_name} does not exist") from error + return shell.describe() + + def get_last_shell(self) -> ShellInfo: + """Return metadata for the most recently selected shell.""" + if self.last_shell is None: + raise KeyError("No shell exists yet") + return self.get_shell(self.last_shell) diff --git a/src/ipython_shell/utils.py b/src/ipython_shell/utils.py index a54b9a8..041538a 100644 --- a/src/ipython_shell/utils.py +++ b/src/ipython_shell/utils.py @@ -29,17 +29,42 @@ def generate_good_names() -> str: type WordCategory = Literal["adjectives", "nouns", "verbs"] type WordCategory1 = Literal["adjective", "noun", "verb"] +type WordCategoryShort = Literal["adj", "n", "v"] +type NamePart = WordCategory | WordCategory1 | WordCategoryShort | int + +_CATEGORY_NAMES: dict[ + WordCategory | WordCategory1 | WordCategoryShort, WordCategory +] = { + "adjectives": "adjectives", + "adjective": "adjectives", + "adj": "adjectives", + "nouns": "nouns", + "noun": "nouns", + "n": "nouns", + "verbs": "verbs", + "verb": "verbs", + "v": "verbs", +} -def generate_omfg_names(uhh: Sequence[WordCategory | WordCategory1 | int]) -> str: +def generate_omfg_names(recipe: Sequence[NamePart]) -> str: """Generate a readable, collision-resistant shell name. - Example: generate_omfg_names(["adjectives", "nouns", 4]) -> "happy-dog-1a2b" + Example: generate_omfg_names(["adj", "n", "v", 4]) -> "happy-dog-run-1a2b" """ words: list[str] = [] - for category in uhh: + for category in recipe: if isinstance(category, int): - words.append(secrets.token_hex((category + 1) // 2)[:category]) # what? + if category < 1: + raise ValueError("Hex suffix length must be positive") + words.append(secrets.token_hex((category + 1) // 2)[:category]) else: - words.append(_WORD_GENERATOR.word(include_categories=[category])) + words.append( + _WORD_GENERATOR.word(include_categories=[_CATEGORY_NAMES[category]]) + ) return "-".join(words) + + +def generate_call_id() -> str: + """Generate a readable application-level identifier for one call.""" + return generate_omfg_names(["adj", "n", "v", 4]) diff --git a/tests/test_app.py b/tests/test_app.py index 90379f2..b6a514a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -49,6 +49,14 @@ class AppTests(unittest.TestCase): ], ) + def test_call_ids_are_readable(self): + app = App() + + with contextlib.redirect_stdout(io.StringIO()): + call = app.run_code("1 + 1", shell_name="new") + + self.assertRegex(call.call_id, r"^[a-z]+-[a-z]+-[a-z]+-[0-9a-f]{4}$") + def test_last_reuses_shell_but_each_call_has_new_id(self): app = App() diff --git a/tests/test_jupyter_app.py b/tests/test_jupyter_app.py index 5379d3c..1be3b2a 100644 --- a/tests/test_jupyter_app.py +++ b/tests/test_jupyter_app.py @@ -1,4 +1,5 @@ import asyncio +import re import unittest from ipython_shell.jupyter.app import JupyterApp @@ -48,6 +49,25 @@ class JupyterShellTests(unittest.IsolatedAsyncioTestCase): class JupyterAppTests(unittest.IsolatedAsyncioTestCase): + async def test_app_exposes_shared_shell_metadata_api(self): + app = JupyterApp() + try: + created = app.create_shell() + listed = app.list_shells() + last = app.get_last_shell() + selected = app.get_shell(created.name) + info = app.info() + finally: + await app.shutdown() + + self.assertEqual(len(listed), 1) + self.assertEqual(created.shell_id, listed[0].shell_id) + self.assertEqual(created.shell_id, last.shell_id) + self.assertEqual(created.shell_id, selected.shell_id) + self.assertEqual(created.execution_count, 0) + self.assertEqual(info.shell_count, 1) + self.assertEqual(info.last_shell, created.name) + async def test_named_shells_have_isolated_namespaces(self): app = JupyterApp() try: @@ -110,7 +130,10 @@ class JupyterAppTests(unittest.IsolatedAsyncioTestCase): self.assertIsInstance(input_request, InputRequest) assert isinstance(input_request, InputRequest) - self.assertTrue(input_request.call_id) + self.assertRegex( + input_request.call_id, + re.compile(r"^[a-z]+-[a-z]+-[a-z]+-[0-9a-f]{4}$"), + ) self.assertEqual(input_request.prompt, "name? ") await app.reply_to_input(input_request.call_id, "Ada") From 0754cb250886575c81e8e8eec8f5016ca2b9b745 Mon Sep 17 00:00:00 2001 From: lda Date: Mon, 31 Aug 2026 00:52:34 +0700 Subject: [PATCH 12/12] feat: preserve Jupyter MIME output in Streamlit --- .streamlit/config.toml | 2 + pyproject.toml | 7 + src/ipython_shell/__init__.py | 5 +- src/ipython_shell/jupyter/__init__.py | 31 ++- src/ipython_shell/jupyter/app.py | 5 +- src/ipython_shell/jupyter/messages.py | 260 +++++++++++++++++++++ src/ipython_shell/jupyter/shell.py | 25 +- src/ipython_shell/jupyter/transport.py | 112 ++++++--- src/ipython_webapp/jupyter/app.py | 10 +- src/st_demo/__init__.py | 1 + src/st_demo/app.py | 312 +++++++++++++++++++++++++ src/st_demo/ui.py | 100 ++++++++ tests/test_jupyter_app.py | 28 +-- tests/test_st_demo.py | 82 +++++++ tests/test_transport.py | 141 ++++++++++- uv.lock | 269 +++++++++++++++++++++ 16 files changed, 1315 insertions(+), 75 deletions(-) create mode 100644 .streamlit/config.toml create mode 100644 src/ipython_shell/jupyter/messages.py create mode 100644 src/st_demo/__init__.py create mode 100644 src/st_demo/app.py create mode 100644 src/st_demo/ui.py create mode 100644 tests/test_st_demo.py diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000..658868f --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,2 @@ +[browser] +gatherUsageStats = false diff --git a/pyproject.toml b/pyproject.toml index e62dab2..eb9f755 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "langchain-openai>=1.6.0", "matplotlib>=3.11.1", "matplotlib-inline>=0.1.7", + "pydantic>=2.13.5", "uvicorn>=0.52.4", "wonderwords>=2.2.0", ] @@ -25,6 +26,7 @@ ipython-shell = "ipython_shell.app:main" ipython-webapp = "ipython_webapp:main" ipython-jupyter-webapp = "ipython_webapp.jupyter.app:main" ipython-mcp = "ipython_mcp.app:main" +ipython-st-demo = "st_demo.app:main" [build-system] requires = ["uv_build>=0.12.6,<0.13.0"] @@ -32,3 +34,8 @@ build-backend = "uv_build" [tool.uv.build-backend] module-name = "ipython_shell" + +[dependency-groups] +dev = [ + "streamlit>=1.37", +] diff --git a/src/ipython_shell/__init__.py b/src/ipython_shell/__init__.py index 8d666b4..b22d9ad 100644 --- a/src/ipython_shell/__init__.py +++ b/src/ipython_shell/__init__.py @@ -8,7 +8,8 @@ from .events import ( ) from .jupyter.app import JupyterApp from .jupyter.shell import JupyterShell -from .jupyter.transport import InputRequest, JupyterTransport, KernelMessage +from .jupyter.messages import JupyterMessage, KernelMessage, ParsedJupyterMessage +from .jupyter.transport import InputRequest, JupyterTransport from .models import ( AppInfo, CallError, @@ -47,7 +48,9 @@ __all__ = [ "JupyterApp", "JupyterShell", "JupyterTransport", + "JupyterMessage", "KernelMessage", + "ParsedJupyterMessage", "Shell", "ShellInfo", "ShellRegistry", diff --git a/src/ipython_shell/jupyter/__init__.py b/src/ipython_shell/jupyter/__init__.py index 3fe577c..1cf1724 100644 --- a/src/ipython_shell/jupyter/__init__.py +++ b/src/ipython_shell/jupyter/__init__.py @@ -1,11 +1,38 @@ from .app import JupyterApp +from .messages import ( + DisplayDataMessage, + ErrorMessage, + ExecuteReplyMessage, + ExecuteResultMessage, + InputRequestMessage, + JupyterMessage, + KernelMessage, + KnownJupyterMessage, + ParsedJupyterMessage, + StatusMessage, + StreamMessage, + UnknownJupyterMessage, + parse_jupyter_message, +) from .shell import JupyterShell -from .transport import InputRequest, JupyterTransport, KernelMessage +from .transport import InputRequest, JupyterTransport __all__ = [ "InputRequest", + "DisplayDataMessage", + "ErrorMessage", + "ExecuteReplyMessage", + "ExecuteResultMessage", + "InputRequestMessage", "JupyterApp", + "JupyterMessage", + "KernelMessage", "JupyterShell", "JupyterTransport", - "KernelMessage", + "KnownJupyterMessage", + "ParsedJupyterMessage", + "StatusMessage", + "StreamMessage", + "UnknownJupyterMessage", + "parse_jupyter_message", ] diff --git a/src/ipython_shell/jupyter/app.py b/src/ipython_shell/jupyter/app.py index ad888f4..f548c30 100644 --- a/src/ipython_shell/jupyter/app.py +++ b/src/ipython_shell/jupyter/app.py @@ -6,8 +6,9 @@ from uuid import uuid4 from ..models import AppInfo, ShellInfo from ..registry import ShellRegistry from ..utils import generate_call_id +from .messages import ParsedJupyterMessage from .shell import JupyterShell -from .transport import InputRequest, KernelMessage +from .transport import InputRequest class JupyterApp: @@ -74,7 +75,7 @@ class JupyterApp: self, code: str, shell_name: str = "last", - ) -> AsyncIterator[KernelMessage | InputRequest]: + ) -> AsyncIterator[ParsedJupyterMessage | InputRequest]: """Execute code and yield its subprocess messages.""" _, shell = self._select_shell(shell_name) call_id = self._new_call_id() diff --git a/src/ipython_shell/jupyter/messages.py b/src/ipython_shell/jupyter/messages.py new file mode 100644 index 0000000..80d5c08 --- /dev/null +++ b/src/ipython_shell/jupyter/messages.py @@ -0,0 +1,260 @@ +from typing import Annotated, ClassVar, Final, Literal + +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter + + +type JupyterChannel = Literal["iopub", "shell", "stdin"] +type MessageStatus = Literal["ok", "error", "abort"] + +STREAM: Final[Literal["stream"]] = "stream" +DISPLAY_DATA: Final[Literal["display_data"]] = "display_data" +UPDATE_DISPLAY_DATA: Final[Literal["update_display_data"]] = "update_display_data" +EXECUTE_RESULT: Final[Literal["execute_result"]] = "execute_result" +ERROR: Final[Literal["error"]] = "error" +STATUS: Final[Literal["status"]] = "status" +EXECUTE_INPUT: Final[Literal["execute_input"]] = "execute_input" +CLEAR_OUTPUT: Final[Literal["clear_output"]] = "clear_output" +EXECUTE_REPLY: Final[Literal["execute_reply"]] = "execute_reply" +INPUT_REQUEST: Final[Literal["input_request"]] = "input_request" + + +class MimeContent(BaseModel): + """The shared payload for rich display messages.""" + + data: dict[str, object] + metadata: dict[str, object] = Field(default_factory=dict) + transient: dict[str, object] | None = None + + +class ExecuteResultContent(MimeContent): + """Rich display data plus the kernel execution counter.""" + + execution_count: int + + +class StreamContent(BaseModel): + """A stdout or stderr chunk emitted by the kernel.""" + + name: Literal["stdout", "stderr"] + text: str + + +class ErrorContent(BaseModel): + """An exception reported on the IOPub channel.""" + + ename: str + evalue: str + traceback: list[str] + + +class ExecuteReplyContent(BaseModel): + """The terminal shell-channel reply for an execution request.""" + + status: MessageStatus + execution_count: int | None = None + ename: str | None = None + evalue: str | None = None + traceback: list[str] | None = None + + +class StatusContent(BaseModel): + """Kernel busy/idle state from an IOPub status message.""" + + execution_state: Literal["busy", "idle", "starting"] + + +class InputRequestContent(BaseModel): + """The prompt and echo policy for a kernel input request.""" + + prompt: str + password: bool = False + + +class ExecuteInputContent(BaseModel): + """The code and execution counter echoed by the kernel.""" + + code: str + execution_count: int + + +class ClearOutputContent(BaseModel): + """Whether a clear-output request should wait for new output.""" + + wait: bool = False + + +class JupyterMessage(BaseModel): + """Common Jupyter envelope, also capable of holding unknown messages. + + The protocol's message vocabulary is extensible, so ``msg_type`` stays a + string on this raw-compatible model. Known messages are narrowed into the + typed subclasses below by :func:`parse_jupyter_message`. + """ + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow") + + channel: JupyterChannel = "iopub" + msg_type: str + message_id: str = "" + parent_id: str | None = None + metadata: dict[str, object] = Field(default_factory=dict) + content: dict[str, object] = Field(default_factory=dict) + buffers: list[bytes] = Field(default_factory=list) + + +class _TypedMessage(BaseModel): + """Common fields for typed messages without narrowing mutable fields.""" + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow") + + message_id: str = "" + parent_id: str | None = None + metadata: dict[str, object] = Field(default_factory=dict) + buffers: list[bytes] = Field(default_factory=list) + + +class StreamMessage(_TypedMessage): + msg_type: Literal["stream"] = STREAM + channel: Literal["iopub"] = "iopub" + content: StreamContent + + +class DisplayDataMessage(_TypedMessage): + msg_type: Literal["display_data"] = DISPLAY_DATA + channel: Literal["iopub"] = "iopub" + content: MimeContent + + +class UpdateDisplayDataMessage(_TypedMessage): + msg_type: Literal["update_display_data"] = UPDATE_DISPLAY_DATA + channel: Literal["iopub"] = "iopub" + content: MimeContent + + +class ExecuteResultMessage(_TypedMessage): + msg_type: Literal["execute_result"] = EXECUTE_RESULT + channel: Literal["iopub"] = "iopub" + content: ExecuteResultContent + + +class ErrorMessage(_TypedMessage): + msg_type: Literal["error"] = ERROR + channel: Literal["iopub"] = "iopub" + content: ErrorContent + + +class StatusMessage(_TypedMessage): + msg_type: Literal["status"] = STATUS + channel: Literal["iopub"] = "iopub" + content: StatusContent + + +class ExecuteInputMessage(_TypedMessage): + msg_type: Literal["execute_input"] = EXECUTE_INPUT + channel: Literal["iopub"] = "iopub" + content: ExecuteInputContent + + +class ClearOutputMessage(_TypedMessage): + msg_type: Literal["clear_output"] = CLEAR_OUTPUT + channel: Literal["iopub"] = "iopub" + content: ClearOutputContent + + +class ExecuteReplyMessage(_TypedMessage): + msg_type: Literal["execute_reply"] = EXECUTE_REPLY + channel: Literal["shell"] = "shell" + content: ExecuteReplyContent + + +class InputRequestMessage(_TypedMessage): + msg_type: Literal["input_request"] = INPUT_REQUEST + channel: Literal["stdin"] = "stdin" + content: InputRequestContent + + +class UnknownJupyterMessage(JupyterMessage): + """An extension or future message preserved without lossy parsing.""" + + raw: dict[str, object] = Field(default_factory=dict) + + +type KnownJupyterMessage = Annotated[ + StreamMessage + | DisplayDataMessage + | UpdateDisplayDataMessage + | ExecuteResultMessage + | ErrorMessage + | StatusMessage + | ExecuteInputMessage + | ClearOutputMessage + | ExecuteReplyMessage + | InputRequestMessage, + Field(discriminator="msg_type"), +] +type ParsedJupyterMessage = KnownJupyterMessage | UnknownJupyterMessage + +_KNOWN_MESSAGE_TYPES: Final[frozenset[str]] = frozenset( + { + STREAM, + DISPLAY_DATA, + UPDATE_DISPLAY_DATA, + EXECUTE_RESULT, + ERROR, + STATUS, + EXECUTE_INPUT, + CLEAR_OUTPUT, + EXECUTE_REPLY, + INPUT_REQUEST, + } +) +_KNOWN_MESSAGE_ADAPTER = TypeAdapter(KnownJupyterMessage) + + +def parse_jupyter_message( + raw: dict[str, object], + *, + channel: JupyterChannel, +) -> ParsedJupyterMessage: + """Parse a decoded Jupyter message without dropping extensions.""" + parent_header = raw.get("parent_header") + parent_id = ( + parent_header.get("msg_id") + if isinstance(parent_header, dict) + and isinstance(parent_header.get("msg_id"), str) + else None + ) + raw_metadata = raw.get("metadata") + metadata = raw_metadata if isinstance(raw_metadata, dict) else {} + raw_content = raw.get("content") + content = raw_content if isinstance(raw_content, dict) else {} + raw_buffers = raw.get("buffers") + buffers = ( + [ + bytes(buffer) + for buffer in raw_buffers + if isinstance(buffer, (bytes, bytearray, memoryview)) + ] + if isinstance(raw_buffers, list) + else [] + ) + raw_msg_type = raw.get("msg_type") + raw_message_id = raw.get("msg_id") + values: dict[str, object] = { + "channel": channel, + "msg_type": raw_msg_type if isinstance(raw_msg_type, str) else "", + "message_id": raw_message_id if isinstance(raw_message_id, str) else "", + "parent_id": parent_id, + "metadata": metadata, + "content": content, + "buffers": buffers, + } + + if values["msg_type"] not in _KNOWN_MESSAGE_TYPES: + return UnknownJupyterMessage.model_validate({**values, "raw": raw}) + return _KNOWN_MESSAGE_ADAPTER.validate_python(values) + + +# Existing callers use this name for the transport-level message. Keep it as +# an alias while the more expressive JupyterMessage name becomes canonical. +KernelMessage = JupyterMessage diff --git a/src/ipython_shell/jupyter/shell.py b/src/ipython_shell/jupyter/shell.py index 20cc4f9..743413b 100644 --- a/src/ipython_shell/jupyter/shell.py +++ b/src/ipython_shell/jupyter/shell.py @@ -1,10 +1,10 @@ from collections.abc import AsyncIterator -from dataclasses import replace from datetime import UTC, datetime from uuid import uuid4 from ..models import ShellInfo, ShellStatus -from .transport import InputRequest, JupyterTransport, KernelMessage +from .messages import ExecuteReplyMessage, ParsedJupyterMessage +from .transport import InputRequest, JupyterTransport class JupyterShell: @@ -30,7 +30,7 @@ class JupyterShell: code: str, *, call_id: str, - ) -> AsyncIterator[KernelMessage | InputRequest]: + ) -> AsyncIterator[ParsedJupyterMessage | InputRequest]: """Yield kernel messages while one cell executes.""" await self.start() self._active_call_id = call_id @@ -39,19 +39,22 @@ class JupyterShell: kernel_call_id = await self.transport.execute(code) async for message in self.transport.messages_for(kernel_call_id): if isinstance(message, InputRequest): - yield replace(message, call_id=call_id) + yield InputRequest( + call_id=call_id, + prompt=message.prompt, + password=message.password, + ) else: # Keep the kernel's execution ID private to the transport. - public_message = replace(message, parent_id=call_id) - if public_message.msg_type == "execute_reply": - self._last_execution_count = int( - public_message.content.get( - "execution_count", self._last_execution_count + public_message = message.model_copy(update={"parent_id": call_id}) + if isinstance(public_message, ExecuteReplyMessage): + if public_message.content.execution_count is not None: + self._last_execution_count = ( + public_message.content.execution_count ) - ) self.status = ( "error" - if public_message.content.get("status") == "error" + if public_message.content.status == "error" else "ready" ) self.last_used_at = datetime.now(UTC).isoformat() diff --git a/src/ipython_shell/jupyter/transport.py b/src/ipython_shell/jupyter/transport.py index 891b673..6d4ff11 100644 --- a/src/ipython_shell/jupyter/transport.py +++ b/src/ipython_shell/jupyter/transport.py @@ -1,19 +1,22 @@ import asyncio +import os from collections.abc import AsyncIterator, Awaitable, Callable from dataclasses import dataclass +from time import monotonic from typing import Any from jupyter_client.manager import AsyncKernelManager - -@dataclass -class KernelMessage: - """A decoded Jupyter message kept at the transport boundary.""" - - msg_type: str - parent_id: str | None - content: dict[str, object] - buffers: list[bytes] +from .messages import ( + ExecuteReplyMessage, + InputRequestMessage, + JupyterChannel, + JupyterMessage, + KernelMessage, + ParsedJupyterMessage, + StatusMessage, + parse_jupyter_message, +) @dataclass @@ -28,14 +31,21 @@ class InputRequest: class JupyterTransport: """Own one persistent ipykernel subprocess and its message channels.""" - def __init__(self) -> None: + _IOPUB_SETTLE_SECONDS = 0.25 + + def __init__( + self, + *, + matplotlib_backend: str | None = "module://matplotlib_inline.backend_inline", + ) -> None: self.manager = AsyncKernelManager() + self.matplotlib_backend = matplotlib_backend self.client = None self._active_call: str | None = None self._call_lock = asyncio.Lock() self._waiting_for_input: str | None = None self._message_queue: ( - asyncio.Queue[tuple[str, dict[str, Any] | BaseException]] | None + asyncio.Queue[tuple[JupyterChannel, dict[str, Any] | BaseException]] | None ) = None self._reader_tasks: set[asyncio.Task[None]] = set() @@ -44,7 +54,15 @@ class JupyterTransport: if self.client is not None: return - await self.manager.start_kernel() + # The parent process may select a non-interactive backend (Streamlit + # sets ``MPLBACKEND=Agg``). That is useful for server-side rendering, + # but it prevents ipykernel from publishing rich display MIME. Give + # each Jupyter subprocess its own inline default without mutating the + # host process environment. + kernel_env = dict(os.environ) + if self.matplotlib_backend is not None: + kernel_env["MPLBACKEND"] = self.matplotlib_backend + await self.manager.start_kernel(env=kernel_env) self.client = self.manager.client() self.client.start_channels() await self.client.wait_for_ready() @@ -67,7 +85,7 @@ class JupyterTransport: async def _read_channel( self, - channel: str, + channel: JupyterChannel, get_message: Callable[..., Awaitable[dict[str, Any]]], ) -> None: """Read one ZMQ channel continuously into the transport queue.""" @@ -106,7 +124,7 @@ class JupyterTransport: async def messages_for( self, call_id: str - ) -> AsyncIterator[KernelMessage | InputRequest]: + ) -> AsyncIterator[ParsedJupyterMessage | InputRequest]: """Yield decoded output, input, and completion messages for one call.""" if self.client is None: raise RuntimeError("JupyterTransport has not been started") @@ -115,11 +133,11 @@ class JupyterTransport: if self._message_queue is None: raise RuntimeError("JupyterTransport message readers are not running") - reply: KernelMessage | None = None + reply: ExecuteReplyMessage | None = None idle = False try: while True: - _, queued_message = await self._message_queue.get() + channel, queued_message = await self._message_queue.get() if isinstance(queued_message, BaseException): raise queued_message @@ -128,37 +146,69 @@ class JupyterTransport: if parent_id != call_id: continue - if message["msg_type"] == "input_request": + decoded = parse_jupyter_message(message, channel=channel) + + if isinstance(decoded, InputRequestMessage): self._waiting_for_input = call_id yield InputRequest( call_id=call_id, - prompt=str(message.get("content", {}).get("prompt", "")), - password=bool( - message.get("content", {}).get("password", False) - ), + prompt=decoded.content.prompt, + password=decoded.content.password, ) continue - decoded = KernelMessage( - msg_type=message["msg_type"], - parent_id=parent_id, - content=dict(message.get("content", {})), - buffers=[bytes(buffer) for buffer in message.get("buffers", [])], - ) - if decoded.msg_type == "execute_reply": + if isinstance(decoded, ExecuteReplyMessage): # The shell reply and IOPub messages use different # channels. Buffer the reply so it remains terminal # even when both channels arrive close together. reply = decoded else: yield decoded - if ( - decoded.msg_type == "status" - and decoded.content.get("execution_state") == "idle" + if isinstance(decoded, StatusMessage) and ( + decoded.content.execution_state == "idle" ): idle = True if reply is not None and idle: + # Shell and IOPub are independent ZMQ channels. The + # kernel can publish a final rich display after the shell + # reply and idle status have already reached this queue. + # Wait briefly for that cross-channel tail before ending + # the call, otherwise inline Matplotlib output can vanish. + settle_deadline = monotonic() + self._IOPUB_SETTLE_SECONDS + while True: + remaining = settle_deadline - monotonic() + if remaining <= 0: + break + try: + late_channel, late_message = await asyncio.wait_for( + self._message_queue.get(), + timeout=remaining, + ) + except TimeoutError: + break + if isinstance(late_message, BaseException): + raise late_message + late_parent_id = late_message.get("parent_header", {}).get( + "msg_id" + ) + if late_parent_id != call_id: + continue + late_decoded = parse_jupyter_message( + late_message, + channel=late_channel, + ) + if isinstance(late_decoded, InputRequestMessage): + self._waiting_for_input = call_id + yield InputRequest( + call_id=call_id, + prompt=late_decoded.content.prompt, + password=late_decoded.content.password, + ) + elif isinstance(late_decoded, ExecuteReplyMessage): + reply = late_decoded + else: + yield late_decoded yield reply return finally: diff --git a/src/ipython_webapp/jupyter/app.py b/src/ipython_webapp/jupyter/app.py index 96aad78..6f72001 100644 --- a/src/ipython_webapp/jupyter/app.py +++ b/src/ipython_webapp/jupyter/app.py @@ -7,7 +7,7 @@ from fastapi import Body, FastAPI, HTTPException, Path, Response from fastapi.responses import StreamingResponse from pydantic import BaseModel -from ipython_shell.jupyter import InputRequest, JupyterApp, KernelMessage +from ipython_shell.jupyter import InputRequest, JupyterApp, ParsedJupyterMessage app = FastAPI(title="IPython Jupyter Web App", version="0.1.0") shell_app = JupyterApp() @@ -19,7 +19,7 @@ class InputReply(BaseModel): value: str -def serialize_event(event: KernelMessage | InputRequest) -> dict[str, object]: +def serialize_event(event: ParsedJupyterMessage | InputRequest) -> dict[str, object]: """Turn a shell event into JSON-safe data for a web client.""" if isinstance(event, InputRequest): return { @@ -31,11 +31,15 @@ def serialize_event(event: KernelMessage | InputRequest) -> dict[str, object]: # Some Jupyter messages keep binary payloads in message buffers. Base64 # makes those buffers safe to carry beside JSON MIME data in the stream. + content = event.content + if isinstance(content, BaseModel): + content = content.model_dump(mode="json") + return { "type": "kernel_message", "msg_type": event.msg_type, "parent_id": event.parent_id, - "content": event.content, + "content": content, "buffers": [ base64.b64encode(buffer).decode("ascii") for buffer in event.buffers ], diff --git a/src/st_demo/__init__.py b/src/st_demo/__init__.py new file mode 100644 index 0000000..2bcd587 --- /dev/null +++ b/src/st_demo/__init__.py @@ -0,0 +1 @@ +"""Streamlit demonstration client for the Jupyter-backed shell.""" diff --git a/src/st_demo/app.py b/src/st_demo/app.py new file mode 100644 index 0000000..319c276 --- /dev/null +++ b/src/st_demo/app.py @@ -0,0 +1,312 @@ +import asyncio +import base64 +import queue +import sys +import threading +from collections.abc import Mapping +from concurrent.futures import Future +from dataclasses import dataclass +from pathlib import Path +from uuid import uuid4 + +from ipython_shell.jupyter import InputRequest, JupyterApp, ParsedJupyterMessage + +from st_demo.ui import event_to_record, preferred_mime + + +@dataclass +class _CallFinished: + """Sentinel placed after a call's final Jupyter message.""" + + +@dataclass +class _CallFailed: + """Exception sentinel placed when the background call task fails.""" + + error: Exception + + +@dataclass +class _BackgroundCall: + events: queue.Queue[ParsedJupyterMessage | InputRequest | _CallFinished | _CallFailed] + task: Future[None] | None = None + + +class ShellController: + """Bridge Streamlit reruns to one persistent async Jupyter application.""" + + def __init__(self) -> None: + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread( + target=self._run_loop, + name="st-demo-jupyter-loop", + daemon=True, + ) + self._ready = threading.Event() + self._app: JupyterApp | None = None + self._calls: dict[str, _BackgroundCall] = {} + self._lock = threading.Lock() + self._thread.start() + self._ready.wait(timeout=5) + if self._app is None: + raise RuntimeError("The Jupyter application did not start") + + def _run_loop(self) -> None: + """Own the asyncio loop so Streamlit's rerun loop stays synchronous.""" + asyncio.set_event_loop(self._loop) + self._app = JupyterApp() + self._ready.set() + self._loop.run_forever() + self._loop.run_until_complete(self._app.shutdown()) + self._loop.close() + + def start_call(self, code: str, shell_name: str) -> str: + """Start a call and return a local handle immediately.""" + handle = uuid4().hex + background_call = _BackgroundCall(events=queue.Queue()) + with self._lock: + self._calls[handle] = background_call + background_call.task = asyncio.run_coroutine_threadsafe( + self._consume_call(handle, code, shell_name), + self._loop, + ) + return handle + + async def _consume_call(self, handle: str, code: str, shell_name: str) -> None: + """Consume the async app stream and publish events for Streamlit.""" + if self._app is None: + raise RuntimeError("The Jupyter application did not start") + background_call = self._calls[handle] + try: + async for event in self._app.run_code_stream(code, shell_name): + background_call.events.put(event) + except Exception as error: + background_call.events.put(_CallFailed(error)) + finally: + background_call.events.put(_CallFinished()) + + def poll( + self, handle: str + ) -> tuple[list[ParsedJupyterMessage | InputRequest], bool, Exception | None]: + """Drain events currently available for one call.""" + try: + background_call = self._calls[handle] + except KeyError as error: + raise KeyError(f"Call {handle} does not exist") from error + + events: list[ParsedJupyterMessage | InputRequest] = [] + failed: Exception | None = None + finished = False + while True: + try: + item = background_call.events.get_nowait() + except queue.Empty: + break + if isinstance(item, _CallFinished): + finished = True + elif isinstance(item, _CallFailed): + failed = item.error + else: + events.append(item) + return events, finished, failed + + def reply_to_input(self, handle: str, call_id: str, value: str) -> None: + """Answer an input request without blocking Streamlit's script.""" + if handle not in self._calls: + raise KeyError(f"Call {handle} does not exist") + if self._app is None: + raise RuntimeError("The Jupyter application did not start") + future = asyncio.run_coroutine_threadsafe( + self._app.reply_to_input(call_id, value), + self._loop, + ) + future.result(timeout=5) + + def shell_names(self) -> list[str]: + """Return known shell names without starting any unused kernels.""" + if self._app is None: + return [] + future = asyncio.run_coroutine_threadsafe(self._shell_names(), self._loop) + return future.result(timeout=5) + + async def _shell_names(self) -> list[str]: + if self._app is None: + return [] + return [shell.name for shell in self._app.list_shells()] + + def close(self) -> None: + """Stop the background application when the Streamlit session ends.""" + if self._loop.is_running(): + self._loop.call_soon_threadsafe(self._loop.stop) + self._thread.join(timeout=5) + + +def _controller() -> ShellController: + import streamlit as st + + if "shell_controller" not in st.session_state: + st.session_state.shell_controller = ShellController() + return st.session_state.shell_controller + + +def _render_mime(data: dict[str, object]) -> None: + import streamlit as st + + selected = preferred_mime(data) + if selected is None: + return + mime, value = selected + if mime == "text/html": + # This is explicitly a local demo: execute only code you trust. + st.html(str(value)) + elif mime in {"image/png", "image/jpeg", "image/svg+xml"}: + if mime == "image/svg+xml": + st.html(str(value)) + else: + st.image(base64.b64decode(str(value))) + elif mime == "application/pdf": + st.download_button( + "Download PDF", + base64.b64decode(str(value)), + file_name="output.pdf", + mime=mime, + ) + elif mime == "application/javascript": + st.code(str(value), language="javascript") + elif mime == "text/latex": + st.latex(str(value)) + else: + st.code(str(value), language="text") + + +def _render_record(record: dict[str, object]) -> None: + import streamlit as st + + kind = record["kind"] + if kind == "stdout": + st.code(str(record.get("text", "")), language="text") + elif kind == "stderr": + st.error(str(record.get("text", ""))) + elif kind in {"display_data", "execute_result"}: + data = record.get("data") + if isinstance(data, dict): + _render_mime(data) + elif kind == "error": + st.error(str(record.get("text", ""))) + data = record.get("data") + if isinstance(data, dict): + traceback = data.get("traceback") + if isinstance(traceback, list): + st.code("".join(str(line) for line in traceback), language="text") + elif kind == "input_request": + st.info(str(record.get("prompt", "Input required"))) + elif kind not in {"status", "execute_reply", "execute_input"}: + with st.expander(f"Jupyter message: {kind}"): + st.json(record) + + +def visible_records( + call_records: Mapping[str, list[dict[str, object]]], +) -> list[dict[str, object]]: + """Flatten stored calls in insertion order for persistent history.""" + return [record for records in call_records.values() for record in records] + + +def _render_active_call(controller: ShellController) -> None: + import streamlit as st + + handle = st.session_state.get("active_handle") + call_records = st.session_state.get("call_records", {}) + if not isinstance(call_records, dict): + return + + finished = False + failed: Exception | None = None + records: list[dict[str, object]] = [] + if isinstance(handle, str): + events, finished, failed = controller.poll(handle) + records = call_records.setdefault(handle, []) + records.extend(event_to_record(event) for event in events) + + for record in visible_records(call_records): + _render_record(record) + + if not isinstance(handle, str): + return + + input_record = next( + (record for record in reversed(records) if record["kind"] == "input_request"), + None, + ) + if input_record is not None and not finished: + value = st.text_input( + str(input_record.get("prompt", "Input")), + type="password" if input_record.get("password") else "default", + key=f"input-value-{handle}", + ) + if st.button("Send input", key=f"send-input-{handle}"): + call_id = str(input_record.get("call_id", "")) + controller.reply_to_input(handle, call_id, value) + st.rerun(scope="fragment") + elif failed is not None: + st.exception(failed) + elif not finished: + st.caption("Running…") + else: + st.success("Call complete") + st.session_state.active_handle = None + + +def render() -> None: + """Render the Streamlit shell workbench.""" + import streamlit as st + + st.set_page_config(page_title="IPython Shell", layout="wide") + st.title("IPython Shell") + st.caption("A persistent Jupyter kernel with typed events and MIME output.") + + controller = _controller() + names = controller.shell_names() + shell_options = ["last", "new", *names] + shell_name = st.sidebar.selectbox("Shell", list(dict.fromkeys(shell_options))) + st.sidebar.caption(f"Known shells: {len(names)}") + + code = st.text_area( + "Code", + value="import matplotlib.pyplot as plt\nplt.plot([1, 2, 3], [4, 5, 6])", + height=180, + ) + if st.button("Run", type="primary"): + handle = controller.start_call(code, shell_name) + st.session_state.active_handle = handle + st.session_state.call_records = getattr( + st.session_state, + "call_records", + {}, + ) + st.session_state.call_records[handle] = [] + + call_records = st.session_state.get("call_records", {}) + active_call = st.session_state.get("active_handle") + if call_records: + st.divider() + st.subheader("Output") + if isinstance(active_call, str) and hasattr(st, "fragment"): + st.fragment(run_every="0.25s")(_render_active_call)(controller) + elif isinstance(active_call, str): + _render_active_call(controller) + else: + for record in visible_records(call_records): + _render_record(record) + + +def main() -> None: + """Launch this module through Streamlit's CLI.""" + from streamlit.web import cli as stcli + + sys.argv = ["streamlit", "run", str(Path(__file__).resolve()), *sys.argv[1:]] + raise SystemExit(stcli.main()) + + +if __name__ == "__main__": + render() diff --git a/src/st_demo/ui.py b/src/st_demo/ui.py new file mode 100644 index 0000000..4627632 --- /dev/null +++ b/src/st_demo/ui.py @@ -0,0 +1,100 @@ +from collections.abc import Mapping +from typing import Final + +from pydantic import BaseModel + +from ipython_shell.jupyter.messages import ( + DisplayDataMessage, + ErrorMessage, + ExecuteResultMessage, + ParsedJupyterMessage, + StreamMessage, + UnknownJupyterMessage, +) +from ipython_shell.jupyter.transport import InputRequest + + +MIME_PRIORITY: Final[tuple[str, ...]] = ( + "text/html", + "image/svg+xml", + "image/png", + "image/jpeg", + "application/pdf", + "application/javascript", + "text/latex", + "text/plain", +) + + +def preferred_mime(data: Mapping[str, object]) -> tuple[str, object] | None: + """Choose the richest representation the demo knows how to display.""" + for mime in MIME_PRIORITY: + if mime in data: + return mime, data[mime] + return next(iter(data.items()), None) + + +def _content_record(content: object) -> dict[str, object]: + if isinstance(content, BaseModel): + return content.model_dump(mode="json") + if isinstance(content, dict): + return dict(content) + return {"value": content} + + +def event_to_record( + event: ParsedJupyterMessage | InputRequest, +) -> dict[str, object]: + """Convert one typed kernel event into a UI-friendly record.""" + if isinstance(event, InputRequest): + return { + "kind": "input_request", + "call_id": event.call_id, + "prompt": event.prompt, + "password": event.password, + } + + call_id = event.parent_id + if isinstance(event, StreamMessage): + return { + "kind": "stdout" if event.content.name == "stdout" else "stderr", + "call_id": call_id, + "text": event.content.text, + } + + if isinstance(event, (DisplayDataMessage, ExecuteResultMessage)): + record: dict[str, object] = { + "kind": event.msg_type, + "call_id": call_id, + "data": dict(event.content.data), + "text": event.content.data.get("text/plain"), + "metadata": dict(event.content.metadata), + } + if event.content.transient is not None: + record["transient"] = dict(event.content.transient) + if isinstance(event, ExecuteResultMessage): + record["execution_count"] = event.content.execution_count + return record + + if isinstance(event, ErrorMessage): + return { + "kind": "error", + "call_id": call_id, + "text": f"{event.content.ename}: {event.content.evalue}", + "data": _content_record(event.content), + } + + if isinstance(event, UnknownJupyterMessage): + return { + "kind": event.msg_type, + "call_id": call_id, + "content": dict(event.content), + "metadata": dict(event.metadata), + "raw": dict(event.raw), + } + + return { + "kind": event.msg_type, + "call_id": call_id, + "content": _content_record(event.content), + } diff --git a/tests/test_jupyter_app.py b/tests/test_jupyter_app.py index 1be3b2a..cfe66d7 100644 --- a/tests/test_jupyter_app.py +++ b/tests/test_jupyter_app.py @@ -4,7 +4,8 @@ import unittest from ipython_shell.jupyter.app import JupyterApp from ipython_shell.jupyter.shell import JupyterShell -from ipython_shell.jupyter.transport import InputRequest, KernelMessage +from ipython_shell.jupyter.messages import ExecuteResultMessage +from ipython_shell.jupyter.transport import InputRequest class JupyterShellTests(unittest.IsolatedAsyncioTestCase): @@ -30,19 +31,17 @@ class JupyterShellTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( next( - message.content["data"]["text/plain"] + message.content.data["text/plain"] for message in first - if isinstance(message, KernelMessage) - and message.msg_type == "execute_result" + if isinstance(message, ExecuteResultMessage) ), "41", ) self.assertEqual( next( - message.content["data"]["text/plain"] + message.content.data["text/plain"] for message in second - if isinstance(message, KernelMessage) - and message.msg_type == "execute_result" + if isinstance(message, ExecuteResultMessage) ), "42", ) @@ -97,19 +96,17 @@ class JupyterAppTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( next( - message.content["data"]["text/plain"] + message.content.data["text/plain"] for message in beta_view - if isinstance(message, KernelMessage) - and message.msg_type == "execute_result" + if isinstance(message, ExecuteResultMessage) ), "False", ) self.assertEqual( next( - message.content["data"]["text/plain"] + message.content.data["text/plain"] for message in alpha_view - if isinstance(message, KernelMessage) - and message.msg_type == "execute_result" + if isinstance(message, ExecuteResultMessage) ), "'alpha'", ) @@ -143,10 +140,9 @@ class JupyterAppTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( next( - message.content["data"]["text/plain"] + message.content.data["text/plain"] for message in messages - if isinstance(message, KernelMessage) - and message.msg_type == "execute_result" + if isinstance(message, ExecuteResultMessage) ), "'Ada'", ) diff --git a/tests/test_st_demo.py b/tests/test_st_demo.py new file mode 100644 index 0000000..d0d1922 --- /dev/null +++ b/tests/test_st_demo.py @@ -0,0 +1,82 @@ +import subprocess +import sys +import unittest +from pathlib import Path + +from ipython_shell.jupyter.messages import ( + ExecuteResultMessage, + UnknownJupyterMessage, +) +from st_demo.app import visible_records +from st_demo.ui import event_to_record, preferred_mime + + +class UIDisplayTests(unittest.TestCase): + def test_completed_call_records_remain_visible_after_active_call_clears(self): + records = { + "finished-call": [{"kind": "execute_result", "text": "42"}], + } + + self.assertEqual(visible_records(records), records["finished-call"]) + + def test_app_module_loads_when_streamlit_executes_file_path(self): + app_path = Path(__file__).parents[1] / "src" / "st_demo" / "app.py" + result = subprocess.run( + [ + sys.executable, + "-c", + "import runpy, sys; runpy.run_path(sys.argv[1], run_name='st_demo_script')", + str(app_path), + ], + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + + def test_preferred_mime_chooses_html_before_plain_text(self): + mime, value = preferred_mime( + { + "text/plain": "fallback", + "text/html": "rich", + } + ) + + self.assertEqual((mime, value), ("text/html", "rich")) + + def test_execute_result_record_keeps_mime_and_repr(self): + event = ExecuteResultMessage( + message_id="message-1", + parent_id="call-1", + content={ + "execution_count": 4, + "data": { + "text/plain": "Line2D(_line0)", + "image/png": "ZmFrZQ==", + }, + }, + ) + + record = event_to_record(event) + + self.assertEqual(record["kind"], "execute_result") + self.assertEqual(record["text"], "Line2D(_line0)") + self.assertEqual(record["data"]["image/png"], "ZmFrZQ==") + + def test_unknown_message_record_keeps_raw_protocol_data(self): + event = UnknownJupyterMessage( + channel="iopub", + msg_type="vendor_extension", + message_id="message-2", + content={"value": 42}, + raw={"msg_type": "vendor_extension", "content": {"value": 42}}, + ) + + record = event_to_record(event) + + self.assertEqual(record["kind"], "vendor_extension") + self.assertEqual(record["content"]["value"], 42) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_transport.py b/tests/test_transport.py index cc2a540..1b82af0 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1,16 +1,20 @@ import asyncio +import os import unittest +from unittest.mock import patch -from ipython_shell.jupyter.transport import ( - InputRequest, - JupyterTransport, - KernelMessage, +from ipython_shell.jupyter.messages import ( + DisplayDataMessage, + JupyterMessage, + UnknownJupyterMessage, + parse_jupyter_message, ) +from ipython_shell.jupyter.transport import InputRequest, JupyterTransport class TransportTests(unittest.TestCase): def test_kernel_message_keeps_content_and_buffers(self): - message = KernelMessage( + message = JupyterMessage( msg_type="display_data", parent_id="call-1", content={"data": {"text/plain": "42"}}, @@ -21,6 +25,46 @@ class TransportTests(unittest.TestCase): self.assertEqual(message.content["data"], {"text/plain": "42"}) self.assertEqual(message.buffers, [b"binary"]) + def test_parser_types_known_message_content(self): + message = parse_jupyter_message( + { + "msg_id": "message-1", + "msg_type": "display_data", + "parent_header": {"msg_id": "call-1"}, + "metadata": {}, + "content": { + "data": {"text/plain": "42"}, + "metadata": {}, + }, + "buffers": [b"binary"], + }, + channel="iopub", + ) + + self.assertIsInstance(message, DisplayDataMessage) + assert isinstance(message, DisplayDataMessage) + self.assertEqual(message.content.data["text/plain"], "42") + self.assertEqual(message.parent_id, "call-1") + self.assertEqual(message.buffers, [b"binary"]) + + def test_parser_preserves_unknown_message(self): + message = parse_jupyter_message( + { + "msg_id": "message-2", + "msg_type": "future_extension", + "parent_header": {"msg_id": "call-1"}, + "metadata": {"vendor": "example"}, + "content": {"answer": 42}, + "buffers": [], + }, + channel="iopub", + ) + + self.assertIsInstance(message, UnknownJupyterMessage) + self.assertEqual(message.msg_type, "future_extension") + self.assertEqual(message.content["answer"], 42) + self.assertEqual(message.metadata["vendor"], "example") + class AsyncTransportTests(unittest.IsolatedAsyncioTestCase): async def test_transport_keeps_channel_readers_alive_during_a_call(self): @@ -54,7 +98,7 @@ class AsyncTransportTests(unittest.IsolatedAsyncioTestCase): any(message.msg_type == "execute_result" for message in messages) ) self.assertEqual(messages[-1].msg_type, "execute_reply") - self.assertEqual(messages[-1].content["status"], "ok") + self.assertEqual(messages[-1].content.status, "ok") async def test_transport_preserves_mime_and_errors(self): transport = JupyterTransport() @@ -80,12 +124,68 @@ class AsyncTransportTests(unittest.IsolatedAsyncioTestCase): for message in display_messages if message.msg_type == "display_data" ) - self.assertEqual(display.content["data"]["text/plain"], "hello") + self.assertEqual(display.content.data["text/plain"], "hello") error = next( message for message in error_messages if message.msg_type == "error" ) - self.assertEqual(error.content["ename"], "ValueError") + self.assertEqual(error.content.ename, "ValueError") + + async def test_transport_waits_for_iopub_output_after_idle_and_reply(self): + transport = JupyterTransport() + transport.client = object() + transport._active_call = "call-1" + transport._message_queue = asyncio.Queue() + transport._message_queue.put_nowait( + ( + "shell", + { + "msg_id": "reply-1", + "msg_type": "execute_reply", + "parent_header": {"msg_id": "call-1"}, + "metadata": {}, + "content": {"status": "ok", "execution_count": 1}, + "buffers": [], + }, + ) + ) + transport._message_queue.put_nowait( + ( + "iopub", + { + "msg_id": "status-1", + "msg_type": "status", + "parent_header": {"msg_id": "call-1"}, + "metadata": {}, + "content": {"execution_state": "idle"}, + "buffers": [], + }, + ) + ) + + async def enqueue_late_display() -> None: + await asyncio.sleep(0.15) + await transport._message_queue.put( + ( + "iopub", + { + "msg_id": "display-1", + "msg_type": "display_data", + "parent_header": {"msg_id": "call-1"}, + "metadata": {}, + "content": { + "data": {"image/png": "ZmFrZQ=="}, + "metadata": {}, + }, + "buffers": [], + }, + ) + ) + + asyncio.create_task(enqueue_late_display()) + messages = [message async for message in transport.messages_for("call-1")] + + self.assertTrue(any(message.msg_type == "display_data" for message in messages)) async def test_transport_preserves_matplotlib_mime_output(self): transport = JupyterTransport() @@ -101,7 +201,30 @@ class AsyncTransportTests(unittest.IsolatedAsyncioTestCase): display = next( message for message in messages if message.msg_type == "display_data" ) - self.assertIn("image/png", display.content["data"]) + self.assertIn("image/png", display.content.data) + + async def test_transport_keeps_mime_output_when_parent_forces_agg(self): + # Streamlit sets MPLBACKEND=Agg in its own process. The kernel must + # still use an inline backend so rich output survives the transport. + with patch.dict(os.environ, {"MPLBACKEND": "Agg"}): + transport = JupyterTransport() + await transport.start() + try: + call_id = await transport.execute( + "import matplotlib.pyplot as plt\n" + "plt.plot([1, 2, 3], [4, 5, 6])" + ) + messages = [ + message + async for message in transport.messages_for(call_id) + ] + finally: + await transport.shutdown() + + display = next( + message for message in messages if message.msg_type == "display_data" + ) + self.assertIn("image/png", display.content.data) async def test_transport_routes_input_reply_without_parent_stdin(self): transport = JupyterTransport() diff --git a/uv.lock b/uv.lock index 0568941..9fc9042 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = ">=3.14" +resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform == 'emscripten'", + "sys_platform != 'emscripten' and sys_platform != 'win32'", +] [[package]] name = "aiofile" @@ -14,6 +19,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/79/6e45e778c4c3cab39e0937b007b720c15f76c50c6453d153282d0fcc3588/aiofile-3.12.3-py3-none-any.whl", hash = "sha256:5c1bcc9e929c50834608e8cc1a4cc1d7503eb60c15a535b779fd39e2f372c017", size = 22122, upload-time = "2026-08-04T22:59:25.838Z" }, ] +[[package]] +name = "altair" +version = "6.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "narwhals" }, + { name = "packaging" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/a1/5e6cc638a66da48cfc89a79c2f4810dfec00b63385f9b009ab1f069779bb/altair-6.2.2.tar.gz", hash = "sha256:a1ff9d9cfe81c75414641826312b9471780e19d39293ba0b012933f6b6cba0fe", size = 766606, upload-time = "2026-06-23T12:47:13.384Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/99/d6031f4f146298951c46b1bf1cc160c2a63f6e44b3c13a30054add100d5f/altair-6.2.2-py3-none-any.whl", hash = "sha256:94014f8ad8617c3cb163d1137359cd6db5ba134b9b46d93cfd8b609fd245a583", size = 797613, upload-time = "2026-06-23T12:47:11.451Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.5" @@ -93,6 +114,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "cachetools" version = "7.1.7" @@ -636,6 +666,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, ] +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -753,10 +805,16 @@ dependencies = [ { name = "langchain-openai" }, { name = "matplotlib" }, { name = "matplotlib-inline" }, + { name = "pydantic" }, { name = "uvicorn" }, { name = "wonderwords" }, ] +[package.dev-dependencies] +dev = [ + { name = "streamlit" }, +] + [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.141.1" }, @@ -768,10 +826,14 @@ requires-dist = [ { name = "langchain-openai", specifier = ">=1.6.0" }, { name = "matplotlib", specifier = ">=3.11.1" }, { name = "matplotlib-inline", specifier = ">=0.1.7" }, + { name = "pydantic", specifier = ">=2.13.5" }, { name = "uvicorn", specifier = ">=0.52.4" }, { name = "wonderwords", specifier = ">=2.2.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "streamlit", specifier = ">=1.37" }] + [[package]] name = "ipython-pygments-lexers" version = "1.1.1" @@ -784,6 +846,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jaraco-classes" version = "3.4.0" @@ -838,6 +909,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jiter" version = "0.16.0" @@ -1237,6 +1320,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "matplotlib" version = "3.11.1" @@ -1325,6 +1438,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, ] +[[package]] +name = "narwhals" +version = "2.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/7b/6248dada39781db1ab3ebf08943080df0796098515a87f6f8696d14ec744/narwhals-2.25.0.tar.gz", hash = "sha256:62c036c810662bf7820b7737077176313bc59350eeeefb808510f388c743e4b2", size = 677076, upload-time = "2026-08-20T18:10:15.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/dc/55481808fd70ef1567cf13540ffd4702af3f74b112e35427564b03f79c2d/narwhals-2.25.0-py3-none-any.whl", hash = "sha256:1f0f403e8c7e4463cde9bfe78b12fdd809e3ae3dda6d9b2f802934fb9c7a6a8f", size = 467373, upload-time = "2026-08-20T18:10:13.834Z" }, +] + [[package]] name = "nest-asyncio2" version = "1.7.2" @@ -1486,6 +1608,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pandas" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, + { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, + { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, +] + [[package]] name = "parso" version = "0.8.7" @@ -1587,6 +1738,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, ] +[[package]] +name = "protobuf" +version = "7.36.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/e7/0553e21d25ca4d9f573135775348a372c3ec34a93a71d5f297c3bac38341/protobuf-7.36.0.tar.gz", hash = "sha256:e8e09cb0d794c6687926fa558a8a6e72aa10edb997d5ca61da0765f12a3e00ea", size = 510034, upload-time = "2026-08-20T16:34:01.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/ae/58e3ca96cb2e118cc546b677359b3c6659f79a140935c08dec94c7998585/protobuf-7.36.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:9103532dffd80c6fab7e50c65a31007680a06eb57537d437bb1b35812c138a37", size = 453256, upload-time = "2026-08-20T16:33:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/f0/15/5162230af4912697f0fe406f6800f80760945babcff0e2c2fe6c84ef2d5d/protobuf-7.36.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:bf94a5917c71058262de683669bc0a797a7669d3de71f0b36d058e3194f47b44", size = 341436, upload-time = "2026-08-20T16:33:55.134Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/1670b2bfc9a45e807e520c3e9be36524db9ccc7dc05ea17af7681cabdc61/protobuf-7.36.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:3297e60abdff301e5f74393d87f6cc59dacab5f024a89548a6e8de1d26576b16", size = 354440, upload-time = "2026-08-20T16:33:56.077Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f8/bd5804695ba400e423c33fd4d9f58c28d86633d5ba1945c36ff3967d98cb/protobuf-7.36.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:70f5ec8eb0da81a44360c0dc0beac99a0d78071d21956a7076bae8bd2051841b", size = 340439, upload-time = "2026-08-20T16:33:56.992Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9f/acd02338235a3e7d03168c4303478347b7624fc8189ff4e7f0d2654bbe86/protobuf-7.36.0-cp310-abi3-win32.whl", hash = "sha256:7326fd717bdc419162a735938d89d4032332bcc3408804012b24ff3a37086071", size = 440216, upload-time = "2026-08-20T16:33:57.99Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4e/12cb93270967a2affff5b3f720694700d4d87712a67afd05c8cb3f6fa52c/protobuf-7.36.0-cp310-abi3-win_amd64.whl", hash = "sha256:1781cc1de61249b750848029bca452c0a8b7e990080316b9bbc2518b2117b488", size = 453731, upload-time = "2026-08-20T16:33:58.951Z" }, + { url = "https://files.pythonhosted.org/packages/01/c3/629999e78d46c1115c11886d51c6bd68c17ce4a944f1ea3e153a91316a33/protobuf-7.36.0-py3-none-any.whl", hash = "sha256:53374d53fc29a67f7dbbf0ade47d7526a0f0137bf0f9c90e48d8a60790ef748c", size = 177024, upload-time = "2026-08-20T16:34:00.053Z" }, +] + [[package]] name = "psutil" version = "7.2.2" @@ -1652,6 +1818,28 @@ memory = [ { name = "cachetools" }, ] +[[package]] +name = "pyarrow" +version = "25.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/4c/b525824ad3094076919273cd97db61fb3d78252dee76fa3b8dc8f76774aa/pyarrow-25.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bf0b672390cdcb640d7288f96b826d71ff4e9abb254a86c89890baf51a29cee6", size = 35885255, upload-time = "2026-08-10T12:39:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/448bb0e940de41aec31d1a956e63ad9c54afdf122a103cc3ab20c2a3ce33/pyarrow-25.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:38a9a4b4b9613380e200641891495a56c3d5a98a092db4a870af9975e220471d", size = 37644461, upload-time = "2026-08-10T12:39:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9a/13587e38bd4806fd218f50fd13b8903fab60588a699ff0c406372e5b4043/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b726ad7e7b669be982b0c71c07fe4b037d654354130da79a7902a669e93a66b", size = 46877146, upload-time = "2026-08-10T12:39:43.722Z" }, + { url = "https://files.pythonhosted.org/packages/8d/61/1c5d1229fa21da4cff5365e41e57177aaac57c563c727f35419b8513d1c1/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9171748cdf796972d85a4b60157c279913e242992e350c90c7450182a9838b2a", size = 50131616, upload-time = "2026-08-10T12:39:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/43/20/291e1d65cc0b09aa19f03cf25cf51a2f5fa94b5db315178f2d254ed5cad4/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b7a296aac7a71fa0886c08e155ddb6c636a50013f801f6178daafa0f9e726188", size = 50008879, upload-time = "2026-08-10T12:39:56.891Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7c/1b7c9ec28e76576337e4f97b31141c9a181b89b6d1d6221e9d8205621a58/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0fe7c8b6c03969b49c8c66182e4a18e3819ab92d07cfab5d8370c531b9369ef0", size = 53170864, upload-time = "2026-08-10T12:40:04.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/75/f3d789dc06011a765d14d86bda799cf72ac1d715b6a6edecaa0d73d95062/pyarrow-25.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:f729cfdbd36fd99d543b67a914d2de044c84ebe45be8b34902b299b608c15c8f", size = 28620729, upload-time = "2026-08-10T12:40:51.41Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/647a8ee6f7c2662feb6921315617bc04dcd6034763fb61b1199720bf6162/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:59a2de54c0cbd954da861eee4d1d330f8e909c45b53455baef696380f2c55033", size = 36130288, upload-time = "2026-08-10T12:40:11.014Z" }, + { url = "https://files.pythonhosted.org/packages/93/f8/c9ee997554d7bea94520667dd1933f109ac1da3ee3556d2b49381e023484/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:35935cd5de130aa5cf4dea052a63e6bf2e17006c35c3a468194242b9b2bf5956", size = 37762187, upload-time = "2026-08-10T12:40:16.592Z" }, + { url = "https://files.pythonhosted.org/packages/a2/08/a28c01c7fe9e96e8233ce2d13df1d402f4f999f848f51d2daacd6bb4c036/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f3831aaa25c67a99f99dc8b05873cb9d64560390372e2aa197ce9dd4a3f06a44", size = 46888003, upload-time = "2026-08-10T12:40:23.242Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b9/58612e977d28dc58c878448866838369ee8da2f1e7cc8ed2c84b952aafee/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1fdfc6659b6b19022f2e50627fb5cf7156a66c46bf4299379955cbe742382a", size = 50079036, upload-time = "2026-08-10T12:40:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/72/13/66e1402dcc860e1dc2760b1e0292c9a569b62b3bccab69def1b3e907d006/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:169d3429d5be7c752125890620f75a60776d38b0035eddae939651640822332e", size = 50040226, upload-time = "2026-08-10T12:40:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/78/10/3f1a5497a7ef732ab0f03ecca3e66d89d9c0f57fdc61b4794c456b781f01/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:119297a6dc197e45d9c6d4415f7814a67ffa36c180d26f68c154c58067ae782d", size = 53149035, upload-time = "2026-08-10T12:40:41.454Z" }, + { url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -1736,6 +1924,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, ] +[[package]] +name = "pydeck" +version = "0.9.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/c9/f71032fca47ecc09d30904d9a610234b07139f89eabc2f054b141edcc30f/pydeck-0.9.3.tar.gz", hash = "sha256:695775cbfe51f5fdffbd9735ba469987fdc5efc96bc40a0ee4808170509c78b2", size = 5900912, upload-time = "2026-07-02T23:27:08.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/34/3998411437aff304a9ed4fa37a6fe1ef3132bcd2b5eac59851b80c86123c/pydeck-0.9.3-py2.py3-none-any.whl", hash = "sha256:d8a47c11c81fb12d51b1feb42427ff4f0e13cb599e48931021b2cba98b6849a6", size = 11428091, upload-time = "2026-07-02T23:27:06.399Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -2143,6 +2344,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] +[[package]] +name = "streamlit" +version = "1.62.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altair" }, + { name = "anyio" }, + { name = "blinker" }, + { name = "click" }, + { name = "httptools" }, + { name = "itsdangerous" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pyarrow" }, + { name = "pydeck" }, + { name = "python-multipart" }, + { name = "requests" }, + { name = "starlette" }, + { name = "toml" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, + { name = "watchdog", marker = "sys_platform != 'darwin'" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/94/928c44a8b7bcd602fc4a16025e9868bcdb88b92bcdb2e53dec188d034fc4/streamlit-1.62.0.tar.gz", hash = "sha256:9d2571da6e6799cbaf0f59548f5773926260a87a69807cf3e2f0f68f9f5e4d45", size = 9883501, upload-time = "2026-08-19T18:31:22.864Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/57/78864764c53885db8a378cc1c47329b4b6e095f7ebd89cd1ffcca4027c89/streamlit-1.62.0-py3-none-any.whl", hash = "sha256:294dbcfe0d6531b0d8593a095e6872dcc6ec4b731723fbb318a0f8102e69162e", size = 10490146, upload-time = "2026-08-19T18:31:19.957Z" }, +] + [[package]] name = "tenacity" version = "9.1.4" @@ -2192,6 +2425,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/5e/9b01afd037bfa22a0033963fa091e0f75b6fb15cd85bffb42ff86e697323/tiktoken-0.14.0-cp315-cp315t-win_amd64.whl", hash = "sha256:561e7580f84a79859af1ef6f676968e9030fcc3fe195700b15235bca64f009c9", size = 987929, upload-time = "2026-08-17T19:49:38.947Z" }, ] +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + [[package]] name = "tornado" version = "6.5.8" @@ -2248,6 +2490,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + [[package]] name = "uncalled-for" version = "0.4.0" @@ -2316,6 +2567,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "watchfiles" version = "1.2.0"