Skip to content

Commit 3aae39b

Browse files
Add --json flag to print download/install information
Fixes #5398
1 parent 6fdcf23 commit 3aae39b

File tree

10 files changed

+246
-35
lines changed

10 files changed

+246
-35
lines changed

news/5398.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add machine readable --json to pip download and add --log-stderr

src/pip/_internal/basecommand.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ def main(self, args):
135135
logger_class = "pip._internal.utils.logging.ColorizedStreamHandler"
136136
handler_class = "pip._internal.utils.logging.BetterRotatingFileHandler"
137137

138-
logging.config.dictConfig({
138+
stdout, stderr = self.log_streams
139+
140+
config = {
139141
"version": 1,
140142
"disable_existing_loggers": False,
141143
"filters": {
@@ -155,15 +157,15 @@ def main(self, args):
155157
"level": level,
156158
"class": logger_class,
157159
"no_color": options.no_color,
158-
"stream": self.log_streams[0],
160+
"stream": stderr if options.log_stderr else stdout,
159161
"filters": ["exclude_warnings"],
160162
"formatter": "indent",
161163
},
162164
"console_errors": {
163165
"level": "WARNING",
164166
"class": logger_class,
165167
"no_color": options.no_color,
166-
"stream": self.log_streams[1],
168+
"stream": stderr,
167169
"formatter": "indent",
168170
},
169171
"user_log": {
@@ -173,6 +175,13 @@ def main(self, args):
173175
"delay": True,
174176
"formatter": "indent",
175177
},
178+
"structured_output": {
179+
"level": "DEBUG",
180+
"class": logger_class,
181+
"no_color": True,
182+
"stream": stdout,
183+
"formatter": "indent",
184+
},
176185
},
177186
"root": {
178187
"level": root_level,
@@ -194,7 +203,11 @@ def main(self, args):
194203
"pip._vendor", "distlib", "requests", "urllib3"
195204
]
196205
},
197-
})
206+
}
207+
config["loggers"]["pip.__structured_output"] = {
208+
"handlers": ["structured_output"],
209+
}
210+
logging.config.dictConfig(config)
198211

199212
# TODO: try to get these passing down from the command?
200213
# without resorting to os.environ to hold these.

src/pip/_internal/cmdoptions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,15 @@ def extra_index_url():
274274
help='Ignore package index (only looking at --find-links URLs instead).',
275275
) # type: Any
276276

277+
log_stderr = partial(
278+
Option,
279+
'--log-stderr',
280+
dest='log_stderr',
281+
action='store_true',
282+
default=False,
283+
help="Log logger warnings to stderr",
284+
)
285+
277286

278287
def find_links():
279288
return Option(
@@ -604,6 +613,7 @@ def _merge_hash(option, opt_str, value, parser):
604613
no_cache,
605614
disable_pip_version_check,
606615
no_color,
616+
log_stderr,
607617
]
608618
}
609619

src/pip/_internal/commands/download.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import absolute_import
22

3+
import json
34
import logging
45
import os
56

@@ -115,6 +116,15 @@ def __init__(self, *args, **kw):
115116
"this option."),
116117
)
117118

119+
cmd_opts.add_option(
120+
'--json',
121+
dest='json',
122+
action='store_true',
123+
default=False,
124+
help=("Output information about downloaded packages as json. "
125+
"See documentation for caveats."),
126+
)
127+
118128
index_opts = cmdoptions.make_option_group(
119129
cmdoptions.index_group,
120130
self.parser,
@@ -227,8 +237,34 @@ def run(self, options, args):
227237
if downloaded:
228238
logger.info('Successfully downloaded %s', downloaded)
229239

240+
if options.json:
241+
details = self._get_download_details(
242+
resolver, requirement_set, options.download_dir)
243+
logging.getLogger('pip.__structured_output').info(
244+
json.dumps(details))
245+
230246
# Clean up
231247
if not options.no_clean:
232248
requirement_set.cleanup_files()
233249

234250
return requirement_set
251+
252+
def _get_download_details(self, resolver, requirement_set, download_dir):
253+
downloaded = []
254+
download_dir = os.path.abspath(download_dir)
255+
for req in requirement_set.successfully_downloaded:
256+
deps = resolver.get_dependencies().get(req.name, [])
257+
download_path = os.path.join(download_dir, req.link.filename)
258+
downloaded.append(
259+
{
260+
'name': req.name,
261+
'download_path': download_path,
262+
'url': req.link.url,
263+
'version': req.version,
264+
'dependencies': [
265+
{'name': dep.name, 'version': dep.version}
266+
for dep in deps
267+
],
268+
}
269+
)
270+
return downloaded

src/pip/_internal/index.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,7 @@ def find_requirement(self, req, upgrade):
486486
"""Try to find a Link matching req
487487
488488
Expects req, an InstallRequirement and upgrade, a boolean
489-
Returns a Link if found,
489+
Returns an InstallationCandidate if found,
490490
Raises DistributionNotFound or BestVersionAlreadyInstalled otherwise
491491
"""
492492
all_candidates = self.find_all_candidates(req.name)
@@ -579,7 +579,7 @@ def find_requirement(self, req, upgrade):
579579
best_candidate.version,
580580
', '.join(sorted(compatible_versions, key=parse_version))
581581
)
582-
return best_candidate.location
582+
return best_candidate
583583

584584
def _get_pages(self, locations, project_name):
585585
"""

src/pip/_internal/req/req_install.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
from pip._internal.download import (
2828
is_archive_file, is_url, path_to_url, url_to_path,
2929
)
30-
from pip._internal.exceptions import InstallationError, UninstallationError
30+
from pip._internal.exceptions import (
31+
InstallationError, InvalidWheelFilename, UninstallationError,
32+
)
3133
from pip._internal.locations import (
3234
PIP_DELETE_MARKER_FILENAME, running_under_virtualenv,
3335
)
@@ -304,7 +306,9 @@ def populate_link(self, finder, upgrade, require_hashes):
304306
to file modification times.
305307
"""
306308
if self.link is None:
307-
self.link = finder.find_requirement(self, upgrade)
309+
candidate = finder.find_requirement(self, upgrade)
310+
self.link = candidate.location
311+
self._found_version = candidate.version
308312
if self._wheel_cache is not None and not require_hashes:
309313
old_link = self.link
310314
self.link = self._wheel_cache.get(self.link, self.name)
@@ -325,6 +329,27 @@ def is_pinned(self):
325329
return (len(specifiers) == 1 and
326330
next(iter(specifiers)).operator in {'==', '==='})
327331

332+
_found_version = None
333+
334+
@property
335+
def version(self):
336+
""" The version if available, else None
337+
338+
The version determined during requirement resolution if available,
339+
the wheel version from the filename if available, or None if no
340+
version information could be obtained
341+
"""
342+
if self._found_version:
343+
return str(self._found_version)
344+
# If we didn't lookup the version from the internet/a finder, try to
345+
# guess it
346+
if self.is_wheel:
347+
try:
348+
return Wheel(self.link.filename).version
349+
except InvalidWheelFilename:
350+
pass
351+
return None
352+
328353
def from_path(self):
329354
if self.req is None:
330355
return None

src/pip/_internal/resolve.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,3 +352,10 @@ def schedule(req):
352352
for install_req in req_set.requirements.values():
353353
schedule(install_req)
354354
return order
355+
356+
def get_dependencies(self):
357+
""" Gets dependencies discovered after resolution
358+
359+
Returns a mapping of package names to lists of requirement objects
360+
"""
361+
return self._discovered_dependencies

tests/functional/test_download.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import json
12
import os
3+
import sys
24
import textwrap
35

46
import pytest
7+
from pip._vendor.six.moves.urllib.parse import urlparse
58

69
from pip._internal.status_codes import ERROR
710
from tests.lib.path import Path
@@ -88,6 +91,82 @@ def test_basic_download_should_download_dependencies(script):
8891
assert script.site_packages / 'openid' not in result.files_created
8992

9093

94+
@pytest.mark.network
95+
def test_prints_json(script):
96+
result = script.pip(
97+
'download', 'flake8==3.5.0', '-d', '.', '--log-stderr', '--json',
98+
expect_stderr=True
99+
)
100+
101+
expected = {
102+
'flake8': {
103+
'dependencies': [
104+
'pyflakes',
105+
'enum34',
106+
'configparser',
107+
'pycodestyle',
108+
'mccabe',
109+
],
110+
'filename': 'flake8-{version}-py2.py3-none-any.whl'
111+
},
112+
'pyflakes': {
113+
'dependencies': [],
114+
'filename': 'pyflakes-{version}-py2.py3-none-any.whl'
115+
},
116+
'pycodestyle': {
117+
'dependencies': [],
118+
'filename': 'pycodestyle-{version}-py2.py3-none-any.whl'
119+
},
120+
'mccabe': {
121+
'dependencies': [],
122+
'filename': 'mccabe-{version}-py2.py3-none-any.whl'
123+
},
124+
'configparser': {
125+
'dependencies': [],
126+
'filename': 'configparser-{version}.tar.gz',
127+
},
128+
'enum34': {
129+
'dependencies': [],
130+
'filename': 'enum34-{version}-py{py_version}-none-any.whl',
131+
},
132+
}
133+
expected_keys = ['dependencies', 'download_path', 'name', 'url', 'version']
134+
135+
actual = json.loads(result.stdout)
136+
transformed = {package['name']: package for package in actual}
137+
138+
assert 'flake8' in transformed
139+
assert 'pyflakes' in transformed
140+
141+
for package in actual:
142+
assert sorted(package.keys()) == expected_keys
143+
144+
expected_package = expected[package['name']]
145+
version = package['version']
146+
url = urlparse(package['url'])
147+
filename = expected_package['filename'].format(
148+
version=version, py_version=sys.version_info[0])
149+
150+
created = result.files_created[Path('scratch') / filename]
151+
created_path = os.path.join(created.base_path, created.path)
152+
153+
# Windows likes to spit this path out lowercase.
154+
assert package['download_path'].lower() == created_path.lower()
155+
assert url.scheme == 'https'
156+
assert url.hostname == 'files.pythonhosted.org'
157+
assert Path(url.path).name == filename
158+
if package['dependencies']:
159+
# Dependencies can change between python versions. Just try to get
160+
# /something/ matched
161+
assert any(
162+
(dep['name'] in expected for dep in package['dependencies']))
163+
for dep in package['dependencies']:
164+
assert sorted(dep.keys()) == ['name', 'version']
165+
if dep['name'] in expected:
166+
expected_version = transformed[dep['name']]['version']
167+
assert dep['version'] == expected_version
168+
169+
91170
def test_download_wheel_archive(script, data):
92171
"""
93172
It should download a wheel archive path

0 commit comments

Comments
 (0)