Skip to content

Commit f041892

Browse files
Allow passing additional environment variables to venv (#19)
This allows to specify environment variables that are added to the virtual python environment when build commands are run. This feature is now available in `VirtualPythonEnvironment` and all derivatives. * README.md : Add example using the `env` keyword. * sphinx_polyversion/pyenv.py (VirtualPythonEnvironment): Add `env` keyword and implement in the new `apply_overrides` method called by `run`. * sphinx_polyversion/pyenv.py : Add the `env` keyword to the subclasses `Poetry` and `Pip` as well. * tests/test_pyenv.py : Test the new behaviour. --------- Co-authored-by: real-yfprojects <[email protected]>
1 parent 623a2c8 commit f041892

File tree

3 files changed

+142
-8
lines changed

3 files changed

+142
-8
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ BUILDER = {
185185
ENVIRONMENT = {
186186
None: Poetry.factory(args="--sync".split()), # first version
187187
"v1.5.7": Poetry.factory(args="--only sphinx --sync".split()),
188-
"v1.8.2": Poetry.factory(args="--only dev --sync".split()),
188+
"v1.8.2": Poetry.factory(args="--only dev --sync".split(), env={"MY_VAR": "value"}),
189189
"v3.0.0": Pip.factory(venv=Path(".venv"), args="-e . -r requirements.txt".split()),
190190
}
191191

sphinx_polyversion/pyvenv.py

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ class VirtualPythonEnvironment(Environment):
9999
The path of the python venv.
100100
creator : Callable[[Path], Any], optional
101101
A callable for creating the venv, by default None
102+
env : dict[str, str], optional
103+
A dictionary of environment variables which are overridden in the
104+
virtual environment, by default None
102105
103106
Attributes
104107
----------
@@ -108,6 +111,8 @@ class VirtualPythonEnvironment(Environment):
108111
The name of the environment.
109112
venv : Path
110113
The path of the python venv.
114+
env : dict
115+
The user-specified environment variables for the virtual environment.
111116
112117
"""
113118

@@ -118,6 +123,7 @@ def __init__(
118123
venv: str | Path,
119124
*,
120125
creator: Callable[[Path], Any] | None = None,
126+
env: dict[str, str] | None = None,
121127
):
122128
"""
123129
Environment for building inside a python virtual environment.
@@ -132,11 +138,15 @@ def __init__(
132138
The path of the python venv.
133139
creator : Callable[[Path], Any], optional
134140
A callable for creating the venv, by default None
141+
env : dict[str, str], optional
142+
A dictionary of environment variables which are forwarded to the
143+
virtual environment, by default None
135144
136145
"""
137146
super().__init__(path, name)
138147
self.venv = Path(venv).resolve()
139148
self._creator = creator
149+
self.env = env or {}
140150

141151
async def create_venv(self) -> None:
142152
"""
@@ -191,6 +201,45 @@ def activate(self, env: dict[str, str]) -> dict[str, str]:
191201
env["PATH"] = str(self.venv / "bin") + ":" + env["PATH"]
192202
return env
193203

204+
def apply_overrides(self, env: dict[str, str]) -> dict[str, str]:
205+
"""
206+
Prepare the environment for the build.
207+
208+
This method is used to modify the environment before running a
209+
build command. It :py:meth:`activates <activate>` the python venv
210+
and overrides those environment variables that were passed to the
211+
:py:class:`constructor <VirtualPythonEnvironment>`.
212+
`PATH` is never replaced but extended instead.
213+
214+
.. warning:: This modifies the given dictionary in-place.
215+
216+
Parameters
217+
----------
218+
env : dict[str, str]
219+
The environment to modify.
220+
221+
Returns
222+
-------
223+
dict[str, str]
224+
The updated environment.
225+
226+
"""
227+
# add user-supplied values to env
228+
for key, value in self.env.items():
229+
if key == "PATH":
230+
# extend PATH instead of replacing
231+
env["PATH"] = value + ":" + env["PATH"]
232+
continue
233+
if key in env:
234+
logger.info(
235+
"Overwriting environment variable %s=%s with user-specified value '%s'.",
236+
key,
237+
env[key],
238+
value,
239+
)
240+
env[key] = value
241+
return env
242+
194243
async def run(
195244
self, *cmd: str, **kwargs: Any
196245
) -> Tuple[str | bytes | None, str | bytes | None, int]:
@@ -199,8 +248,9 @@ async def run(
199248
200249
This implementation passes the arguments to
201250
:func:`asyncio.create_subprocess_exec`. But alters `env` to
202-
activate the correct python venv. If a python venv is already activated
203-
this activation is overridden.
251+
:py:meth:`activates <activate>` the correct python
252+
and overrides the use-specified vars using :py:meth:`prepare_env`.
253+
If a python venv is already activated this activation is overridden.
204254
205255
Returns
206256
-------
@@ -213,7 +263,9 @@ async def run(
213263
214264
"""
215265
# activate venv
216-
kwargs["env"] = self.activate(kwargs.get("env", os.environ).copy())
266+
kwargs["env"] = self.activate(
267+
self.apply_overrides(kwargs.get("env", os.environ).copy())
268+
)
217269
return await super().run(*cmd, **kwargs)
218270

219271

@@ -232,10 +284,20 @@ class Poetry(VirtualPythonEnvironment):
232284
The name of the environment (usually the name of the revision).
233285
args : Iterable[str]
234286
The cmd arguments to pass to `poetry install`.
287+
env : dict[str, str], optional
288+
A dictionary of environment variables which are overidden in the
289+
virtual environment, by default None
235290
236291
"""
237292

238-
def __init__(self, path: Path, name: str, *, args: Iterable[str]):
293+
def __init__(
294+
self,
295+
path: Path,
296+
name: str,
297+
*,
298+
args: Iterable[str],
299+
env: dict[str, str] | None = None,
300+
):
239301
"""
240302
Build Environment for isolated builds using poetry.
241303
@@ -247,12 +309,16 @@ def __init__(self, path: Path, name: str, *, args: Iterable[str]):
247309
The name of the environment (usually the name of the revision).
248310
args : Iterable[str]
249311
The cmd arguments to pass to `poetry install`.
312+
env : dict[str, str], optional
313+
A dictionary of environment variables which are forwarded to the
314+
virtual environment, by default None
250315
251316
"""
252317
super().__init__(
253318
path,
254319
name,
255320
path / ".venv", # placeholder, determined later
321+
env=env,
256322
)
257323
self.args = args
258324

@@ -273,6 +339,8 @@ async def __aenter__(self) -> Self:
273339
cmd += self.args
274340

275341
env = os.environ.copy()
342+
self.apply_overrides(env)
343+
276344
env.pop("VIRTUAL_ENV", None) # unset poetry env
277345
env["POETRY_VIRTUALENVS_IN_PROJECT"] = "False"
278346
venv_path = self.path / ".venv"
@@ -344,6 +412,9 @@ class Pip(VirtualPythonEnvironment):
344412
The cmd arguments to pass to `pip install`.
345413
creator : Callable[[Path], Any] | None, optional
346414
A callable for creating the venv, by default None
415+
env : dict[str, str], optional
416+
A dictionary of environment variables which are overridden in the
417+
virtual environment, by default None
347418
348419
"""
349420

@@ -355,6 +426,7 @@ def __init__(
355426
*,
356427
args: Iterable[str],
357428
creator: Callable[[Path], Any] | None = None,
429+
env: dict[str, str] | None = None,
358430
):
359431
"""
360432
Build Environment for using a venv and pip.
@@ -371,9 +443,12 @@ def __init__(
371443
The cmd arguments to pass to `pip install`.
372444
creator : Callable[[Path], Any], optional
373445
A callable for creating the venv, by default None
446+
env : dict[str, str], optional
447+
A dictionary of environment variables which are overridden in the
448+
virtual environment, by default None
374449
375450
"""
376-
super().__init__(path, name, venv, creator=creator)
451+
super().__init__(path, name, venv, creator=creator, env=env)
377452
self.args = args
378453

379454
async def __aenter__(self) -> Self:
@@ -396,7 +471,7 @@ async def __aenter__(self) -> Self:
396471
process = await asyncio.create_subprocess_exec(
397472
*cmd,
398473
cwd=self.path,
399-
env=self.activate(os.environ.copy()),
474+
env=self.activate(self.apply_overrides(os.environ.copy())),
400475
stdout=PIPE,
401476
stderr=PIPE,
402477
)

tests/test_pyvenv.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,23 @@ async def test_run_with_creator(self, tmp_path: Path):
106106
assert rc == 0
107107
assert str(location) == out.strip()
108108

109+
@pytest.mark.asyncio
110+
async def test_run_with_env_variables(self, tmp_path: Path):
111+
"""Test passing an environment variable to a venv."""
112+
location = tmp_path / "venv"
113+
114+
async with VirtualPythonEnvironment(
115+
tmp_path, "main", location, creator=VenvWrapper(), env={"TESTVAR": "1"}
116+
) as env:
117+
out, err, rc = await env.run(
118+
"python",
119+
"-c",
120+
"import os; print(os.environ['TESTVAR'])",
121+
stdout=asyncio.subprocess.PIPE,
122+
)
123+
assert rc == 0
124+
assert out.strip() == "1"
125+
109126

110127
class TestPip:
111128
"""Test the `Pip` class."""
@@ -139,6 +156,38 @@ async def test_install_into_existing_venv(self, tmp_path: Path):
139156
)
140157
assert rc == 0
141158

159+
@pytest.mark.asyncio
160+
async def test_run_with_env_variables(self, tmp_path: Path):
161+
"""Test passing an environment variable to a venv."""
162+
location = tmp_path / "venv"
163+
164+
# create env
165+
await VenvWrapper(with_pip=True)(location)
166+
167+
# test that tomli is not installed
168+
proc = await asyncio.create_subprocess_exec(
169+
str(location / "bin/python"),
170+
"-c",
171+
"import tomli",
172+
stdout=asyncio.subprocess.PIPE,
173+
)
174+
rc = await proc.wait()
175+
assert rc == 1
176+
177+
# init env with tomli
178+
async with Pip(
179+
tmp_path, "main", location, args=["tomli"], env={"TESTVAR": "1"}
180+
) as env:
181+
# test that tomli is installed
182+
out, err, rc = await env.run(
183+
"python",
184+
"-c",
185+
"import os; print(os.environ['TESTVAR'])",
186+
stdout=asyncio.subprocess.PIPE,
187+
)
188+
assert rc == 0
189+
assert out.strip() == "1"
190+
142191

143192
class TestPoetry:
144193
"""Test the `Poetry` environment."""
@@ -169,7 +218,7 @@ async def test_simple_project(self, tmp_path: Path):
169218
)
170219

171220
# create poetry env
172-
async with Poetry(tmp_path, "main", args=[]) as env:
221+
async with Poetry(tmp_path, "main", args=[], env={"TESTVAR": "1"}) as env:
173222
# check sourcing works
174223
out, err, rc = await env.run(
175224
"python",
@@ -198,6 +247,16 @@ async def test_simple_project(self, tmp_path: Path):
198247
)
199248
assert rc == 0
200249

250+
# test that custom environment variables are passed correctly
251+
out, err, rc = await env.run(
252+
"python",
253+
"-c",
254+
"import os; print(os.environ['TESTVAR'])",
255+
stdout=asyncio.subprocess.PIPE,
256+
)
257+
assert rc == 0
258+
assert out.strip() == "1"
259+
201260
@pytest.mark.asyncio
202261
async def test_simple_project_with_optional_deps(self, tmp_path: Path):
203262
"""Test installing a simple project with poetry."""

0 commit comments

Comments
 (0)