Skip to content

Commit ce2bf1f

Browse files
committed
Add environments REST API
1 parent b4bfc95 commit ce2bf1f

File tree

41 files changed

+815
-137
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+815
-137
lines changed

.github/workflows/test.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ jobs:
1818
runs-on: ubuntu-latest
1919
steps:
2020
- name: Checkout
21-
uses: actions/checkout@v3
22-
- uses: actions/setup-python@v4
21+
uses: actions/checkout@v4
22+
- uses: actions/setup-python@v5
2323
with:
2424
python-version: '3.13'
2525
cache: 'pip'
@@ -38,7 +38,7 @@ jobs:
3838
fail-fast: false
3939
matrix:
4040
os: [ubuntu-latest, macos-latest, windows-latest]
41-
python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ]
41+
python-version: [ '3.10', '3.11', '3.12', '3.13' ]
4242

4343
steps:
4444
- name: Checkout
@@ -49,6 +49,8 @@ jobs:
4949
python-version: ${{ matrix.python-version }}
5050
cache: 'pip'
5151

52+
- uses: mamba-org/setup-micromamba@v2
53+
5254
- name: Install hatch
5355
run: |
5456
python3 -m pip install --upgrade pip

jupyverse_api/jupyverse_api/asgi_websocket_transport.py

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,7 @@ def __init__(self, event: wsproto.events.Event) -> None:
3232

3333

3434
class ASGIWebSocketAsyncNetworkStream(AsyncNetworkStream):
35-
def __init__(
36-
self, app: ASGIApp, scope: Scope, task_group: anyio.abc.TaskGroup
37-
) -> None:
35+
def __init__(self, app: ASGIApp, scope: Scope, task_group: anyio.abc.TaskGroup) -> None:
3836
self.app = app
3937
self.scope = scope
4038
self._task_group = task_group
@@ -71,9 +69,7 @@ async def __aenter__(
7169
async def __aexit__(self, exc_type, exc_val, exc_tb) -> typing.Union[bool, None]:
7270
return await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
7371

74-
async def read(
75-
self, max_bytes: int, timeout: typing.Optional[float] = None
76-
) -> bytes:
72+
async def read(self, max_bytes: int, timeout: typing.Optional[float] = None) -> bytes:
7773
message: Message = await self.receive(timeout=timeout)
7874
type = message["type"]
7975

@@ -93,9 +89,7 @@ async def read(
9389

9490
return self.connection.send(event)
9591

96-
async def write(
97-
self, buffer: bytes, timeout: typing.Optional[float] = None
98-
) -> None:
92+
async def write(self, buffer: bytes, timeout: typing.Optional[float] = None) -> None:
9993
self.connection.receive_data(buffer)
10094
for event in self.connection.events():
10195
if isinstance(event, wsproto.events.Request):
@@ -169,9 +163,7 @@ def __init__(self, *args, **kwargs) -> None:
169163

170164
async def __aenter__(self) -> "ASGIWebSocketTransport":
171165
async with contextlib.AsyncExitStack() as stack:
172-
self._task_group = await stack.enter_async_context(
173-
anyio.create_task_group()
174-
)
166+
self._task_group = await stack.enter_async_context(anyio.create_task_group())
175167
self.exit_stack = stack.pop_all()
176168

177169
return self
@@ -191,9 +183,7 @@ async def handle_async_request(self, request: Request) -> Response:
191183

192184
if scheme in {"ws", "wss"} or headers.get("upgrade") == "websocket":
193185
subprotocols: list[str] = []
194-
if (
195-
subprotocols_header := headers.get("sec-websocket-protocol")
196-
) is not None:
186+
if (subprotocols_header := headers.get("sec-websocket-protocol")) is not None:
197187
subprotocols = subprotocols_header.split(",")
198188

199189
scope = {

jupyverse_api/jupyverse_api/cli.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,18 @@
8484
type=str,
8585
help="Disable plugin.",
8686
)
87+
@click.option(
88+
"--timeout",
89+
type=float,
90+
default=None,
91+
help="The timeout for starting Jupyverse.",
92+
)
93+
@click.option(
94+
"--stop-timeout",
95+
type=float,
96+
default=1,
97+
help="The timeout for stopping Jupyverse.",
98+
)
8799
def main(
88100
debug: bool = False,
89101
show_config: bool = False,
@@ -96,6 +108,8 @@ def main(
96108
disable: tuple[str, ...] = (),
97109
allow_origin: tuple[str, ...] = (),
98110
query_param: tuple[str, ...] = (),
111+
timeout: float | None = None,
112+
stop_timeout: float = 1,
99113
) -> None:
100114
query_params_dict = {}
101115
for qp in query_param:
@@ -118,6 +132,8 @@ def main(
118132
show_config=show_config,
119133
help_all=help_all,
120134
backend=backend,
135+
timeout=timeout,
136+
stop_timeout=stop_timeout,
121137
) # type: ignore
122138

123139

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from __future__ import annotations
2+
3+
from abc import ABC, abstractmethod
4+
5+
from fastapi import APIRouter, Depends
6+
7+
from jupyverse_api import Router
8+
9+
from ..app import App
10+
from ..auth import Auth, User
11+
from .models import CreateEnvironment, Environment, EnvironmentStatus
12+
13+
14+
class Environments(Router, ABC):
15+
def __init__(self, app: App, auth: Auth):
16+
super().__init__(app=app)
17+
18+
router = APIRouter()
19+
20+
@router.delete("/api/environments/{environment_id}", status_code=204)
21+
async def delete_environment(
22+
environment_id: str,
23+
user: User = Depends(auth.current_user(permissions={"sessions": ["write"]})),
24+
):
25+
return await self.delete_environment(environment_id, user)
26+
27+
@router.post(
28+
"/api/environments",
29+
status_code=201,
30+
response_model=Environment,
31+
)
32+
async def create_environment(
33+
environment: CreateEnvironment,
34+
user: User = Depends(auth.current_user(permissions={"sessions": ["write"]})),
35+
) -> Environment:
36+
return await self.create_environment(environment, user)
37+
38+
@router.get("/api/environments/wait/{environment_id}")
39+
async def wait_for_environment(environment_id: str) -> None:
40+
return await self.wait_for_environment(environment_id)
41+
42+
@router.get("/api/environments/status/{environment_id}")
43+
async def get_status(id: str) -> EnvironmentStatus:
44+
return await self.get_status(id)
45+
46+
self.include_router(router)
47+
48+
@abstractmethod
49+
async def delete_environment(
50+
self,
51+
id: str,
52+
user: User,
53+
) -> None: ...
54+
55+
@abstractmethod
56+
async def create_environment(
57+
self,
58+
environment: CreateEnvironment,
59+
user: User,
60+
) -> Environment: ...
61+
62+
@abstractmethod
63+
async def wait_for_environment(self, id: str) -> None: ...
64+
65+
@abstractmethod
66+
async def get_status(self, id: str) -> EnvironmentStatus: ...
67+
68+
@abstractmethod
69+
async def run_in_environment(self, id: str, command: str) -> int: ...
70+
71+
@abstractmethod
72+
def add_package_manager(self, name: str, package_manager: PackageManager): ...
73+
74+
75+
class PackageManager(ABC):
76+
@abstractmethod
77+
async def create_environment(self, environment_file_path: str) -> str: ...
78+
79+
@abstractmethod
80+
async def delete_environment(self, id: str) -> None: ...
81+
82+
@abstractmethod
83+
async def wait_for_environment(self, id: str) -> None: ...
84+
85+
@abstractmethod
86+
async def get_status(self, id: str) -> EnvironmentStatus: ...
87+
88+
@abstractmethod
89+
async def run_in_environment(self, id: str, command: str) -> int: ...
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from __future__ import annotations
2+
3+
from typing import Literal
4+
5+
from pydantic import BaseModel
6+
7+
EnvironmentStatus = (
8+
Literal["package manager not found"]
9+
| Literal["environment uninitialized"]
10+
| Literal["environment creation start"]
11+
| Literal["environment creation success"]
12+
| Literal["environment creation error"]
13+
| Literal["environment file not found"]
14+
| Literal["environment file not readable"]
15+
)
16+
17+
18+
class CreateEnvironment(BaseModel):
19+
package_manager_name: str
20+
environment_file_path: str
21+
22+
23+
class Environment(BaseModel):
24+
id: str
25+
status: EnvironmentStatus

jupyverse_api/jupyverse_api/kernel/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@ def __init__(self) -> None:
4646
)
4747

4848
@abstractmethod
49-
async def start(self, *, task_status: TaskStatus[None] = TASK_STATUS_IGNORED,) -> None: ...
49+
async def start(
50+
self,
51+
*,
52+
task_status: TaskStatus[None] = TASK_STATUS_IGNORED,
53+
) -> None: ...
5054

5155
@abstractmethod
5256
async def stop(self) -> None: ...

jupyverse_api/jupyverse_api/kernels/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,4 +251,3 @@ class KernelsConfig(Config):
251251
default=None,
252252
)
253253
require_yjs: bool = False
254-
kernelenv_path: str = ""

jupyverse_api/jupyverse_api/kernels/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
class KernelInfo(BaseModel):
77
name: str | None = None
88
id: str | None = None
9+
environment_id: str | None = None
910

1011

1112
class CreateSession(BaseModel):

jupyverse_api/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ dependencies = [
2929
"importlib_metadata >=3.6; python_version<'3.10'",
3030
"pydantic >=2,<3",
3131
"fastapi >=0.95.0,<1",
32-
"fps >=0.5.1,<0.6.0",
32+
"fps >=0.5.2,<0.6.0",
3333
"anyio >=3.6.2,<5",
3434
"anyioutils >=0.7.4",
3535
]
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Licensing terms
2+
3+
This project is licensed under the terms of the Modified BSD License
4+
(also known as New or Revised or 3-Clause BSD), as follows:
5+
6+
- Copyright (c) 2025-, Jupyter Development Team
7+
8+
All rights reserved.
9+
10+
Redistribution and use in source and binary forms, with or without
11+
modification, are permitted provided that the following conditions are met:
12+
13+
Redistributions of source code must retain the above copyright notice, this
14+
list of conditions and the following disclaimer.
15+
16+
Redistributions in binary form must reproduce the above copyright notice, this
17+
list of conditions and the following disclaimer in the documentation and/or
18+
other materials provided with the distribution.
19+
20+
Neither the name of the Jupyter Development Team nor the names of its
21+
contributors may be used to endorse or promote products derived from this
22+
software without specific prior written permission.
23+
24+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
25+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
26+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
27+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
28+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
29+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
30+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
31+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
32+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
33+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
34+
35+
## About the Jupyter Development Team
36+
37+
The Jupyter Development Team is the set of all contributors to the Jupyter project.
38+
This includes all of the Jupyter subprojects.
39+
40+
The core team that coordinates development on GitHub can be found here:
41+
https://github.com/jupyter/.
42+
43+
## Our Copyright Policy
44+
45+
Jupyter uses a shared copyright model. Each contributor maintains copyright
46+
over their contributions to Jupyter. But, it is important to note that these
47+
contributions are typically only changes to the repositories. Thus, the Jupyter
48+
source code, in its entirety is not the copyright of any single person or
49+
institution. Instead, it is the collective copyright of the entire Jupyter
50+
Development Team. If individual contributors want to maintain a record of what
51+
changes/contributions they have specific copyright on, they should indicate
52+
their copyright in the commit message of the change, when they commit the
53+
change to one of the Jupyter repositories.
54+
55+
With this in mind, the following banner should be used in any source code file
56+
to indicate the copyright and license terms:
57+
58+
# Copyright (c) Jupyter Development Team.
59+
# Distributed under the terms of the Modified BSD License.

0 commit comments

Comments
 (0)