Skip to content
This repository was archived by the owner on Mar 18, 2022. It is now read-only.

Custom conda channels #20

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions readthedocs_build/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .validation import validate_directory
from .validation import validate_file
from .validation import validate_string
from .validation import validate_url
from .validation import ValidationError


Expand All @@ -29,6 +30,7 @@
CONF_FILE_REQUIRED = 'conf-file-required'
TYPE_REQUIRED = 'type-required'
PYTHON_INVALID = 'python-invalid'
CONDA_CHANNELS_INVALID = 'conda-channel-invalid'


class ConfigError(Exception):
Expand Down Expand Up @@ -77,6 +79,7 @@ class BuildConfig(dict):
PYTHON_INVALID_MESSAGE = '"python" section must be a mapping.'
PYTHON_EXTRA_REQUIREMENTS_INVALID_MESSAGE = (
'"python.extra_requirements" section must be a list.')
CONDA_CHANNELS_INVALID_MESSAGE = 'conda channels must be a list of anaconda.org channels.'

def __init__(self, env_config, raw_config, source_file, source_position):
self.env_config = env_config
Expand Down Expand Up @@ -289,6 +292,34 @@ def validate_conda(self):
conda['file'] = validate_file(
raw_conda['file'], base_path)

if 'channels' in raw_conda:
channels = raw_conda["channels"]
with self.catch_validation_error('conda.channels'):
if not isinstance(channels, (list, tuple)):
self.error('conda.channels',
self.CONDA_CHANNELS_INVALID_MESSAGE,
code=CONDA_CHANNELS_INVALID)
for c in channels:
# While conda supports arbitrary URLs for channels,
# it's a massive rabbit hole to try and chase down
# the "proper" security stance for that. Let's make it
# easier and only allow normal anaconda.org channels.
try:
validate_url(c)
except ValidationError:
# We want that one to fail.
# Now let's check if the value is path-like.
if c[0] in "./~":
# Any of those characters will make conda
# search the local file system, which is a no-go.
self.error('conda.channels',
self.CONDA_CHANNELS_INVALID_MESSAGE,
code=CONDA_CHANNELS_INVALID)
else:
self.error('conda.channels',
self.CONDA_CHANNELS_INVALID_MESSAGE,
code=CONDA_CHANNELS_INVALID)

self['conda'] = conda

def validate_requirements_file(self):
Expand Down
90 changes: 90 additions & 0 deletions readthedocs_build/config/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .config import NAME_REQUIRED
from .config import NAME_INVALID
from .config import PYTHON_INVALID
from .config import CONDA_CHANNELS_INVALID
from .validation import INVALID_BOOL
from .validation import INVALID_CHOICE
from .validation import INVALID_DIRECTORY
Expand Down Expand Up @@ -423,3 +424,92 @@ def test_project_set_output_base():
for build_config in project:
assert (
build_config['output_base'] == os.path.join(os.getcwd(), 'random'))


def describe_validate_conda():

def it_rejects_nonexistant_condafiles(tmpdir):
apply_fs(tmpdir, minimal_config_dir)
source_path = os.path.join(str(tmpdir), "readthedocs.yml")
conda_config = {"file": "environment.yml"}
build = BuildConfig({},
{"conda": conda_config},
source_path,
1)
raised = False
try:
build.validate_conda()
except InvalidConfig as e:
raised = e

assert raised is not False

def it_accepts_real_condafiles(tmpdir):
fs = {'environment.txt': 'numpy=1.9'}
fs.update(minimal_config_dir)
apply_fs(tmpdir, fs)
source_path = os.path.join(str(tmpdir), "readthedocs.yml")
conda_config = {"file": "environment.txt"}
build = BuildConfig({},
{"conda": conda_config},
source_path,
0)
build.validate_conda()

def it_rejects_url_channels(tmpdir):
fs = {'environment.txt': 'numpy=1.9'}
fs.update(minimal_config_dir)
apply_fs(tmpdir, fs)
source_path = os.path.join(str(tmpdir), "readthedocs.yml")
bad_paths = [
"file:///test/path",
"https://test.anaconda.org/test"
]
for p in bad_paths:
conda_config = {"channels": [p]}
build = BuildConfig({},
{"conda": conda_config},
source_path,
0)
raised = False
try:
build.validate_conda()
except InvalidConfig as e:
raised = e
assert raised is not False

def it_rejects_filesystem_channels(tmpdir):
fs = {'environment.txt': 'numpy=1.9'}
fs.update(minimal_config_dir)
apply_fs(tmpdir, fs)
source_path = os.path.join(str(tmpdir), "readthedocs.yml")
bad_paths = [
"/test/path",
"~/test/path",
"./test/path"
]
for p in bad_paths:
conda_config = {"channels": [p]}
build = BuildConfig({},
{"conda": conda_config},
source_path,
0)
raised = False
try:
build.validate_conda()
except InvalidConfig as e:
raised = e
assert raised is not False

def it_accepts_anaconda_channels(tmpdir):
fs = {'environment.txt': 'numpy=1.9'}
fs.update(minimal_config_dir)
apply_fs(tmpdir, fs)
source_path = os.path.join(str(tmpdir), "readthedocs.yml")
good_channel = "conda-forge"
conda_config = {"channels": [good_channel]}
build = BuildConfig({},
{"conda": conda_config},
source_path,
0)
build.validate_conda()
24 changes: 24 additions & 0 deletions readthedocs_build/config/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
from .validation import validate_file
from .validation import validate_path
from .validation import validate_string
from .validation import validate_url
from .validation import ValidationError
from .validation import INVALID_BOOL
from .validation import INVALID_CHOICE
from .validation import INVALID_DIRECTORY
from .validation import INVALID_FILE
from .validation import INVALID_PATH
from .validation import INVALID_STRING
from .validation import INVALID_URL


def describe_validate_bool():
Expand Down Expand Up @@ -137,3 +139,25 @@ def it_rejects_none():
with raises(ValidationError) as excinfo:
validate_string(None)
assert excinfo.value.code == INVALID_STRING


def describe_validate_url():

def it_accepts_complex_urls():
result = validate_url("ftp://user:[email protected]/test?myvalue=test")
assert isinstance(result, basestring)

def it_accepts_simple_urls():
result = validate_url("http://www.example.com/")
assert isinstance(result, basestring)

def it_rejects_no_scheme():
with raises(ValidationError) as excinfo:
validate_url("www.example.com/test")
assert excinfo.value.code == INVALID_URL

def it_rejects_no_host():
with raises(ValidationError) as excinfo:
validate_url("http:///test")
assert excinfo.value.code == INVALID_URL
assert validate_url("file:///test") == "file:///test"
15 changes: 15 additions & 0 deletions readthedocs_build/config/validation.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import urlparse


INVALID_BOOL = 'invalid-bool'
Expand All @@ -7,6 +8,7 @@
INVALID_FILE = 'invalid-file'
INVALID_PATH = 'invalid-path'
INVALID_STRING = 'invalid-string'
INVALID_URL = 'invalid-url'


class ValidationError(Exception):
Expand All @@ -17,6 +19,7 @@ class ValidationError(Exception):
INVALID_FILE: '{value} is not a file',
INVALID_PATH: 'path {value} does not exist',
INVALID_STRING: 'expected string',
INVALID_URL: 'expected URL, received {value}',
}

def __init__(self, value, code, format_kwargs=None):
Expand Down Expand Up @@ -72,3 +75,15 @@ def validate_string(value):
if not isinstance(value, basestring):
raise ValidationError(value, INVALID_STRING)
return unicode(value)


def validate_url(value):
string_value = validate_string(value)
parsed = urlparse.urlparse(string_value)
if not parsed.scheme:
raise ValidationError(value, INVALID_URL)
# Expand as necessary for schemes that allow no hostname
if parsed.scheme not in ["file"]:
if not parsed.netloc:
raise ValidationError(value, INVALID_URL)
return string_value