diff --git a/readthedocs/config/tests/test_validation.py b/readthedocs/config/tests/test_validation.py new file mode 100644 index 00000000000..5325aab3f62 --- /dev/null +++ b/readthedocs/config/tests/test_validation.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +from __future__ import division, print_function, unicode_literals + +import os + +from mock import patch +from pytest import raises +from six import text_type + +from readthedocs.config.validation import ( + INVALID_BOOL, INVALID_CHOICE, INVALID_DIRECTORY, INVALID_FILE, INVALID_LIST, + INVALID_PATH, INVALID_STRING, ValidationError, validate_bool, + validate_choice, validate_directory, validate_file, validate_list, + validate_path, validate_string) + + +def describe_validate_bool(): + def it_accepts_true(): + assert validate_bool(True) is True + + def it_accepts_false(): + assert validate_bool(False) is False + + def it_accepts_0(): + assert validate_bool(0) is False + + def it_accepts_1(): + assert validate_bool(1) is True + + def it_fails_on_string(): + with raises(ValidationError) as excinfo: + validate_bool('random string') + assert excinfo.value.code == INVALID_BOOL + + +def describe_validate_choice(): + + def it_accepts_valid_choice(): + result = validate_choice('choice', ('choice', 'another_choice')) + assert result is 'choice' + + with raises(ValidationError) as excinfo: + validate_choice('c', 'abc') + assert excinfo.value.code == INVALID_LIST + + def it_rejects_invalid_choice(): + with raises(ValidationError) as excinfo: + validate_choice('not-a-choice', ('choice', 'another_choice')) + assert excinfo.value.code == INVALID_CHOICE + + +def describe_validate_list(): + + def it_accepts_list_types(): + result = validate_list(['choice', 'another_choice']) + assert result == ['choice', 'another_choice'] + + result = validate_list(('choice', 'another_choice')) + assert result == ['choice', 'another_choice'] + + def iterator(): + yield 'choice' + + result = validate_list(iterator()) + assert result == ['choice'] + + with raises(ValidationError) as excinfo: + validate_choice('c', 'abc') + assert excinfo.value.code == INVALID_LIST + + def it_rejects_string_types(): + with raises(ValidationError) as excinfo: + result = validate_list('choice') + assert excinfo.value.code == INVALID_LIST + + +def describe_validate_directory(): + + def it_uses_validate_path(tmpdir): + patcher = patch('readthedocs.config.validation.validate_path') + with patcher as validate_path: + path = text_type(tmpdir.mkdir('a directory')) + validate_path.return_value = path + validate_directory(path, str(tmpdir)) + validate_path.assert_called_with(path, str(tmpdir)) + + def it_rejects_files(tmpdir): + tmpdir.join('file').write('content') + with raises(ValidationError) as excinfo: + validate_directory('file', str(tmpdir)) + assert excinfo.value.code == INVALID_DIRECTORY + + +def describe_validate_file(): + + def it_uses_validate_path(tmpdir): + patcher = patch('readthedocs.config.validation.validate_path') + with patcher as validate_path: + path = tmpdir.join('a file') + path.write('content') + path = str(path) + validate_path.return_value = path + validate_file(path, str(tmpdir)) + validate_path.assert_called_with(path, str(tmpdir)) + + def it_rejects_directories(tmpdir): + tmpdir.mkdir('directory') + with raises(ValidationError) as excinfo: + validate_file('directory', str(tmpdir)) + assert excinfo.value.code == INVALID_FILE + + +def describe_validate_path(): + + def it_accepts_relative_path(tmpdir): + tmpdir.mkdir('a directory') + validate_path('a directory', str(tmpdir)) + + def it_accepts_files(tmpdir): + tmpdir.join('file').write('content') + validate_path('file', str(tmpdir)) + + def it_accepts_absolute_path(tmpdir): + path = str(tmpdir.mkdir('a directory')) + validate_path(path, 'does not matter') + + def it_returns_absolute_path(tmpdir): + tmpdir.mkdir('a directory') + path = validate_path('a directory', str(tmpdir)) + assert path == os.path.abspath(path) + + def it_only_accepts_strings(): + with raises(ValidationError) as excinfo: + validate_path(None, '') + assert excinfo.value.code == INVALID_STRING + + def it_rejects_non_existent_path(tmpdir): + with raises(ValidationError) as excinfo: + validate_path('does not exist', str(tmpdir)) + assert excinfo.value.code == INVALID_PATH + + +def describe_validate_string(): + + def it_accepts_unicode(): + result = validate_string(u'Unicöde') + assert isinstance(result, text_type) + + def it_accepts_nonunicode(): + result = validate_string('Unicode') + assert isinstance(result, text_type) + + def it_rejects_float(): + with raises(ValidationError) as excinfo: + validate_string(123.456) + assert excinfo.value.code == INVALID_STRING + + def it_rejects_none(): + with raises(ValidationError) as excinfo: + validate_string(None) + assert excinfo.value.code == INVALID_STRING diff --git a/readthedocs/config/validation.py b/readthedocs/config/validation.py new file mode 100644 index 00000000000..b317612dd2c --- /dev/null +++ b/readthedocs/config/validation.py @@ -0,0 +1,99 @@ +"""Validations for the RTD configuration file.""" +from __future__ import division, print_function, unicode_literals + +import os + +from six import string_types, text_type + +INVALID_BOOL = 'invalid-bool' +INVALID_CHOICE = 'invalid-choice' +INVALID_LIST = 'invalid-list' +INVALID_DIRECTORY = 'invalid-directory' +INVALID_FILE = 'invalid-file' +INVALID_PATH = 'invalid-path' +INVALID_STRING = 'invalid-string' + + +class ValidationError(Exception): + + """Base error for validations.""" + + messages = { + INVALID_BOOL: 'expected one of (0, 1, true, false), got {value}', + INVALID_CHOICE: 'expected one of ({choices}), got {value}', + INVALID_DIRECTORY: '{value} is not a directory', + INVALID_FILE: '{value} is not a file', + INVALID_PATH: 'path {value} does not exist', + INVALID_STRING: 'expected string', + INVALID_LIST: 'expected list', + } + + def __init__(self, value, code, format_kwargs=None): + self.value = value + self.code = code + defaults = { + 'value': value, + } + if format_kwargs is not None: + defaults.update(format_kwargs) + message = self.messages[code].format(**defaults) + super(ValidationError, self).__init__(message) + + +def validate_list(value): + """Check if ``value`` is an iterable.""" + if isinstance(value, str): + raise ValidationError(value, INVALID_LIST) + if not hasattr(value, '__iter__'): + raise ValidationError(value, INVALID_LIST) + return list(value) + + +def validate_choice(value, choices): + """Check that ``value`` is in ``choices``.""" + choices = validate_list(choices) + if value not in choices: + raise ValidationError(value, INVALID_CHOICE, { + 'choices': ', '.join(map(str, choices)) + }) + return value + + +def validate_bool(value): + """Check that ``value`` is an boolean value.""" + if value not in (0, 1, False, True): + raise ValidationError(value, INVALID_BOOL) + return bool(value) + + +def validate_directory(value, base_path): + """Check that ``value`` is a directory.""" + path = validate_path(value, base_path) + if not os.path.isdir(path): + raise ValidationError(value, INVALID_DIRECTORY) + return path + + +def validate_file(value, base_path): + """Check that ``value`` is a file.""" + path = validate_path(value, base_path) + if not os.path.isfile(path): + raise ValidationError(value, INVALID_FILE) + return path + + +def validate_path(value, base_path): + """Check that ``value`` is an existent file in ``base_path``.""" + string_value = validate_string(value) + pathed_value = os.path.join(base_path, string_value) + final_value = os.path.abspath(pathed_value) + if not os.path.exists(final_value): + raise ValidationError(value, INVALID_PATH) + return final_value + + +def validate_string(value): + """Check that ``value`` is a string type.""" + if not isinstance(value, string_types): + raise ValidationError(value, INVALID_STRING) + return text_type(value) diff --git a/readthedocs/rtd_tests/tests/test_build_config.py b/readthedocs/rtd_tests/tests/test_build_config.py index 73419c5cf48..11afa046711 100644 --- a/readthedocs/rtd_tests/tests/test_build_config.py +++ b/readthedocs/rtd_tests/tests/test_build_config.py @@ -5,7 +5,7 @@ import pytest import six import yamale -from readthedocs_build.testing import utils +from readthedocs.config.tests import utils from yamale.validators import DefaultValidators, Validator V2_SCHEMA = path.join( diff --git a/requirements/testing.txt b/requirements/testing.txt index 786f2070276..d742a8f4c7f 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -2,8 +2,11 @@ django-dynamic-fixture==2.0.0 -pytest<4,>=3.3.2 +# 3.6.1 and >3.2.5 is incompatible +# with pytest-describe 0.11.0 +pytest==3.2.5 pytest-django==3.1.2 +pytest-describe==0.11.0 pytest-xdist==1.22.0 apipkg==1.4 execnet==1.5.0