Skip to content

Commit a73ab4a

Browse files
committed
fix VCS environment variable expansion
1 parent 678724b commit a73ab4a

File tree

3 files changed

+438
-36
lines changed

3 files changed

+438
-36
lines changed

pipenv/utils/dependencies.py

Lines changed: 117 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
from functools import lru_cache
1111
from pathlib import Path
1212
from tempfile import NamedTemporaryFile, TemporaryDirectory
13-
from typing import Any, AnyStr, Dict, List, Mapping, Optional, Sequence, Union
13+
from typing import Any, AnyStr, Dict, List, Mapping, Optional, Sequence, Tuple, Union
1414
from urllib.parse import urlparse, urlsplit, urlunparse, urlunsplit
1515

16+
from pipenv.exceptions import PipenvUsageError
1617
from pipenv.patched.pip._internal.models.link import Link
1718
from pipenv.patched.pip._internal.network.download import Downloader
1819
from pipenv.patched.pip._internal.req.constructors import (
@@ -1083,13 +1084,72 @@ def normalize_vcs_url(vcs_url):
10831084
return vcs_url, vcs_ref
10841085

10851086

1086-
def install_req_from_pipfile(name, pipfile):
1087-
"""Creates an InstallRequirement from a name and a pipfile entry.
1088-
Handles VCS, local & remote paths, and regular named requirements.
1089-
"file" and "path" entries are treated the same.
1087+
class VCSURLProcessor:
1088+
"""Handles processing and environment variable expansion in VCS URLs."""
1089+
1090+
ENV_VAR_PATTERN = re.compile(r"\${([^}]+)}|\$([a-zA-Z_][a-zA-Z0-9_]*)")
1091+
1092+
@classmethod
1093+
def expand_env_vars(cls, value: str) -> str:
1094+
"""
1095+
Expands environment variables in a string, with detailed error handling.
1096+
Supports both ${VAR} and $VAR syntax.
1097+
"""
1098+
1099+
def _replace_var(match):
1100+
var_name = match.group(1) or match.group(2)
1101+
if var_name not in os.environ:
1102+
raise PipenvUsageError(
1103+
f"Environment variable '${var_name}' not found. "
1104+
"Please ensure all required environment variables are set."
1105+
)
1106+
return os.environ[var_name]
1107+
1108+
try:
1109+
return cls.ENV_VAR_PATTERN.sub(_replace_var, value)
1110+
except Exception as e:
1111+
raise PipenvUsageError(f"Error expanding environment variables: {str(e)}")
1112+
1113+
@classmethod
1114+
def process_vcs_url(cls, url: str) -> str:
1115+
"""
1116+
Processes a VCS URL, expanding environment variables in individual components.
1117+
Handles URLs of the form: vcs+protocol://username:password@hostname/path
1118+
"""
1119+
parsed = urlparse(url)
1120+
1121+
# Process each component separately
1122+
netloc_parts = parsed.netloc.split("@")
1123+
if len(netloc_parts) > 1:
1124+
# Handle auth information
1125+
auth, host = netloc_parts
1126+
if ":" in auth:
1127+
username, password = auth.split(":")
1128+
username = cls.expand_env_vars(username)
1129+
password = cls.expand_env_vars(password)
1130+
auth = f"{username}:{password}"
1131+
else:
1132+
auth = cls.expand_env_vars(auth)
1133+
netloc = f"{auth}@{host}"
1134+
else:
1135+
netloc = cls.expand_env_vars(parsed.netloc)
1136+
1137+
# Reconstruct URL with processed components
1138+
processed_parts = list(parsed)
1139+
processed_parts[1] = netloc # Update netloc
1140+
processed_parts[2] = cls.expand_env_vars(parsed.path) # Update path
1141+
1142+
return urlunparse(tuple(processed_parts))
1143+
1144+
1145+
def install_req_from_pipfile(name: str, pipfile: Dict[str, Any]) -> Tuple[Any, Any, str]:
1146+
"""
1147+
Creates an InstallRequirement from a name and a pipfile entry.
1148+
Enhanced to handle environment variables within VCS URLs.
10901149
"""
10911150
_pipfile = {}
10921151
vcs = None
1152+
10931153
if hasattr(pipfile, "keys"):
10941154
_pipfile = dict(pipfile).copy()
10951155
else:
@@ -1098,43 +1158,41 @@ def install_req_from_pipfile(name, pipfile):
10981158
_pipfile[vcs] = pipfile
10991159

11001160
extras = _pipfile.get("extras", [])
1101-
extras_str = ""
1102-
if extras:
1103-
extras_str = f"[{','.join(extras)}]"
1161+
extras_str = f"[{','.join(extras)}]" if extras else ""
1162+
11041163
if not vcs:
11051164
vcs = next(iter([vcs for vcs in VCS_LIST if vcs in _pipfile]), None)
11061165

11071166
if vcs:
1108-
vcs_url = _pipfile[vcs]
1109-
subdirectory = _pipfile.get("subdirectory", "")
1110-
if subdirectory:
1111-
subdirectory = f"#subdirectory={subdirectory}"
1112-
vcs_url, fallback_ref = normalize_vcs_url(vcs_url)
1113-
req_str = f"{vcs_url}@{_pipfile.get('ref', fallback_ref)}{extras_str}"
1114-
if not req_str.startswith(f"{vcs}+"):
1115-
req_str = f"{vcs}+{req_str}"
1116-
if _pipfile.get("editable", False):
1117-
req_str = f"-e {name}{extras_str} @ {req_str}{subdirectory}"
1118-
else:
1119-
req_str = f"{name}{extras_str} @ {req_str}{subdirectory}"
1120-
elif "path" in _pipfile:
1121-
req_str = file_path_from_pipfile(_pipfile["path"], _pipfile)
1122-
elif "file" in _pipfile:
1123-
req_str = file_path_from_pipfile(_pipfile["file"], _pipfile)
1124-
else:
1125-
# We ensure version contains an operator. Default to equals (==)
1126-
_pipfile["version"] = version = get_version(pipfile)
1127-
if version and not is_star(version) and COMPARE_OP.match(version) is None:
1128-
_pipfile["version"] = f"=={version}"
1129-
if is_star(version) or version == "==*":
1130-
version = ""
1131-
req_str = f"{name}{extras_str}{version}"
1167+
try:
1168+
vcs_url = _pipfile[vcs]
1169+
subdirectory = _pipfile.get("subdirectory", "")
1170+
if subdirectory:
1171+
subdirectory = f"#subdirectory={subdirectory}"
1172+
1173+
# Process VCS URL with environment variable handling
1174+
vcs_url, fallback_ref = normalize_vcs_url(vcs_url)
1175+
ref = _pipfile.get("ref", fallback_ref)
1176+
1177+
# Construct requirement string
1178+
req_str = f"{vcs_url}@{ref}{extras_str}"
1179+
if not req_str.startswith(f"{vcs}+"):
1180+
req_str = f"{vcs}+{req_str}"
1181+
1182+
if _pipfile.get("editable", False):
1183+
req_str = f"-e {name}{extras_str} @ {req_str}{subdirectory}"
1184+
else:
1185+
req_str = f"{name}{extras_str} @ {req_str}{subdirectory}"
11321186

1133-
# Handle markers before constructing InstallRequirement
1134-
markers = PipenvMarkers.from_pipfile(name, _pipfile)
1135-
if markers:
1136-
req_str = f"{req_str};{markers}"
1187+
except PipenvUsageError as e:
1188+
raise PipenvUsageError(
1189+
f"Error processing VCS URL for requirement '{name}': {str(e)}"
1190+
)
1191+
else:
1192+
# Handle non-VCS requirements (unchanged)
1193+
req_str = handle_non_vcs_requirement(name, _pipfile, extras_str)
11371194

1195+
# Create InstallRequirement
11381196
install_req, _ = expansive_install_req_from_line(
11391197
req_str,
11401198
comes_from=None,
@@ -1144,10 +1202,33 @@ def install_req_from_pipfile(name, pipfile):
11441202
constraint=False,
11451203
expand_env=True,
11461204
)
1205+
11471206
markers = PipenvMarkers.from_pipfile(name, _pipfile)
11481207
return install_req, markers, req_str
11491208

11501209

1210+
def handle_non_vcs_requirement(
1211+
name: str, _pipfile: Dict[str, Any], extras_str: str
1212+
) -> str:
1213+
"""Helper function to handle non-VCS requirements."""
1214+
if "path" in _pipfile:
1215+
return file_path_from_pipfile(_pipfile["path"], _pipfile)
1216+
elif "file" in _pipfile:
1217+
return file_path_from_pipfile(_pipfile["file"], _pipfile)
1218+
else:
1219+
version = get_version(_pipfile)
1220+
if version and not is_star(version) and COMPARE_OP.match(version) is None:
1221+
version = f"=={version}"
1222+
if is_star(version) or version == "==*":
1223+
version = ""
1224+
1225+
req_str = f"{name}{extras_str}{version}"
1226+
markers = PipenvMarkers.from_pipfile(name, _pipfile)
1227+
if markers:
1228+
req_str = f"{req_str};{markers}"
1229+
return req_str
1230+
1231+
11511232
def from_pipfile(name, pipfile):
11521233
install_req, markers, req_str = install_req_from_pipfile(name, pipfile)
11531234
if markers:

tests/integration/test_install_vcs.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import os
2+
from unittest.mock import patch, Mock, MagicMock
23

34
import pytest
45

6+
from pipenv.patched.pip._internal.vcs.git import Git
7+
from pipenv.utils import requirementslib
8+
59

610
@pytest.mark.basic
711
@pytest.mark.install

0 commit comments

Comments
 (0)