mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-16 22:43:00 +00:00
user-initiated sharing (#4594)
Squashed merge of https://github.com/jupyterhub/jupyterhub/pull/4594 Co-authored-by: Samuel Gaist <samuel.gaist@idiap.ch>
This commit is contained in:
77
examples/user-sharing/README.md
Normal file
77
examples/user-sharing/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# User-initiated sharing
|
||||
|
||||
This example contains a jupyterhub configuration and sample notebooks demonstrating user-initiated sharing from within a JupyterLab session.
|
||||
|
||||
What _admins_ need to do is enable sharing:
|
||||
|
||||
```python
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "user",
|
||||
"scopes": ["self", "shares!user"],
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Getting a token with sharing permission
|
||||
|
||||
Users can always issue themselves tokens with the desired permissions.
|
||||
But for a deployment, it's likely that you want to grant sharing permission to something,
|
||||
be it a service or some part of the single-user application.
|
||||
|
||||
There are two ways to do this in a single-user session,
|
||||
and for convenience, this example includes both.
|
||||
In most real deployments, it will only make sense to do one or the other.
|
||||
|
||||
### Sharing via JupyterLab extension
|
||||
|
||||
If you have a JupyterLab javascript sharing extension or server extension,
|
||||
sharing permissions should be granted to the oauth tokens used to visit the single-user server.
|
||||
These permissions can be specified:
|
||||
|
||||
```python
|
||||
# OAuth token should have sharing permissions,
|
||||
# so JupyterLab javascript can manage shares
|
||||
c.Spawner.oauth_client_allowed_scopes = ["access:servers!server", "shares!server"]
|
||||
```
|
||||
|
||||
The notebook `share-jupyterlab.ipynb` contains a few javascript snippets which will use the JupyterLab configuration to make API requests to JupyterHub from javascript in order to grant access.
|
||||
|
||||
This workflow _should_ be handled by a proper JupyterLab extension,
|
||||
but this notebook of javascript snippets serves as a proof of concept for what is required to build such an extension.
|
||||
|
||||
## Sharing via API token
|
||||
|
||||
These same permissions can also be granted to the server token itself,
|
||||
which is available as $JUPYTERHUB_API_TOKEN in the server,
|
||||
as well as terminals and notebooks.
|
||||
|
||||
```python
|
||||
# grant $JUPYTERHUB_API_TOKEN sharing permissions
|
||||
# so that _python_ code can manage shares
|
||||
c.Spawner.server_token_scopes = [
|
||||
"shares!server", # manage shares
|
||||
"servers!server", # start/stop itself
|
||||
"users:activity!server", # report activity (default permission)
|
||||
]
|
||||
```
|
||||
|
||||
This method is not preferable, because it means anyone with _access_ to the server also has access to the token to grant further sharing permissions,
|
||||
which is not the case when using the oauth permissions above,
|
||||
where each visiting user has their own permissions.
|
||||
|
||||
But it is more convenient for demonstration purposes, because we can write a Python notebook to use it, share-api.ipynb.
|
||||
|
||||
## Run the example
|
||||
|
||||
First, launch jupyterhub: `jupyterhub`.
|
||||
|
||||
Then login as the user `sharer`.
|
||||
|
||||
Run the first couple of cells of the notebook, until you get a `/hub/accept-share` URL.
|
||||
|
||||
Open a new private browser window, and paste this URL. When prompted, login with the username `shared-with`.
|
||||
|
||||
In the end, you should arrive at `sharer`'s server as the user `shared-with`.
|
||||
|
||||
After visiting as `shared-with`, you can proceed in the notebook as `sharer` and view who has permissions, revoke share codes, permissions, etc.
|
37
examples/user-sharing/jupyterhub_config.py
Normal file
37
examples/user-sharing/jupyterhub_config.py
Normal file
@@ -0,0 +1,37 @@
|
||||
c = get_config() # noqa
|
||||
|
||||
|
||||
c.JupyterHub.authenticator_class = 'dummy'
|
||||
c.JupyterHub.spawner_class = 'simple'
|
||||
|
||||
c.Authenticator.allowed_users = {"sharer", "shared-with"}
|
||||
|
||||
# put the current directory on sys.path for shareextension.py
|
||||
from pathlib import Path
|
||||
|
||||
here = Path(__file__).parent.absolute()
|
||||
c.Spawner.notebook_dir = str(here)
|
||||
|
||||
# users need sharing permissions for their own servers
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "user",
|
||||
"scopes": ["self", "shares!user"],
|
||||
},
|
||||
]
|
||||
|
||||
# below are two ways to grant sharing permission to a single-user server.
|
||||
# there's no reason to use both
|
||||
|
||||
|
||||
# OAuth token should have sharing permissions,
|
||||
# so JupyterLab javascript can manage shares
|
||||
c.Spawner.oauth_client_allowed_scopes = ["access:servers!server", "shares!server"]
|
||||
|
||||
# grant $JUPYTERHUB_API_TOKEN sharing permissions
|
||||
# so that _python_ code can manage shares
|
||||
c.Spawner.server_token_scopes = [
|
||||
"shares!server", # manage shares
|
||||
"servers!server", # start/stop itself
|
||||
"users:activity!server", # report activity
|
||||
]
|
685
examples/user-sharing/share-api.ipynb
Normal file
685
examples/user-sharing/share-api.ipynb
Normal file
@@ -0,0 +1,685 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "8c5bd1ca-3329-4062-8851-9bd33009d805",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Using the sharing API from Python \n",
|
||||
"\n",
|
||||
"In this example, we use $JUPYTERHUB_API_TOKEN to communicate with the sharing API via Python.\n",
|
||||
"\n",
|
||||
"The permissions used here are granted via the `c.Spawner.server_token_scopes` config in jupyterhub_config.py\n",
|
||||
"\n",
|
||||
"By using this token, any user who has access to this server has access to sharing permissions.\n",
|
||||
"\n",
|
||||
"First, get some useful configuration from the server environment:\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"id": "95fa629b-ac65-46da-86a6-9798f3d0a2ba",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"'http://127.0.0.1:8081/hub/api'"
|
||||
]
|
||||
},
|
||||
"execution_count": 1,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import os\n",
|
||||
"\n",
|
||||
"hub_api = os.environ[\"JUPYTERHUB_API_URL\"]\n",
|
||||
"token = os.environ[\"JUPYTERHUB_API_TOKEN\"]\n",
|
||||
"username = os.environ[\"JUPYTERHUB_USER\"]\n",
|
||||
"user_server = f\"{username}/{os.environ['JUPYTERHUB_SERVER_NAME']}\"\n",
|
||||
"hub_host = os.environ[\"JUPYTERHUB_HOST\"]\n",
|
||||
"server_base_url = os.environ[\"JUPYTERHUB_SERVICE_PREFIX\"]\n",
|
||||
"\n",
|
||||
"hub_api"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "456697ab-e7ce-4f75-bc8f-3ce424830e0d",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Create a requests.Session to make jupyterhub API requests with our token"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"id": "f3335eeb-64e5-4caa-acb5-1032aaf727bb",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import requests\n",
|
||||
"\n",
|
||||
"session = requests.Session()\n",
|
||||
"session.headers = {\"Authorization\": f\"Bearer {token}\"}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "3cf5605b-2022-4d2a-b0cc-de6f80e8f2fe",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"We can check the permissions our token has with a request to /hub/api/user:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"id": "17529af3-5ec8-4495-9183-bd5d423a1d76",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"{'kind': 'user',\n",
|
||||
" 'last_activity': '2024-01-23T11:43:50.800864Z',\n",
|
||||
" 'groups': [],\n",
|
||||
" 'admin': False,\n",
|
||||
" 'name': 'sharer',\n",
|
||||
" 'servers': {'': {'name': '',\n",
|
||||
" 'full_name': 'sharer/',\n",
|
||||
" 'last_activity': '2024-01-23T11:43:50.800864Z',\n",
|
||||
" 'started': '2024-01-23T11:28:44.948553Z',\n",
|
||||
" 'pending': None,\n",
|
||||
" 'ready': True,\n",
|
||||
" 'stopped': False,\n",
|
||||
" 'url': '/user/sharer/',\n",
|
||||
" 'user_options': {},\n",
|
||||
" 'progress_url': '/hub/api/users/sharer/server/progress'}},\n",
|
||||
" 'session_id': None,\n",
|
||||
" 'scopes': ['access:servers!server=sharer/',\n",
|
||||
" 'delete:servers!server=sharer/',\n",
|
||||
" 'groups:shares!server=sharer/',\n",
|
||||
" 'read:groups:shares!server=sharer/',\n",
|
||||
" 'read:servers!server=sharer/',\n",
|
||||
" 'read:shares!server=sharer/',\n",
|
||||
" 'read:users:activity!user=sharer',\n",
|
||||
" 'read:users:groups!user=sharer',\n",
|
||||
" 'read:users:name!user=sharer',\n",
|
||||
" 'servers!server=sharer/',\n",
|
||||
" 'shares!server=sharer/',\n",
|
||||
" 'users:activity!server=sharer/',\n",
|
||||
" 'users:activity!user=sharer',\n",
|
||||
" 'users:shares!server=sharer/']}"
|
||||
]
|
||||
},
|
||||
"execution_count": 3,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"r = session.get(f\"{hub_api}/user\")\n",
|
||||
"r.json()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a14578b8-577e-4b0a-b74c-40d7a04a1a1a",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"We can see who has access to this server:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"id": "63520e3c-3621-4ebf-9775-52e6150ccca5",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"{'items': [],\n",
|
||||
" '_pagination': {'offset': 0, 'limit': 200, 'total': 0, 'next': None}}"
|
||||
]
|
||||
},
|
||||
"execution_count": 4,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"shares_url = f\"{hub_api}/shares/{user_server}\"\n",
|
||||
"share_codes_url = f\"{hub_api}/share-codes/{user_server}\"\n",
|
||||
"r = session.get(shares_url)\n",
|
||||
"r.json()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "5c8870d0-5b7e-4065-b227-fd463289cfdf",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"and if there are any outstanding codes:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"id": "6fad5334-6034-48f8-b008-1fd3d6e4d139",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"{'items': [],\n",
|
||||
" '_pagination': {'offset': 0, 'limit': 200, 'total': 0, 'next': None}}"
|
||||
]
|
||||
},
|
||||
"execution_count": 5,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"r = session.get(share_codes_url)\n",
|
||||
"r.json()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "e6d598b3-5eab-4d56-b2ea-9aec345a3948",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Next, we can create a code:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"id": "facb1872-44a5-4e4e-84d4-1fd829b1a27b",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"{'server': {'user': {'name': 'sharer'},\n",
|
||||
" 'name': '',\n",
|
||||
" 'url': '/user/sharer/',\n",
|
||||
" 'ready': True},\n",
|
||||
" 'scopes': ['access:servers!server=sharer/'],\n",
|
||||
" 'id': 'sc_1',\n",
|
||||
" 'created_at': '2024-01-23T11:46:32.154416Z',\n",
|
||||
" 'expires_at': '2024-01-24T11:46:32.153582Z',\n",
|
||||
" 'exchange_count': 0,\n",
|
||||
" 'last_exchanged_at': None,\n",
|
||||
" 'code': 'gTs7DmIhFu6UG5VQkOzRMk2MN-cdDiQ_RONCnkaq5Cg',\n",
|
||||
" 'accept_url': '/hub/accept-share?code=gTs7DmIhFu6UG5VQkOzRMk2MN-cdDiQ_RONCnkaq5Cg'}"
|
||||
]
|
||||
},
|
||||
"execution_count": 6,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"r = session.post(share_codes_url)\n",
|
||||
"code_info = r.json()\n",
|
||||
"code_info"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 7,
|
||||
"id": "83e23f83-18cb-4e54-9683-68c2ab0fcfc2",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/html": [
|
||||
"\n",
|
||||
" <a href='/hub/accept-share?code=gTs7DmIhFu6UG5VQkOzRMk2MN-cdDiQ_RONCnkaq5Cg'>share this link to grant access</a>\n",
|
||||
" "
|
||||
],
|
||||
"text/plain": [
|
||||
"<IPython.core.display.HTML object>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from IPython.display import display, HTML\n",
|
||||
"full_accept_url = f\"{hub_host}{code_info['accept_url']}\"\n",
|
||||
"\n",
|
||||
"display(\n",
|
||||
" HTML(f\"\"\"\n",
|
||||
" <a href='{full_accept_url}'>share this link to grant access</a>\n",
|
||||
" \"\"\")\n",
|
||||
")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "0ce4f6f8-a0f3-4556-ab25-1f86ef49a6db",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"(in jupyterlab, shift-right-click to copy link)\n",
|
||||
"\n",
|
||||
"We can now give this to the shared-with user (i.e. us in another private browsing tab).\n",
|
||||
"\n",
|
||||
"After accepting the link, we can see who we've shared with again:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 8,
|
||||
"id": "e056a9f0-7cec-414b-a3ce-03c8abfbc087",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"{'items': [{'server': {'user': {'name': 'sharer'},\n",
|
||||
" 'name': '',\n",
|
||||
" 'url': '/user/sharer/',\n",
|
||||
" 'ready': True},\n",
|
||||
" 'scopes': ['access:servers!server=sharer/'],\n",
|
||||
" 'user': {'name': 'shared-with'},\n",
|
||||
" 'group': None,\n",
|
||||
" 'kind': 'user',\n",
|
||||
" 'created_at': '2024-01-23T11:46:43.585455Z'}],\n",
|
||||
" '_pagination': {'offset': 0, 'limit': 200, 'total': 1, 'next': None}}"
|
||||
]
|
||||
},
|
||||
"execution_count": 8,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"session.get(shares_url).json()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "9933ef79-3f59-45fc-92a9-13e7bc1f3b82",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"The share code can also include a `?next=` url parameter, to enable a link to take users to a specific file or view after accepting the code:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 9,
|
||||
"id": "9c313d99-4dc9-45af-9643-f2b0e08b2bb4",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"/hub/accept-share?code=gTs7DmIhFu6UG5VQkOzRMk2MN-cdDiQ_RONCnkaq5Cg&next=%2Fuser%2Fsharer%2Flab%2Ftree%2Fshare-api.ipynb\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text/html": [
|
||||
"\n",
|
||||
" share <a href='/hub/accept-share?code=gTs7DmIhFu6UG5VQkOzRMk2MN-cdDiQ_RONCnkaq5Cg&next=%2Fuser%2Fsharer%2Flab%2Ftree%2Fshare-api.ipynb'>this link</a>\n",
|
||||
" to grant access and direct users to this notebook\n",
|
||||
" "
|
||||
],
|
||||
"text/plain": [
|
||||
"<IPython.core.display.HTML object>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from urllib.parse import urlencode\n",
|
||||
"\n",
|
||||
"this_notebook_url = server_base_url + \"lab/tree/share-api.ipynb\"\n",
|
||||
"this_notebook_accept_url = full_accept_url + \"&\" + urlencode({\"next\": this_notebook_url})\n",
|
||||
"print(this_notebook_accept_url)\n",
|
||||
"\n",
|
||||
"display(\n",
|
||||
" HTML(f\"\"\"\n",
|
||||
" share <a href='{this_notebook_accept_url}'>this link</a>\n",
|
||||
" to grant access and direct users to this notebook\n",
|
||||
" \"\"\")\n",
|
||||
")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "2455ba21-f558-4391-b8fc-6501feb3bbfb",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Reviewing and managing access\n",
|
||||
"\n",
|
||||
"Listing share codes doesn't reveal the code - if you need to get a code, issue a new sharing code.\n",
|
||||
"\n",
|
||||
"But we can see in `exchange_count` whether and how often the code has been used"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 10,
|
||||
"id": "313116ed-2aa5-4a0a-bc91-f15a9428ae0c",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"{'items': [{'server': {'user': {'name': 'sharer'},\n",
|
||||
" 'name': '',\n",
|
||||
" 'url': '/user/sharer/',\n",
|
||||
" 'ready': True},\n",
|
||||
" 'scopes': ['access:servers!server=sharer/'],\n",
|
||||
" 'id': 'sc_1',\n",
|
||||
" 'created_at': '2024-01-23T11:46:32.154416Z',\n",
|
||||
" 'expires_at': '2024-01-24T11:46:32.153582Z',\n",
|
||||
" 'exchange_count': 1,\n",
|
||||
" 'last_exchanged_at': '2024-01-23T11:46:43.589701Z'}],\n",
|
||||
" '_pagination': {'offset': 0, 'limit': 200, 'total': 1, 'next': None}}"
|
||||
]
|
||||
},
|
||||
"execution_count": 10,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"session.get(share_codes_url).json()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "5f0d4d8e-fabb-4423-b9bd-d96643a9b9f7",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"we can also revoke the code. Codes can be deleted by code or id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 11,
|
||||
"id": "666ac28c-70f5-4a75-9bfa-6d26c5ea610f",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"<Response [204]>"
|
||||
]
|
||||
},
|
||||
"execution_count": 11,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"session.delete(share_codes_url + f\"?id={code_info['id']}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "ce2fdb7f-a2de-4c95-996b-527ce0536794",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"or if you're done sharing via code, you can delete all sharing codes for a server without looking it up their ids:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 12,
|
||||
"id": "4633b0af-ad75-4e72-9702-9cb16182aecb",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"<Response [204]>"
|
||||
]
|
||||
},
|
||||
"execution_count": 12,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"session.delete(share_codes_url)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "c322e058-bbf5-4058-8e20-415c7da0aa8a",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"scopes and expiration can be customized in the request when creating the share code:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 13,
|
||||
"id": "462bcec7-a899-4e3e-8e77-7d08adedb7a4",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"<Response [200]>"
|
||||
]
|
||||
},
|
||||
"execution_count": 13,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import json\n",
|
||||
"\n",
|
||||
"options = {\n",
|
||||
" \"scopes\": [\n",
|
||||
" f\"access:servers!server={user_server}\", # access the server (default)\n",
|
||||
" f\"servers!server={user_server}\", # start/stop the server\n",
|
||||
" f\"shares!server={user_server}\", # further share the server with others\n",
|
||||
" ],\n",
|
||||
" \"expires_in\": 3600, # code expires in one hour\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"session.post(share_codes_url, data=json.dumps(options))\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 14,
|
||||
"id": "c8babe6c-6339-4990-9241-d3db54205108",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"{'items': [{'server': {'user': {'name': 'sharer'},\n",
|
||||
" 'name': '',\n",
|
||||
" 'url': '/user/sharer/',\n",
|
||||
" 'ready': True},\n",
|
||||
" 'scopes': ['access:servers!server=sharer/'],\n",
|
||||
" 'user': {'name': 'shared-with'},\n",
|
||||
" 'group': None,\n",
|
||||
" 'kind': 'user',\n",
|
||||
" 'created_at': '2024-01-23T11:46:43.585455Z'}],\n",
|
||||
" '_pagination': {'offset': 0, 'limit': 200, 'total': 1, 'next': None}}"
|
||||
]
|
||||
},
|
||||
"execution_count": 14,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"r = session.get(shares_url)\n",
|
||||
"r.json()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "616d8f46-940d-4527-98a7-27894a23974f",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Revoking permissions\n",
|
||||
"\n",
|
||||
"We can revoke specific permssions via a PATCH request\n",
|
||||
"by specifying the user (or group) and one or more scopes:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 15,
|
||||
"id": "13d54943-e95a-4222-a67d-8f3832b70e3c",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"<Response [200]>"
|
||||
]
|
||||
},
|
||||
"execution_count": 15,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"options = {\n",
|
||||
" \"user\": \"shared-with\",\n",
|
||||
" \"scopes\": ['shares!server=sharer/'],\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"session.patch(shares_url, data=json.dumps(options))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "0b2b2619-3bb4-41a7-852b-de019f49185e",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"If scopes are unspecified, all permissions are revoked:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 16,
|
||||
"id": "1d8997fa-7093-44dd-abdf-405fe8cc7fcd",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"<Response [200]>"
|
||||
]
|
||||
},
|
||||
"execution_count": 16,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"options = {\n",
|
||||
" \"user\": \"shared-with\",\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"session.patch(shares_url, data=json.dumps(options))\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "bb4e37a4-d206-420b-ad21-93a15c65bbd9",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"_All_ shared access can be revoked via a DELETE request to the shares URL:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 17,
|
||||
"id": "203ec184-df77-493b-bd8f-e6bb461abe7e",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"<Response [204]>"
|
||||
]
|
||||
},
|
||||
"execution_count": 17,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"session.delete(shares_url)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "129d98a3-df16-4607-8a9d-784843a7eeaf",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"and we can see that nobody has shared access anymore"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 18,
|
||||
"id": "d40f97b8-171f-4958-9cbf-bcc08a5f0db7",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"{'items': [],\n",
|
||||
" '_pagination': {'offset': 0, 'limit': 200, 'total': 0, 'next': None}}"
|
||||
]
|
||||
},
|
||||
"execution_count": 18,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"session.get(shares_url).json()"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.10.13"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
403
examples/user-sharing/share-jupyterlab.ipynb
Normal file
403
examples/user-sharing/share-jupyterlab.ipynb
Normal file
@@ -0,0 +1,403 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "7609a68a-c01b-43a8-90aa-3d004af614dd",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Sharing access to a server\n",
|
||||
"\n",
|
||||
"This notebook executes some javascript in the browser, using the user's OAuth token.\n",
|
||||
"\n",
|
||||
"This code would normally reside in a jupyterlab extension.\n",
|
||||
"The notebook serves only for demonstration purposes.\n",
|
||||
"\n",
|
||||
"First, collect some configuration from the page, so we can talk to the JupyterHub API:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"id": "b3cf16bd-ff9b-4140-a394-5a7ca5d96d88",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"application/javascript": [
|
||||
"// define some globals to share\n",
|
||||
"\n",
|
||||
"var configElement = document.getElementById(\"jupyter-config-data\");\n",
|
||||
"var jupyterConfig = JSON.parse(configElement.innerHTML);\n",
|
||||
"\n",
|
||||
"window.token = jupyterConfig.token;\n",
|
||||
"window.hubOrigin = `${document.location.protocol}//${jupyterConfig.hubHost || window.location.host}`\n",
|
||||
"window.hubUrl = `${hubOrigin}${jupyterConfig.hubPrefix}`;\n",
|
||||
"window.shareCodesUrl = `${hubUrl}api/share-codes/${jupyterConfig.hubServerUser}/${jupyterConfig.hubServerName}`\n",
|
||||
"window.sharesUrl = `${hubUrl}api/shares/${jupyterConfig.hubServerUser}/${jupyterConfig.hubServerName}`\n",
|
||||
"console.log(shareCodesUrl);\n",
|
||||
"\n",
|
||||
"// utility function to make API requests and parse errors\n",
|
||||
"window.apiRequest = async function (url, options) {\n",
|
||||
" var element = options.element;\n",
|
||||
" var okStatus = options.ok || 200;\n",
|
||||
" var resp = await fetch(url, {headers: {Authorization: `Bearer ${token}`}, method: options.method || 'GET'});\n",
|
||||
" var replyText = await resp.text();\n",
|
||||
" var replyJSON = {};\n",
|
||||
" if (replyText.length) {\n",
|
||||
" replyJSON = JSON.parse(replyText);\n",
|
||||
" }\n",
|
||||
" \n",
|
||||
" if (resp.status != okStatus) {\n",
|
||||
" var p = document.createElement('p');\n",
|
||||
" p.innerText = `Error ${resp.status}: ${replyJSON.message}`;\n",
|
||||
" element.appendChild(p);\n",
|
||||
" return;\n",
|
||||
" }\n",
|
||||
" return replyJSON;\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"// `element` is a special variable for the current cell's output area\n",
|
||||
"element.innerText = `API URL for sharing codes is: ${shareCodesUrl}`;\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<IPython.core.display.Javascript object>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"%%javascript\n",
|
||||
"// define some globals to share\n",
|
||||
"\n",
|
||||
"var configElement = document.getElementById(\"jupyter-config-data\");\n",
|
||||
"var jupyterConfig = JSON.parse(configElement.innerHTML);\n",
|
||||
"\n",
|
||||
"window.token = jupyterConfig.token;\n",
|
||||
"window.hubOrigin = `${document.location.protocol}//${jupyterConfig.hubHost || window.location.host}`\n",
|
||||
"window.hubUrl = `${hubOrigin}${jupyterConfig.hubPrefix}`;\n",
|
||||
"window.shareCodesUrl = `${hubUrl}api/share-codes/${jupyterConfig.hubServerUser}/${jupyterConfig.hubServerName}`\n",
|
||||
"window.sharesUrl = `${hubUrl}api/shares/${jupyterConfig.hubServerUser}/${jupyterConfig.hubServerName}`\n",
|
||||
"console.log(shareCodesUrl);\n",
|
||||
"\n",
|
||||
"// utility function to make API requests and parse errors\n",
|
||||
"window.apiRequest = async function (url, options) {\n",
|
||||
" var element = options.element;\n",
|
||||
" var okStatus = options.ok || 200;\n",
|
||||
" var resp = await fetch(url, {headers: {Authorization: `Bearer ${token}`}, method: options.method || 'GET'});\n",
|
||||
" var replyText = await resp.text();\n",
|
||||
" var replyJSON = {};\n",
|
||||
" if (replyText.length) {\n",
|
||||
" replyJSON = JSON.parse(replyText);\n",
|
||||
" }\n",
|
||||
" \n",
|
||||
" if (resp.status != okStatus) {\n",
|
||||
" var p = document.createElement('p');\n",
|
||||
" p.innerText = `Error ${resp.status}: ${replyJSON.message}`;\n",
|
||||
" element.appendChild(p);\n",
|
||||
" return;\n",
|
||||
" }\n",
|
||||
" return replyJSON;\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"// `element` is a special variable for the current cell's output area\n",
|
||||
"element.innerText = `API URL for sharing codes is: ${shareCodesUrl}`;"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "b2049fa1-bb60-4073-9167-2e116b198f0e",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Next, we can request a share code with\n",
|
||||
"\n",
|
||||
"```\n",
|
||||
"POST $hub/api/share-codes/$user/$server\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"The URL for _accepting_ a sharing invitation code is `/hub/accept-share?code=abc123...`:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"id": "ee9430f3-4866-41f7-942d-423bd48dc6b8",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"application/javascript": [
|
||||
"\n",
|
||||
"(async function f() {\n",
|
||||
" var shareCode = await apiRequest(shareCodesUrl, {method: 'POST', element: element});\n",
|
||||
"\n",
|
||||
" // laziest way to display\n",
|
||||
" var shareCodeUrl = `${hubOrigin}${shareCode.accept_url}`\n",
|
||||
" var a = document.createElement('a');\n",
|
||||
" a.href = shareCodeUrl;\n",
|
||||
" a.innerText = shareCodeUrl;\n",
|
||||
" var p = document.createElement(p);\n",
|
||||
" p.append(\"Share this URL to grant access to this server: \");\n",
|
||||
" p.appendChild(a);\n",
|
||||
" element.appendChild(p);\n",
|
||||
"})();\n",
|
||||
"\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<IPython.core.display.Javascript object>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"%%javascript\n",
|
||||
"\n",
|
||||
"(async function f() {\n",
|
||||
" var shareCode = await apiRequest(shareCodesUrl, {method: 'POST', element: element});\n",
|
||||
"\n",
|
||||
" // laziest way to display\n",
|
||||
" var shareCodeUrl = `${hubOrigin}${shareCode.accept_url}`\n",
|
||||
" var a = document.createElement('a');\n",
|
||||
" a.href = shareCodeUrl;\n",
|
||||
" a.innerText = shareCodeUrl;\n",
|
||||
" var p = document.createElement(p);\n",
|
||||
" p.append(\"Share this URL to grant access to this server: \");\n",
|
||||
" p.appendChild(a);\n",
|
||||
" element.appendChild(p);\n",
|
||||
"})();\n",
|
||||
"\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "418152a0-2e0f-4ca6-8b53-9295d15c4345",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Share this URL to grant access to your server (e.g. visit the URL in a private window and login as the user `shared-with`).\n",
|
||||
"\n",
|
||||
"After our code has been used, we can see who has access to this server:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"id": "5c9c6e1b-ccab-4c85-8afb-db141651236a",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"application/javascript": [
|
||||
"\n",
|
||||
"(async function f() {\n",
|
||||
"\n",
|
||||
" var shares = await apiRequest(sharesUrl, {element: element});\n",
|
||||
"\n",
|
||||
" var list = document.createElement('ul');\n",
|
||||
" for (var share of shares.items) {\n",
|
||||
" var p = document.createElement('li');\n",
|
||||
" p.append(`${share.kind} ${share[share.kind].name} has access: `)\n",
|
||||
" var scopes = document.createElement('tt');\n",
|
||||
" scopes.innerText = share.scopes.join(',');\n",
|
||||
" p.appendChild(scopes);\n",
|
||||
" list.append(p);\n",
|
||||
" }\n",
|
||||
" var p = document.createElement('p');\n",
|
||||
" p.innerText = `Shared with ${shares.items.length} users:`;\n",
|
||||
" element.appendChild(p);\n",
|
||||
" element.appendChild(list);\n",
|
||||
" return;\n",
|
||||
"})();\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<IPython.core.display.Javascript object>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"%%javascript\n",
|
||||
"\n",
|
||||
"(async function f() {\n",
|
||||
"\n",
|
||||
" var shares = await apiRequest(sharesUrl, {element: element});\n",
|
||||
"\n",
|
||||
" var list = document.createElement('ul');\n",
|
||||
" for (var share of shares.items) {\n",
|
||||
" var p = document.createElement('li');\n",
|
||||
" p.append(`${share.kind} ${share[share.kind].name} has access: `)\n",
|
||||
" var scopes = document.createElement('tt');\n",
|
||||
" scopes.innerText = share.scopes.join(',');\n",
|
||||
" p.appendChild(scopes);\n",
|
||||
" list.append(p);\n",
|
||||
" }\n",
|
||||
" var p = document.createElement('p');\n",
|
||||
" p.innerText = `Shared with ${shares.items.length} users:`;\n",
|
||||
" element.appendChild(p);\n",
|
||||
" element.appendChild(list);\n",
|
||||
" return;\n",
|
||||
"})();\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "18165205-67f3-44d4-ad6b-40ab27ec469c",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"We could also use this info to revoke permissions, or share with individuals by name.\n",
|
||||
"\n",
|
||||
"We can also review outstanding sharing _codes_:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"id": "40cd1e2d-fc21-4238-8cbc-9ae45be248c9",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"application/javascript": [
|
||||
"\n",
|
||||
"(async function f() {\n",
|
||||
" var shareCodes = await apiRequest(shareCodesUrl, {element: element});\n",
|
||||
" var p = document.createElement('pre');\n",
|
||||
" p.innerText = JSON.stringify(shareCodes.items, null, ' ');\n",
|
||||
" element.appendChild(p);\n",
|
||||
"})();\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<IPython.core.display.Javascript object>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"%%javascript\n",
|
||||
"\n",
|
||||
"(async function f() {\n",
|
||||
" var shareCodes = await apiRequest(shareCodesUrl, {element: element});\n",
|
||||
" var p = document.createElement('pre');\n",
|
||||
" p.innerText = JSON.stringify(shareCodes.items, null, ' ');\n",
|
||||
" element.appendChild(p);\n",
|
||||
"})();\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "0effee22-c132-4b44-a677-c4375594c462",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"And finally, when we're done, we can revoke the codes, at which point nobody _new_ can use the code to gain access to this server,\n",
|
||||
"but anyone who has accepted the code will still have access:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"id": "51a795f5-9a74-49b1-9ef3-e7978da0cc44",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"application/javascript": [
|
||||
"\n",
|
||||
"(async function f() {\n",
|
||||
" await apiRequest(shareCodesUrl, {method: 'DELETE', element: element, ok: 204});\n",
|
||||
" var p = document.createElement('p');\n",
|
||||
" p.innerText = `Deleted all share codes`;\n",
|
||||
" element.appendChild(p); \n",
|
||||
"})();\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<IPython.core.display.Javascript object>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"%%javascript\n",
|
||||
"\n",
|
||||
"(async function f() {\n",
|
||||
" await apiRequest(shareCodesUrl, {method: 'DELETE', element: element, ok: 204});\n",
|
||||
" var p = document.createElement('p');\n",
|
||||
" p.innerText = `Deleted all share codes`;\n",
|
||||
" element.appendChild(p); \n",
|
||||
"})();"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "2ad12718-1f68-4d2f-9934-dd6bd4555a1d",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Or even revoke all shared access, so anyone who may have used the code no longer has any access:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 7,
|
||||
"id": "efa5ae15-ac99-4bf4-b7b4-3d93747dba8c",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"application/javascript": [
|
||||
"\n",
|
||||
"(async function f() {\n",
|
||||
" var resp = await apiRequest(sharesUrl, {method: 'DELETE', element: element, ok: 204});\n",
|
||||
" var p = document.createElement('p');\n",
|
||||
" p.innerText = `Deleted all shared access`;\n",
|
||||
" element.appendChild(p); \n",
|
||||
"})();\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<IPython.core.display.Javascript object>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"%%javascript\n",
|
||||
"\n",
|
||||
"(async function f() {\n",
|
||||
" var resp = await apiRequest(sharesUrl, {method: 'DELETE', element: element, ok: 204});\n",
|
||||
" var p = document.createElement('p');\n",
|
||||
" p.innerText = `Deleted all shared access`;\n",
|
||||
" element.appendChild(p); \n",
|
||||
"})();"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.10.13"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
Reference in New Issue
Block a user