Skip to content

Commit 88807c7

Browse files
authored
Merge pull request pypa#4332 from pypa/debt/package-index-vcs
Modernize package_index VCS handling
2 parents 4a0a9ce + 9bc2e87 commit 88807c7

File tree

4 files changed

+108
-96
lines changed

4 files changed

+108
-96
lines changed

newsfragments/4332.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Modernized and refactored VCS handling in package_index.

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ testing =
7676
tomli
7777
# No Python 3.12 dependencies require importlib_metadata, but needed for type-checking since we import it directly
7878
importlib_metadata
79+
pytest-subprocess
7980

8081
# workaround for pypa/setuptools#4333
8182
pyproject-hooks!=1.1

setuptools/package_index.py

Lines changed: 80 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""PyPI and direct package downloading."""
22

33
import sys
4+
import subprocess
45
import os
56
import re
67
import io
@@ -587,7 +588,7 @@ def download(self, spec, tmpdir):
587588
scheme = URL_SCHEME(spec)
588589
if scheme:
589590
# It's a url, download it to tmpdir
590-
found = self._download_url(scheme.group(1), spec, tmpdir)
591+
found = self._download_url(spec, tmpdir)
591592
base, fragment = egg_info_for_url(spec)
592593
if base.endswith('.py'):
593594
found = self.gen_setup(found, fragment, tmpdir)
@@ -816,7 +817,7 @@ def open_url(self, url, warning=None): # noqa: C901 # is too complex (12)
816817
else:
817818
raise DistutilsError("Download error for %s: %s" % (url, v)) from v
818819

819-
def _download_url(self, scheme, url, tmpdir):
820+
def _download_url(self, url, tmpdir):
820821
# Determine download filename
821822
#
822823
name, fragment = egg_info_for_url(url)
@@ -831,19 +832,59 @@ def _download_url(self, scheme, url, tmpdir):
831832

832833
filename = os.path.join(tmpdir, name)
833834

834-
# Download the file
835-
#
836-
if scheme == 'svn' or scheme.startswith('svn+'):
837-
return self._download_svn(url, filename)
838-
elif scheme == 'git' or scheme.startswith('git+'):
839-
return self._download_git(url, filename)
840-
elif scheme.startswith('hg+'):
841-
return self._download_hg(url, filename)
842-
elif scheme == 'file':
843-
return urllib.request.url2pathname(urllib.parse.urlparse(url)[2])
844-
else:
845-
self.url_ok(url, True) # raises error if not allowed
846-
return self._attempt_download(url, filename)
835+
return self._download_vcs(url, filename) or self._download_other(url, filename)
836+
837+
@staticmethod
838+
def _resolve_vcs(url):
839+
"""
840+
>>> rvcs = PackageIndex._resolve_vcs
841+
>>> rvcs('git+http://foo/bar')
842+
'git'
843+
>>> rvcs('hg+https://foo/bar')
844+
'hg'
845+
>>> rvcs('git:myhost')
846+
'git'
847+
>>> rvcs('hg:myhost')
848+
>>> rvcs('http://foo/bar')
849+
"""
850+
scheme = urllib.parse.urlsplit(url).scheme
851+
pre, sep, post = scheme.partition('+')
852+
# svn and git have their own protocol; hg does not
853+
allowed = set(['svn', 'git'] + ['hg'] * bool(sep))
854+
return next(iter({pre} & allowed), None)
855+
856+
def _download_vcs(self, url, spec_filename):
857+
vcs = self._resolve_vcs(url)
858+
if not vcs:
859+
return
860+
if vcs == 'svn':
861+
raise DistutilsError(
862+
f"Invalid config, SVN download is not supported: {url}"
863+
)
864+
865+
filename, _, _ = spec_filename.partition('#')
866+
url, rev = self._vcs_split_rev_from_url(url)
867+
868+
self.info(f"Doing {vcs} clone from {url} to {filename}")
869+
subprocess.check_call([vcs, 'clone', '--quiet', url, filename])
870+
871+
co_commands = dict(
872+
git=[vcs, '-C', filename, 'checkout', '--quiet', rev],
873+
hg=[vcs, '--cwd', filename, 'up', '-C', '-r', rev, '-q'],
874+
)
875+
if rev is not None:
876+
self.info(f"Checking out {rev}")
877+
subprocess.check_call(co_commands[vcs])
878+
879+
return filename
880+
881+
def _download_other(self, url, filename):
882+
scheme = urllib.parse.urlsplit(url).scheme
883+
if scheme == 'file': # pragma: no cover
884+
return urllib.request.url2pathname(urllib.parse.urlparse(url).path)
885+
# raise error if not allowed
886+
self.url_ok(url, True)
887+
return self._attempt_download(url, filename)
847888

848889
def scan_url(self, url):
849890
self.process_url(url, True)
@@ -859,64 +900,37 @@ def _invalid_download_html(self, url, headers, filename):
859900
os.unlink(filename)
860901
raise DistutilsError(f"Unexpected HTML page found at {url}")
861902

862-
def _download_svn(self, url, _filename):
863-
raise DistutilsError(f"Invalid config, SVN download is not supported: {url}")
864-
865903
@staticmethod
866-
def _vcs_split_rev_from_url(url, pop_prefix=False):
867-
scheme, netloc, path, query, frag = urllib.parse.urlsplit(url)
904+
def _vcs_split_rev_from_url(url):
905+
"""
906+
Given a possible VCS URL, return a clean URL and resolved revision if any.
907+
908+
>>> vsrfu = PackageIndex._vcs_split_rev_from_url
909+
>>> vsrfu('git+https://github.com/pypa/[email protected]#egg-info=setuptools')
910+
('https://github.com/pypa/setuptools', 'v69.0.0')
911+
>>> vsrfu('git+https://github.com/pypa/setuptools#egg-info=setuptools')
912+
('https://github.com/pypa/setuptools', None)
913+
>>> vsrfu('http://foo/bar')
914+
('http://foo/bar', None)
915+
"""
916+
parts = urllib.parse.urlsplit(url)
868917

869-
scheme = scheme.split('+', 1)[-1]
918+
clean_scheme = parts.scheme.split('+', 1)[-1]
870919

871920
# Some fragment identification fails
872-
path = path.split('#', 1)[0]
873-
874-
rev = None
875-
if '@' in path:
876-
path, rev = path.rsplit('@', 1)
877-
878-
# Also, discard fragment
879-
url = urllib.parse.urlunsplit((scheme, netloc, path, query, ''))
880-
881-
return url, rev
882-
883-
def _download_git(self, url, filename):
884-
filename = filename.split('#', 1)[0]
885-
url, rev = self._vcs_split_rev_from_url(url, pop_prefix=True)
886-
887-
self.info("Doing git clone from %s to %s", url, filename)
888-
os.system("git clone --quiet %s %s" % (url, filename))
889-
890-
if rev is not None:
891-
self.info("Checking out %s", rev)
892-
os.system(
893-
"git -C %s checkout --quiet %s"
894-
% (
895-
filename,
896-
rev,
897-
)
898-
)
921+
no_fragment_path, _, _ = parts.path.partition('#')
899922

900-
return filename
923+
pre, sep, post = no_fragment_path.rpartition('@')
924+
clean_path, rev = (pre, post) if sep else (post, None)
901925

902-
def _download_hg(self, url, filename):
903-
filename = filename.split('#', 1)[0]
904-
url, rev = self._vcs_split_rev_from_url(url, pop_prefix=True)
926+
resolved = parts._replace(
927+
scheme=clean_scheme,
928+
path=clean_path,
929+
# discard the fragment
930+
fragment='',
931+
).geturl()
905932

906-
self.info("Doing hg clone from %s to %s", url, filename)
907-
os.system("hg clone --quiet %s %s" % (url, filename))
908-
909-
if rev is not None:
910-
self.info("Updating to %s", rev)
911-
os.system(
912-
"hg --cwd %s up -C -r %s -q"
913-
% (
914-
filename,
915-
rev,
916-
)
917-
)
918-
919-
return filename
933+
return resolved, rev
920934

921935
def debug(self, msg, *args):
922936
log.debug(msg, *args)

setuptools/tests/test_packageindex.py

Lines changed: 26 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import urllib.error
44
import http.client
55
from inspect import cleandoc
6-
from unittest import mock
76

87
import pytest
98

@@ -171,49 +170,46 @@ def test_egg_fragment(self):
171170
assert dists[0].version == ''
172171
assert dists[1].version == vc
173172

174-
def test_download_git_with_rev(self, tmpdir):
173+
def test_download_git_with_rev(self, tmp_path, fp):
175174
url = 'git+https://github.example/group/project@master#egg=foo'
176175
index = setuptools.package_index.PackageIndex()
177176

178-
with mock.patch("os.system") as os_system_mock:
179-
result = index.download(url, str(tmpdir))
177+
expected_dir = tmp_path / 'project@master'
178+
fp.register([
179+
'git',
180+
'clone',
181+
'--quiet',
182+
'https://github.example/group/project',
183+
expected_dir,
184+
])
185+
fp.register(['git', '-C', expected_dir, 'checkout', '--quiet', 'master'])
180186

181-
os_system_mock.assert_called()
187+
result = index.download(url, tmp_path)
182188

183-
expected_dir = str(tmpdir / 'project@master')
184-
expected = (
185-
'git clone --quiet ' 'https://github.example/group/project {expected_dir}'
186-
).format(**locals())
187-
first_call_args = os_system_mock.call_args_list[0][0]
188-
assert first_call_args == (expected,)
189+
assert result == str(expected_dir)
190+
assert len(fp.calls) == 2
189191

190-
tmpl = 'git -C {expected_dir} checkout --quiet master'
191-
expected = tmpl.format(**locals())
192-
assert os_system_mock.call_args_list[1][0] == (expected,)
193-
assert result == expected_dir
194-
195-
def test_download_git_no_rev(self, tmpdir):
192+
def test_download_git_no_rev(self, tmp_path, fp):
196193
url = 'git+https://github.example/group/project#egg=foo'
197194
index = setuptools.package_index.PackageIndex()
198195

199-
with mock.patch("os.system") as os_system_mock:
200-
result = index.download(url, str(tmpdir))
201-
202-
os_system_mock.assert_called()
203-
204-
expected_dir = str(tmpdir / 'project')
205-
expected = (
206-
'git clone --quiet ' 'https://github.example/group/project {expected_dir}'
207-
).format(**locals())
208-
os_system_mock.assert_called_once_with(expected)
209-
210-
def test_download_svn(self, tmpdir):
196+
expected_dir = tmp_path / 'project'
197+
fp.register([
198+
'git',
199+
'clone',
200+
'--quiet',
201+
'https://github.example/group/project',
202+
expected_dir,
203+
])
204+
index.download(url, tmp_path)
205+
206+
def test_download_svn(self, tmp_path):
211207
url = 'svn+https://svn.example/project#egg=foo'
212208
index = setuptools.package_index.PackageIndex()
213209

214210
msg = r".*SVN download is not supported.*"
215211
with pytest.raises(distutils.errors.DistutilsError, match=msg):
216-
index.download(url, str(tmpdir))
212+
index.download(url, tmp_path)
217213

218214

219215
class TestContentCheckers:

0 commit comments

Comments
 (0)