Skip to content

Commit

Permalink
Merge pull request #16 from rcthomas/tests
Browse files Browse the repository at this point in the history
Tests
  • Loading branch information
rcthomas authored May 19, 2022
2 parents f3c43e0 + 8db3d55 commit 6db8d6e
Show file tree
Hide file tree
Showing 12 changed files with 370 additions and 147 deletions.
12 changes: 4 additions & 8 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions

name: Python package
name: Test

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
on: [push, pull_request]

jobs:
build:
Expand All @@ -27,8 +23,8 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8 pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
Expand All @@ -37,4 +33,4 @@ jobs:
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pytest jupyterhub_announcement
python -m pytest -v --cov=jupyterhub_announcement --cov-report=term-missing tests
139 changes: 6 additions & 133 deletions jupyterhub_announcement/announcement.py
Original file line number Diff line number Diff line change
@@ -1,124 +1,21 @@
import binascii
import datetime
import json
import os
import sys

import aiofiles
from html_sanitizer import Sanitizer
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader
from jupyterhub._data import DATA_FILES_PATH
from jupyterhub.handlers.static import LogoHandler
from jupyterhub.services.auth import HubOAuthCallbackHandler, HubOAuthenticated
from jupyterhub.utils import make_ssl_context, url_path_join
from jupyterhub.utils import url_path_join
from tornado import escape, gen, ioloop, web
from traitlets import Any, Bool, Dict, Float, Integer, List, Unicode, default
from traitlets.config import Application, Configurable, LoggingConfigurable
from traitlets import Any, Bool, Dict, Integer, List, Unicode, default
from traitlets.config import Application


class _JSONEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, datetime.datetime):
return obj.isoformat()
return json.JSONEncoder.default(self, obj)


def _datetime_hook(json_dict):
for (key, value) in json_dict.items():
try:
json_dict[key] = datetime.datetime.fromisoformat(value)
except Exception:
pass
return json_dict


class AnnouncementQueue(LoggingConfigurable):

announcements = List()

persist_path = Unicode(
"",
help="""File path where announcements persist as JSON.
For a persistent announcement queue, this parameter must be set to
a non-empty value and correspond to a read+write-accessible path.
The announcement queue is stored as a list of JSON objects. If this
parameter is set to a non-empty value:
* The persistence file is used to initialize the announcement queue
at start-up. This is the only time the persistence file is read.
* If the persistence file does not exist at start-up, it is
created when an announcement is added to the queue.
* The persistence file is over-written with the contents of the
announcement queue each time a new announcement is added.
If this parameter is set to an empty value (the default) then the
queue is just empty at initialization and the queue is ephemeral;
announcements will not be persisted on updates to the queue.""",
).tag(config=True)

lifetime_days = Float(
7.0,
help="""Number of days to retain announcements.
Announcements that have been in the queue for this many days are
purged from the queue.""",
).tag(config=True)

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

if self.persist_path:
self.log.info(f"restoring queue from {self.persist_path}")
self._handle_restore()
else:
self.log.info("ephemeral queue, persist_path not set")
self.log.info(f"queue has {len(self.announcements)} announcements")

def _handle_restore(self):
try:
self._restore()
except FileNotFoundError:
self.log.info(f"persist_path not found ({self.persist_path})")
except Exception as err:
self.log.error(f"failed to restore queue ({err})")

def _restore(self):
with open(self.persist_path) as stream:
self.announcements = json.load(stream, object_hook=_datetime_hook)

async def update(self, user, announcement=""):
self.announcements.append(
dict(
user=user, announcement=announcement, timestamp=datetime.datetime.now()
)
)
if self.persist_path:
self.log.info(f"persisting queue to {self.persist_path}")
await self._handle_persist()

async def _handle_persist(self):
try:
await self._persist()
except Exception as err:
self.log.error(f"failed to persist queue ({err})")

async def _persist(self):
async with aiofiles.open(self.persist_path, "w") as stream:
await stream.write(
json.dumps(self.announcements, cls=_JSONEncoder, indent=2)
)

async def purge(self):
max_age = datetime.timedelta(days=self.lifetime_days)
now = datetime.datetime.now()
old_count = len(self.announcements)
self.announcements = [
a for a in self.announcements if now - a["timestamp"] < max_age
]
if self.persist_path and len(self.announcements) < old_count:
self.log.info(f"persisting queue to {self.persist_path}")
await self._handle_persist()
from jupyterhub_announcement.encoder import _JSONEncoder
from jupyterhub_announcement.queue import AnnouncementQueue
from jupyterhub_announcement.ssl import SSLContext


class AnnouncementHandler(HubOAuthenticated, web.RequestHandler):
Expand Down Expand Up @@ -191,30 +88,6 @@ async def post(self):
self.redirect(self.application.reverse_url("view"))


class SSLContext(Configurable):

keyfile = Unicode(
os.getenv("JUPYTERHUB_SSL_KEYFILE", ""), help="SSL key, use with certfile"
).tag(config=True)

certfile = Unicode(
os.getenv("JUPYTERHUB_SSL_CERTFILE", ""), help="SSL cert, use with keyfile"
).tag(config=True)

cafile = Unicode(
os.getenv("JUPYTERHUB_SSL_CLIENT_CA", ""),
help="SSL CA, use with keyfile and certfile",
).tag(config=True)

def ssl_context(self):
if self.keyfile and self.certfile and self.cafile:
return make_ssl_context(
self.keyfile, self.certfile, cafile=self.cafile, check_hostname=False
)
else:
return None


class AnnouncementService(Application):

classes = [AnnouncementQueue, SSLContext]
Expand Down
9 changes: 9 additions & 0 deletions jupyterhub_announcement/encoder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import datetime
import json


class _JSONEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, datetime.datetime):
return obj.isoformat()
return json.JSONEncoder.default(self, obj)
109 changes: 109 additions & 0 deletions jupyterhub_announcement/queue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import datetime
import json

import aiofiles
from traitlets import Float, List, Unicode
from traitlets.config import LoggingConfigurable

from jupyterhub_announcement.encoder import _JSONEncoder


def _datetime_hook(json_dict):
for (key, value) in json_dict.items():
try:
json_dict[key] = datetime.datetime.fromisoformat(value)
except Exception:
pass
return json_dict


class AnnouncementQueue(LoggingConfigurable):

announcements = List()

persist_path = Unicode(
"",
help="""File path where announcements persist as JSON.
For a persistent announcement queue, this parameter must be set to
a non-empty value and correspond to a read+write-accessible path.
The announcement queue is stored as a list of JSON objects. If this
parameter is set to a non-empty value:
* The persistence file is used to initialize the announcement queue
at start-up. This is the only time the persistence file is read.
* If the persistence file does not exist at start-up, it is
created when an announcement is added to the queue.
* The persistence file is over-written with the contents of the
announcement queue each time a new announcement is added.
If this parameter is set to an empty value (the default) then the
queue is just empty at initialization and the queue is ephemeral;
announcements will not be persisted on updates to the queue.""",
).tag(config=True)

lifetime_days = Float(
7.0,
help="""Number of days to retain announcements.
Announcements that have been in the queue for this many days are
purged from the queue.""",
).tag(config=True)

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

if self.persist_path:
self.log.info(f"restoring queue from {self.persist_path}")
self._handle_restore()
else:
self.log.info("ephemeral queue, persist_path not set")
self.log.info(f"queue has {len(self.announcements)} announcements")

def __len__(self):
return len(self.announcements)

def _handle_restore(self):
try:
self._restore()
except FileNotFoundError:
self.log.info(f"persist_path not found ({self.persist_path})")
except Exception as err:
self.log.error(f"failed to restore queue ({err})")

def _restore(self):
with open(self.persist_path) as stream:
self.announcements = json.load(stream, object_hook=_datetime_hook)

async def update(self, user, announcement=""):
self.announcements.append(
dict(
user=user, announcement=announcement, timestamp=datetime.datetime.now()
)
)
if self.persist_path:
self.log.info(f"persisting queue to {self.persist_path}")
await self._handle_persist()

async def _handle_persist(self):
try:
await self._persist()
except Exception as err:
self.log.error(f"failed to persist queue ({err})")

async def _persist(self):
async with aiofiles.open(self.persist_path, "w") as stream:
await stream.write(
json.dumps(self.announcements, cls=_JSONEncoder, indent=2)
)

async def purge(self):
max_age = datetime.timedelta(days=self.lifetime_days)
now = datetime.datetime.now()
old_count = len(self.announcements)
self.announcements = [
a for a in self.announcements if now - a["timestamp"] < max_age
]
if self.persist_path and len(self.announcements) < old_count:
self.log.info(f"persisting queue to {self.persist_path}")
await self._handle_persist()
29 changes: 29 additions & 0 deletions jupyterhub_announcement/ssl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import os

from jupyterhub.utils import make_ssl_context
from traitlets import Unicode
from traitlets.config import Configurable


class SSLContext(Configurable):

keyfile = Unicode(
os.getenv("JUPYTERHUB_SSL_KEYFILE", ""), help="SSL key, use with certfile"
).tag(config=True)

certfile = Unicode(
os.getenv("JUPYTERHUB_SSL_CERTFILE", ""), help="SSL cert, use with keyfile"
).tag(config=True)

cafile = Unicode(
os.getenv("JUPYTERHUB_SSL_CLIENT_CA", ""),
help="SSL CA, use with keyfile and certfile",
).tag(config=True)

def ssl_context(self):
if self.keyfile and self.certfile and self.cafile:
return make_ssl_context(
self.keyfile, self.certfile, cafile=self.cafile, check_hostname=False
)
else:
return None
5 changes: 4 additions & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
pre-commit
flake8
pytest
pytest-asyncio
pytest-cov
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
aiofiles
html-sanitizer
jupyterhub
6 changes: 1 addition & 5 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,7 @@
author_email="[email protected]",
data_files=[("share/jupyterhub/announcement/templates", ["templates/index.html"])],
description="JupyterHub Announcement Service",
install_requires=[
"aiofiles",
"html-sanitizer",
"jupyterhub",
],
install_requires=open("requirements.txt").read().splitlines(),
name="jupyterhub-announcement",
packages=["jupyterhub_announcement"],
version="0.8.0.dev",
Expand Down
Empty file added tests/__init__.py
Empty file.
Loading

0 comments on commit 6db8d6e

Please sign in to comment.