Skip to content

Commit 9b935e1

Browse files
committed
allow publishing via /api/bookstore/published
1 parent 196d7c0 commit 9b935e1

8 files changed

+146
-27
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,5 @@ binder/*run*.ipynb
112112

113113
# Local jupyter notebook config for working on bookstore
114114
jupyter_notebook_config.py
115+
jupyter_config.py
115116

bookstore/archive.py

+5-15
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from .bookstore_config import BookstoreSettings
1313

14+
from .s3_paths import s3_path, s3_display_path
15+
1416

1517
class ArchiveRecord(NamedTuple):
1618
filepath: str
@@ -28,7 +30,8 @@ def __init__(self, *args, **kwargs):
2830
# opt ourselves into being part of the Jupyter App that should have Bookstore Settings applied
2931
self.settings = BookstoreSettings(parent=self)
3032

31-
self.log.info("Archiving notebooks to {}".format(self.full_prefix))
33+
self.log.info("Archiving notebooks to {}".format(s3_display_path(
34+
self.settings.s3_bucket, self.settings.workspace_prefix)))
3235

3336
self.fs = s3fs.S3FileSystem(
3437
key=self.settings.s3_access_key_id,
@@ -82,22 +85,9 @@ def write_to_s3(self, full_path: str, content: str):
8285
with self.fs.open(full_path, mode="wb") as f:
8386
f.write(content.encode("utf-8"))
8487

85-
@property
86-
def delimiter(self):
87-
"""It's a slash! Normally this could be configurable. This leaves room for that later,
88-
keeping it centralized for now"""
89-
return "/"
90-
91-
@property
92-
def full_prefix(self):
93-
"""Full prefix: bucket + workspace prefix"""
94-
return self.delimiter.join(
95-
[self.settings.s3_bucket, self.settings.workspace_prefix]
96-
)
97-
9888
def s3_path(self, path):
9989
"""compute the s3 path based on the bucket, prefix, and the path to the notebook"""
100-
return self.delimiter.join([self.full_prefix, path])
90+
return s3_path(self.settings.s3_bucket, self.settings.workspace_prefix, path)
10191

10292
def run_pre_save_hook(self, model, path, **kwargs):
10393
"""Store notebook to S3 when saves happen

bookstore/handlers.py

+104-7
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import json
2+
import s3fs
23

34
from notebook.base.handlers import APIHandler
45
from notebook.utils import url_path_join
5-
from tornado import web
6+
from tornado import web, gen
67

78
from ._version import get_versions
9+
from .bookstore_config import BookstoreSettings
10+
11+
from notebook.base.handlers import IPythonHandler, APIHandler, path_regex
812

913
version = get_versions()['version']
1014

15+
from .s3_paths import s3_path, s3_display_path
16+
1117

1218
class BookstoreVersionHandler(APIHandler):
1319
"""Returns the version of bookstore currently running. Used mostly to lay foundations
@@ -19,13 +25,104 @@ def get(self):
1925
self.finish(json.dumps({"bookstore": True, "version": version}))
2026

2127

28+
# NOTE: We need to ensure that publishing is not configured if bookstore settings are not
29+
# set. Because of how the APIHandlers cannot be configurable, all we can do is reach into settings
30+
31+
32+
class BookstorePublishHandler(APIHandler):
33+
"""Publish a notebook to the publish path"""
34+
35+
def __init__(self, *args, **kwargs):
36+
super(APIHandler, self).__init__(*args, **kwargs)
37+
# create an easy helper to get at our bookstore settings quickly
38+
self.bookstore_settings = BookstoreSettings(
39+
config=self.settings['config']['BookstoreSettings']
40+
)
41+
42+
self.fs = s3fs.S3FileSystem(
43+
key=self.bookstore_settings.s3_access_key_id,
44+
secret=self.bookstore_settings.s3_secret_access_key,
45+
client_kwargs={
46+
"endpoint_url": self.bookstore_settings.s3_endpoint_url,
47+
"region_name": self.bookstore_settings.s3_region_name,
48+
},
49+
config_kwargs={},
50+
s3_additional_kwargs={},
51+
)
52+
53+
@property
54+
def bucket(self):
55+
return self.bookstore_settings.s3_bucket
56+
57+
@property
58+
def prefix(self):
59+
return self.bookstore_settings.published_prefix
60+
61+
def s3_path(self, path):
62+
"""compute the s3 path based on the bucket, prefix, and the path to the notebook"""
63+
return s3_path(self.bucket, self.prefix, path)
64+
65+
def _publish(self, model, path):
66+
if model['type'] != 'notebook':
67+
raise web.HTTPError(400, "bookstore only publishes notebooks")
68+
content = model['content']
69+
70+
full_s3_path = self.s3_path(path)
71+
72+
self.log.info("Publishing to %s", s3_display_path(self.bucket, self.prefix, path))
73+
74+
# Likely implementation:
75+
#
76+
# with self.fs.open(full_s3_path, mode="wb") as f:
77+
# f.write(content.encode("utf-8"))
78+
#
79+
# However, we need to get back other information for our response
80+
# Ideally, we'd return back the version id
81+
#
82+
# self.status(201)
83+
# self.finish(json.dumps({"s3path": full_s3_path, "versionID": vID}))
84+
#
85+
86+
# Return 501 - Not Implemented
87+
# Until we're ready
88+
self.set_status(501)
89+
90+
@web.authenticated
91+
@gen.coroutine
92+
def put(self, path=''):
93+
'''Publish a notebook on a given path. The payload for this directly matches that of the contents API for PUT.
94+
'''
95+
if path == '' or path == '/':
96+
raise web.HTTPError(400, "Must have a path to publish to")
97+
98+
model = self.get_json_body()
99+
100+
if model:
101+
self._publish(model, path.lstrip('/'))
102+
else:
103+
raise web.HTTPError(400, "Cannot publish empty model")
104+
105+
22106
def load_jupyter_server_extension(nb_app):
23107
web_app = nb_app.web_app
24108
host_pattern = '.*$'
25-
base_bookstore_pattern = url_path_join(
26-
web_app.settings['base_url'], '/api/bookstore'
27-
)
109+
base_bookstore_pattern = url_path_join(web_app.settings['base_url'], '/api/bookstore')
110+
111+
# Always enable the version handler
112+
web_app.add_handlers(host_pattern, [(base_bookstore_pattern, BookstoreVersionHandler)])
113+
114+
config = web_app.settings['config']
115+
bookstore_settings = config.get("BookstoreSettings")
28116

29-
web_app.add_handlers(
30-
host_pattern, [(base_bookstore_pattern, BookstoreVersionHandler)]
31-
)
117+
if not bookstore_settings:
118+
nb_app.log.info("Not enabling bookstore publishing since bookstore endpoint not configured")
119+
else:
120+
web_app.add_handlers(
121+
host_pattern,
122+
[
123+
(
124+
url_path_join(base_bookstore_pattern, r"/published%s" % path_regex),
125+
BookstorePublishHandler,
126+
)
127+
],
128+
)

bookstore/s3_paths.py

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Our S3 path delimiter will remain fixed as '/' in all uses
2+
delimiter = "/"
3+
4+
5+
def _join(*args):
6+
'''Join S3 bucket args together, removing empty entries and stripping left-leading
7+
'''
8+
return delimiter.join(filter(lambda s: s != '', map(lambda s: s.lstrip(delimiter), args)))
9+
10+
11+
def s3_path(bucket, prefix, path=''):
12+
"""compute the s3 path based on the bucket, prefix, and the path to the notebook"""
13+
return _join(bucket, prefix, path)
14+
15+
16+
def s3_display_path(bucket, prefix, path=''):
17+
"""create a display name for use in logs"""
18+
return 's3://' + s3_path(bucket, prefix, path)
19+
20+
21+
print(s3_path('notebooks', 'workspace', 'test/what.ipynb'))
22+
print(s3_display_path('notebooks', 'workspace', 'test/what.ipynb'))

ci/jupyter_notebook_config.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#!/usr/bin/env python
22
# -*- coding: utf-8 -*-
33

4+
print("using CI config")
5+
46
from bookstore import BookstoreContentsArchiver, BookstoreSettings
57

68
# jupyter config
@@ -11,9 +13,10 @@
1113
c.NotebookApp.contents_manager_class = BookstoreContentsArchiver
1214

1315
c.BookstoreSettings.workspace_prefix = "ci-workspace"
16+
c.BookstoreSettings.published_prefix = "ci-published"
1417

1518
# If using minio for development
16-
c.BookstoreSettings.s3_endpoint_url = "http://127.0.0.1:9000"
19+
c.BookstoreSettings.s3_endpoint_url = "http://localhost:9000"
1720
c.BookstoreSettings.s3_bucket = "bookstore"
1821

1922
# Straight out of `circleci/config.yml`

ci/s3.js

+9-3
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,17 @@ class Client {
4545
this.minioClient = new Minio.Client(s3Config);
4646
}
4747

48-
makeBucket(bucketName) {
49-
return makeBucket(this.minioClient, bucketName);
48+
async makeBucket(bucketName) {
49+
const bucketExists = await this.minioClient.bucketExists(bucketName);
50+
51+
if (bucketExists) {
52+
return;
53+
}
54+
55+
return await makeBucket(this.minioClient, bucketName);
5056
}
5157

52-
getObject(bucketName, objectName) {
58+
async getObject(bucketName, objectName) {
5359
return getObject(this.minioClient, bucketName, objectName);
5460
}
5561
}
File renamed without changes.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
},
99
"private": "true",
1010
"scripts": {
11-
"test": "echo \"Error: no test specified\" && exit 1"
11+
"test": "node ci/integration.js"
1212
},
1313
"repository": {
1414
"type": "git",

0 commit comments

Comments
 (0)