Skip to content

Commit 1a4bba1

Browse files
authored
Merge pull request #3432 from rtfd/humitos/wipe/automatically
Check versions used to create the venv and auto-wipe
2 parents 3de48a0 + f31144a commit 1a4bba1

File tree

4 files changed

+241
-15
lines changed

4 files changed

+241
-15
lines changed

readthedocs/doc_builder/config.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,7 @@ def extra_requirements(self):
5151

5252
@property
5353
def python_interpreter(self):
54-
ver = self.python_version
55-
if ver in [2, 3]:
56-
# Get the highest version of the major series version if user only
57-
# gave us a version of '2', or '3'
58-
ver = max(
59-
list(
60-
filter(
61-
lambda x: x < ver + 1,
62-
self._yaml_config.get_valid_python_versions(),
63-
)))
54+
ver = self.python_full_version
6455
return 'python{0}'.format(ver)
6556

6657
@property
@@ -75,6 +66,20 @@ def python_version(self):
7566
version = 3
7667
return version
7768

69+
@property
70+
def python_full_version(self):
71+
ver = self.python_version
72+
if ver in [2, 3]:
73+
# Get the highest version of the major series version if user only
74+
# gave us a version of '2', or '3'
75+
ver = max(
76+
list(
77+
filter(
78+
lambda x: x < ver + 1,
79+
self._yaml_config.get_valid_python_versions(),
80+
)))
81+
return ver
82+
7883
@property
7984
def use_system_site_packages(self):
8085
if 'use_system_site_packages' in self._yaml_config.get('python', {}):

readthedocs/doc_builder/python_environments.py

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1+
# -*- coding: utf-8 -*-
12
"""An abstraction over virtualenv and Conda environments."""
23

3-
from __future__ import absolute_import
4-
from builtins import object
4+
from __future__ import (
5+
absolute_import, division, print_function, unicode_literals)
6+
7+
import json
58
import logging
69
import os
710
import shutil
11+
from builtins import object, open
812

913
from django.conf import settings
1014

1115
from readthedocs.doc_builder.config import ConfigWrapper
16+
from readthedocs.doc_builder.constants import DOCKER_IMAGE
1217
from readthedocs.doc_builder.loader import get_builder_class
1318
from readthedocs.projects.constants import LOG_TEMPLATE
1419
from readthedocs.projects.models import Feature
@@ -38,7 +43,6 @@ def _log(self, msg):
3843
msg=msg))
3944

4045
def delete_existing_build_dir(self):
41-
4246
# Handle deleting old build dir
4347
build_dir = os.path.join(
4448
self.venv_path(),
@@ -47,6 +51,13 @@ def delete_existing_build_dir(self):
4751
self._log('Removing existing build directory')
4852
shutil.rmtree(build_dir)
4953

54+
def delete_existing_venv_dir(self):
55+
venv_dir = self.venv_path()
56+
# Handle deleting old venv dir
57+
if os.path.exists(venv_dir):
58+
self._log('Removing existing venv directory')
59+
shutil.rmtree(venv_dir)
60+
5061
def install_package(self):
5162
if self.config.install_project:
5263
if self.config.pip_install or getattr(settings, 'USE_PIP_INSTALL', False):
@@ -87,6 +98,73 @@ def venv_bin(self, filename=None):
8798
parts.append(filename)
8899
return os.path.join(*parts)
89100

101+
def environment_json_path(self):
102+
"""Return the path to the ``readthedocs-environment.json`` file."""
103+
return os.path.join(
104+
self.venv_path(),
105+
'readthedocs-environment.json',
106+
)
107+
108+
@property
109+
def is_obsolete(self):
110+
"""
111+
Determine if the Python version of the venv obsolete.
112+
113+
It checks the the data stored at ``readthedocs-environment.json`` and
114+
compares it against the Python version in the project version to be
115+
built and the Docker image used to create the venv against the one in
116+
the project version config.
117+
118+
:returns: ``True`` when it's obsolete and ``False`` otherwise
119+
120+
:rtype: bool
121+
"""
122+
# Always returns False if we don't have information about what Python
123+
# version/Docker image was used to create the venv as backward
124+
# compatibility.
125+
if not os.path.exists(self.environment_json_path()):
126+
return False
127+
128+
try:
129+
with open(self.environment_json_path(), 'r') as fpath:
130+
environment_conf = json.load(fpath)
131+
env_python_version = environment_conf['python']['version']
132+
env_build_image = environment_conf['build']['image']
133+
except (IOError, TypeError, KeyError, ValueError):
134+
log.error('Unable to read/parse readthedocs-environment.json file')
135+
return False
136+
137+
# TODO: remove getattr when https://github.com/rtfd/readthedocs.org/pull/3339 got merged
138+
build_image = getattr(self.config, 'build_image', self.version.project.container_image) or DOCKER_IMAGE # noqa
139+
140+
# If the user define the Python version just as a major version
141+
# (e.g. ``2`` or ``3``) we won't know exactly which exact version was
142+
# used to create the venv but we can still compare it against the new
143+
# one coming from the project version config.
144+
return any([
145+
env_python_version != self.config.python_full_version,
146+
env_build_image != build_image,
147+
])
148+
149+
def save_environment_json(self):
150+
"""Save on disk Python and build image versions used to create the venv."""
151+
# TODO: remove getattr when https://github.com/rtfd/readthedocs.org/pull/3339 got merged
152+
build_image = getattr(self.config, 'build_image', self.version.project.container_image) or DOCKER_IMAGE # noqa
153+
154+
data = {
155+
'python': {
156+
'version': self.config.python_full_version,
157+
},
158+
'build': {
159+
'image': build_image,
160+
},
161+
}
162+
163+
with open(self.environment_json_path(), 'w') as fpath:
164+
# Compatibility for Py2 and Py3. ``io.TextIOWrapper`` expects
165+
# unicode but ``json.dumps`` returns str in Py2.
166+
fpath.write(unicode(json.dumps(data)))
167+
90168

91169
class Virtualenv(PythonEnvironment):
92170

readthedocs/projects/tasks.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,8 +435,16 @@ def setup_environment(self):
435435
version=self.version,
436436
max_lock_age=getattr(settings, 'REPO_LOCK_SECONDS', 30)):
437437

438-
self.python_env.delete_existing_build_dir()
438+
# Check if the python version/build image in the current venv is the
439+
# same to be used in this build and if it differs, wipe the venv to
440+
# avoid conflicts.
441+
if self.python_env.is_obsolete:
442+
self.python_env.delete_existing_venv_dir()
443+
else:
444+
self.python_env.delete_existing_build_dir()
445+
439446
self.python_env.setup_base()
447+
self.python_env.save_environment_json()
440448
self.python_env.install_core_requirements()
441449
self.python_env.install_user_requirements()
442450
self.python_env.install_package()

readthedocs/rtd_tests/tests/test_doc_building.py

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,22 @@
1414
from builtins import str
1515

1616
import mock
17+
import pytest
1718
from django.test import TestCase
1819
from docker.errors import APIError as DockerAPIError
1920
from docker.errors import DockerException
20-
from mock import Mock, PropertyMock, patch
21+
from mock import Mock, PropertyMock, mock_open, patch
2122

2223
from readthedocs.builds.constants import BUILD_STATE_CLONING
2324
from readthedocs.builds.models import Version
25+
from readthedocs.doc_builder.config import ConfigWrapper
2426
from readthedocs.doc_builder.environments import (
2527
BuildCommand, DockerBuildCommand, DockerEnvironment, LocalEnvironment)
2628
from readthedocs.doc_builder.exceptions import BuildEnvironmentError
29+
from readthedocs.doc_builder.python_environments import Virtualenv
2730
from readthedocs.projects.models import Project
2831
from readthedocs.rtd_tests.mocks.environment import EnvironmentMockGroup
32+
from readthedocs.rtd_tests.tests.test_config_wrapper import create_load
2933

3034
DUMMY_BUILD_ID = 123
3135
SAMPLE_UNICODE = u'HérÉ îß sömê ünïçó∂é'
@@ -831,3 +835,134 @@ def test_command_oom_kill(self):
831835
self.assertEqual(
832836
str(cmd.output),
833837
u'Command killed due to excessive memory consumption\n')
838+
839+
840+
841+
842+
class TestAutoWipeEnvironment(TestCase):
843+
fixtures = ['test_data']
844+
845+
def setUp(self):
846+
self.pip = Project.objects.get(slug='pip')
847+
self.version = self.pip.versions.get(slug='0.8')
848+
849+
def test_is_obsolete_without_env_json_file(self):
850+
yaml_config = create_load()()[0]
851+
config = ConfigWrapper(version=self.version, yaml_config=yaml_config)
852+
853+
with patch('os.path.exists') as exists:
854+
exists.return_value = False
855+
python_env = Virtualenv(
856+
version=self.version,
857+
build_env=None,
858+
config=config,
859+
)
860+
861+
self.assertFalse(python_env.is_obsolete)
862+
863+
def test_is_obsolete_with_invalid_env_json_file(self):
864+
yaml_config = create_load()()[0]
865+
config = ConfigWrapper(version=self.version, yaml_config=yaml_config)
866+
867+
with patch('os.path.exists') as exists:
868+
exists.return_value = True
869+
python_env = Virtualenv(
870+
version=self.version,
871+
build_env=None,
872+
config=config,
873+
)
874+
875+
self.assertFalse(python_env.is_obsolete)
876+
877+
def test_is_obsolete_with_json_different_python_version(self):
878+
config_data = {
879+
'build': {
880+
'image': '2.0',
881+
},
882+
'python': {
883+
'version': 2.7,
884+
},
885+
}
886+
yaml_config = create_load(config_data)()[0]
887+
config = ConfigWrapper(version=self.version, yaml_config=yaml_config)
888+
889+
python_env = Virtualenv(
890+
version=self.version,
891+
build_env=None,
892+
config=config,
893+
)
894+
env_json_data = '{"build": {"image": "readthedocs/build:2.0"}, "python": {"version": 3.5}}'
895+
with patch('os.path.exists') as exists, patch('readthedocs.doc_builder.python_environments.open', mock_open(read_data=env_json_data)) as _open: # noqa
896+
exists.return_value = True
897+
self.assertTrue(python_env.is_obsolete)
898+
899+
@pytest.mark.xfail(reason='build.image is not being considered yet')
900+
def test_is_obsolete_with_json_different_build_image(self):
901+
config_data = {
902+
'build': {
903+
'image': 'latest',
904+
},
905+
'python': {
906+
'version': 2.7,
907+
},
908+
}
909+
yaml_config = create_load(config_data)()[0]
910+
config = ConfigWrapper(version=self.version, yaml_config=yaml_config)
911+
912+
python_env = Virtualenv(
913+
version=self.version,
914+
build_env=None,
915+
config=config,
916+
)
917+
env_json_data = '{"build": {"image": "readthedocs/build:2.0"}, "python": {"version": 2.7}}'
918+
with patch('os.path.exists') as exists, patch('readthedocs.doc_builder.python_environments.open', mock_open(read_data=env_json_data)) as _open: # noqa
919+
exists.return_value = True
920+
self.assertTrue(python_env.is_obsolete)
921+
922+
def test_is_obsolete_with_project_different_build_image(self):
923+
config_data = {
924+
'build': {
925+
'image': '2.0',
926+
},
927+
'python': {
928+
'version': 2.7,
929+
},
930+
}
931+
yaml_config = create_load(config_data)()[0]
932+
config = ConfigWrapper(version=self.version, yaml_config=yaml_config)
933+
934+
# Set container_image manually
935+
self.pip.container_image = 'readthedocs/build:latest'
936+
self.pip.save()
937+
938+
python_env = Virtualenv(
939+
version=self.version,
940+
build_env=None,
941+
config=config,
942+
)
943+
env_json_data = '{"build": {"image": "readthedocs/build:2.0"}, "python": {"version": 2.7}}'
944+
with patch('os.path.exists') as exists, patch('readthedocs.doc_builder.python_environments.open', mock_open(read_data=env_json_data)) as _open: # noqa
945+
exists.return_value = True
946+
self.assertTrue(python_env.is_obsolete)
947+
948+
def test_is_obsolete_with_json_same_data_as_version(self):
949+
config_data = {
950+
'build': {
951+
'image': '2.0',
952+
},
953+
'python': {
954+
'version': 3.5,
955+
},
956+
}
957+
yaml_config = create_load(config_data)()[0]
958+
config = ConfigWrapper(version=self.version, yaml_config=yaml_config)
959+
960+
python_env = Virtualenv(
961+
version=self.version,
962+
build_env=None,
963+
config=config,
964+
)
965+
env_json_data = '{"build": {"image": "readthedocs/build:2.0"}, "python": {"version": 3.5}}'
966+
with patch('os.path.exists') as exists, patch('readthedocs.doc_builder.python_environments.open', mock_open(read_data=env_json_data)) as _open: # noqa
967+
exists.return_value = True
968+
self.assertFalse(python_env.is_obsolete)

0 commit comments

Comments
 (0)