Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
ea89b2c
initial implementation
andrii-i Dec 10, 2025
6553a04
Use entry points instead of jupyter server setting traitlets for back…
andrii-i Dec 10, 2025
7926459
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Dec 10, 2025
ceb1246
adjust details page field display order
andrii-i Dec 10, 2025
b9cb64f
add advancedOptionsOverride token to avoid token mismatch in extensions
andrii-i Dec 11, 2025
658d52e
Eencode backend into job IDs, add backend field to job definitions, u…
andrii-i Dec 12, 2025
d4a21f9
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Dec 12, 2025
720d26d
rename legacy backend, cleanup tests and comments
andrii-i Dec 12, 2025
3e48af6
add python backend
andrii-i Dec 12, 2025
ea2fb73
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Dec 12, 2025
09e3fac
add dynamic context menu registration
andrii-i Dec 12, 2025
939a2aa
Only validate notebooks have a kernel for .ipynb files
andrii-i Dec 12, 2025
b092f48
add stdour and stderr for py files
andrii-i Dec 12, 2025
a37a4df
Auto-select backend by file extension, fall back to default
andrii-i Dec 12, 2025
dd4ce1c
Create stdout/stderr files only when there's actual content
andrii-i Dec 12, 2025
8e24c36
Only add output files that actually exist to job_files / output files
andrii-i Dec 12, 2025
a82594f
hide backend picker only while loading
andrii-i Dec 12, 2025
e96b854
rename default backends
andrii-i Dec 12, 2025
87290fb
change AdvancedOptions imports
andrii-i Dec 12, 2025
4529a84
demo: output file formats
andrii-i Dec 15, 2025
2db6ce5
demo: hyperpod backend
andrii-i Dec 15, 2025
d167452
support listing from multiple backends, add hardcoded putput formats
andrii-i Dec 16, 2025
4ace22d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Dec 16, 2025
9073459
optimize backend_id logic
andrii-i Dec 16, 2025
66ffaf6
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Dec 16, 2025
a489b59
fix output format picker
andrii-i Dec 17, 2025
2a05a69
update tests
andrii-i Dec 17, 2025
9dc9248
update output_formats.name to id
andrii-i Dec 18, 2025
7f1b477
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Dec 18, 2025
520ac90
update tests
andrii-i Dec 18, 2025
7d6d2a1
update UpdateJob model
andrii-i Dec 18, 2025
6f6b544
comment out # jupyter_server_nb, jupyter_server_py, sagemaker_hyperpo…
andrii-i Dec 18, 2025
89eeb58
nake list jobs async
andrii-i Dec 19, 2025
a36ecb5
cleanup code
andrii-i Dec 19, 2025
9e55aae
fix backend picker
andrii-i Dec 20, 2025
518d742
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 21, 2026
570012f
fix backend picker
andrii-i Jan 21, 2026
4e2e9f0
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 21, 2026
71f6686
simplify default backend choice logic
andrii-i Jan 21, 2026
2e72c0c
Remove allowed_backends / blocked_backends
andrii-i Jan 21, 2026
80bb153
remove unused code SageMakerHyperPodBackend, OutputFormatDescriptor
andrii-i Jan 21, 2026
32e1308
type BaseBackend.OutputFormats
andrii-i Jan 22, 2026
07c5e08
streamline custromization of default backend per file extension via p…
andrii-i Jan 23, 2026
4b07c70
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 23, 2026
ce9f4eb
raise error on an unknown backend instaed of silently falling back on…
andrii-i Jan 23, 2026
f0dd31e
abstract scheduler resolution into helper function
andrii-i Jan 23, 2026
0dab0cf
add playwright test for multiple backends
andrii-i Jan 23, 2026
63175bf
simplify docstrings
andrii-i Jan 23, 2026
b59ba54
add autoflake to CI and run it to remove unused imports, statements, …
andrii-i Jan 23, 2026
34e7016
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 23, 2026
35c4021
run lint
andrii-i Jan 23, 2026
409f5f0
remove duplicate test
andrii-i Jan 23, 2026
b89ce01
remove unnecessary tests and comments
andrii-i Jan 23, 2026
37f9ca2
reorder backends.py structure
andrii-i Jan 24, 2026
5aa7b01
remove advanced options override
andrii-i Jan 26, 2026
a2258ec
update snapshots
andrii-i Jan 26, 2026
e9df0f5
refactor helper functions
andrii-i Jan 26, 2026
2ebe15c
add comments
andrii-i Feb 3, 2026
1da0e91
change id formulation
andrii-i Feb 3, 2026
52b7125
rename backend to backend_id, remove `local` fallback logic, fill in …
andrii-i Feb 3, 2026
3114e4b
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 3, 2026
d5ba046
comment out flakey snapshot comparasions
andrii-i Feb 4, 2026
3293cd2
implement comments
andrii-i Feb 4, 2026
24dca85
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 4, 2026
508c5d9
return error strings with 500
andrii-i Feb 4, 2026
3c10450
properly map ValidationError to 400
andrii-i Feb 4, 2026
bbbf1fd
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 4, 2026
33b6f55
update logger.error to logger.exception to catch trace
andrii-i Feb 4, 2026
91e4052
check for absence of colon in the backend_id
andrii-i Feb 4, 2026
2d791e3
add typing
andrii-i Feb 4, 2026
7a77a99
make auto-seleciong of a valid backend when preferred backend doesn't…
andrii-i Feb 4, 2026
82a0ea8
reference strerr rather than embed part of the output
andrii-i Feb 5, 2026
e217c4e
fix test assertions
andrii-i Feb 5, 2026
878e984
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ repos:
- id: check-builtin-literals
- id: trailing-whitespace

- repo: https://github.com/PyCQA/autoflake
rev: v2.3.1
hooks:
- id: autoflake

- repo: https://github.com/psf/black
rev: 24.2.0
hooks:
Expand Down
20 changes: 19 additions & 1 deletion conftest.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
from pathlib import Path
from unittest.mock import patch

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from jupyter_scheduler.orm import Base
from jupyter_scheduler.scheduler import Scheduler
from jupyter_scheduler.tests.mocks import MockEnvironmentManager
from jupyter_scheduler.tests.mocks import MockEnvironmentManager, MockTestBackend

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


def _mock_discover_backends(*args, **kwargs):
"""Return test backends for testing."""
from jupyter_scheduler.backends import JupyterServerNotebookBackend

return {"jupyter_server_nb": JupyterServerNotebookBackend, "test": MockTestBackend}


@pytest.fixture(autouse=True)
def mock_backend_discovery():
"""Patch backend discovery to include test backend for all tests."""
with patch(
"jupyter_scheduler.extension.discover_backends", side_effect=_mock_discover_backends
):
yield


@pytest.fixture(scope="session")
def static_test_files_dir() -> Path:
return Path(__file__).parent.resolve() / "jupyter_scheduler" / "tests" / "static"
Expand Down Expand Up @@ -51,6 +68,7 @@ def jp_scheduler_db(jp_scheduler_db_url):
session = Session()
yield session
session.close()
engine.dispose()


@pytest.fixture
Expand Down
2 changes: 1 addition & 1 deletion dev/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ async def load_data(jobs_count: int, job_defs_count: int, db_path: str):
f"\nCreated {jobs_count} jobs and {job_defs_count} job definitions in the scheduler database"
)
click.echo(f"present at {db_path}. Copy the following command")
click.echo(f"to start JupyterLab with this database.\n")
click.echo("to start JupyterLab with this database.\n")
click.echo(f"`jupyter lab --SchedulerApp.db_url={db_url}`\n")


Expand Down
1 change: 0 additions & 1 deletion jupyter_scheduler/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Scheduling API for JupyterLab"""

from ._version import __version__
from .extension import SchedulerApp


Expand Down
3 changes: 0 additions & 3 deletions jupyter_scheduler/_version.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import json
from pathlib import Path

__all__ = ["__version__"]

version_info = (2, 11, 0, "", "")
Expand Down
167 changes: 167 additions & 0 deletions jupyter_scheduler/backend_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import logging
from typing import Any, Dict, List, Optional, Type

from jupyter_scheduler.backends import BackendConfig, DescribeBackendResponse
from jupyter_scheduler.environments import EnvironmentManager
from jupyter_scheduler.orm import create_tables
from jupyter_scheduler.pydantic_v1 import BaseModel

logger = logging.getLogger(__name__)


def import_class(class_path: str) -> Type:
"""Import a class from a fully qualified path like 'module.submodule.ClassName'."""
module_path, class_name = class_path.rsplit(".", 1)
module = __import__(module_path, fromlist=[class_name])
return getattr(module, class_name)


class BackendInstance(BaseModel):
"""A running backend with its configuration and initialized scheduler."""

config: BackendConfig
scheduler: Any # BaseScheduler at runtime, but Any to support test mocks
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hum, compromising typing integrity for the sake of unit tests doesn't sound right...



class BackendRegistry:
"""Registry for storing, initializing, and routing to scheduler backends."""

def __init__(
self,
configs: List[BackendConfig],
legacy_job_backend: str,
preferred_backends: Optional[Dict[str, str]] = None,
):
self._configs = configs
self._backends: Dict[str, BackendInstance] = {}
self._legacy_job_backend = legacy_job_backend
self._preferred_backends = preferred_backends or {}
self._extension_map: Dict[str, List[str]] = {}

def initialize(
self,
root_dir: str,
environments_manager: EnvironmentManager,
db_url: str,
config: Optional[Any] = None,
):
"""Instantiate all backends from configs."""
seen_ids = set()
for cfg in self._configs:
if cfg.id in seen_ids:
raise ValueError(f"Duplicate backend ID: '{cfg.id}'")
if ":" in cfg.id:
raise ValueError(f"Backend ID cannot contain ':': '{cfg.id}'")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: performing two validation operations in the same loop, this is a bit odd.
Any way we could use pydantic models to validate the ID regex?

pydantic most likely supports disallowing duplicates in list.

seen_ids.add(cfg.id)

for cfg in self._configs:
try:
instance = self._create_backend(cfg, root_dir, environments_manager, db_url, config)
self._backends[cfg.id] = instance

for ext in cfg.file_extensions:
ext_lower = ext.lower().lstrip(".")
if ext_lower not in self._extension_map:
self._extension_map[ext_lower] = []
self._extension_map[ext_lower].append(cfg.id)

logger.info(f"Initialized backend: {cfg.id} ({cfg.name})")
except Exception as e:
logger.error(f"Failed to initialize backend {cfg.id}: {e}")
raise

def _create_backend(
self,
cfg: BackendConfig,
root_dir: str,
environments_manager: EnvironmentManager,
global_db_url: str,
config: Optional[Any] = None,
) -> BackendInstance:
"""Import scheduler class, instantiate it, and return a BackendInstance.

Creates database tables if not found and backend uses default SQLAlchemy storage.
"""
scheduler_class = import_class(cfg.scheduler_class)

backend_db_url = cfg.db_url or global_db_url

# Create SQL tables only if backend uses default SQLAlchemy storage.
# Backends with custom database_manager_class handle their own storage.
if backend_db_url and cfg.database_manager_class is None:
create_tables(backend_db_url)

scheduler = scheduler_class(
root_dir=root_dir,
environments_manager=environments_manager,
db_url=backend_db_url,
config=config,
backend_id=cfg.id,
)

if cfg.execution_manager_class:
scheduler.execution_manager_class = import_class(cfg.execution_manager_class)

return BackendInstance(config=cfg, scheduler=scheduler)

def get_backend(self, backend_id: str) -> Optional[BackendInstance]:
"""Return a backend with matching ID, None if none is found."""
return self._backends.get(backend_id)

def get_legacy_job_backend(self) -> BackendInstance:
"""Get the backend for routing legacy jobs (UUID-only IDs from pre-3.0).

Raises:
KeyError: If the configured legacy_job_backend ID is not found.
"""
if self._legacy_job_backend not in self._backends:
raise KeyError(f"Legacy job backend '{self._legacy_job_backend}' not found in registry")
return self._backends[self._legacy_job_backend]

def get_for_file(self, input_uri: str) -> BackendInstance:
"""Auto-select backend by file extension. Prefers configured backend, else alphabetical.

Raises:
ValueError: If no backend supports the file extension.
"""
ext = ""
if "." in input_uri:
ext = input_uri.rsplit(".", 1)[-1].lower()

candidate_ids = self._extension_map.get(ext, [])
if not candidate_ids:
raise ValueError(f"No backend supports file extension '.{ext}'")

# 1. Check explicit preference for this extension
preferred_id = self._preferred_backends.get(ext)
if preferred_id and preferred_id in candidate_ids:
return self._backends[preferred_id]

# 2. Otherwise return min by name (first alphabetically)
candidate_instances = [self._backends[bid] for bid in candidate_ids]
return min(candidate_instances, key=lambda b: b.config.name)

def describe_backends(self) -> List[DescribeBackendResponse]:
"""Return backend descriptions sorted alphabetically by name. Frontend uses first as default."""
backends_sorted = sorted(self._backends.values(), key=lambda b: b.config.name)
return [
DescribeBackendResponse(
id=b.config.id,
name=b.config.name,
description=b.config.description,
file_extensions=b.config.file_extensions,
output_formats=b.config.output_formats,
)
for b in backends_sorted
]

@property
def backends(self) -> List[BackendInstance]:
"""Return all backend instances."""
return list(self._backends.values())

def __len__(self) -> int:
return len(self._backends)

def __contains__(self, backend_id: str) -> bool:
return backend_id in self._backends
70 changes: 70 additions & 0 deletions jupyter_scheduler/backend_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import logging
from importlib.metadata import entry_points
from typing import Dict, Optional, Type

from jupyter_scheduler.backends import DEFAULT_FALLBACK_BACKEND_ID
from jupyter_scheduler.base_backend import BaseBackend

ENTRY_POINT_GROUP = "jupyter_scheduler.backends"

logger = logging.getLogger(__name__)


def discover_backends(
log: Optional[logging.Logger] = None,
) -> Dict[str, Type[BaseBackend]]:
"""Discover backends registered in the 'jupyter_scheduler.backends' entry point group."""
if log is None:
log = logger

backends: Dict[str, Type[BaseBackend]] = {}

eps = entry_points()
if hasattr(eps, "select"):
backend_eps = eps.select(group=ENTRY_POINT_GROUP)
else:
backend_eps = eps.get(ENTRY_POINT_GROUP, [])

for ep in backend_eps:
try:
backend_class = ep.load()
except ImportError as e:
missing_package = getattr(e, "name", str(e))
log.warning(
f"Unable to load backend '{ep.name}': missing dependency '{missing_package}'. "
f"Install the required package to enable this backend."
)
continue
except Exception as e:
log.warning(f"Unable to load backend '{ep.name}': {e}")
continue

if not hasattr(backend_class, "id"):
log.warning(f"Backend '{ep.name}' does not define 'id' attribute. Skipping.")
continue

backend_id = backend_class.id
backends[backend_id] = backend_class
log.info(f"Registered backend '{backend_id}' ({backend_class.name})")

return backends


def get_legacy_job_backend_id(
available_backends: Dict[str, Type[BaseBackend]],
legacy_job_backend: Optional[str] = None,
) -> str:
"""Get backend ID for routing legacy jobs (UUID-only IDs from pre-3.0)."""
if not available_backends:
raise ValueError("No scheduler backends available.")

if legacy_job_backend and legacy_job_backend in available_backends:
return legacy_job_backend

if DEFAULT_FALLBACK_BACKEND_ID in available_backends:
return DEFAULT_FALLBACK_BACKEND_ID

raise ValueError(
f"No backend for legacy jobs. Set SchedulerApp.legacy_job_backend. "
f"Available: {list(available_backends.keys())}"
)
72 changes: 72 additions & 0 deletions jupyter_scheduler/backends.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from typing import Any, Dict, List, Optional

from jupyter_scheduler.base_backend import BaseBackend
from jupyter_scheduler.models import OutputFormat
from jupyter_scheduler.pydantic_v1 import BaseModel, Field

JUPYTER_SERVER_NB_BACKEND_ID = "jupyter_server_nb"
JUPYTER_SERVER_PY_BACKEND_ID = "jupyter_server_py"
DEFAULT_FALLBACK_BACKEND_ID = JUPYTER_SERVER_NB_BACKEND_ID


class BackendConfig(BaseModel):
"""Runtime configuration for an initialized backend instance."""

id: str
name: str
description: str
scheduler_class: str
execution_manager_class: str
database_manager_class: Optional[str] = None
db_url: Optional[str] = None
file_extensions: List[str] = Field(default_factory=list)
output_formats: List[Dict[str, str]] = Field(default_factory=list)
metadata: Optional[Dict[str, Any]] = None


class DescribeBackendResponse(BaseModel):
"""API response model for GET /scheduler/backends.

Backends are returned sorted alphabetically by name for consistent UI ordering.
Use preferred_backends config to control which backend is pre-selected per file extension.
"""

id: str
name: str
description: str
file_extensions: List[str]
output_formats: List[OutputFormat]

class Config:
orm_mode = True


class JupyterServerNotebookBackend(BaseBackend):
"""Built-in backend executing notebooks via nbconvert on the Jupyter server."""

id = JUPYTER_SERVER_NB_BACKEND_ID
name = "Jupyter Server Notebook"
description = "Execute notebooks on the Jupyter server"
scheduler_class = "jupyter_scheduler.scheduler.Scheduler"
execution_manager_class = "jupyter_scheduler.executors.DefaultExecutionManager"
file_extensions = ["ipynb"]
output_formats = [
{"id": "ipynb", "label": "Notebook", "description": "Executed notebook with outputs"},
{"id": "html", "label": "HTML", "description": "HTML export of notebook"},
]


class JupyterServerPythonBackend(BaseBackend):
"""Built-in backend executing Python scripts via subprocess on the Jupyter server."""

id = JUPYTER_SERVER_PY_BACKEND_ID
name = "Jupyter Server Python"
description = "Execute Python scripts on the Jupyter server"
scheduler_class = "jupyter_scheduler.scheduler.Scheduler"
execution_manager_class = "jupyter_scheduler.python_executor.PythonScriptExecutionManager"
file_extensions = ["py"]
output_formats = [
{"id": "stdout", "label": "Output", "description": "Standard output from script"},
{"id": "stderr", "label": "Errors", "description": "Standard error from script"},
{"id": "json", "label": "JSON", "description": "JSON result if script produces one"},
]
Loading
Loading