Compare commits

...

8 Commits
4.0.1 ... 3.x

Author SHA1 Message Date
Min RK
7b2c89d46c Bump to 3.1.1 2023-01-27 12:53:10 +01:00
Min RK
2aa856269f Merge pull request #4318 from meeseeksmachine/auto-backport-of-pr-4316-on-3.x
Backport PR #4316 on branch 3.x (changelog for 3.1.1)
2023-01-27 12:52:36 +01:00
Min RK
9d7f3cd9a3 Backport PR #4316: changelog for 3.1.1 2023-01-27 11:49:35 +00:00
Min RK
767404ab33 Merge pull request #4317 from meeseeksmachine/auto-backport-of-pr-4303-on-3.x
Backport PR #4303 on branch 3.x (make sure event-schemas are installed)
2023-01-27 11:53:32 +01:00
Erik Sundell
3e608cfc38 Backport PR #4303: make sure event-schemas are installed 2023-01-27 10:42:41 +00:00
Min RK
193ebc970c Merge pull request #4315 from minrk/3.x
Backport PR #4302: sqlalchemy 2 compatibility
2023-01-27 11:31:16 +01:00
Min RK
3dccb5dd99 Backport PR #4302: sqlalchemy 2 compatibility 2023-01-27 11:02:54 +01:00
Min RK
43b0897d6e Bump to 3.1.1.dev 2023-01-27 09:14:47 +01:00
18 changed files with 155 additions and 60 deletions

View File

@@ -29,6 +29,7 @@ env:
# UTF-8 content may be interpreted as ascii and causes errors without this. # UTF-8 content may be interpreted as ascii and causes errors without this.
LANG: C.UTF-8 LANG: C.UTF-8
PYTEST_ADDOPTS: "--verbose --color=yes" PYTEST_ADDOPTS: "--verbose --color=yes"
SQLALCHEMY_WARN_20: "1"
permissions: permissions:
contents: read contents: read
@@ -140,7 +141,7 @@ jobs:
- name: Install Python dependencies - name: Install Python dependencies
run: | run: |
pip install --upgrade pip pip install --upgrade pip
pip install ".[test]" pip install -e ".[test]"
if [ "${{ matrix.oldest_dependencies }}" != "" ]; then if [ "${{ matrix.oldest_dependencies }}" != "" ]; then
# take any dependencies in requirements.txt such as tornado>=5.0 # take any dependencies in requirements.txt such as tornado>=5.0
@@ -152,6 +153,7 @@ jobs:
if [ "${{ matrix.main_dependencies }}" != "" ]; then if [ "${{ matrix.main_dependencies }}" != "" ]; then
pip install git+https://github.com/ipython/traitlets#egg=traitlets --force pip install git+https://github.com/ipython/traitlets#egg=traitlets --force
pip install --upgrade --pre sqlalchemy
fi fi
if [ "${{ matrix.legacy_notebook }}" != "" ]; then if [ "${{ matrix.legacy_notebook }}" != "" ]; then
pip uninstall jupyter_server --yes pip uninstall jupyter_server --yes

View File

@@ -2,19 +2,34 @@
# Check that installed package contains everything we expect # Check that installed package contains everything we expect
import os from pathlib import Path
import jupyterhub
from jupyterhub._data import DATA_FILES_PATH from jupyterhub._data import DATA_FILES_PATH
print("Checking jupyterhub._data") print("Checking jupyterhub._data", end=" ")
print(f"DATA_FILES_PATH={DATA_FILES_PATH}") print(f"DATA_FILES_PATH={DATA_FILES_PATH}", end=" ")
assert os.path.exists(DATA_FILES_PATH), DATA_FILES_PATH DATA_FILES_PATH = Path(DATA_FILES_PATH)
assert DATA_FILES_PATH.is_dir(), DATA_FILES_PATH
for subpath in ( for subpath in (
"templates/page.html", "templates/page.html",
"static/css/style.min.css", "static/css/style.min.css",
"static/components/jquery/dist/jquery.js", "static/components/jquery/dist/jquery.js",
"static/js/admin-react.js", "static/js/admin-react.js",
): ):
path = os.path.join(DATA_FILES_PATH, subpath) path = DATA_FILES_PATH / subpath
assert os.path.exists(path), path assert path.is_file(), path
print("OK")
print("Checking package_data", end=" ")
jupyterhub_path = Path(jupyterhub.__file__).parent.resolve()
for subpath in (
"alembic.ini",
"alembic/versions/833da8570507_rbac.py",
"event-schemas/server-actions/v1.yaml",
):
path = jupyterhub_path / subpath
assert path.is_file(), path
print("OK") print("OK")

View File

@@ -6,7 +6,7 @@ info:
description: The REST API for JupyterHub description: The REST API for JupyterHub
license: license:
name: BSD-3-Clause name: BSD-3-Clause
version: 3.1.0 version: 3.1.1
servers: servers:
- url: /hub/api - url: /hub/api
security: security:

View File

@@ -8,6 +8,17 @@ command line for details.
## 3.1 ## 3.1
### 3.1.1 - 2023-01-27
3.1.1 has only tiny bugfixes, enabling compatibility with the latest sqlalchemy 2.0 release, and fixing some metadata files that were not being included in wheel installs.
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/3.1.0...3.1.1))
#### Bugs fixed
- Backport PR #4303 on branch 3.x: make sure event-schemas are installed [#4317](https://github.com/jupyterhub/jupyterhub/pull/4317) ([@minrk](https://github.com/minrk))
- Backport PR #4302 on branch 3.x: sqlalchemy 2 compatibility [#4315](https://github.com/jupyterhub/jupyterhub/pull/4315) ([@minrk](https://github.com/minrk))
### 3.1.0 - 2022-12-05 ### 3.1.0 - 2022-12-05
3.1.0 is a small release, fixing various bugs and introducing some small new features and metrics. 3.1.0 is a small release, fixing various bugs and introducing some small new features and metrics.

View File

@@ -2,7 +2,7 @@
# Copyright (c) Jupyter Development Team. # Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
# version_info updated by running `tbump` # version_info updated by running `tbump`
version_info = (3, 1, 0, "", "") version_info = (3, 1, 1, "", "")
# pep 440 version: no dot before beta/rc, but before .dev # pep 440 version: no dot before beta/rc, but before .dev
# 0.1.0rc1 # 0.1.0rc1

View File

@@ -94,8 +94,9 @@ class GroupListAPIHandler(_GroupAPIHandler):
# create the group # create the group
self.log.info("Creating new group %s with %i users", name, len(users)) self.log.info("Creating new group %s with %i users", name, len(users))
self.log.debug("Users: %s", usernames) self.log.debug("Users: %s", usernames)
group = orm.Group(name=name, users=users) group = orm.Group(name=name)
self.db.add(group) self.db.add(group)
group.users = users
self.db.commit() self.db.commit()
created.append(group) created.append(group)
self.write(json.dumps([self.group_model(group) for group in created])) self.write(json.dumps([self.group_model(group) for group in created]))
@@ -131,8 +132,9 @@ class GroupAPIHandler(_GroupAPIHandler):
# create the group # create the group
self.log.info("Creating new group %s with %i users", group_name, len(users)) self.log.info("Creating new group %s with %i users", group_name, len(users))
self.log.debug("Users: %s", usernames) self.log.debug("Users: %s", usernames)
group = orm.Group(name=group_name, users=users) group = orm.Group(name=group_name)
self.db.add(group) self.db.add(group)
group.users = users
self.db.commit() self.db.commit()
self.write(json.dumps(self.group_model(group))) self.write(json.dumps(self.group_model(group)))
self.set_status(201) self.set_status(201)

View File

@@ -1948,9 +1948,9 @@ class JupyterHub(Application):
user = orm.User.find(db, name) user = orm.User.find(db, name)
if user is None: if user is None:
user = orm.User(name=name, admin=True) user = orm.User(name=name, admin=True)
db.add(user)
roles.assign_default_roles(self.db, entity=user) roles.assign_default_roles(self.db, entity=user)
new_users.append(user) new_users.append(user)
db.add(user)
else: else:
user.admin = True user.admin = True
# the admin_users config variable will never be used after this point. # the admin_users config variable will never be used after this point.
@@ -2343,6 +2343,7 @@ class JupyterHub(Application):
if orm_service is None: if orm_service is None:
# not found, create a new one # not found, create a new one
orm_service = orm.Service(name=name) orm_service = orm.Service(name=name)
self.db.add(orm_service)
if spec.get('admin', False): if spec.get('admin', False):
self.log.warning( self.log.warning(
f"Service {name} sets `admin: True`, which is deprecated in JupyterHub 2.0." f"Service {name} sets `admin: True`, which is deprecated in JupyterHub 2.0."
@@ -2351,7 +2352,6 @@ class JupyterHub(Application):
"the Service admin flag will be ignored." "the Service admin flag will be ignored."
) )
roles.update_roles(self.db, entity=orm_service, roles=['admin']) roles.update_roles(self.db, entity=orm_service, roles=['admin'])
self.db.add(orm_service)
orm_service.admin = spec.get('admin', False) orm_service.admin = spec.get('admin', False)
self.db.commit() self.db.commit()
service = Service( service = Service(

View File

@@ -257,16 +257,16 @@ class JupyterHubRequestValidator(RequestValidator):
raise ValueError("No such client: %s" % client_id) raise ValueError("No such client: %s" % client_id)
orm_code = orm.OAuthCode( orm_code = orm.OAuthCode(
client=orm_client,
code=code['code'], code=code['code'],
# oauth has 5 minutes to complete # oauth has 5 minutes to complete
expires_at=int(orm.OAuthCode.now() + 300), expires_at=int(orm.OAuthCode.now() + 300),
scopes=list(request.scopes), scopes=list(request.scopes),
user=request.user.orm_user,
redirect_uri=orm_client.redirect_uri, redirect_uri=orm_client.redirect_uri,
session_id=request.session_id, session_id=request.session_id,
) )
self.db.add(orm_code) self.db.add(orm_code)
orm_code.client = orm_client
orm_code.user = request.user.orm_user
self.db.commit() self.db.commit()
def get_authorization_code_scopes(self, client_id, code, redirect_uri, request): def get_authorization_code_scopes(self, client_id, code, redirect_uri, request):

View File

@@ -8,7 +8,9 @@ from datetime import datetime, timedelta
import alembic.command import alembic.command
import alembic.config import alembic.config
import sqlalchemy
from alembic.script import ScriptDirectory from alembic.script import ScriptDirectory
from packaging.version import parse as parse_version
from sqlalchemy import ( from sqlalchemy import (
Boolean, Boolean,
Column, Column,
@@ -24,8 +26,8 @@ from sqlalchemy import (
inspect, inspect,
or_, or_,
select, select,
text,
) )
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import ( from sqlalchemy.orm import (
Session, Session,
backref, backref,
@@ -34,6 +36,13 @@ from sqlalchemy.orm import (
relationship, relationship,
sessionmaker, sessionmaker,
) )
try:
from sqlalchemy.orm import declarative_base
except ImportError:
# sqlalchemy < 1.4
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from sqlalchemy.types import LargeBinary, Text, TypeDecorator from sqlalchemy.types import LargeBinary, Text, TypeDecorator
from tornado.log import app_log from tornado.log import app_log
@@ -749,6 +758,7 @@ class APIToken(Hashed, Base):
session_id=session_id, session_id=session_id,
scopes=list(scopes), scopes=list(scopes),
) )
db.add(orm_token)
orm_token.token = token orm_token.token = token
if user: if user:
assert user.id is not None assert user.id is not None
@@ -759,7 +769,6 @@ class APIToken(Hashed, Base):
if expires_in is not None: if expires_in is not None:
orm_token.expires_at = cls.now() + timedelta(seconds=expires_in) orm_token.expires_at = cls.now() + timedelta(seconds=expires_in)
db.add(orm_token)
db.commit() db.commit()
return token return token
@@ -901,7 +910,7 @@ def register_ping_connection(engine):
""" """
@event.listens_for(engine, "engine_connect") @event.listens_for(engine, "engine_connect")
def ping_connection(connection, branch): def ping_connection(connection, branch=None):
if branch: if branch:
# "branch" refers to a sub-connection of a connection, # "branch" refers to a sub-connection of a connection,
# we don't want to bother pinging on these. # we don't want to bother pinging on these.
@@ -912,11 +921,17 @@ def register_ping_connection(engine):
save_should_close_with_result = connection.should_close_with_result save_should_close_with_result = connection.should_close_with_result
connection.should_close_with_result = False connection.should_close_with_result = False
if parse_version(sqlalchemy.__version__) < parse_version("1.4"):
one = [1]
else:
one = 1
try: try:
# run a SELECT 1. use a core select() so that # run a SELECT 1. use a core select() so that
# the SELECT of a scalar value without a table is # the SELECT of a scalar value without a table is
# appropriately formatted for the backend # appropriately formatted for the backend
connection.scalar(select([1])) with connection.begin() as transaction:
connection.scalar(select(one))
except exc.DBAPIError as err: except exc.DBAPIError as err:
# catch SQLAlchemy's DBAPIError, which is a wrapper # catch SQLAlchemy's DBAPIError, which is a wrapper
# for the DBAPI's exception. It includes a .connection_invalidated # for the DBAPI's exception. It includes a .connection_invalidated
@@ -931,7 +946,8 @@ def register_ping_connection(engine):
# itself and establish a new connection. The disconnect detection # itself and establish a new connection. The disconnect detection
# here also causes the whole connection pool to be invalidated # here also causes the whole connection pool to be invalidated
# so that all stale connections are discarded. # so that all stale connections are discarded.
connection.scalar(select([1])) with connection.begin() as transaction:
connection.scalar(select(one))
else: else:
raise raise
finally: finally:
@@ -955,7 +971,13 @@ def check_db_revision(engine):
from .dbutil import _temp_alembic_ini from .dbutil import _temp_alembic_ini
with _temp_alembic_ini(engine.url) as ini: if hasattr(engine.url, "render_as_string"):
# sqlalchemy >= 1.4
engine_url = engine.url.render_as_string(hide_password=False)
else:
engine_url = str(engine.url)
with _temp_alembic_ini(engine_url) as ini:
cfg = alembic.config.Config(ini) cfg = alembic.config.Config(ini)
scripts = ScriptDirectory.from_config(cfg) scripts = ScriptDirectory.from_config(cfg)
head = scripts.get_heads()[0] head = scripts.get_heads()[0]
@@ -990,9 +1012,10 @@ def check_db_revision(engine):
# check database schema version # check database schema version
# it should always be defined at this point # it should always be defined at this point
alembic_revision = engine.execute( with engine.begin() as connection:
'SELECT version_num FROM alembic_version' alembic_revision = connection.execute(
).first()[0] text('SELECT version_num FROM alembic_version')
).first()[0]
if alembic_revision == head: if alembic_revision == head:
app_log.debug("database schema version found: %s", alembic_revision) app_log.debug("database schema version found: %s", alembic_revision)
else: else:
@@ -1009,13 +1032,16 @@ def mysql_large_prefix_check(engine):
"""Check mysql has innodb_large_prefix set""" """Check mysql has innodb_large_prefix set"""
if not str(engine.url).startswith('mysql'): if not str(engine.url).startswith('mysql'):
return False return False
variables = dict( with engine.begin() as connection:
engine.execute( variables = dict(
'show variables where variable_name like ' connection.execute(
'"innodb_large_prefix" or ' text(
'variable_name like "innodb_file_format";' 'show variables where variable_name like '
).fetchall() '"innodb_large_prefix" or '
) 'variable_name like "innodb_file_format";'
)
).fetchall()
)
if ( if (
variables.get('innodb_file_format', 'Barracuda') == 'Barracuda' variables.get('innodb_file_format', 'Barracuda') == 'Barracuda'
and variables.get('innodb_large_prefix', 'ON') == 'ON' and variables.get('innodb_large_prefix', 'ON') == 'ON'

View File

@@ -521,11 +521,12 @@ async def test_get_self(app):
db.add(oauth_client) db.add(oauth_client)
db.commit() db.commit()
oauth_token = orm.APIToken( oauth_token = orm.APIToken(
user=u.orm_user,
oauth_client=oauth_client,
token=token, token=token,
) )
db.add(oauth_token) db.add(oauth_token)
oauth_token.user = u.orm_user
oauth_token.oauth_client = oauth_client
db.commit() db.commit()
r = await api_request( r = await api_request(
app, app,
@@ -2117,13 +2118,13 @@ def test_shutdown(app):
def stop(): def stop():
stop.called = True stop.called = True
loop.call_later(1, real_stop) loop.call_later(2, real_stop)
real_cleanup = app.cleanup real_cleanup = app.cleanup
def cleanup(): def cleanup():
cleanup.called = True cleanup.called = True
return real_cleanup() loop.call_later(1, real_cleanup)
app.cleanup = cleanup app.cleanup = cleanup

View File

@@ -323,7 +323,9 @@ def test_spawner_delete_cascade(db):
db.add(user) db.add(user)
db.commit() db.commit()
spawner = orm.Spawner(user=user) spawner = orm.Spawner()
db.add(spawner)
spawner.user = user
db.commit() db.commit()
spawner.server = server = orm.Server() spawner.server = server = orm.Server()
db.commit() db.commit()
@@ -350,16 +352,19 @@ def test_user_delete_cascade(db):
# these should all be deleted automatically when the user goes away # these should all be deleted automatically when the user goes away
user.new_api_token() user.new_api_token()
api_token = user.api_tokens[0] api_token = user.api_tokens[0]
spawner = orm.Spawner(user=user) spawner = orm.Spawner()
db.add(spawner)
spawner.user = user
db.commit() db.commit()
spawner.server = server = orm.Server() spawner.server = server = orm.Server()
oauth_code = orm.OAuthCode(client=oauth_client, user=user) oauth_code = orm.OAuthCode()
db.add(oauth_code) db.add(oauth_code)
oauth_token = orm.APIToken( oauth_code.client = oauth_client
oauth_client=oauth_client, oauth_code.user = user
user=user, oauth_token = orm.APIToken()
)
db.add(oauth_token) db.add(oauth_token)
oauth_token.oauth_client = oauth_client
oauth_token.user = user
db.commit() db.commit()
# record all of the ids # record all of the ids
@@ -390,13 +395,14 @@ def test_oauth_client_delete_cascade(db):
# create a bunch of objects that reference the User # create a bunch of objects that reference the User
# these should all be deleted automatically when the user goes away # these should all be deleted automatically when the user goes away
oauth_code = orm.OAuthCode(client=oauth_client, user=user) oauth_code = orm.OAuthCode()
db.add(oauth_code) db.add(oauth_code)
oauth_token = orm.APIToken( oauth_code.client = oauth_client
oauth_client=oauth_client, oauth_code.user = user
user=user, oauth_token = orm.APIToken()
)
db.add(oauth_token) db.add(oauth_token)
oauth_token.oauth_client = oauth_client
oauth_token.user = user
db.commit() db.commit()
assert user.api_tokens == [oauth_token] assert user.api_tokens == [oauth_token]
@@ -517,11 +523,11 @@ def test_expiring_oauth_token(app, user):
db.add(client) db.add(client)
orm_token = orm.APIToken( orm_token = orm.APIToken(
token=token, token=token,
oauth_client=client,
user=user,
expires_at=now() + timedelta(seconds=30), expires_at=now() + timedelta(seconds=30),
) )
db.add(orm_token) db.add(orm_token)
orm_token.oauth_client = client
orm_token.user = user
db.commit() db.commit()
found = orm.APIToken.find(db, token) found = orm.APIToken.find(db, token)

View File

@@ -1045,11 +1045,10 @@ async def test_oauth_token_page(app):
user = app.users[orm.User.find(app.db, name)] user = app.users[orm.User.find(app.db, name)]
client = orm.OAuthClient(identifier='token') client = orm.OAuthClient(identifier='token')
app.db.add(client) app.db.add(client)
oauth_token = orm.APIToken( oauth_token = orm.APIToken()
oauth_client=client,
user=user,
)
app.db.add(oauth_token) app.db.add(oauth_token)
oauth_token.oauth_client = client
oauth_token.user = user
app.db.commit() app.db.commit()
r = await get_page('token', app, cookies=cookies) r = await get_page('token', app, cookies=cookies)
r.raise_for_status() r.raise_for_status()

View File

@@ -3,9 +3,11 @@
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
import json import json
import os import os
import warnings
import pytest import pytest
from pytest import mark from pytest import mark
from sqlalchemy.exc import SADeprecationWarning
from tornado.log import app_log from tornado.log import app_log
from .. import orm, roles from .. import orm, roles
@@ -343,7 +345,13 @@ async def test_creating_roles(app, role, role_def, response_type, response):
# make sure no warnings/info logged when the role exists and its definition hasn't been changed # make sure no warnings/info logged when the role exists and its definition hasn't been changed
elif response_type == 'no-log': elif response_type == 'no-log':
with pytest.warns(response) as record: with pytest.warns(response) as record:
# don't catch already-suppressed sqlalchemy warnings
warnings.simplefilter("ignore", SADeprecationWarning)
roles.create_role(db, role_def) roles.create_role(db, role_def)
for warning in record.list:
# show warnings for debugging
print("Unexpected warning", warning)
assert not record.list assert not record.list
role = orm.Role.find(db, role_def['name']) role = orm.Role.find(db, role_def['name'])
assert role is not None assert role is not None

View File

@@ -6,12 +6,27 @@ from concurrent.futures import ThreadPoolExecutor
import pytest import pytest
import requests import requests
from certipy import Certipy from certipy import Certipy
from sqlalchemy import text
from jupyterhub import metrics, orm from jupyterhub import metrics, orm
from jupyterhub.objects import Server from jupyterhub.objects import Server
from jupyterhub.roles import assign_default_roles, update_roles from jupyterhub.roles import assign_default_roles, update_roles
from jupyterhub.utils import url_path_join as ujoin from jupyterhub.utils import url_path_join as ujoin
try:
from sqlalchemy.exc import RemovedIn20Warning
except ImportError:
class RemovedIn20Warning(DeprecationWarning):
"""
I only exist so I can be used in warnings filters in pytest.ini
I will never be displayed.
sqlalchemy 1.4 introduces RemovedIn20Warning,
but we still test against older sqlalchemy.
"""
class _AsyncRequests: class _AsyncRequests:
"""Wrapper around requests to return a Future from request methods """Wrapper around requests to return a Future from request methods
@@ -84,8 +99,8 @@ def check_db_locks(func):
def _check(_=None): def _check(_=None):
temp_session = app.session_factory() temp_session = app.session_factory()
try: try:
temp_session.execute('CREATE TABLE dummy (foo INT)') temp_session.execute(text('CREATE TABLE dummy (foo INT)'))
temp_session.execute('DROP TABLE dummy') temp_session.execute(text('DROP TABLE dummy'))
finally: finally:
temp_session.close() temp_session.close()

View File

@@ -416,9 +416,10 @@ class User:
yield orm_spawner yield orm_spawner
def _new_orm_spawner(self, server_name): def _new_orm_spawner(self, server_name):
"""Creat the low-level orm Spawner object""" """Create the low-level orm Spawner object"""
orm_spawner = orm.Spawner(user=self.orm_user, name=server_name) orm_spawner = orm.Spawner(name=server_name)
self.db.add(orm_spawner) self.db.add(orm_spawner)
orm_spawner.user = self.orm_user
self.db.commit() self.db.commit()
assert server_name in self.orm_spawners assert server_name in self.orm_spawners
return orm_spawner return orm_spawner

View File

@@ -43,7 +43,7 @@ target_version = [
github_url = "https://github.com/jupyterhub/jupyterhub" github_url = "https://github.com/jupyterhub/jupyterhub"
[tool.tbump.version] [tool.tbump.version]
current = "3.1.0" current = "3.1.1"
# Example of a semver regexp. # Example of a semver regexp.
# Make sure this matches current_version before # Make sure this matches current_version before

View File

@@ -18,3 +18,7 @@ markers =
slow: mark a test as slow slow: mark a test as slow
role: mark as a test for roles role: mark as a test for roles
selenium: web tests that run with selenium selenium: web tests that run with selenium
filterwarnings =
error:.*:jupyterhub.tests.utils.RemovedIn20Warning
ignore:.*event listener has changed as of version 2.0.*:sqlalchemy.exc.SADeprecationWarning

View File

@@ -50,7 +50,12 @@ def get_package_data():
(mostly alembic config) (mostly alembic config)
""" """
package_data = {} package_data = {}
package_data['jupyterhub'] = ['alembic.ini', 'alembic/*', 'alembic/versions/*'] package_data['jupyterhub'] = [
'alembic.ini',
'alembic/*',
'alembic/versions/*',
'event-schemas/*/*.yaml',
]
return package_data return package_data