Skip to content

Commit ce5d03a

Browse files
committed
Add support for running local Python kernels in virtual environments
1 parent 8117dba commit ce5d03a

File tree

3 files changed

+109
-1
lines changed

3 files changed

+109
-1
lines changed

CHANGELOG.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ Notable changes to this project will be documented in this file.
66

77
----
88

9+
********************
10+
v2.8.11 (2025-04-11)
11+
********************
12+
13+
Added
14+
=====
15+
16+
- Add ability for local Python kernels to run inside virtual environments without euporie being installed in that virtual environment
17+
18+
919
********************
1020
v2.8.10 (2025-04-02)
1121
********************

euporie/core/kernel/_settings.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Defines kernel settings."""
2+
3+
from euporie.core.config import add_setting
4+
5+
add_setting(
6+
name="warn_venv",
7+
# Kernel implementations are loaded lazily, so we add kernel settings to the root
8+
# kernel module group, causing them to get loaded before any kernel implementations
9+
group="euporie.core.kernel",
10+
flags=["--warn-venv"],
11+
type_=bool,
12+
default=True,
13+
help_="Warn when running in a virtual environment",
14+
description="""
15+
When enabled, displays a warning message when the kernel is running inside a
16+
virtual environment.
17+
18+
This can be helpful to remind users that they are working in an isolated Python
19+
environment with potentially different package versions than their system Python
20+
installation.
21+
22+
Disable this setting if you prefer not to see these warnings.
23+
""",
24+
)

euporie/core/kernel/local.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
import code
77
import getpass
88
import logging
9+
import os
910
import sys
1011
import threading
1112
import traceback
1213
from asyncio import to_thread
1314
from functools import update_wrapper
1415
from linecache import cache as line_cache
16+
from pathlib import Path
1517
from typing import TYPE_CHECKING, cast
1618

1719
from pygments import highlight
@@ -124,6 +126,77 @@ def info(
124126
if callable(set_status):
125127
set_status(self.status)
126128

129+
def init_venv(self) -> None:
130+
"""Add the current venv to sys.path so we can import modules from it.
131+
132+
This isn't perfect: it doesn't use the Python interpreter with which the
133+
virtualenv was built, and it ignores the --no-site-packages option. A warning
134+
will appear suggesting the user installs euporie in the virtualenv or to use a
135+
Jupyter kernel, but for many cases, it probably works well enough.
136+
137+
Adapted from :py:mod:`IPython`.
138+
"""
139+
# Check if we are in a virtual environment
140+
if "VIRTUAL_ENV" not in os.environ:
141+
return
142+
elif os.environ["VIRTUAL_ENV"] == "":
143+
log.warning(
144+
"The virtual environment path is set to '': "
145+
"please check if this is intentional."
146+
)
147+
return
148+
149+
# Follow sys.executable symlink trail, recoding all paths along the way
150+
# We need to check all of these
151+
paths = [Path(sys.executable)]
152+
while (path := paths[-1]).is_symlink():
153+
new_path = path.readlink()
154+
if not new_path.is_absolute():
155+
new_path = path.parent / new_path
156+
paths.append(new_path)
157+
158+
# Get the venv path
159+
p_venv = Path(os.environ["VIRTUAL_ENV"]).resolve()
160+
# In Cygwin paths like "c:\..." and '\cygdrive\c\...' are possible
161+
if len(p_venv.parts) > 2 and p_venv.parts[1] == "cygdrive":
162+
drive_name = p_venv.parts[2]
163+
p_venv = (drive_name + ":/") / Path(*p_venv.parts[3:])
164+
165+
# Check if the executable is already inside or has access to the virtualenv
166+
if any(p_venv == p.parents[1].resolve() for p in paths):
167+
return
168+
169+
# Locate the site-packages of the virtual environment
170+
if sys.platform == "win32":
171+
virtual_env = str(Path(os.environ["VIRTUAL_ENV"], "Lib", "site-packages"))
172+
else:
173+
import re
174+
175+
virtual_env_path = Path(
176+
os.environ["VIRTUAL_ENV"], "lib", "python{}.{}", "site-packages"
177+
)
178+
p_ver = sys.version_info[:2]
179+
180+
# Predict version from py[thon]-x.x in the $VIRTUAL_ENV
181+
re_m = re.search(r"\bpy(?:thon)?([23])\.(\d+)\b", os.environ["VIRTUAL_ENV"])
182+
if re_m:
183+
predicted_path = Path(str(virtual_env_path).format(*re_m.groups()))
184+
if predicted_path.exists():
185+
p_ver = re_m.groups()
186+
187+
virtual_env = str(virtual_env_path).format(*p_ver)
188+
189+
if self.kernel_tab.app.config.warn_venv:
190+
log.warning("Attempting to work in virtualenv %r.", virtual_env)
191+
log.warning(
192+
"If you encounter problems, please install euporie inside the virtual "
193+
"environment, or use a Jupyter kernel."
194+
)
195+
import site
196+
197+
sys.path.insert(0, virtual_env)
198+
site.addsitedir(virtual_env)
199+
127200
async def start_async(self) -> None:
128201
"""Start the local interpreter."""
129202
self.error = None
@@ -134,6 +207,7 @@ async def start_async(self) -> None:
134207
set_execution_count := self.default_callbacks.get("set_execution_count")
135208
):
136209
set_execution_count(self.execution_count)
210+
self.init_venv()
137211

138212
@property
139213
def spec(self) -> dict[str, str]:
@@ -177,7 +251,7 @@ def showtraceback(
177251
colored_traceback = highlight(
178252
traceback_text,
179253
Python3TracebackLexer(),
180-
Terminal256Formatter(style=get_app().config.syntax_theme),
254+
Terminal256Formatter(style=self.kernel_tab.app.config.syntax_theme),
181255
).rstrip()
182256

183257
# Send the error through the callback

0 commit comments

Comments
 (0)