diff --git a/readthedocs/config/__init__.py b/readthedocs/config/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/readthedocs/config/find.py b/readthedocs/config/find.py new file mode 100644 index 00000000000..df83d098785 --- /dev/null +++ b/readthedocs/config/find.py @@ -0,0 +1,22 @@ +"""Helper functions to search files.""" + +from __future__ import division, print_function, unicode_literals + +import os + + +def find_all(path, filenames): + """Find all files in ``path`` that match in ``filenames``.""" + path = os.path.abspath(path) + for root, dirs, files in os.walk(path, topdown=True): + dirs.sort() + for filename in filenames: + if filename in files: + yield os.path.abspath(os.path.join(root, filename)) + + +def find_one(path, filenames): + """Find the first file in ``path`` that match in ``filenames``.""" + for _path in find_all(path, filenames): + return _path + return '' diff --git a/readthedocs/config/parser.py b/readthedocs/config/parser.py new file mode 100644 index 00000000000..7428da22055 --- /dev/null +++ b/readthedocs/config/parser.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""YAML parser for the RTD configuration file.""" + +from __future__ import division, print_function, unicode_literals + +import yaml + +__all__ = ('parse', 'ParseError') + + +class ParseError(Exception): + + """Parser related errors.""" + + pass + + +def parse(stream): + """ + Take file-like object and return a list of project configurations. + + The files need be valid YAML and only contain mappings as documents. + Everything else raises a ``ParseError``. + """ + try: + configs = list(yaml.safe_load_all(stream)) + except yaml.YAMLError as error: + raise ParseError('YAML: {message}'.format(message=error)) + if not configs: + raise ParseError('Empty config') + for config in configs: + if not isinstance(config, dict): + raise ParseError('Expected mapping') + return configs diff --git a/readthedocs/config/tests/__init__.py b/readthedocs/config/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git "a/readthedocs/config/tests/fixtures/bad_encode_project/schaue p\303\274laylist an.py" "b/readthedocs/config/tests/fixtures/bad_encode_project/schaue p\303\274laylist an.py" new file mode 100644 index 00000000000..e69de29bb2d diff --git a/readthedocs/config/tests/test_find.py b/readthedocs/config/tests/test_find.py new file mode 100644 index 00000000000..3a74d027fbb --- /dev/null +++ b/readthedocs/config/tests/test_find.py @@ -0,0 +1,96 @@ +from __future__ import division, print_function, unicode_literals + +import os + +import pytest +import six + +from readthedocs.config.find import find_all, find_one + +from .utils import apply_fs + + +def test_find_no_files(tmpdir): + with tmpdir.as_cwd(): + paths = list(find_all(os.getcwd(), ('readthedocs.yml',))) + assert len(paths) == 0 + + +def test_find_at_root(tmpdir): + apply_fs(tmpdir, {'readthedocs.yml': '', 'otherfile.txt': ''}) + + base = str(tmpdir) + paths = list(find_all(base, ('readthedocs.yml',))) + assert paths == [ + os.path.abspath(os.path.join(base, 'readthedocs.yml')) + ] + + +def test_find_nested(tmpdir): + apply_fs(tmpdir, { + 'first': { + 'readthedocs.yml': '', + }, + 'second': { + 'confuser.txt': 'content', + }, + 'third': { + 'readthedocs.yml': 'content', + 'Makefile': '', + }, + }) + apply_fs(tmpdir, {'first/readthedocs.yml': ''}) + + base = str(tmpdir) + paths = list(find_all(base, ('readthedocs.yml',))) + assert set(paths) == set([ + str(tmpdir.join('first', 'readthedocs.yml')), + str(tmpdir.join('third', 'readthedocs.yml')), + ]) + + +def test_find_multiple_files(tmpdir): + apply_fs(tmpdir, { + 'first': { + 'readthedocs.yml': '', + '.readthedocs.yml': 'content', + }, + 'second': { + 'confuser.txt': 'content', + }, + 'third': { + 'readthedocs.yml': 'content', + 'Makefile': '', + }, + }) + apply_fs(tmpdir, {'first/readthedocs.yml': ''}) + + base = str(tmpdir) + paths = list(find_all(base, ('readthedocs.yml', + '.readthedocs.yml'))) + assert paths == [ + str(tmpdir.join('first', 'readthedocs.yml')), + str(tmpdir.join('first', '.readthedocs.yml')), + str(tmpdir.join('third', 'readthedocs.yml')), + ] + + paths = list(find_all(base, ('.readthedocs.yml', + 'readthedocs.yml'))) + assert paths == [ + str(tmpdir.join('first', '.readthedocs.yml')), + str(tmpdir.join('first', 'readthedocs.yml')), + str(tmpdir.join('third', 'readthedocs.yml')), + ] + + +@pytest.mark.skipif(not six.PY2, reason='Only for python2') +def test_find_unicode_path(tmpdir): + base_path = os.path.abspath( + os.path.join(os.path.dirname(__file__), 'fixtures/bad_encode_project') + ) + path = find_one(base_path, ('readthedocs.yml',)) + assert path == '' + unicode_base_path = base_path.decode('utf-8') + assert isinstance(unicode_base_path, unicode) + path = find_one(unicode_base_path, ('readthedocs.yml',)) + assert path == '' diff --git a/readthedocs/config/tests/test_parser.py b/readthedocs/config/tests/test_parser.py new file mode 100644 index 00000000000..ff116b19f36 --- /dev/null +++ b/readthedocs/config/tests/test_parser.py @@ -0,0 +1,56 @@ +from __future__ import division, print_function, unicode_literals + +from io import StringIO + +from pytest import raises + +from readthedocs.config.parser import ParseError, parse + + +def test_parse_empty_config_file(): + buf = StringIO(u'') + with raises(ParseError): + parse(buf) + + +def test_parse_invalid_yaml(): + buf = StringIO(u'- - !asdf') + with raises(ParseError): + parse(buf) + + +def test_parse_bad_type(): + buf = StringIO(u'Hello') + with raises(ParseError): + parse(buf) + + +def test_parse_single_config(): + buf = StringIO(u'base: path') + config = parse(buf) + assert isinstance(config, list) + assert len(config) == 1 + assert config[0]['base'] == 'path' + + +def test_parse_empty_list(): + buf = StringIO(u'base: []') + config = parse(buf) + assert config[0]['base'] == [] + + +def test_parse_multiple_configs_in_one_file(): + buf = StringIO( + u''' +base: path +--- +base: other_path +name: second +nested: + works: true + ''') + configs = parse(buf) + assert isinstance(configs, list) + assert len(configs) == 2 + assert configs[0]['base'] == 'path' + assert configs[1]['nested'] == {'works': True} diff --git a/readthedocs/config/tests/test_utils.py b/readthedocs/config/tests/test_utils.py new file mode 100644 index 00000000000..6309a15f3f2 --- /dev/null +++ b/readthedocs/config/tests/test_utils.py @@ -0,0 +1,28 @@ +from __future__ import division, print_function, unicode_literals + +from .utils import apply_fs + + +def test_apply_fs_with_empty_contents(tmpdir): + # Doesn't do anything if second paramter is empty. + apply_fs(tmpdir, {}) + assert tmpdir.listdir() == [] + + +def test_apply_fs_create_empty_file(tmpdir): + # Create empty file. + apply_fs(tmpdir, {'file': ''}) + assert len(tmpdir.listdir()) == 1 + assert tmpdir.join('file').read() == '' + + +def test_apply_fs_create_file_with_content(tmpdir): + # Create file with content. + apply_fs(tmpdir, {'file': 'content'}) + assert tmpdir.join('file').read() == 'content' + + +def test_apply_fs_create_subdirectory(tmpdir): + # Create file with content. + apply_fs(tmpdir, {'subdir': {'file': 'content'}}) + assert tmpdir.join('subdir', 'file').read() == 'content' diff --git a/readthedocs/config/tests/utils.py b/readthedocs/config/tests/utils.py new file mode 100644 index 00000000000..b1b312420bb --- /dev/null +++ b/readthedocs/config/tests/utils.py @@ -0,0 +1,16 @@ +from __future__ import division, print_function, unicode_literals + + +def apply_fs(tmpdir, contents): + """ + Create the directory structure specified in ``contents``. It's a dict of + filenames as keys and the file contents as values. If the value is another + dict, it's a subdirectory. + """ + for filename, content in contents.items(): + if hasattr(content, 'items'): + apply_fs(tmpdir.mkdir(filename), content) + else: + file = tmpdir.join(filename) + file.write(content) + return tmpdir