Skip to content

Commit

Permalink
upadte
Browse files Browse the repository at this point in the history
  • Loading branch information
Adam-D-Lewis committed Dec 18, 2024
1 parent db648c6 commit b8f8c12
Show file tree
Hide file tree
Showing 5 changed files with 116 additions and 44 deletions.
99 changes: 70 additions & 29 deletions jhub_apps/config_utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,66 @@
from traitlets import Any, Instance, Unicode, Union, List, Callable, Integer, Bool
from traitlets import Any, Instance, Int, Unicode, Union, List, Callable, Integer, Bool
from traitlets.config import SingletonConfigurable, Enum

from jhub_apps.service.models import JHubAppConfig
from jhub_apps.service.models import JHubAppConfig, ServerCreation, StartupApp

import textwrap
import typing as t
from pydantic import BaseModel, ValidationError
from traitlets import TraitType, TraitError, HasTraits
import traitlets

class PydanticModelTrait(TraitType):
"""A trait type for validating Pydantic models.
This trait ensures that the input is an instance of a specific Pydantic model type.
"""

def __init__(self, model_class: t.Type[BaseModel], *args, **kwargs):
"""
Initialize the trait with a specific Pydantic model class.
Args:
model_class: The Pydantic model class to validate against
*args: Additional arguments for TraitType
**kwargs: Additional keyword arguments for TraitType
"""
super().__init__(*args, **kwargs)
self.model_class = model_class
self.info_text = f"an instance of {model_class.__name__}"

def validate(self, obj: t.Any, value: t.Any) -> BaseModel:
"""
Validate that the input is an instance of the specified Pydantic model.
Args:
obj: The object the trait is attached to
value: The value to validate
Returns:
Validated Pydantic model instance
Raises:
TraitError: If the value is not a valid instance of the model
"""
# If None is allowed and value is None, return None
if self.allow_none and value is None:
return None

# Check if value is an instance of the specified model class
if isinstance(value, self.model_class):
return value

# If not an instance, try to create an instance from a dict
if isinstance(value, dict):
try:
return self.model_class(**value)
except ValidationError as e:
# Convert Pydantic validation error to TraitError
raise traitlets.TraitError(f'Could not parse input as a valid {self.model_class.__name__} Pydantic model:\n'
f'{textwrap.indent(str(e), prefix=" ")}')

raise traitlets.TraitError(f'Input must be a valid {self.model_class.__name__} Pydantic model or dict object, but got {value}.')


class JAppsConfig(SingletonConfigurable):
apps_auth_type = Enum(
Expand Down Expand Up @@ -51,42 +109,25 @@ class JAppsConfig(SingletonConfigurable):
help="The number of workers to create for the JHub Apps FastAPI service",
).tag(config=True)

allowed_frameworks = Bool(
allowed_frameworks = List(
None,
help="Allow only a specific set of frameworks to spun up apps.",
allow_none=True,
).tag(config=True)

blocked_frameworks = Bool(
blocked_frameworks = List(
None,
help="Disallow a set of frameworks to avoid spinning up apps using those frameworks",
allow_none=True,
).tag(config=True)

startup_apps = List(
trait=Any, # TODO: Change this, use Instance() maybe or define a new type - https://traitlets.readthedocs.io/en/stable/defining_traits.html
my_int_list = List(
trait=Int,
).tag(config=True)

startup_apps = List(
trait=PydanticModelTrait(StartupApp),
description="only add a server if it is not already created or edit an existing one to match the config, won't delete any servers",

# This class should be a ServerCreation class + user + thumbnail
default_value=[{
'display_name': 'Adam\'s App',
'description': 'description',
'thumbnail': 'data:image/jpeg;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNkYPhfz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC',
'filepath': '/home/balast/CodingProjects/jhub-apps/jhub_apps/examples/panel_basic2.py',
'framework': 'panel',
'custom_command': '',
'public': False,
'keep_alive': False,
'env': {'ENV_VAR_KEY_1': 'ENV_VAR_KEY_1',
'ENV_VAR_KEY_2': 'ENV_VAR_KEY_2'},
'repository': None,
'jhub_app': True,
'conda_env': '',
'profile': '',
'share_with':
{'users': ['alice', 'john', 'admin'],
'groups': ['alpha', 'beta']},
'servername': 'my-server',
'username': 'alice',
}],
default_value=[],
help="List of apps to start on JHub Apps Launcher startup",
).tag(config=True)
31 changes: 16 additions & 15 deletions jhub_apps/service/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,37 +32,38 @@
@asynccontextmanager
async def lifespan(app: FastAPI):
config = get_jupyterhub_config()
user_options_list = config['JAppsConfig']['startup_apps']
startup_apps_list = config['JAppsConfig']['startup_apps']
# group user options by username

grouped_user_options_list = groupby(user_options_list, itemgetter('username'))
for username, user_options_list in grouped_user_options_list:
instantiate_startup_apps(user_options_list, username=username)

grouped_user_options_list = groupby(startup_apps_list, lambda x: x.username)
for username, startup_apps_list in grouped_user_options_list:
instantiate_startup_apps(startup_apps_list, username=username)

yield

def instantiate_startup_apps(server_creation_list: list[dict[str, Any]], username: str):
def instantiate_startup_apps(startup_apps_list: list[dict[str, Any]], username: str):
# TODO: Support defining app from git repo
hub_client = HubClient(username=username)

existing_servers = hub_client.get_server(username=username)

for server_creation_dict in server_creation_list:
user_options = UserOptions(**server_creation_dict)
servername = server_creation_dict['servername']
if server_creation_dict['servername'] in existing_servers:
for startup_app in startup_apps_list:
user_options = startup_app.user_options
servername = startup_app.servername
if servername in existing_servers:
# update the server
logger.info(f"{'='*50}Updating server: {server_creation_dict['servername']}")
logger.info(f"Updating server: {servername}")
hub_client.edit_server(username, servername, user_options)
else:
# create the server
logger.info(f"{'='*50}Instantiating app with user_options: {pprint.pformat(server_creation_dict)}") # TODO: Remove
# user_options = UserOptions(**server_creation_dict)

logger.info(f"Creating server {servername}")
hub_client.create_server(
username=username,
servername=servername,
user_options=user_options,
)

# stop server
hub_client.delete_server(username, servername, remove=False)
logger.info('Done instantiating apps')

app = FastAPI(
Expand Down
3 changes: 3 additions & 0 deletions jhub_apps/service/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,6 @@ class UserOptions(JHubAppConfig):
class ServerCreation(BaseModel):
servername: str
user_options: UserOptions

class StartupApp(ServerCreation):
username: str
4 changes: 4 additions & 0 deletions jhub_apps/service/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from jupyterhub.app import JupyterHub
from traitlets.config import LazyConfigValue

from jhub_apps.config_utils import JAppsConfig
from jhub_apps.hub_client.hub_client import HubClient
from jhub_apps.service.models import UserOptions
from jhub_apps.spawner.types import FrameworkConf, FRAMEWORKS_MAPPING, FRAMEWORKS
Expand All @@ -25,9 +26,12 @@
@cached(cache=TTLCache(maxsize=1024, ttl=CACHE_JUPYTERHUB_CONFIG_TIMEOUT))
def get_jupyterhub_config():
hub = JupyterHub()
hub.classes.append(JAppsConfig)
jhub_config_file_path = os.environ["JHUB_JUPYTERHUB_CONFIG"]
logger.info(f"Getting JHub config from file: {jhub_config_file_path}")
hub.load_config_file(jhub_config_file_path)
# hacky, but I couldn't figure out how to get validation of the config otherwise (In this case, validation converts the dict in the config to a Pydantic model)
hub.config.JAppsConfig.startup_apps = JAppsConfig(config=hub.config).startup_apps
config = hub.config
logger.info(f"JHub Apps config: {config.JAppsConfig}")
return config
Expand Down
23 changes: 23 additions & 0 deletions jupyterhub_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,29 @@
c.JAppsConfig.jupyterhub_config_path = "jupyterhub_config.py"
c.JAppsConfig.conda_envs = []
c.JAppsConfig.service_workers = 1
c.JAppsConfig.my_int_list = [1]
c.JAppsConfig.startup_apps = [
{
"username": "alice",
"servername": "alice's-startup-server",
"user_options": {
"display_name": "Alice's Panel App",
"description": "description",
"thumbnail": "data:image/jpeg;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNkYPhfz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC",
"filepath": "",
"framework": "panel",
"custom_command": "",
"public": False,
"keep_alive": False,
"env": {"ENV_VAR_KEY_1": "ENV_VAR_KEY_1"},
"repository": None,
"jhub_app": True,
"conda_env": "",
"profile": "",
"share_with": {"users": ["admin"], "groups": ["class-A"]},
},
}
]
c.JupyterHub.default_url = "/hub/home"

c = install_jhub_apps(
Expand Down

0 comments on commit b8f8c12

Please sign in to comment.