Skip to content

Commit 97aa17b

Browse files
committed
Use entry points instead of jupyter server setting traitlets for backend definition and discovery
1 parent 8611059 commit 97aa17b

File tree

6 files changed

+725
-54
lines changed

6 files changed

+725
-54
lines changed

jupyter_scheduler/backend_utils.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""Backend discovery utilities.
2+
3+
This module provides functions for discovering scheduler backends registered
4+
via Python entry points. The entry point group "jupyter_scheduler.backends"
5+
is scanned at startup to find all available backend implementations.
6+
7+
The discovery system supports:
8+
- Automatic registration of pip-installed backend packages
9+
- Allow/block lists for filtering available backends
10+
- Graceful handling of missing dependencies
11+
"""
12+
13+
import logging
14+
from importlib.metadata import entry_points
15+
from typing import Dict, List, Optional, Type
16+
17+
from jupyter_scheduler.base_backend import BaseBackend
18+
19+
ENTRY_POINT_GROUP = "jupyter_scheduler.backends"
20+
21+
logger = logging.getLogger(__name__)
22+
23+
24+
def discover_backends(
25+
log: Optional[logging.Logger] = None,
26+
allowed_backends: Optional[List[str]] = None,
27+
blocked_backends: Optional[List[str]] = None,
28+
) -> Dict[str, Type[BaseBackend]]:
29+
"""Discover all registered backends via entry points.
30+
31+
Scans the "jupyter_scheduler.backends" entry point group for registered
32+
backend classes. Each entry point should reference a class that inherits
33+
from BaseBackend.
34+
35+
Parameters
36+
----------
37+
log : logging.Logger, optional
38+
Logger for status messages. If None, uses module logger.
39+
allowed_backends : list of str, optional
40+
If provided, only backends with IDs in this list are included.
41+
Takes precedence over blocked_backends for the same ID.
42+
blocked_backends : list of str, optional
43+
If provided, backends with IDs in this list are excluded.
44+
45+
Returns
46+
-------
47+
dict
48+
Mapping of backend_id -> backend class for all discovered backends.
49+
50+
Notes
51+
-----
52+
Backends are filtered in this order:
53+
1. Entry point is loaded (skip on ImportError with warning)
54+
2. Backend ID is checked against blocked_backends (skip if blocked)
55+
3. Backend ID is checked against allowed_backends (skip if not allowed)
56+
57+
Example entry point registration in pyproject.toml:
58+
59+
[project.entry-points."jupyter_scheduler.backends"]
60+
local = "jupyter_scheduler.backends:LocalBackend"
61+
k8s = "jupyter_scheduler_k8s:K8sBackend"
62+
"""
63+
if log is None:
64+
log = logger
65+
66+
backends: Dict[str, Type[BaseBackend]] = {}
67+
68+
# Get entry points for the backends group
69+
# Compatible with Python 3.9+ importlib.metadata
70+
eps = entry_points()
71+
if hasattr(eps, "select"):
72+
# Python 3.10+ / importlib_metadata style
73+
backend_eps = eps.select(group=ENTRY_POINT_GROUP)
74+
else:
75+
# Python 3.9 style (returns dict)
76+
backend_eps = eps.get(ENTRY_POINT_GROUP, [])
77+
78+
for ep in backend_eps:
79+
# Attempt to load the backend class
80+
try:
81+
backend_class = ep.load()
82+
except ImportError as e:
83+
# Missing dependency - provide actionable message
84+
missing_package = getattr(e, "name", str(e))
85+
log.warning(
86+
f"Unable to load backend '{ep.name}': missing dependency '{missing_package}'. "
87+
f"Install the required package to enable this backend."
88+
)
89+
continue
90+
except Exception as e:
91+
log.warning(f"Unable to load backend '{ep.name}': {e}")
92+
continue
93+
94+
# Validate the backend class has required attributes
95+
if not hasattr(backend_class, "id"):
96+
log.warning(
97+
f"Backend '{ep.name}' does not define 'id' attribute. Skipping."
98+
)
99+
continue
100+
101+
backend_id = backend_class.id
102+
103+
# Apply block list
104+
if blocked_backends and backend_id in blocked_backends:
105+
log.debug(f"Backend '{backend_id}' is blocked by configuration.")
106+
continue
107+
108+
# Apply allow list (if specified, only allowed backends pass)
109+
if allowed_backends is not None and backend_id not in allowed_backends:
110+
log.debug(f"Backend '{backend_id}' is not in allowed list.")
111+
continue
112+
113+
backends[backend_id] = backend_class
114+
log.info(f"Registered backend '{backend_id}' ({backend_class.name})")
115+
116+
return backends
117+
118+
119+
def get_default_backend_id(
120+
available_backends: Dict[str, Type[BaseBackend]],
121+
configured_default: Optional[str] = None,
122+
) -> str:
123+
"""Determine the default backend ID.
124+
125+
Selection priority:
126+
1. Explicitly configured default (if available)
127+
2. "local" backend (if available)
128+
3. First available backend (sorted by ID for determinism)
129+
130+
Parameters
131+
----------
132+
available_backends : dict
133+
Mapping of backend_id -> backend class from discover_backends().
134+
configured_default : str, optional
135+
Administrator-configured default backend ID.
136+
137+
Returns
138+
-------
139+
str
140+
The backend ID to use as default.
141+
142+
Raises
143+
------
144+
ValueError
145+
If no backends are available.
146+
"""
147+
if not available_backends:
148+
raise ValueError(
149+
"No scheduler backends available. "
150+
"Ensure at least one backend package is installed."
151+
)
152+
153+
# Explicit configuration takes precedence
154+
if configured_default and configured_default in available_backends:
155+
return configured_default
156+
157+
# Warn if configured default is not available
158+
if configured_default and configured_default not in available_backends:
159+
logger.warning(
160+
f"Configured default_backend '{configured_default}' not found. "
161+
f"Available backends: {list(available_backends.keys())}"
162+
)
163+
164+
# Fall back to "local" if available
165+
if "local" in available_backends:
166+
return "local"
167+
168+
# Last resort: first available (sorted for determinism)
169+
return sorted(available_backends.keys())[0]

jupyter_scheduler/backends.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,45 @@
11
"""Backend configuration models for multi-backend support.
22
3+
This module provides:
4+
- BackendConfig: Runtime configuration dataclass for initialized backends
5+
- DescribeBackend: API response model for frontend consumption
6+
- LocalBackend: Built-in backend for local notebook execution
7+
38
A backend bundles a tightly-coupled set of classes:
4-
- scheduler_class
5-
- execution_manager_class
6-
- database_manager_class (optional)
9+
- scheduler_class: Manages job lifecycle and persistence
10+
- execution_manager_class: Handles actual notebook execution
11+
- database_manager_class (optional): Custom storage implementation
12+
13+
Backends are discovered via Python entry points at startup. Third-party packages
14+
register backends in their pyproject.toml:
715
8-
This allows multiple execution backends to be configured and selected at job creation time.
16+
[project.entry-points."jupyter_scheduler.backends"]
17+
mybackend = "my_package:MyBackend"
918
"""
1019

1120
from dataclasses import dataclass, field
12-
from typing import Dict, List, Optional, Any
21+
from typing import Any, Dict, List, Optional
1322

23+
from jupyter_scheduler.base_backend import BaseBackend
1424
from jupyter_scheduler.pydantic_v1 import BaseModel
1525

1626

27+
class LocalBackend(BaseBackend):
28+
"""Built-in backend for local notebook execution.
29+
30+
Executes notebooks as subprocesses on the Jupyter server host.
31+
This is the default backend when no other backends are configured.
32+
"""
33+
34+
id = "local"
35+
name = "Local Execution"
36+
description = "Execute notebooks locally on the Jupyter server"
37+
scheduler_class = "jupyter_scheduler.scheduler.Scheduler"
38+
execution_manager_class = "jupyter_scheduler.executors.DefaultExecutionManager"
39+
file_extensions = ["ipynb"]
40+
priority = 0 # Lowest priority allows other backends to take precedence
41+
42+
1743
@dataclass
1844
class BackendConfig:
1945
"""Configuration for a backend instance.

jupyter_scheduler/base_backend.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Base class for scheduler backends.
2+
3+
This module defines the BaseBackend class that all scheduler backends must inherit from.
4+
Backend packages declare their capabilities via class attributes, enabling automatic
5+
discovery through Python entry points.
6+
7+
Third-party packages register backends in their pyproject.toml:
8+
9+
[project.entry-points."jupyter_scheduler.backends"]
10+
mybackend = "my_package:MyBackend"
11+
12+
Example backend implementation:
13+
14+
class MyBackend(BaseBackend):
15+
id = "mybackend"
16+
name = "My Custom Backend"
17+
description = "Execute notebooks on my infrastructure"
18+
scheduler_class = "my_package.scheduler.MyScheduler"
19+
execution_manager_class = "my_package.executors.MyExecutionManager"
20+
file_extensions = ["ipynb", "py"]
21+
priority = 10
22+
"""
23+
24+
from typing import Any, ClassVar, Dict, List, Optional
25+
26+
27+
class BaseBackend:
28+
"""Base class for scheduler backends.
29+
30+
Backend implementations declare their capabilities through class attributes.
31+
The entry points system discovers these classes at runtime, enabling
32+
pip-installable backend packages that auto-register without configuration.
33+
34+
Class Attributes
35+
----------------
36+
id : str
37+
Unique identifier used in API requests and configuration.
38+
Convention: lowercase, alphanumeric with hyphens (e.g., "local", "k8s", "sagemaker").
39+
40+
name : str
41+
Human-readable display name shown in the UI.
42+
43+
description : str
44+
Help text explaining the backend's purpose and requirements.
45+
46+
scheduler_class : str
47+
Fully qualified path to the scheduler class (e.g., "module.submodule.ClassName").
48+
Must be a subclass of jupyter_scheduler.scheduler.BaseScheduler.
49+
50+
execution_manager_class : str
51+
Fully qualified path to the execution manager class.
52+
Must be a subclass of jupyter_scheduler.executors.ExecutionManager.
53+
54+
database_manager_class : str, optional
55+
Fully qualified path to a custom database manager class.
56+
If None, uses the default SQLAlchemy-based storage.
57+
58+
file_extensions : list of str
59+
File extensions this backend supports (e.g., ["ipynb", "py"]).
60+
Empty list means all extensions are supported.
61+
62+
priority : int
63+
Auto-selection priority when multiple backends support the same file type.
64+
Higher values take precedence. Default is 0.
65+
66+
Notes
67+
-----
68+
This class intentionally does not inherit from pydantic.BaseModel to avoid
69+
adding pydantic validation overhead for what is essentially a static
70+
configuration container. The class attributes are read once at startup.
71+
"""
72+
73+
id: ClassVar[str]
74+
name: ClassVar[str]
75+
description: ClassVar[str] = ""
76+
scheduler_class: ClassVar[str]
77+
execution_manager_class: ClassVar[str]
78+
database_manager_class: ClassVar[Optional[str]] = None
79+
file_extensions: ClassVar[List[str]] = []
80+
priority: ClassVar[int] = 0
81+
82+
@classmethod
83+
def to_dict(cls) -> Dict[str, Any]:
84+
"""Convert class attributes to a dictionary for BackendConfig creation.
85+
86+
Returns
87+
-------
88+
dict
89+
Dictionary containing all backend configuration attributes.
90+
"""
91+
return {
92+
"id": cls.id,
93+
"name": cls.name,
94+
"description": cls.description,
95+
"scheduler_class": cls.scheduler_class,
96+
"execution_manager_class": cls.execution_manager_class,
97+
"database_manager_class": cls.database_manager_class,
98+
"file_extensions": list(cls.file_extensions),
99+
"priority": cls.priority,
100+
}

0 commit comments

Comments
 (0)