Skip to content

Commit fd28e09

Browse files
authored
Merge pull request #4740 from stsewd/implement-extend-install-option
Implement extended install option
2 parents 44a4fe3 + 296e365 commit fd28e09

File tree

8 files changed

+853
-310
lines changed

8 files changed

+853
-310
lines changed

readthedocs/config/config.py

+140-44
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,33 @@
1-
# -*- coding: utf-8 -*-
2-
31
# pylint: disable=too-many-lines
42

53
"""Build configuration for rtd."""
4+
5+
import copy
66
import os
77
from contextlib import contextmanager
88

9+
from readthedocs.config.utils import list_to_dict, to_dict
910
from readthedocs.projects.constants import DOCUMENTATION_CHOICES
1011

1112
from .find import find_one
12-
from .models import Build, Conda, Mkdocs, Python, Sphinx, Submodules
13+
from .models import (
14+
Build,
15+
Conda,
16+
Mkdocs,
17+
Python,
18+
PythonInstall,
19+
PythonInstallRequirements,
20+
Sphinx,
21+
Submodules,
22+
)
1323
from .parser import ParseError, parse
1424
from .validation import (
1525
VALUE_NOT_FOUND,
1626
ValidationError,
1727
validate_bool,
1828
validate_choice,
1929
validate_dict,
30+
validate_directory,
2031
validate_file,
2132
validate_list,
2233
validate_string,
@@ -31,9 +42,13 @@
3142
'ConfigError',
3243
'ConfigOptionNotSupportedError',
3344
'InvalidConfig',
45+
'PIP',
46+
'SETUPTOOLS',
3447
)
3548

3649
ALL = 'all'
50+
PIP = 'pip'
51+
SETUPTOOLS = 'setuptools'
3752
CONFIG_FILENAME_REGEX = r'^\.?readthedocs.ya?ml$'
3853

3954
CONFIG_NOT_SUPPORTED = 'config-not-supported'
@@ -145,7 +160,7 @@ class BuildConfigBase:
145160

146161
def __init__(self, env_config, raw_config, source_file):
147162
self.env_config = env_config
148-
self.raw_config = raw_config
163+
self.raw_config = copy.deepcopy(raw_config)
149164
self.source_file = source_file
150165
if os.path.isdir(self.source_file):
151166
self.base_path = self.source_file
@@ -245,10 +260,7 @@ def as_dict(self):
245260
config = {}
246261
for name in self.PUBLIC_ATTRIBUTES:
247262
attr = getattr(self, name)
248-
if hasattr(attr, '_asdict'):
249-
config[name] = attr._asdict()
250-
else:
251-
config[name] = attr
263+
config[name] = to_dict(attr)
252264
return config
253265

254266
def __getattr__(self, name):
@@ -465,7 +477,9 @@ def validate_requirements_file(self):
465477
if not requirements_file:
466478
return None
467479
with self.catch_validation_error('requirements_file'):
468-
validate_file(requirements_file, self.base_path)
480+
requirements_file = validate_file(
481+
requirements_file, self.base_path
482+
)
469483
return requirements_file
470484

471485
def validate_formats(self):
@@ -491,9 +505,39 @@ def formats(self):
491505
@property
492506
def python(self):
493507
"""Python related configuration."""
508+
python = self._config['python']
494509
requirements = self._config['requirements_file']
495-
self._config['python']['requirements'] = requirements
496-
return Python(**self._config['python'])
510+
python_install = []
511+
512+
# Always append a `PythonInstallRequirements` option.
513+
# If requirements is None, rtd will try to find a requirements file.
514+
python_install.append(
515+
PythonInstallRequirements(
516+
requirements=requirements,
517+
)
518+
)
519+
if python['install_with_pip']:
520+
python_install.append(
521+
PythonInstall(
522+
path=self.base_path,
523+
method=PIP,
524+
extra_requirements=python['extra_requirements'],
525+
)
526+
)
527+
elif python['install_with_setup']:
528+
python_install.append(
529+
PythonInstall(
530+
path=self.base_path,
531+
method=SETUPTOOLS,
532+
extra_requirements=[],
533+
)
534+
)
535+
536+
return Python(
537+
version=python['version'],
538+
install=python_install,
539+
use_system_site_packages=python['use_system_site_packages'],
540+
)
497541

498542
@property
499543
def conda(self):
@@ -545,7 +589,7 @@ class BuildConfigV2(BuildConfigBase):
545589
valid_formats = ['htmlzip', 'pdf', 'epub']
546590
valid_build_images = ['1.0', '2.0', '3.0', 'stable', 'latest']
547591
default_build_image = 'latest'
548-
valid_install_options = ['pip', 'setup.py']
592+
valid_install_method = [PIP, SETUPTOOLS]
549593
valid_sphinx_builders = {
550594
'html': 'sphinx',
551595
'htmldir': 'sphinx_htmldir',
@@ -668,39 +712,22 @@ def validate_python(self):
668712
self.get_valid_python_versions(),
669713
)
670714

671-
with self.catch_validation_error('python.requirements'):
672-
requirements = self.defaults.get('requirements_file')
673-
requirements = self.pop_config('python.requirements', requirements)
674-
if requirements != '' and requirements is not None:
675-
requirements = validate_file(requirements, self.base_path)
676-
python['requirements'] = requirements
677-
678715
with self.catch_validation_error('python.install'):
679-
install = (
680-
'setup.py' if self.defaults.get('install_project') else None
681-
)
682-
install = self.pop_config('python.install', install)
683-
if install is not None:
684-
validate_choice(install, self.valid_install_options)
685-
python['install_with_setup'] = install == 'setup.py'
686-
python['install_with_pip'] = install == 'pip'
687-
688-
with self.catch_validation_error('python.extra_requirements'):
689-
extra_requirements = self.pop_config(
690-
'python.extra_requirements',
691-
[],
692-
)
693-
extra_requirements = validate_list(extra_requirements)
694-
if extra_requirements and not python['install_with_pip']:
695-
self.error(
696-
'python.extra_requirements',
697-
'You need to install your project with pip '
698-
'to use extra_requirements',
699-
code=PYTHON_INVALID,
716+
raw_install = self.raw_config.get('python', {}).get('install', [])
717+
validate_list(raw_install)
718+
if raw_install:
719+
# Transform to a dict, so it's easy to validate extra keys.
720+
self.raw_config.setdefault('python', {})['install'] = (
721+
list_to_dict(raw_install)
700722
)
701-
python['extra_requirements'] = [
702-
validate_string(extra) for extra in extra_requirements
703-
]
723+
else:
724+
self.pop_config('python.install')
725+
726+
raw_install = self.raw_config.get('python', {}).get('install', [])
727+
python['install'] = [
728+
self.validate_python_install(index)
729+
for index in range(len(raw_install))
730+
]
704731

705732
with self.catch_validation_error('python.system_packages'):
706733
system_packages = self.defaults.get(
@@ -715,6 +742,60 @@ def validate_python(self):
715742

716743
return python
717744

745+
def validate_python_install(self, index):
746+
"""Validates the python.install.{index} key."""
747+
python_install = {}
748+
key = 'python.install.{}'.format(index)
749+
raw_install = self.raw_config['python']['install'][str(index)]
750+
with self.catch_validation_error(key):
751+
validate_dict(raw_install)
752+
753+
if 'requirements' in raw_install:
754+
requirements_key = key + '.requirements'
755+
with self.catch_validation_error(requirements_key):
756+
requirements = validate_file(
757+
self.pop_config(requirements_key),
758+
self.base_path
759+
)
760+
python_install['requirements'] = requirements
761+
elif 'path' in raw_install:
762+
path_key = key + '.path'
763+
with self.catch_validation_error(path_key):
764+
path = validate_directory(
765+
self.pop_config(path_key),
766+
self.base_path
767+
)
768+
python_install['path'] = path
769+
770+
method_key = key + '.method'
771+
with self.catch_validation_error(method_key):
772+
method = validate_choice(
773+
self.pop_config(method_key, PIP),
774+
self.valid_install_method
775+
)
776+
python_install['method'] = method
777+
778+
extra_req_key = key + '.extra_requirements'
779+
with self.catch_validation_error(extra_req_key):
780+
extra_requirements = validate_list(
781+
self.pop_config(extra_req_key, [])
782+
)
783+
if extra_requirements and python_install['method'] != PIP:
784+
self.error(
785+
extra_req_key,
786+
'You need to install your project with pip '
787+
'to use extra_requirements',
788+
code=PYTHON_INVALID,
789+
)
790+
python_install['extra_requirements'] = extra_requirements
791+
else:
792+
self.error(
793+
key,
794+
'"path" or "requirements" key is required',
795+
code=CONFIG_REQUIRED,
796+
)
797+
return python_install
798+
718799
def get_valid_python_versions(self):
719800
"""
720801
Get the valid python versions for the current docker image.
@@ -951,7 +1032,22 @@ def build(self):
9511032

9521033
@property
9531034
def python(self):
954-
return Python(**self._config['python'])
1035+
python_install = []
1036+
python = self._config['python']
1037+
for install in python['install']:
1038+
if 'requirements' in install:
1039+
python_install.append(
1040+
PythonInstallRequirements(**install)
1041+
)
1042+
elif 'path' in install:
1043+
python_install.append(
1044+
PythonInstall(**install)
1045+
)
1046+
return Python(
1047+
version=python['version'],
1048+
install=python_install,
1049+
use_system_site_packages=python['use_system_site_packages'],
1050+
)
9551051

9561052
@property
9571053
def sphinx(self):

readthedocs/config/models.py

+64-33
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,67 @@
22

33
"""Models for the response of the configuration object."""
44

5-
from collections import namedtuple
6-
7-
8-
Build = namedtuple('Build', ['image']) # noqa
9-
10-
Python = namedtuple( # noqa
11-
'Python',
12-
[
13-
'version',
14-
'requirements',
15-
'install_with_pip',
16-
'install_with_setup',
17-
'extra_requirements',
18-
'use_system_site_packages',
19-
],
20-
)
21-
22-
Conda = namedtuple('Conda', ['environment']) # noqa
23-
24-
Sphinx = namedtuple( # noqa
25-
'Sphinx',
26-
['builder', 'configuration', 'fail_on_warning'],
27-
)
28-
29-
Mkdocs = namedtuple( # noqa
30-
'Mkdocs',
31-
['configuration', 'fail_on_warning'],
32-
)
33-
34-
Submodules = namedtuple( # noqa
35-
'Submodules',
36-
['include', 'exclude', 'recursive'],
37-
)
5+
from readthedocs.config.utils import to_dict
6+
7+
8+
class Base(object):
9+
10+
"""
11+
Base class for every configuration.
12+
13+
Each inherited class should define
14+
its attibutes in the `__slots__` attribute.
15+
16+
We are using `__slots__` so we can't add more attributes by mistake,
17+
this is similar to a namedtuple.
18+
"""
19+
20+
def __init__(self, **kwargs):
21+
for name in self.__slots__:
22+
setattr(self, name, kwargs[name])
23+
24+
def as_dict(self):
25+
return {
26+
name: to_dict(getattr(self, name))
27+
for name in self.__slots__
28+
}
29+
30+
31+
class Build(Base):
32+
33+
__slots__ = ('image',)
34+
35+
36+
class Python(Base):
37+
38+
__slots__ = ('version', 'install', 'use_system_site_packages')
39+
40+
41+
class PythonInstallRequirements(Base):
42+
43+
__slots__ = ('requirements',)
44+
45+
46+
class PythonInstall(Base):
47+
48+
__slots__ = ('path', 'method', 'extra_requirements',)
49+
50+
51+
class Conda(Base):
52+
53+
__slots__ = ('environment',)
54+
55+
56+
class Sphinx(Base):
57+
58+
__slots__ = ('builder', 'configuration', 'fail_on_warning')
59+
60+
61+
class Mkdocs(Base):
62+
63+
__slots__ = ('configuration', 'fail_on_warning')
64+
65+
66+
class Submodules(Base):
67+
68+
__slots__ = ('include', 'exclude', 'recursive')

0 commit comments

Comments
 (0)