Skip to content

Commit

Permalink
Use htmx-based frontend in fps-spacex
Browse files Browse the repository at this point in the history
  • Loading branch information
davidbrochart committed Apr 20, 2023
1 parent 1b0a518 commit 5274793
Show file tree
Hide file tree
Showing 53 changed files with 1,182 additions and 234 deletions.
15 changes: 6 additions & 9 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,18 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3

- name: Setup mamba
uses: conda-incubator/setup-miniconda@v2
- name: Install micromamba
uses: mamba-org/provision-with-micromamba@main
with:
activate-environment: jupyspace
environment-file: dev-environment.yml
python-version: ${{ matrix.python-version }}
mamba-version: "*"
auto-activate-base: false
channels: conda-forge
environment-name: jupyspace

- name: Install jupyspace
run: |
pip install ./plugins/fps-localspace
pip install ./jupyspace_api
pip install ./plugins/localspace
pip install .[test]
- name: Run tests
Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,23 @@
- [Installation](#installation)
- [License](#license)

## Installation
## Development installation

Install [micromamba](https://mamba.readthedocs.io/en/latest/installation.html#micromamba) for your platform, then:
```console
pip install jupyspace
micromamba create -n jupyspace
micromamba activate jupyspace
micromamba install -c conda-forge python
pip install -e jupyspace_api
pip install -e plugins/localspace
pip install -e plugins/spacex
pip install -e .[test]
```

## Usage

```console
asphalt run config.yaml
```

## License
Expand Down
26 changes: 26 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
component:
type: jupyspace
components:
app:
type: app
space:
type: space
spacex:
type: spacex

logging:
version: 1
disable_existing_loggers: false
formatters:
default:
format: '[%(asctime)s %(levelname)s] %(message)s'
handlers:
console:
class: logging.StreamHandler
formatter: default
root:
handlers: [console]
level: INFO
loggers:
webnotifier:
level: DEBUG
1 change: 0 additions & 1 deletion dev-environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,3 @@ channels:
- conda-forge
dependencies:
- pip
- mamba
4 changes: 0 additions & 4 deletions jupyspace/__about__.py

This file was deleted.

4 changes: 1 addition & 3 deletions jupyspace/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
# SPDX-FileCopyrightText: 2022-present David Brochart <david.brochart@gmail.com>
#
# SPDX-License-Identifier: MIT
__version__ = "0.0.1"
File renamed without changes.
3 changes: 3 additions & 0 deletions jupyspace_api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Jupyspace API

The public API for Jupyspace.
5 changes: 5 additions & 0 deletions jupyspace_api/jupyspace_api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .app import App
from .router import Router


__version__ = "0.0.1"
51 changes: 51 additions & 0 deletions jupyspace_api/jupyspace_api/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import logging
from collections import defaultdict
from typing import Dict, List

from fastapi import FastAPI

from .exceptions import RedirectException, _redirect_exception_handler


logger = logging.getLogger("app")


class App:
"""A wrapper around FastAPI that checks for endpoint path conflicts."""

_app: FastAPI
_router_paths: Dict[str, List[str]]

def __init__(self, app: FastAPI):
self._app = app
app.add_exception_handler(RedirectException, _redirect_exception_handler)
self._router_paths = defaultdict(list)

@property
def _paths(self):
return [path for router, paths in self._router_paths.items() for path in paths]

def _include_router(self, router, _type, **kwargs) -> None:
new_paths = []
for route in router.routes:
path = kwargs.get("prefix", "") + route.path
for _router, _paths in self._router_paths.items():
if path in _paths:
raise RuntimeError(
f"{_type} adds a handler for a path that is already defined in "
f"{_router}: {path}"
)
logger.debug("%s added handler for path: %s", _type, path)
new_paths.append(path)
self._router_paths[_type].extend(new_paths)
self._app.include_router(router, **kwargs)

def _mount(self, path: str, _type, *args, **kwargs) -> None:
for _router, _paths in self._router_paths.items():
if path in _paths:
raise RuntimeError(
f"{_type } mounts a path that is already defined in {_router}: {path}"
)
self._router_paths[_type].append(path)
logger.debug("%s mounted path: %s", _type, path)
self._app.mount(path, *args, **kwargs)
11 changes: 11 additions & 0 deletions jupyspace_api/jupyspace_api/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from fastapi import Request, Response
from fastapi.responses import RedirectResponse


class RedirectException(Exception):
def __init__(self, redirect_to: str):
self.redirect_to = redirect_to


async def _redirect_exception_handler(request: Request, exc: RedirectException) -> Response:
return RedirectResponse(url=exc.redirect_to)
80 changes: 80 additions & 0 deletions jupyspace_api/jupyspace_api/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from __future__ import annotations

import webbrowser
from typing import Any, Callable, Dict, Sequence

from asgiref.typing import ASGI3Application
from asphalt.core import Component, Context
from asphalt.web.fastapi import FastAPIComponent
from fastapi import FastAPI
from pydantic import BaseModel

from .app import App


class AppComponent(Component):
async def start(
self,
ctx: Context,
) -> None:
app = await ctx.request_resource(FastAPI)

_app = App(app)
ctx.add_resource(_app)


class JupyspaceComponent(FastAPIComponent):
def __init__(
self,
components: dict[str, dict[str, Any] | None] | None = None,
*,
app: FastAPI | str | None = None,
host: str = "127.0.0.1",
port: int = 8000,
open_browser: bool = False,
query_params: Dict[str, Any] | None = None,
debug: bool | None = None,
middlewares: Sequence[Callable[..., ASGI3Application] | dict[str, Any]] = (),
) -> None:
super().__init__(
components, # type: ignore
app=app,
host=host,
port=port,
debug=debug,
middlewares=middlewares,
)
self.host = host
self.port = port
self.open_browser = open_browser
self.query_params = query_params

async def start(
self,
ctx: Context,
) -> None:
query_params = QueryParams(d={})
host = self.host
if not host.startswith("http"):
host = f"http://{host}"
host_url = Host(url=f"{host}:{self.port}/")
ctx.add_resource(query_params)
ctx.add_resource(host_url)

await super().start(ctx)

# at this point, the server has started
if self.open_browser:
qp = query_params.d
if self.query_params:
qp.update(**self.query_params)
query_params_str = "?" + "&".join([f"{k}={v}" for k, v in qp.items()]) if qp else ""
webbrowser.open_new_tab(f"{self.host}:{self.port}{query_params_str}")


class QueryParams(BaseModel):
d: Dict[str, Any]


class Host(BaseModel):
url: str
21 changes: 21 additions & 0 deletions jupyspace_api/jupyspace_api/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from .app import App


class Router:
_app: App

def __init__(
self,
app: App,
) -> None:
self._app = app

@property
def _type(self):
return self.__class__.__name__

def include_router(self, router, **kwargs):
self._app._include_router(router, self._type, **kwargs)

def mount(self, path: str, *args, **kwargs) -> None:
self._app._mount(path, self._type, *args, **kwargs)
87 changes: 87 additions & 0 deletions jupyspace_api/jupyspace_api/space/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from typing import List

from fastapi import APIRouter

from .models import EnvironmentRead, EnvironmentCreate, ServerRead
from ..app import App
from ..router import Router


class Space(Router, ABC):

def __init__(
self,
app: App,
) -> None:
super().__init__(app)

router = APIRouter()

@router.get("/api/environments", response_model=List[EnvironmentRead])
async def get_environments():
return await self.get_environments()

@router.get("/api/environments/{name}", response_model=EnvironmentRead)
async def get_environment(name: str):
return await self.get_environment(name)

@router.post("/api/environments", response_model=EnvironmentRead, status_code=201)
async def create_environment(environment: EnvironmentCreate):
return await self.create_environment(environment)

@router.delete("/api/environments/{env_id}")
async def delete_environment(env_id: int):
return await self.delete_environment(env_id)

@router.get("/api/servers", response_model=List[ServerRead])
async def get_servers():
return await self.get_servers()

@router.post("/api/servers/{env_name}/{cwd:path}", response_model=ServerRead, status_code=201)
async def create_server(env_name, cwd):
return await self.create_server(env_name, cwd)

@router.get("/api/servers/{server_id}", response_model=ServerRead)
async def get_server(server_id: int):
return await self.get_server(server_id)

@router.delete("/api/servers/{server_id}")
async def stop_server(server_id: int):
return await self.stop_server(server_id)

self.include_router(router)

@abstractmethod
async def get_environments(self):
...

@abstractmethod
async def get_environment(self, name: str):
...

@abstractmethod
async def create_environment(self, environment: EnvironmentCreate):
...

@abstractmethod
async def delete_environment(self, env_id: int):
...

@abstractmethod
async def get_servers(self):
...

@abstractmethod
async def create_server(self, env_name: str):
...

@abstractmethod
async def get_server(self, server_id: int):
...

@abstractmethod
async def stop_server(self, server_id: int):
...
Loading

0 comments on commit 5274793

Please sign in to comment.