1
- # -*- coding: utf-8 -*-
2
-
3
1
"""
4
2
Classes to copy files between build and web servers.
5
3
13
11
import shutil
14
12
15
13
from django .conf import settings
14
+ from django .core .exceptions import SuspiciousFileOperation
15
+ from django .core .files .storage import get_storage_class
16
16
17
17
from readthedocs .core .utils import safe_makedirs
18
18
from readthedocs .core .utils .extend import SettingsOverrideObject
19
19
20
20
21
21
log = logging .getLogger (__name__ )
22
+ storage = get_storage_class ()()
22
23
23
24
24
25
class BaseSyncer :
@@ -42,6 +43,11 @@ def copy(cls, path, target, is_file=False, **kwargs):
42
43
return
43
44
if os .path .exists (target ):
44
45
os .remove (target )
46
+
47
+ # Create containing directory if it doesn't exist
48
+ directory = os .path .dirname (target )
49
+ safe_makedirs (directory )
50
+
45
51
shutil .copy2 (path , target )
46
52
else :
47
53
if os .path .exists (target ):
@@ -143,6 +149,10 @@ def copy(cls, path, target, host, is_file=False, **kwargs): # pylint: disable=a
143
149
log .info ('Remote Pull %s to %s' , path , target )
144
150
if not is_file and not os .path .exists (target ):
145
151
safe_makedirs (target )
152
+ if is_file :
153
+ # Create containing directory if it doesn't exist
154
+ directory = os .path .dirname (target )
155
+ safe_makedirs (directory )
146
156
# Add a slash when copying directories
147
157
sync_cmd = "rsync -e 'ssh -T' -av --delete {user}@{host}:{path} {target}" .format (
148
158
host = host ,
@@ -159,6 +169,59 @@ def copy(cls, path, target, host, is_file=False, **kwargs): # pylint: disable=a
159
169
)
160
170
161
171
172
+ class SelectiveStorageRemotePuller (RemotePuller ):
173
+
174
+ """
175
+ Like RemotePuller but certain files are copied via Django's storage system.
176
+
177
+ If a file with extensions specified by ``extensions`` is copied, it will be copied to storage
178
+ and the original is removed.
179
+
180
+ See: https://docs.djangoproject.com/en/1.11/ref/settings/#std:setting-DEFAULT_FILE_STORAGE
181
+ """
182
+
183
+ extensions = ('.pdf' , '.epub' , '.zip' )
184
+
185
+ @classmethod
186
+ def get_storage_path (cls , path ):
187
+ """
188
+ Gets the path to the file within the storage engine.
189
+
190
+ For example, if the path was $MEDIA_ROOT/pdfs/latest.pdf
191
+ the storage_path is 'pdfs/latest.pdf'
192
+
193
+ :raises: SuspiciousFileOperation if the path isn't under settings.MEDIA_ROOT
194
+ """
195
+ path = os .path .normpath (path )
196
+ if not path .startswith (settings .MEDIA_ROOT ):
197
+ raise SuspiciousFileOperation
198
+
199
+ path = path .replace (settings .MEDIA_ROOT , '' ).lstrip ('/' )
200
+ return path
201
+
202
+ @classmethod
203
+ def copy (cls , path , target , host , is_file = False , ** kwargs ): # pylint: disable=arguments-differ
204
+ RemotePuller .copy (path , target , host , is_file , ** kwargs )
205
+
206
+ if getattr (storage , 'write_build_media' , False ):
207
+ # This is a sanity check for the case where
208
+ # storage is backed by the local filesystem
209
+ # In that case, removing the original target file locally
210
+ # would remove the file from storage as well
211
+
212
+ if is_file and os .path .exists (target ) and \
213
+ any ([target .lower ().endswith (ext ) for ext in cls .extensions ]):
214
+ log .info ('Selective Copy %s to media storage' , target )
215
+
216
+ storage_path = cls .get_storage_path (target )
217
+
218
+ if storage .exists (storage_path ):
219
+ storage .delete (storage_path )
220
+
221
+ with open (target , 'rb' ) as fd :
222
+ storage .save (storage_path , fd )
223
+
224
+
162
225
class Syncer (SettingsOverrideObject ):
163
226
_default_class = LocalSyncer
164
227
_override_setting = 'FILE_SYNCER'
0 commit comments