Skip to content

Commit

Permalink
Implement configuration of exhibits via traitlets (#8)
Browse files Browse the repository at this point in the history
* [WiP] Implement configuration of exhibits via traitlets

* Rename, add docs, add title as configurable
  • Loading branch information
krassowski authored May 28, 2024
1 parent 8118bd4 commit 4d009c2
Show file tree
Hide file tree
Showing 12 changed files with 285 additions and 176 deletions.
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,38 @@ This extension is composed of a Python package named `jupyterlab-gallery`
for the server extension and a NPM package named `jupyterlab-gallery`
for the frontend extension.

When [`jupyterlab-new-launcher`](https://github.com/nebari-dev/jupyterlab-new-launcher) is installed, the gallery will be added as a "Gallery" section in the launcher; otherwise it will be shown in the left sidebar.

## Configuration

You can configure the gallery with the following traitlets:

- `GalleryManager.exhibits`: controls the tiles shown in the gallery
- `GalleryManager.destination`: defined the path into which the exhibits will be cloned (by default `/gallery`)
- `GalleryManager.title`: the display name of the widget (by default "Gallery")

These traitlets can be passed from the command line, a JSON file (`.json`) or a Python file (`.py`).

You must name the file `jupyter_gallery_config.py` or `jupyter_gallery_config.json` and place it in one of the paths returned by `jupyter --paths` under the `config` section.

An example Python file would include:

```python
c.GalleryManager.title = "Examples"
c.GalleryManager.destination = "examples"
c.GalleryManager.exhibits = [
{
"git": "https://github.com/jupyterlab/jupyterlab.git",
"repository": "https://github.com/jupyterlab/jupyterlab/",
"title": "JupyterLab",
"description": "JupyterLab",
"icon": "https://raw.githubusercontent.com/jupyterlab/jupyterlab/main/packages/ui-components/style/icons/jupyter/jupyter.svg"
}
]
```

Using the Python file enables including the PAT access token in the `git` stanza (note: while the `git` value is never exposed to the user, the `repository` is and should not contain the secret if you do not want it to be shared with the users).

## Requirements

- JupyterLab >= 4.0.0
Expand Down
2 changes: 1 addition & 1 deletion conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import pytest

pytest_plugins = ("pytest_jupyter.jupyter_server", )
pytest_plugins = ("pytest_jupyter.jupyter_server",)


@pytest.fixture
Expand Down
17 changes: 2 additions & 15 deletions jupyterlab_gallery/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,12 @@

warnings.warn("Importing 'jupyterlab_gallery' outside a proper installation.")
__version__ = "dev"
from .handlers import setup_handlers
from .app import GalleryApp


def _jupyter_labextension_paths():
return [{"src": "labextension", "dest": "jupyterlab-gallery"}]


def _jupyter_server_extension_points():
return [{"module": "jupyterlab_gallery"}]


def _load_jupyter_server_extension(server_app):
"""Registers the API handler to receive HTTP requests from the frontend extension.
Parameters
----------
server_app: jupyterlab.labapp.LabApp
JupyterLab application instance
"""
setup_handlers(server_app.web_app, server_app)
name = "jupyterlab_gallery"
server_app.log.info(f"Registered {name} server extension")
return [{"module": "jupyterlab_gallery", "app": GalleryApp}]
30 changes: 30 additions & 0 deletions jupyterlab_gallery/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from jupyter_server.extension.application import ExtensionApp
from .handlers import ExhibitsHandler, GalleryHandler, PullHandler
from .manager import GalleryManager


class GalleryApp(ExtensionApp):
name = "gallery"

handlers = [
("jupyterlab-gallery/gallery", GalleryHandler),
("jupyterlab-gallery/exhibits", ExhibitsHandler),
("jupyterlab-gallery/pull", PullHandler),
]

# default_url = "/jupyterlab-gallery"
# load_other_extensions = True
# file_url_prefix = "/gallery"

def initialize_settings(self):
self.log.info("Configured gallery manager")
gallery_manager = GalleryManager(
log=self.log, root_dir=self.serverapp.root_dir, config=self.config
)
self.settings.update({"gallery_manager": gallery_manager})

def initialize_handlers(self):
# setting nbapp is needed for nbgitpuller
self.serverapp.web_app.settings["nbapp"] = self.serverapp

self.log.info(f"Registered {self.name} server extension")
94 changes: 53 additions & 41 deletions jupyterlab_gallery/gitpuller.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,19 @@


class SyncHandlerBase(JupyterHandler):

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

if 'pull_status_queues' not in self.settings:
self.settings['pull_status_queues'] = defaultdict(Queue)
if "pull_status_queues" not in self.settings:
self.settings["pull_status_queues"] = defaultdict(Queue)

# store the most recent message from each queue to re-emit when client re-connects
self.last_message = {}

# We use this lock to make sure that only one sync operation
# can be happening at a time. Git doesn't like concurrent use!
if 'git_lock' not in self.settings:
self.settings['git_lock'] = locks.Lock()
if "git_lock" not in self.settings:
self.settings["git_lock"] = locks.Lock()

def get_login_url(self):
# raise on failed auth, not redirect
Expand All @@ -43,26 +42,25 @@ def get_login_url(self):

@property
def git_lock(self):
return self.settings['git_lock']
return self.settings["git_lock"]

async def _pull(self, repo: str, targetpath: str, exhibit_id: int):
q = self.settings['pull_status_queues'][exhibit_id]
q = self.settings["pull_status_queues"][exhibit_id]
try:
q.put_nowait({
'phase': 'waiting',
'message': 'Waiting for a git lock'
})
q.put_nowait({"phase": "waiting", "message": "Waiting for a git lock"})
await self.git_lock.acquire(1)
except gen.TimeoutError:
q.put_nowait({
'phase': 'error',
'message': 'Another git operations is currently running, try again in a few minutes'
})
q.put_nowait(
{
"phase": "error",
"message": "Another git operations is currently running, try again in a few minutes",
}
)
return

try:
branch = self.get_argument('branch', None)
depth = self.get_argument('depth', None)
branch = self.get_argument("branch", None)
depth = self.get_argument("depth", None)
if depth:
depth = int(depth)
# The default working directory is the directory from which Jupyter
Expand All @@ -74,11 +72,19 @@ async def _pull(self, repo: str, targetpath: str, exhibit_id: int):
# so that all repos are always in scope after cloning. Sometimes
# server_root_dir will include things like `~` and so the path
# must be expanded.
repo_parent_dir = os.path.join(os.path.expanduser(self.settings['server_root_dir']),
os.getenv('NBGITPULLER_PARENTPATH', ''))
repo_dir = os.path.join(repo_parent_dir, targetpath or repo.split('/')[-1])

gp = GitPuller(repo, repo_dir, branch=branch, depth=depth, parent=self.settings['nbapp'])
repo_parent_dir = os.path.join(
os.path.expanduser(self.settings["server_root_dir"]),
os.getenv("NBGITPULLER_PARENTPATH", ""),
)
repo_dir = os.path.join(repo_parent_dir, targetpath or repo.split("/")[-1])

gp = GitPuller(
repo,
repo_dir,
branch=branch,
depth=depth,
parent=self.settings["nbapp"],
)

def pull():
try:
Expand All @@ -88,6 +94,7 @@ def pull():
q.put_nowait(None)
except Exception as e:
raise e

self.gp_thread = threading.Thread(target=pull)
self.gp_thread.start()
except Exception as e:
Expand All @@ -97,23 +104,23 @@ def pull():

async def emit(self, data: dict):
serialized_data = json.dumps(data)
if 'output' in data:
self.log.info(data['output'])
if "output" in data:
self.log.info(data["output"])
else:
self.log.info(data)
self.write('data: {}\n\n'.format(serialized_data))
self.write("data: {}\n\n".format(serialized_data))
await self.flush()

async def _stream(self):
# We gonna send out event streams!
self.set_header('content-type', 'text/event-stream')
self.set_header('cache-control', 'no-cache')
self.set_header("content-type", "text/event-stream")
self.set_header("cache-control", "no-cache")

# start by re-emitting last message so that client can catch up after reconnecting
for _exhibit_id, msg in self.last_message.items():
await self.emit(msg)

queues = self.settings['pull_status_queues']
queues = self.settings["pull_status_queues"]

# stream new messages as they are put on respective queues
while True:
Expand All @@ -128,26 +135,31 @@ async def _stream(self):
continue

if progress is None:
msg = {'phase': 'finished', 'exhibit_id': exhibit_id}
del self.settings['pull_status_queues'][exhibit_id]
msg = {"phase": "finished", "exhibit_id": exhibit_id}
del self.settings["pull_status_queues"][exhibit_id]
elif isinstance(progress, Exception):
msg = {
'phase': 'error',
'exhibit_id': exhibit_id,
'message': str(progress),
'output': '\n'.join([
line.strip()
for line in traceback.format_exception(
type(progress), progress, progress.__traceback__
)
])
"phase": "error",
"exhibit_id": exhibit_id,
"message": str(progress),
"output": "\n".join(
[
line.strip()
for line in traceback.format_exception(
type(progress), progress, progress.__traceback__
)
]
),
}
else:
msg = {'output': progress, 'phase': 'syncing', 'exhibit_id': exhibit_id}
msg = {
"output": progress,
"phase": "syncing",
"exhibit_id": exhibit_id,
}

self.last_message[exhibit_id] = msg
await self.emit(msg)

if empty_queues == len(queues_view):
await gen.sleep(0.5)

Loading

0 comments on commit 4d009c2

Please sign in to comment.