-
Notifications
You must be signed in to change notification settings - Fork 46
Add support for creating envs from requirements.txt files via pip #172
base: develop
Are you sure you want to change the base?
Changes from all commits
759baee
153d481
ceb3a17
4ab5f15
3d9707c
dba2c47
877a43a
a114e73
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -19,8 +19,10 @@ source: | |
requirements: | ||
build: | ||
- python | ||
- pip | ||
run: | ||
- python | ||
- pip | ||
|
||
test: | ||
commands: | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,26 @@ | ||
from __future__ import absolute_import, print_function | ||
|
||
import errno | ||
import os | ||
import subprocess | ||
from collections import OrderedDict | ||
from copy import copy | ||
import os | ||
from io import open | ||
from os.path import isdir | ||
from shutil import rmtree | ||
|
||
# Try to import PipSession to support new pips | ||
try: | ||
from pip.download import PipSession | ||
except ImportError: | ||
pass | ||
from pip.req import parse_requirements | ||
|
||
# TODO This should never have to import from conda.cli | ||
from conda.cli import common | ||
from conda.cli import main_list | ||
from conda import install | ||
from conda.api import get_index | ||
from conda.cli import common, main_list | ||
from conda.resolve import NoPackagesFound, Resolve, MatchSpec | ||
|
||
from . import compat | ||
from . import exceptions | ||
|
@@ -50,19 +64,83 @@ def from_environment(name, prefix, no_builds=False): | |
|
||
|
||
def from_yaml(yamlstr, **kwargs): | ||
"""Load and return a ``Environment`` from a given ``yaml string``""" | ||
"""Load and return an ``Environment`` from a given ``yaml string``""" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nice catch! |
||
data = yaml.load(yamlstr) | ||
if kwargs is not None: | ||
for key, value in kwargs.items(): | ||
data[key] = value | ||
return Environment(**data) | ||
|
||
|
||
def get_all_pip_dependencies(requirements_path, temp_dir='_delete_when_done'): | ||
""" | ||
Use pip to gather all of the packages that would be installed for the given | ||
requirements.txt file. | ||
""" | ||
pip_cmd = ('pip', 'install', '--src', temp_dir, '--download', temp_dir, | ||
'--no-use-wheel', '-r', requirements_path) | ||
try: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See comment elsewhere. Please use isdir to check if dir exists, then create if necessary rather than try/except. |
||
os.makedirs(temp_dir) | ||
except OSError as exc: | ||
# Don't raise exception if directory already exists | ||
if not (exc.errno == errno.EEXIST and isdir(temp_dir)): | ||
raise | ||
process = subprocess.Popen(pip_cmd, stdout=subprocess.PIPE, | ||
universal_newlines=True) | ||
stdout_data = process.communicate()[0] | ||
reqs = [] | ||
for line in stdout_data.splitlines(): | ||
req = '' | ||
if line.startswith('Collecting'): | ||
req = line.split(' (', 1)[0][11:] | ||
elif line.startswith('Obtaining'): | ||
req = line.split(' (', 1)[0][10:] | ||
if req: | ||
if ' from ' in req: | ||
req = '-e {}'.format(req.split(' from ', 1)[1]) | ||
reqs.append(req) | ||
# Remove temporary directory | ||
rmtree(temp_dir) | ||
return reqs | ||
|
||
|
||
def from_requirements_txt(filename, **kwargs): | ||
"""Load and return an ``Environment`` from a given ``requirements.txt``""" | ||
pip_reqs = [] | ||
dep_list = [] | ||
r = Resolve(get_index()) | ||
parsed_reqs = get_all_pip_dependencies(filename) | ||
for req in parsed_reqs: | ||
if req.startswith('-e'): | ||
pip_reqs.append(req) | ||
# If it's not an editable package, check if it's available via conda | ||
else: | ||
try: | ||
# If package is available via conda, use that | ||
r.get_pkgs(MatchSpec(common.arg2spec(req))) | ||
dep_list.append(req) | ||
except NoPackagesFound: | ||
# Otherwise, just use pip | ||
pip_reqs.append(req) | ||
# Add pip requirements to environment if there were any left | ||
if pip_reqs: | ||
dep_list.append('pip') | ||
dep_list.append({'pip': pip_reqs}) | ||
data = {'dependencies': dep_list} | ||
if kwargs is not None: | ||
for key, value in kwargs.items(): | ||
data[key] = value | ||
return Environment(**data) | ||
|
||
|
||
def from_file(filename): | ||
if not os.path.exists(filename): | ||
raise exceptions.EnvironmentFileNotFound(filename) | ||
with open(filename, 'rb') as fp: | ||
return from_yaml(fp.read(), filename=filename) | ||
if filename.endswith('.txt'): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems a little too broad of a catch-all. Since the standard name is requirements.txt, would it be OK to match just that? Otherwise, I can see this leading to some interesting bug hunts. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel like people frequently have things like if filename.endswith('requirements.txt'): There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FWIW, I'd like to match |
||
return from_requirements_txt(filename) | ||
else: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here, rather than a catch-all else statement, replace with the standard for yaml input (I think this is environment.yaml?) If we don't match standard names, then we need to fall out, or provide a way for people to manually specify what kind of file they are providing. This means modifying this function to accept another argument to specify this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel like checking for specific file names is a little overly strict. If people are passing in file names as arguments part of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is fine, until someone else defines a new, shiny format for their package management tool that also happens to use yaml. Making it explicit outside of standard filenames is safer over the long haul. I think your idea of "endswith" is a good compromise between flexibility and prevention of future unexpected failure. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not dangerous, but the error messages that someone will encounter in the future will not be helpful. All I am really asking is that you provide a helpful error message if loading fails. Loading should never fail, so long as files follow known standards (environment.yml and requirements.txt). YAML and txt as file extensions happen to currently capture the current range of possibilities. When someone adds something new (and you know they will, and I hope they use YAML, because I think it is technically good), I'd like users to meet a friendly error message along the lines of:
Rather than something obscure like: |
||
with open(filename, 'rb') as fp: | ||
return from_yaml(fp.read(), filename=filename) | ||
|
||
|
||
# TODO test explicitly | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1,19 @@ | ||
from __future__ import absolute_import | ||
|
||
import subprocess | ||
from itertools import chain | ||
from os.path import join | ||
|
||
import conda.config as config | ||
from conda.cli import main_list | ||
|
||
|
||
def install(prefix, specs, args, env): | ||
pip_cmd = main_list.pip_args(prefix) + ['install', ] + specs | ||
# This mess is necessary to get --editable package sent to subprocess | ||
# properly. | ||
specs = list(chain(*[s.split() if '-e' in s else [s] for s in specs])) | ||
# Directory where pip will store VCS checkouts for editable packages | ||
src_dir = join(config.envs_dirs[0], env.name, 'src') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. looks like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
pip_cmd = main_list.pip_args(prefix) + ['install', '--src', src_dir] + specs | ||
process = subprocess.Popen(pip_cmd, universal_newlines=True) | ||
process.communicate() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
# Comment to be ignored | ||
skll | ||
# Comment 2 | ||
simplejson | ||
-e git+https://github.com/fabric/fabric.git#egg=fabric |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
don't fully understand what's going on here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Without this the environment won't get a name, because
requirements.txt
files cannot contain the name of the environment.