-
Notifications
You must be signed in to change notification settings - Fork 35
Support multiple backends #596
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
74 commits
Select commit
Hold shift + click to select a range
ea89b2c
initial implementation
andrii-i 6553a04
Use entry points instead of jupyter server setting traitlets for back…
andrii-i 7926459
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] ceb1246
adjust details page field display order
andrii-i b9cb64f
add advancedOptionsOverride token to avoid token mismatch in extensions
andrii-i 658d52e
Eencode backend into job IDs, add backend field to job definitions, u…
andrii-i d4a21f9
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 720d26d
rename legacy backend, cleanup tests and comments
andrii-i 3e48af6
add python backend
andrii-i ea2fb73
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 09e3fac
add dynamic context menu registration
andrii-i 939a2aa
Only validate notebooks have a kernel for .ipynb files
andrii-i b092f48
add stdour and stderr for py files
andrii-i a37a4df
Auto-select backend by file extension, fall back to default
andrii-i dd4ce1c
Create stdout/stderr files only when there's actual content
andrii-i 8e24c36
Only add output files that actually exist to job_files / output files
andrii-i a82594f
hide backend picker only while loading
andrii-i e96b854
rename default backends
andrii-i 87290fb
change AdvancedOptions imports
andrii-i 4529a84
demo: output file formats
andrii-i 2db6ce5
demo: hyperpod backend
andrii-i d167452
support listing from multiple backends, add hardcoded putput formats
andrii-i 4ace22d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 9073459
optimize backend_id logic
andrii-i 66ffaf6
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] a489b59
fix output format picker
andrii-i 2a05a69
update tests
andrii-i 9dc9248
update output_formats.name to id
andrii-i 7f1b477
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 520ac90
update tests
andrii-i 7d6d2a1
update UpdateJob model
andrii-i 6f6b544
comment out # jupyter_server_nb, jupyter_server_py, sagemaker_hyperpo…
andrii-i 89eeb58
nake list jobs async
andrii-i a36ecb5
cleanup code
andrii-i 9e55aae
fix backend picker
andrii-i 518d742
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 570012f
fix backend picker
andrii-i 4e2e9f0
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 71f6686
simplify default backend choice logic
andrii-i 2e72c0c
Remove allowed_backends / blocked_backends
andrii-i 80bb153
remove unused code SageMakerHyperPodBackend, OutputFormatDescriptor
andrii-i 32e1308
type BaseBackend.OutputFormats
andrii-i 07c5e08
streamline custromization of default backend per file extension via p…
andrii-i 4b07c70
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] ce9f4eb
raise error on an unknown backend instaed of silently falling back on…
andrii-i f0dd31e
abstract scheduler resolution into helper function
andrii-i 0dab0cf
add playwright test for multiple backends
andrii-i 63175bf
simplify docstrings
andrii-i b59ba54
add autoflake to CI and run it to remove unused imports, statements, …
andrii-i 34e7016
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 35c4021
run lint
andrii-i 409f5f0
remove duplicate test
andrii-i b89ce01
remove unnecessary tests and comments
andrii-i 37f9ca2
reorder backends.py structure
andrii-i 5aa7b01
remove advanced options override
andrii-i a2258ec
update snapshots
andrii-i e9df0f5
refactor helper functions
andrii-i 2ebe15c
add comments
andrii-i 1da0e91
change id formulation
andrii-i 52b7125
rename backend to backend_id, remove `local` fallback logic, fill in …
andrii-i 3114e4b
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] d5ba046
comment out flakey snapshot comparasions
andrii-i 3293cd2
implement comments
andrii-i 24dca85
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 508c5d9
return error strings with 500
andrii-i 3c10450
properly map ValidationError to 400
andrii-i bbbf1fd
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 33b6f55
update logger.error to logger.exception to catch trace
andrii-i 91e4052
check for absence of colon in the backend_id
andrii-i 2d791e3
add typing
andrii-i 7a77a99
make auto-seleciong of a valid backend when preferred backend doesn't…
andrii-i 82a0ea8
reference strerr rather than embed part of the output
andrii-i e217c4e
fix test assertions
andrii-i 878e984
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
|
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, "", "") | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
|
|
||
| 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}'") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
|
||
| 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) | ||
andrii-i marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
andrii-i marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| raise ValueError( | ||
| f"No backend for legacy jobs. Set SchedulerApp.legacy_job_backend. " | ||
| f"Available: {list(available_backends.keys())}" | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"}, | ||
| ] |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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...