Skip to content

Commit 0150308

Browse files
committed
Use normalize_filepath across all tools that work with filepaths
This change ensures consistent filepath handling across all toolkit functions by using the normalize_filepath utility function. This handles URL-encoded paths, relative paths, and special characters consistently. - Updated file_system toolkit: read, write, edit, search_and_replace, glob, grep, ls - Updated notebook toolkit: all functions that take file_path parameter - Updated git toolkit: all functions that take path parameter - Added comprehensive tests for normalize_filepath functionality All tools now properly decode URL-encoded paths (e.g., my%20notebook.ipynb → my notebook.ipynb) and resolve relative paths against the Jupyter server's root directory.
1 parent 6b5f6cf commit 0150308

File tree

5 files changed

+220
-15
lines changed

5 files changed

+220
-15
lines changed

jupyter_ai_tools/toolkits/file_system.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
from jupyter_ai.tools.models import Tool, Toolkit
1010

11+
from ..utils import normalize_filepath
12+
1113

1214
def read(file_path: str, offset: Optional[int] = None, limit: Optional[int] = None) -> str:
1315
"""Reads a file from the local filesystem
@@ -21,6 +23,7 @@ def read(file_path: str, offset: Optional[int] = None, limit: Optional[int] = No
2123
The contents of the file, potentially with line numbers
2224
"""
2325
try:
26+
file_path = normalize_filepath(file_path)
2427
if not os.path.exists(file_path):
2528
return f"Error: File not found: {file_path}"
2629

@@ -73,6 +76,7 @@ def write(file_path: str, content: str) -> str:
7376
A success message or error message
7477
"""
7578
try:
79+
file_path = normalize_filepath(file_path)
7680
# Ensure the directory exists
7781
directory = os.path.dirname(file_path)
7882
if directory and not os.path.exists(directory):
@@ -107,6 +111,7 @@ def edit(file_path: str, old_string: str, new_string: str, replace_all: bool = F
107111
A success message or error message
108112
"""
109113
try:
114+
file_path = normalize_filepath(file_path)
110115
if not os.path.exists(file_path):
111116
return f"Error: File not found: {file_path}"
112117

@@ -159,6 +164,7 @@ async def search_and_replace(
159164
A success message or error message
160165
"""
161166
try:
167+
file_path = normalize_filepath(file_path)
162168
if not os.path.exists(file_path):
163169
return f"Error: File not found: {file_path}"
164170

@@ -212,7 +218,7 @@ async def glob(pattern: str, path: Optional[str] = None) -> str:
212218
A list of matching file paths sorted by modification time
213219
"""
214220
try:
215-
search_path = path or os.getcwd()
221+
search_path = normalize_filepath(path) if path else os.getcwd()
216222
if not os.path.exists(search_path):
217223
return f"Error: Path not found: {search_path}"
218224

@@ -260,7 +266,7 @@ async def grep(
260266
A list of file paths with at least one match
261267
"""
262268
try:
263-
search_path = path or os.getcwd()
269+
search_path = normalize_filepath(path) if path else os.getcwd()
264270
if not os.path.exists(search_path):
265271
return [f"Error: Path not found: {search_path}"]
266272

@@ -312,6 +318,7 @@ async def ls(path: str, ignore: Optional[List[str]] = None) -> str:
312318
A list of files and directories in the given path
313319
"""
314320
try:
321+
path = normalize_filepath(path)
315322
if not os.path.exists(path):
316323
return f"Error: Path not found: {path}"
317324

jupyter_ai_tools/toolkits/git.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from jupyter_ai.tools.models import Tool, Toolkit
55
from jupyterlab_git.git import Git
66

7+
from ..utils import normalize_filepath
8+
79
git = Git()
810

911

@@ -19,6 +21,7 @@ async def git_clone(path: str, url: str) -> str:
1921
Returns:
2022
str: Success or error message.
2123
"""
24+
path = normalize_filepath(path)
2225
res = await git.clone(path, repo_url=url)
2326
if res["code"] == 0:
2427
return f"✅ Cloned repo into {res['path']}"
@@ -36,6 +39,7 @@ async def git_status(path: str) -> str:
3639
Returns:
3740
str: A JSON-formatted string of status or an error message.
3841
"""
42+
path = normalize_filepath(path)
3943
res = await git.status(path)
4044
if res["code"] == 0:
4145
return f"📋 Status:\n{json.dumps(res, indent=2)}"
@@ -54,6 +58,7 @@ async def git_log(path: str, history_count: int = 10) -> str:
5458
Returns:
5559
str: A JSON-formatted commit log or error message.
5660
"""
61+
path = normalize_filepath(path)
5762
res = await git.log(path, history_count=history_count)
5863
if res["code"] == 0:
5964
return f"🕓 Recent commits:\n{json.dumps(res, indent=2)}"
@@ -71,6 +76,7 @@ async def git_pull(path: str) -> str:
7176
Returns:
7277
str: Success or error message.
7378
"""
79+
path = normalize_filepath(path)
7480
res = await git.pull(path)
7581
return (
7682
"✅ Pulled latest changes."
@@ -91,6 +97,7 @@ async def git_push(path: str, branch: str) -> str:
9197
Returns:
9298
str: Success or error message.
9399
"""
100+
path = normalize_filepath(path)
94101
res = await git.push(remote="origin", branch=branch, path=path)
95102
return (
96103
"✅ Pushed changes."
@@ -111,6 +118,7 @@ async def git_commit(path: str, message: str) -> str:
111118
Returns:
112119
str: Success or error message.
113120
"""
121+
path = normalize_filepath(path)
114122
res = await git.commit(commit_msg=message, amend=False, path=path)
115123
return (
116124
"✅ Commit successful."
@@ -132,6 +140,7 @@ async def git_add(path: str, add_all: bool = True, filename: str = "") -> str:
132140
Returns:
133141
str: Success or error message.
134142
"""
143+
path = normalize_filepath(path)
135144
if add_all:
136145
res = await git.add_all(path)
137146
elif filename:
@@ -158,6 +167,7 @@ async def git_get_repo_root(path: str) -> str:
158167
Returns:
159168
str: The path to the Git repository root or an error message.
160169
"""
170+
path = normalize_filepath(path)
161171
dir_path = os.path.dirname(path)
162172
res = await git.show_top_level(dir_path)
163173
if res["code"] == 0 and res.get("path"):

jupyter_ai_tools/toolkits/notebook.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
cell_to_md,
1414
get_file_id,
1515
get_jupyter_ydoc,
16+
normalize_filepath,
1617
notebook_json_to_md,
1718
)
1819

@@ -70,6 +71,7 @@ async def read_notebook(file_path: str, include_outputs=False) -> str:
7071
The notebook content as a markdown string.
7172
"""
7273
try:
74+
file_path = normalize_filepath(file_path)
7375
notebook_dict = await read_notebook_json(file_path)
7476
notebook_md = notebook_json_to_md(notebook_dict, include_outputs=include_outputs)
7577
return notebook_md
@@ -91,6 +93,7 @@ async def read_notebook_json(file_path: str) -> Dict[str, Any]:
9193
A dictionary containing the complete notebook structure.
9294
"""
9395
try:
96+
file_path = normalize_filepath(file_path)
9497
with open(file_path, "r", encoding="utf-8") as f:
9598
notebook_dict = json.load(f)
9699
return notebook_dict
@@ -120,6 +123,7 @@ async def read_cell(file_path: str, cell_id: str, include_outputs: bool = True)
120123
LookupError: If no cell with the given ID is found.
121124
"""
122125
try:
126+
file_path = normalize_filepath(file_path)
123127
# Resolve cell_id in case it's an index
124128
resolved_cell_id = await _resolve_cell_id(file_path, cell_id)
125129
cell, cell_index = await read_cell_json(file_path, resolved_cell_id)
@@ -150,6 +154,7 @@ async def read_cell_json(file_path: str, cell_id: str) -> Tuple[Dict[str, Any],
150154
LookupError: If no cell with the given ID is found.
151155
"""
152156
try:
157+
file_path = normalize_filepath(file_path)
153158
# Resolve cell_id in case it's an index
154159
resolved_cell_id = await _resolve_cell_id(file_path, cell_id)
155160
notebook_json = await read_notebook_json(file_path)
@@ -182,7 +187,7 @@ async def get_cell_id_from_index(file_path: str, cell_index: int) -> str:
182187
or if the cell does not have an ID.
183188
"""
184189
try:
185-
190+
file_path = normalize_filepath(file_path)
186191
cell_id = None
187192
notebook_json = await read_notebook_json(file_path)
188193
cells = notebook_json["cells"]
@@ -233,7 +238,7 @@ async def add_cell(
233238
None
234239
"""
235240
try:
236-
241+
file_path = normalize_filepath(file_path)
237242
# Resolve cell_id in case it's an index
238243
resolved_cell_id = await _resolve_cell_id(file_path, cell_id) if cell_id else None
239244

@@ -304,7 +309,7 @@ async def insert_cell(
304309
None
305310
"""
306311
try:
307-
312+
file_path = normalize_filepath(file_path)
308313
file_id = await get_file_id(file_path)
309314
ydoc = await get_jupyter_ydoc(file_id)
310315

@@ -357,7 +362,7 @@ async def delete_cell(file_path: str, cell_id: str):
357362
None
358363
"""
359364
try:
360-
365+
file_path = normalize_filepath(file_path)
361366
# Resolve cell_id in case it's an index
362367
resolved_cell_id = await _resolve_cell_id(file_path, cell_id)
363368

@@ -762,7 +767,7 @@ async def edit_cell(file_path: str, cell_id: str, content: str) -> None:
762767
ValueError: If the cell_id is not found in the notebook.
763768
"""
764769
try:
765-
770+
file_path = normalize_filepath(file_path)
766771
# Resolve cell_id in case it's an index
767772
resolved_cell_id = await _resolve_cell_id(file_path, cell_id)
768773

@@ -814,7 +819,7 @@ def read_cell_nbformat(file_path: str, cell_id: str) -> Dict[str, Any]:
814819
Raises:
815820
ValueError: If no cell with the given ID is found.
816821
"""
817-
822+
file_path = normalize_filepath(file_path)
818823
with open(file_path, "r", encoding="utf-8") as f:
819824
notebook = nbformat.read(f, as_version=nbformat.NO_CONVERT)
820825

jupyter_ai_tools/utils.py

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,72 @@
11
import functools
22
import inspect
3+
import os
34
import typing
5+
from pathlib import Path
46
from typing import Optional
7+
from urllib.parse import unquote
58

69
from jupyter_server.serverapp import ServerApp
710
from jupyter_server.auth.identity import User
811
from pycrdt import Awareness
912

1013

11-
async def get_serverapp():
14+
def get_serverapp():
1215
"""Returns the server app from the request context"""
1316

1417
server = ServerApp.instance()
1518
return server
1619

1720

21+
def normalize_filepath(file_path: str) -> str:
22+
"""
23+
Normalizes a file path for Jupyter applications to return an absolute path.
24+
25+
Handles various input formats:
26+
- Relative paths from current working directory
27+
- URL-encoded relative paths (common in Jupyter contexts)
28+
- Absolute paths (returned as-is after normalization)
29+
30+
Args:
31+
file_path: Path in any of the supported formats
32+
33+
Returns:
34+
Absolute path to the file
35+
36+
Example:
37+
>>> normalize_filepath("notebooks/my%20notebook.ipynb")
38+
"/current/working/dir/notebooks/my notebook.ipynb"
39+
>>> normalize_filepath("/absolute/path/file.ipynb")
40+
"/absolute/path/file.ipynb"
41+
>>> normalize_filepath("relative/file.ipynb")
42+
"/current/working/dir/relative/file.ipynb"
43+
"""
44+
if not file_path or not file_path.strip():
45+
raise ValueError("file_path cannot be empty")
46+
47+
# URL decode the path in case it contains encoded characters
48+
decoded_path = unquote(file_path)
49+
50+
# Convert to Path object for easier manipulation
51+
path = Path(decoded_path)
52+
53+
# If already absolute, just normalize and return
54+
if path.is_absolute():
55+
return str(path.resolve())
56+
57+
# For relative paths, get the Jupyter server's root directory
58+
try:
59+
serverapp = get_serverapp()
60+
root_dir = serverapp.root_dir
61+
except Exception:
62+
# Fallback to current working directory if server app is not available
63+
root_dir = os.getcwd()
64+
65+
# Resolve relative path against the root directory
66+
resolved_path = Path(root_dir) / path
67+
return str(resolved_path.resolve())
68+
69+
1870
async def get_jupyter_ydoc(file_id: str):
1971
"""Returns the notebook ydoc
2072
@@ -24,7 +76,7 @@ async def get_jupyter_ydoc(file_id: str):
2476
Returns:
2577
`YNotebook` ydoc for the notebook
2678
"""
27-
serverapp = await get_serverapp()
79+
serverapp = get_serverapp()
2880
yroom_manager = serverapp.web_app.settings["yroom_manager"]
2981
room_id = f"json:notebook:{file_id}"
3082

@@ -35,7 +87,7 @@ async def get_jupyter_ydoc(file_id: str):
3587

3688

3789
async def get_global_awareness() -> Optional[Awareness]:
38-
serverapp = await get_serverapp()
90+
serverapp = get_serverapp()
3991
yroom_manager = serverapp.web_app.settings["yroom_manager"]
4092

4193
room_id = "JupyterLab:globalAwareness"
@@ -57,10 +109,11 @@ async def get_file_id(file_path: str) -> str:
57109
Returns:
58110
The file ID of the document
59111
"""
60-
61-
serverapp = await get_serverapp()
112+
normalized_file_path = normalize_filepath(file_path)
113+
114+
serverapp = get_serverapp()
62115
file_id_manager = serverapp.web_app.settings["file_id_manager"]
63-
file_id = file_id_manager.get_id(file_path)
116+
file_id = file_id_manager.get_id(normalized_file_path)
64117

65118
return file_id
66119

@@ -100,7 +153,7 @@ async def wrapper(*args, **kwargs):
100153

101154
# Get serverapp for logging
102155
try:
103-
serverapp = await get_serverapp()
156+
serverapp = get_serverapp()
104157
logger = serverapp.log
105158
except Exception:
106159
logger = None

0 commit comments

Comments
 (0)