Skip to content

Commit 24e7f2d

Browse files
dlqqqsrdas
andauthored
Fix litellm branch CI, add EnvSecretsManager tests (#1459)
* update prettier ignore file * fix prettier errors & warnings * fix mypy errors in jupyter_ai * skip ConfigManager and inline completion tests until fixed in v3 * add tests for secrets manager * fix mypy error * run minimum dependency tests on Python 3.10 * comment out precommit job to be added in future * updated test_magics.py --------- Co-authored-by: Sanjiv Das <[email protected]>
1 parent 8d8cdbe commit 24e7f2d

File tree

20 files changed

+376
-109
lines changed

20 files changed

+376
-109
lines changed

.github/workflows/lint.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ on:
33
- pull_request
44

55
jobs:
6-
lint_ts:
7-
name: Lint TypeScript source
6+
lint_frontend:
7+
name: Stylelint, Prettier, ESLint
88
runs-on: ubuntu-latest
99
steps:
1010
- uses: actions/checkout@v4
@@ -17,3 +17,7 @@ jobs:
1717
run: jlpm
1818
- name: Lint TypeScript source
1919
run: jlpm lerna run lint:check
20+
21+
# TODO
22+
# precommit:
23+
# name: pre-commit check

.github/workflows/unit-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
matrix:
2020
include:
2121
- dependency-type: minimum
22-
python-version: "3.9"
22+
python-version: "3.10"
2323
- dependency-type: standard
2424
python-version: "3.12"
2525
steps:

conftest.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1+
from __future__ import annotations
2+
import asyncio
13
from pathlib import Path
2-
34
import pytest
5+
from traitlets.config import Config, LoggingConfigurable
6+
import logging
7+
from jupyter_server.services.contents.filemanager import AsyncFileContentsManager
8+
from typing import TYPE_CHECKING
49

5-
pytest_plugins = ("jupyter_server.pytest_plugin",)
10+
if TYPE_CHECKING:
11+
from jupyter_server.serverapp import ServerApp
612

13+
pytest_plugins = ("jupyter_server.pytest_plugin",)
714

815
@pytest.fixture
9-
def jp_server_config(jp_server_config):
10-
return {"ServerApp": {"jpserver_extensions": {"jupyter_ai": True}}}
16+
def jp_server_config(jp_server_config, tmp_path):
17+
return Config({"ServerApp": {"jpserver_extensions": {"jupyter_ai": True}}, "ContentsManager": {"root_dir": str(tmp_path)}})
1118

1219

1320
@pytest.fixture(scope="session")
@@ -21,9 +28,45 @@ def static_test_files_dir() -> Path:
2128
/ "static"
2229
)
2330

31+
class MockAiExtension(LoggingConfigurable):
32+
"""Mock AiExtension class for testing purposes."""
33+
34+
serverapp: ServerApp
35+
36+
def __init__(self, *args, serverapp: ServerApp, **kwargs):
37+
super().__init__(*args, **kwargs)
38+
self.serverapp = serverapp
39+
self._log = None
40+
41+
@property
42+
def log(self) -> logging.Logger:
43+
return self.serverapp.log
44+
45+
@property
46+
def event_loop(self) -> asyncio.AbstractEventLoop:
47+
return self.serverapp.io_loop.asyncio_loop
48+
49+
@property
50+
def contents_manager(self) -> AsyncFileContentsManager:
51+
return self.serverapp.contents_manager
52+
2453

2554
@pytest.fixture
26-
def jp_ai_staging_dir(jp_data_dir: Path) -> Path:
27-
staging_area = jp_data_dir / "scheduler_staging_area"
28-
staging_area.mkdir()
29-
return staging_area
55+
def mock_ai_extension(jp_server_config, jp_configurable_serverapp) -> MockAiExtension:
56+
"""
57+
Returns a mocked `AiExtension` object that can be passed as the `parent`
58+
argument to objects normally initialized by `AiExtension`. This should be
59+
passed to most of the "manager singletons" like `ConfigManager`,
60+
`PersonaManager`, and `EnvSecretsManager`.
61+
62+
See `MockAiExtension` in `conftest.py` for a complete description of the
63+
attributes, properties, and methods available. If something is missing,
64+
please feel free to add to it in your PR.
65+
66+
Returns:
67+
A `MockAiExtension` instance that can be passed as the `parent` argument
68+
to objects normally initialized by `AiExtension`.
69+
"""
70+
serverapp = jp_configurable_serverapp()
71+
return MockAiExtension(config=jp_server_config, serverapp=serverapp)
72+

packages/jupyter-ai-magics/jupyter_ai_magics/magics.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,4 +535,14 @@ def handle_list(self, args: ListArgs):
535535
text_output += f"* {model}\n"
536536
markdown_output += f"* `{model}`\n"
537537

538+
# Also list any custom aliases for this provider
539+
if len(self.aliases) > 0:
540+
text_output += "\nAliases:\n"
541+
markdown_output += "\n### Aliases\n\n"
542+
for alias, target in self.aliases.items():
543+
if target.startswith(provider_id + "/"):
544+
text_output += f"* {alias} -> {target}\n"
545+
markdown_output += f"* `{alias}` -> `{target}`\n"
546+
547+
538548
return TextOrMarkdown(text_output, markdown_output)

packages/jupyter-ai-magics/jupyter_ai_magics/tests/test_magics.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ def ip() -> InteractiveShell:
1616
def test_aliases_config(ip):
1717
ip.config.AiMagics.initial_aliases = {"my_custom_alias": "my_provider:my_model"}
1818
ip.extension_manager.load_extension("jupyter_ai_magics")
19-
providers_list = ip.run_line_magic("ai", "list").text
20-
assert "my_custom_alias" in providers_list
19+
# Use 'list all' to see all models and aliases
20+
providers_list = ip.run_line_magic("ai", "list all").text
21+
# Check that alias appears in the output
22+
assert "my_custom_alias -> my_provider:my_model" in providers_list
2123

2224

2325
def test_default_model_cell(ip):

packages/jupyter-ai/.prettierignore

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
node_modules
1+
# Local NPM dependencies
22
**/node_modules
3+
4+
# Build artifacts
35
**/lib
4-
**/package.json
5-
jupyter_ai
6+
**/labextension/*
7+
8+
# Local caches
9+
**/.mypy_cache

packages/jupyter-ai/jupyter_ai/extension.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
from __future__ import annotations
12
import os
23
import time
34
from asyncio import get_event_loop_policy
45
from functools import partial
5-
from typing import TYPE_CHECKING, Optional
6+
from typing import TYPE_CHECKING
67

78
import traitlets
89
from jupyter_events import EventLogger
@@ -29,6 +30,7 @@
2930
from .secrets.secrets_rest_api import SecretsRestAPI
3031

3132
if TYPE_CHECKING:
33+
from typing import Any, Optional
3234
from asyncio import AbstractEventLoop
3335

3436
from jupyter_collaboration import ( # type:ignore[import-untyped] # isort:skip
@@ -206,16 +208,17 @@ class AiExtension(ExtensionApp):
206208
config=True,
207209
)
208210

209-
def initialize(self):
211+
def initialize(self, argv: Any = None) -> None:
210212
super().initialize()
211213

212214
self.ychats_by_room: dict[str, YChat] = {}
213215
"""Cache of YChat instances, indexed by room ID."""
214216

215-
self.event_logger = self.serverapp.web_app.settings["event_logger"]
216-
self.event_logger.add_listener(
217-
schema_id=JUPYTER_COLLABORATION_EVENTS_URI, listener=self.connect_chat
218-
)
217+
if self.serverapp is not None:
218+
self.event_logger = self.serverapp.web_app.settings["event_logger"]
219+
self.event_logger.add_listener(
220+
schema_id=JUPYTER_COLLABORATION_EVENTS_URI, listener=self.connect_chat
221+
)
219222

220223
@property
221224
def event_loop(self) -> "AbstractEventLoop":

packages/jupyter-ai/jupyter_ai/mcp/mcp_config_loader.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
class MCPConfigLoader:
1212
"""Loader for MCP server configuration files with JSON schema validation."""
1313

14-
def __init__(self):
14+
def __init__(self) -> None:
1515
# Load the schema from the schema.json file
1616
with open(SCHEMA_FILE) as f:
1717
self.schema = json.load(f)
Lines changed: 51 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,53 @@
11
{
2-
"$schema": "http://json-schema.org/draft-07/schema#",
3-
"title": "Extended MCP Server Configuration Schema",
4-
"type": "object",
5-
"properties": {
6-
"mcpServers": {
7-
"type": "object",
8-
"patternProperties": {
9-
"^[a-zA-Z0-9_-]+$": {
10-
"type": "object",
11-
"properties": {
12-
"command": {
13-
"type": "string",
14-
"description": "Executable command for local stdio-based MCP server"
15-
},
16-
"args": {
17-
"type": "array",
18-
"description": "Command-line arguments passed to the executable",
19-
"items": { "type": "string" }
20-
},
21-
"url": {
22-
"type": "string",
23-
"format": "uri",
24-
"description": "Remote server URL (e.g. 'https://my-server.com/mcp')"
25-
},
26-
"transport": {
27-
"type": "string",
28-
"enum": ["stdio", "http", "streamable_http", "sse"],
29-
"description": "Communication protocol between client and server"
30-
},
31-
"env": {
32-
"type": "object",
33-
"description": "Optional environment variables passed to the server",
34-
"additionalProperties": { "type": "string" }
35-
},
36-
"disabled": {
37-
"type": "boolean",
38-
"description": "Optional flag to disable this server"
39-
}
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"title": "Extended MCP Server Configuration Schema",
4+
"type": "object",
5+
"properties": {
6+
"mcpServers": {
7+
"type": "object",
8+
"patternProperties": {
9+
"^[a-zA-Z0-9_-]+$": {
10+
"type": "object",
11+
"properties": {
12+
"command": {
13+
"type": "string",
14+
"description": "Executable command for local stdio-based MCP server"
4015
},
41-
"anyOf": [
42-
{ "required": ["command", "args"] },
43-
{ "required": ["url"] }
44-
],
45-
"additionalProperties": false
46-
}
47-
},
48-
"additionalProperties": false
49-
}
50-
},
51-
"required": ["mcpServers"],
52-
"additionalProperties": false
53-
}
16+
"args": {
17+
"type": "array",
18+
"description": "Command-line arguments passed to the executable",
19+
"items": { "type": "string" }
20+
},
21+
"url": {
22+
"type": "string",
23+
"format": "uri",
24+
"description": "Remote server URL (e.g. 'https://my-server.com/mcp')"
25+
},
26+
"transport": {
27+
"type": "string",
28+
"enum": ["stdio", "http", "streamable_http", "sse"],
29+
"description": "Communication protocol between client and server"
30+
},
31+
"env": {
32+
"type": "object",
33+
"description": "Optional environment variables passed to the server",
34+
"additionalProperties": { "type": "string" }
35+
},
36+
"disabled": {
37+
"type": "boolean",
38+
"description": "Optional flag to disable this server"
39+
}
40+
},
41+
"anyOf": [
42+
{ "required": ["command", "args"] },
43+
{ "required": ["url"] }
44+
],
45+
"additionalProperties": false
46+
}
47+
},
48+
"additionalProperties": false
49+
}
50+
},
51+
"required": ["mcpServers"],
52+
"additionalProperties": false
53+
}

packages/jupyter-ai/jupyter_ai/secrets/secrets_manager.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
from io import StringIO
77
from typing import TYPE_CHECKING
88

9-
from dotenv import dotenv_values, load_dotenv
9+
from dotenv import load_dotenv
1010
from tornado.web import HTTPError
1111
from traitlets.config import LoggingConfigurable
1212

1313
from .secrets_types import SecretsList
14-
from .secrets_utils import build_updated_dotenv
14+
from .secrets_utils import build_updated_dotenv, parse_dotenv
1515

1616
if TYPE_CHECKING:
1717
import logging
@@ -85,7 +85,11 @@ class EnvSecretsManager(LoggingConfigurable):
8585

8686
@property
8787
def contents_manager(self) -> AsyncFileContentsManager:
88-
return self.parent.serverapp.contents_manager
88+
assert self.parent.serverapp
89+
# By default, `serverapp.contents_manager` is typed as `ContentsManager |
90+
# None`. However, a custom implementation may provide async methods, so
91+
# we cast it as the async version of the default `FileContentsManager`.
92+
return self.parent.serverapp.contents_manager # type:ignore[return-value]
8993

9094
@property
9195
def event_loop(self) -> asyncio.AbstractEventLoop:
@@ -166,8 +170,7 @@ def _apply_dotenv(self, content: str) -> None:
166170
"""
167171
# Parse the latest `.env` file and store it in `self._dotenv_env`,
168172
# tracking deleted environment variables in `deleted_envvars`.
169-
new_dotenv_env = dotenv_values(stream=StringIO(content))
170-
new_dotenv_env = {k: v for k, v in new_dotenv_env.items() if v != None}
173+
new_dotenv_env = parse_dotenv(content)
171174
deleted_envvars = [k for k in self._dotenv_env if k not in new_dotenv_env]
172175
self._dotenv_env = new_dotenv_env
173176

@@ -181,7 +184,7 @@ def _apply_dotenv(self, content: str) -> None:
181184
load_dotenv(stream=StringIO(content), override=True)
182185
self.log.info("Applied '.env' to the environment.")
183186

184-
async def _ensure_dotenv_gitignored(self) -> bool:
187+
async def _ensure_dotenv_gitignored(self) -> None:
185188
"""
186189
Ensures the `.env` file is listed in the `.gitignore` file at the
187190
workspace root, creating/updating the `.gitignore` file to list `.env`
@@ -236,7 +239,7 @@ def _reset_envvars(self, names: list[str]) -> None:
236239
"""
237240
for ev_name in names:
238241
if ev_name in self._initial_env:
239-
os.environ[ev_name] = self._initial_env.get(ev_name)
242+
os.environ[ev_name] = self._initial_env[ev_name]
240243
else:
241244
del os.environ[ev_name]
242245

@@ -377,3 +380,9 @@ def stop(self) -> None:
377380
This method should be called if and only if the server is shutting down.
378381
"""
379382
self._watch_dotenv_task.cancel()
383+
384+
# Reset any environment variables set in `.env` back to their initial
385+
# values. This is only required for the unit test suite.
386+
envvar_names = list(self._dotenv_env.keys())
387+
self._reset_envvars(envvar_names)
388+
self._dotenv_env = {}

0 commit comments

Comments
 (0)