Skip to content

🚸 Support inferring notebook path when executed through nbconvert #288

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 3 commits into from
Apr 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion docs/guide/received.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"The header now makes package verion mismatches evident. "
"The header now makes package version mismatches evident. "
]
},
{
Expand Down
119 changes: 118 additions & 1 deletion nbproject/dev/_jupyter_communicate.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
import sys
from itertools import chain
from pathlib import PurePath
from pathlib import Path, PurePath
from urllib import request

import orjson
Expand Down Expand Up @@ -67,6 +67,113 @@
return servers_nbapp, servers_juserv


def find_nb_path_via_parent_process():
"""Tries to find the notebook path by inspecting the parent process's command line.

Requires the 'psutil' library. Heuristic and potentially fragile.
"""
import psutil

try:
current_process = psutil.Process(os.getpid())
parent_process = current_process.parent()

if parent_process is None:
logger.warning("psutil: Could not get parent process.")
return None

Check warning on line 83 in nbproject/dev/_jupyter_communicate.py

View check run for this annotation

Codecov / codecov/patch

nbproject/dev/_jupyter_communicate.py#L82-L83

Added lines #L82 - L83 were not covered by tests

# Get parent command line arguments
cmdline = parent_process.cmdline()
if not cmdline:
logger.warning(

Check warning on line 88 in nbproject/dev/_jupyter_communicate.py

View check run for this annotation

Codecov / codecov/patch

nbproject/dev/_jupyter_communicate.py#L88

Added line #L88 was not covered by tests
f"psutil: Parent process ({parent_process.pid}) has empty cmdline."
)
# Maybe check grandparent? This gets complicated quickly.
return None

Check warning on line 92 in nbproject/dev/_jupyter_communicate.py

View check run for this annotation

Codecov / codecov/patch

nbproject/dev/_jupyter_communicate.py#L92

Added line #L92 was not covered by tests

logger.info(f"psutil: Parent cmdline: {cmdline}")

# Heuristic parsing: Look for 'nbconvert' and '.ipynb'
# This is fragile and depends on how nbconvert was invoked.
is_nbconvert_call = False
potential_path = None

for i, arg in enumerate(cmdline):
# Check if 'nbconvert' command is present
if "nbconvert" in arg.lower():
# Check if it's the main command (e.g. /path/to/jupyter-nbconvert)
# or a subcommand (e.g. ['jupyter', 'nbconvert', ...])
# or a module call (e.g. ['python', '-m', 'nbconvert', ...])
base_arg = os.path.basename(arg).lower() # noqa: PTH119
if (
"jupyter-nbconvert" in base_arg
or arg == "nbconvert"
or (
cmdline[i - 1].endswith("python")
and arg == "-m"
and cmdline[i + 1] == "nbconvert"
)
):
is_nbconvert_call = True

# Find the argument ending in .ipynb AFTER 'nbconvert' is likely found
# Or just find the last argument ending in .ipynb as a guess
if arg.endswith(".ipynb"):
potential_path = arg # Store the last one found

if is_nbconvert_call and potential_path:
# We found something that looks like an nbconvert call and an ipynb file
# The path might be relative to the parent process's CWD.
# Try to resolve it. Parent CWD might not be notebook dir if called like
# jupyter nbconvert --execute /abs/path/to/notebook.ipynb
try:
# Get parent's CWD
parent_cwd = parent_process.cwd()
resolved_path = Path(parent_cwd) / Path(potential_path)
if resolved_path.is_file():
logger.info(f"psutil: Found potential path: {resolved_path}")
return resolved_path.resolve() # Return absolute path
else:
# Maybe the path was already absolute?
abs_path = Path(potential_path)
if abs_path.is_absolute() and abs_path.is_file():
logger.info(

Check warning on line 140 in nbproject/dev/_jupyter_communicate.py

View check run for this annotation

Codecov / codecov/patch

nbproject/dev/_jupyter_communicate.py#L138-L140

Added lines #L138 - L140 were not covered by tests
f"psutil: Found potential absolute path: {abs_path}"
)
return abs_path.resolve()

Check warning on line 143 in nbproject/dev/_jupyter_communicate.py

View check run for this annotation

Codecov / codecov/patch

nbproject/dev/_jupyter_communicate.py#L143

Added line #L143 was not covered by tests
else:
logger.warning(

Check warning on line 145 in nbproject/dev/_jupyter_communicate.py

View check run for this annotation

Codecov / codecov/patch

nbproject/dev/_jupyter_communicate.py#L145

Added line #L145 was not covered by tests
f"psutil: Potential path '{potential_path}' not found relative to parent CWD '{parent_cwd}' or as absolute path."
)
return None

Check warning on line 148 in nbproject/dev/_jupyter_communicate.py

View check run for this annotation

Codecov / codecov/patch

nbproject/dev/_jupyter_communicate.py#L148

Added line #L148 was not covered by tests

except psutil.AccessDenied:
logger.warning("psutil: Access denied when getting parent CWD.")

Check warning on line 151 in nbproject/dev/_jupyter_communicate.py

View check run for this annotation

Codecov / codecov/patch

nbproject/dev/_jupyter_communicate.py#L150-L151

Added lines #L150 - L151 were not covered by tests
# Fallback: assume path might be relative to kernel's CWD (less likely)
maybe_path = Path(potential_path)
if maybe_path.is_file():
return maybe_path.resolve()
return None # Give up trying to resolve relative path
except Exception as e:
logger.warning(f"psutil: Error resolving path '{potential_path}': {e}")
return None

Check warning on line 159 in nbproject/dev/_jupyter_communicate.py

View check run for this annotation

Codecov / codecov/patch

nbproject/dev/_jupyter_communicate.py#L153-L159

Added lines #L153 - L159 were not covered by tests

logger.warning(

Check warning on line 161 in nbproject/dev/_jupyter_communicate.py

View check run for this annotation

Codecov / codecov/patch

nbproject/dev/_jupyter_communicate.py#L161

Added line #L161 was not covered by tests
"psutil: Could not reliably identify notebook path from parent cmdline."
)
return None

Check warning on line 164 in nbproject/dev/_jupyter_communicate.py

View check run for this annotation

Codecov / codecov/patch

nbproject/dev/_jupyter_communicate.py#L164

Added line #L164 was not covered by tests

except ImportError:
logger.warning("psutil library not found. Cannot inspect parent process.")
return None
except psutil.Error as e:
logger.warning(f"psutil error: {e}")
return None
except Exception as e:
logger.warning(f"Unexpected error during psutil check: {e}")
return None

Check warning on line 174 in nbproject/dev/_jupyter_communicate.py

View check run for this annotation

Codecov / codecov/patch

nbproject/dev/_jupyter_communicate.py#L166-L174

Added lines #L166 - L174 were not covered by tests


def notebook_path(return_env=False):
"""Return the path to the current notebook.

Expand Down Expand Up @@ -157,6 +264,16 @@
nb_path = PurePath(os.environ["JPY_SESSION_NAME"])
return (nb_path, "lab" if env is None else env) if return_env else nb_path

# try inspecting parent process using psutil, needed if notebook is run via nbconvert
nb_path_psutil = find_nb_path_via_parent_process()
if nb_path_psutil is not None:
logger.info("Detected path via psutil parent process inspection.")
return (
(nb_path_psutil, "nbconvert" if env is None else env)
if return_env
else nb_path_psutil
)

# no running servers
if servers_nbapp == [] and servers_juserv == []:
logger.warning("Can not find any servers running.")
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dependencies = [
"pyyaml",
"packaging",
"orjson",
"psutil",
"importlib-metadata",
"stdlib_list; python_version < '3.10'",
"lamin_utils>=0.13.2",
Expand All @@ -38,6 +39,7 @@ dev = [
"nbproject_test >= 0.4.5",
"laminci",
"ipylab",
"nbconvert",
]

[project.scripts]
Expand Down
22 changes: 22 additions & 0 deletions tests/for-nbconvert.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from nbproject.dev._jupyter_communicate import notebook_path\n",
"\n",
"assert notebook_path() is not None, \"Cannot infer notebook path.\""
]
}
],
"metadata": {
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 2
}
13 changes: 13 additions & 0 deletions tests/test_nbconvert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import subprocess
from pathlib import Path


def test_running_via_nbconvert():
result = subprocess.run(
"jupyter nbconvert --to notebook --execute ./tests/for-nbconvert.ipynb",
shell=True,
capture_output=True,
)
print(result.stdout.decode())
print(result.stderr.decode())
assert result.returncode == 0