wip video conference module
[cds-indico.git] / fabfile.py
blob25be04ddfed4b36340fb6139f3cae2b680ac5c02
1 # This file is part of Indico.
2 # Copyright (C) 2002 - 2015 European Organization for Nuclear Research (CERN).
4 # Indico is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License as
6 # published by the Free Software Foundation; either version 3 of the
7 # License, or (at your option) any later version.
9 # Indico is distributed in the hope that it will be useful, but
10 # WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12 # General Public License for more details.
14 # You should have received a copy of the GNU General Public License
15 # along with Indico; if not, see <http://www.gnu.org/licenses/>.
17 """
18 fabfile for Indico development operations
19 """
21 import os
22 import re
23 import sys
24 import glob
25 import shutil
26 import requests
27 import json
28 import getpass
29 from contextlib import contextmanager
30 import operator
32 from fabric.api import local, lcd, task, env
33 from fabric.context_managers import prefix, settings
34 from fabric.colors import red, green, yellow, cyan
35 from fabric.contrib import console
36 from fabric.operations import put, run
39 ASSET_TYPES = ['js', 'sass', 'css']
40 DOC_DIRS = ['guides']
41 RECIPES = {}
42 DEFAULT_REQUEST_ERROR_MSG = 'UNDEFINED ERROR (no error description from server)'
43 CONF_FILE_NAME = 'fabfile.conf'
44 SOURCE_DIR = os.path.dirname(__file__)
46 execfile(CONF_FILE_NAME, {}, env)
48 env.update({
49 'conf': CONF_FILE_NAME,
50 'src_dir': SOURCE_DIR,
51 'ext_dir': os.path.join(SOURCE_DIR, env.ext_dirname),
52 'target_dir': os.path.join(SOURCE_DIR, env.target_dirname),
53 'node_env_path': os.path.join(SOURCE_DIR, env.node_env_dirname)
57 def recipe(name):
58 def _wrapper(f):
59 RECIPES[name] = f
60 return _wrapper
63 # Decorators
65 @contextmanager
66 def node_env():
67 if env.system_node:
68 yield
69 else:
70 with prefix('. {0}'.format(os.path.join(env.node_env_path, 'bin/activate'))):
71 yield
74 @contextmanager
75 def pyenv_env(version):
76 cmd_dir = os.path.join(env.pyenv_dir, 'versions', 'indico-build-{0}'.format(version), 'bin')
77 with prefix('PATH={0}:$PATH'.format(cmd_dir)):
78 yield
81 def pyenv_cmd(cmd, **kwargs):
82 cmd_dir = os.path.join(env.pyenv_dir, 'bin')
83 return local('{0}/pyenv {1}'.format(cmd_dir, cmd), **kwargs)
86 # Util functions
88 def _yes_no_input(message, default):
89 c = '? '
90 if default.lower() == 'y':
91 c = ' [Y/n]? '
92 elif default.lower() == 'n':
93 c = ' [y/N]? '
94 s = raw_input(message+c) or default
95 if s.lower() == 'y':
96 return True
97 else:
98 return False
101 def _putl(source_file, dest_dir):
103 To be used instead of put, since it doesn't support symbolic links
106 put(source_file, '/')
107 run("mkdir -p {0}".format(dest_dir))
108 run("mv -f /{0} {1}".format(os.path.basename(source_file), dest_dir))
111 def create_node_env():
112 with settings(warn_only=True):
113 local('nodeenv -c -n {0} {1}'.format(env.node_version, env.node_env_path))
116 def lib_dir(src_dir, dtype):
117 target_dir = os.path.join(src_dir, 'indico', 'htdocs')
118 return os.path.join(target_dir, dtype, 'lib')
121 def _check_pyenv(py_versions):
123 Check that pyenv and pyenv-virtualenv are installed and set up the
124 compilers/virtual envs in case they do not exist
127 if os.system('which pyenv'):
128 print red("Can't find pyenv!")
129 print yellow("Are you sure you have installed it?")
130 sys.exit(-2)
131 elif os.system('which pyenv-virtualenv'):
132 print red("Can't find pyenv-virtualenv!")
133 print yellow("Are you sure you have installed it?")
134 sys.exit(-2)
136 # list available pythonbrew versions
137 av_versions = list(entry.strip() for entry in pyenv_cmd('versions', capture=True).split('\n')[1:])
139 for py_version in py_versions:
140 if (py_version) not in av_versions:
141 print green('Installing Python {0}'.format(py_version))
142 pyenv_cmd('install {0}'.format(py_version), capture=True)
144 local("echo \'y\' | pyenv virtualenv {0} indico-build-{0}".format(py_version))
146 with pyenv_env(py_version):
147 local("pip install -r requirements.dev.txt")
150 def _check_present(executable, message="Please install it first."):
152 Check that executable exists in $PATH
155 with settings(warn_only=True):
156 if local('which {0} > /dev/null && echo $?'.format(executable), capture=True) != '0':
157 print red('{0} is not available in this system. {1}'.format(executable, message))
158 sys.exit(-2)
161 def _safe_rm(path, recursive=False, ask=True):
162 if path[0] != '/':
163 path = os.path.join(env.lcwd, path)
164 if ask:
165 files = glob.glob(path)
166 if files:
167 print yellow("The following files are going to be deleted:\n ") + '\n '.join(files)
168 if console.confirm(cyan("Are you sure you want to delete them?")):
169 local('rm {0}{1}'.format('-rf ' if recursive else '', path))
170 else:
171 print red("Delete operation cancelled")
172 else:
173 local('rm {0}{1}'.format('-rf ' if recursive else '', path))
176 def _cp_tree(dfrom, dto, exclude=[]):
178 Simple copy with exclude option
180 if dfrom[0] != '/':
181 dfrom = os.path.join(env.lcwd, dfrom)
182 if dto[0] != '/':
183 dto = os.path.join(env.lcwd, dto)
185 print "{0} -> {1}".format(dfrom, dto)
187 shutil.copytree(dfrom, dto, ignore=shutil.ignore_patterns(*exclude))
190 def _find_most_recent(path, cmp=operator.gt, maxt=0):
191 for dirpath, __, fnames in os.walk(path):
192 for fname in fnames:
194 # ignore hidden files and ODTs
195 if fname.startswith(".") or fname.endswith(".odt"):
196 continue
198 mtime = os.stat(os.path.join(dirpath, fname)).st_mtime
199 if cmp(mtime, maxt):
200 maxt = mtime
201 return maxt
204 def _find_least_recent(path):
205 return _find_most_recent(path, cmp=operator.lt, maxt=sys.maxint)
208 def _install_dependencies(mod_name, sub_path, dtype, dest_subpath=None):
209 l_dir = lib_dir(env.src_dir, dtype)
210 dest_dir = os.path.join(l_dir, dest_subpath) if dest_subpath else l_dir
211 local('mkdir -p {0}'.format(dest_dir))
212 local('cp -R {0} {1}/'.format(
213 os.path.join(env.ext_dir, mod_name, sub_path),
214 dest_dir))
217 # Recipes
219 @recipe('angular')
220 def install_angular():
222 Install Angular.js from Git
224 with node_env():
225 with lcd(os.path.join(env.ext_dir, 'angular')):
226 local('npm install')
227 local('grunt clean buildall copy write compress')
228 dest_dir_js = lib_dir(env.src_dir, 'js')
229 dest_dir_css = lib_dir(env.src_dir, 'css')
230 local('mkdir -p {0}'.format(dest_dir_js))
231 local('cp build/angular.js {0}/'.format(dest_dir_js))
232 local('cp build/angular-resource.js {0}/'.format(dest_dir_js))
233 local('cp build/angular-sanitize.js {0}/'.format(dest_dir_js))
234 local('cp css/angular.css {0}'.format(dest_dir_css))
237 @recipe('ui-sortable')
238 def install_ui_sortable():
240 Install angular ui-sortable from Git
242 with node_env():
243 with lcd(os.path.join(env.ext_dir, 'ui-sortable')):
244 dest_dir_js = lib_dir(env.src_dir, 'js')
245 local('mkdir -p {0}'.format(dest_dir_js))
246 local('cp src/sortable.js {0}/'.format(dest_dir_js))
249 @recipe('compass')
250 def install_compass():
252 Install compass stylesheets from Git
254 _install_dependencies('compass', 'frameworks/compass/stylesheets/*', 'sass', 'compass')
257 @recipe('jquery')
258 def install_jquery():
260 Install jquery from Git
262 with node_env():
263 with lcd(os.path.join(env.ext_dir, 'jquery')):
264 local('npm install')
265 local('grunt')
266 dest_dir = lib_dir(env.src_dir, 'js')
267 local('mkdir -p {0}'.format(dest_dir))
268 local('cp dist/jquery.js {0}/'.format(dest_dir))
271 @recipe('jqplot')
272 def install_jqplot():
273 """Install jQPlot from Git"""
274 plugins = ['axis', 'bar', 'cursor', 'highlighter', 'points', 'text']
275 with lcd(os.path.join(env.ext_dir, 'jqplot')):
276 dest_dir_js = os.path.join(lib_dir(env.src_dir, 'js'), 'jqplot')
277 dest_dir_css = lib_dir(env.src_dir, 'css')
278 dest_dir_js_core = os.path.join(dest_dir_js, 'core')
279 dest_dir_js_plugins = os.path.join(dest_dir_js, 'plugins')
280 local('mkdir -p {0} {1}'.format(dest_dir_js_core, dest_dir_css))
281 local('cp src/core/*.js {0}'.format(dest_dir_js_core))
282 local('cp src/core/*.css {0}'.format(dest_dir_css))
283 for plugin_name in plugins:
284 dest = os.path.join(dest_dir_js_plugins, plugin_name)
285 local('mkdir -p {0}'.format(dest))
286 local('cp src/plugins/{0}/* {1}'.format(plugin_name, dest))
289 @recipe('underscore')
290 def install_underscore():
292 Install underscore from Git
294 _install_dependencies('underscore', 'underscore.js', 'js')
297 @recipe('rrule') # rrule.js
298 def install_rrule():
300 Install rrule from Git
302 _install_dependencies('rrule', 'lib/rrule.js', 'js')
305 @recipe('qtip2')
306 def install_qtip2():
308 Install qtip2 from Git
310 with node_env():
311 with lcd(os.path.join(env.ext_dir, 'qtip2')):
312 local('npm install')
313 local('grunt --plugins="tips modal viewport svg" init clean concat:dist concat:css concat:libs replace')
314 dest_dir_js, dest_dir_css = lib_dir(env.src_dir, 'js'), lib_dir(env.src_dir, 'css')
315 local('mkdir -p {0} {1}'.format(dest_dir_js, dest_dir_css))
316 local('cp dist/jquery.qtip.js {0}/'.format(dest_dir_js))
317 local('cp dist/jquery.qtip.css {0}/'.format(dest_dir_css))
320 @recipe('jquery-ui-multiselect')
321 def install_jquery_ui_multiselect():
323 Install jquery ui multiselect widget from Git
325 with node_env():
326 with lcd(os.path.join(env.ext_dir, 'jquery-ui-multiselect')):
327 dest_dir_js = lib_dir(env.src_dir, 'js')
328 dest_dir_css = lib_dir(env.src_dir, 'css')
329 local('mkdir -p {0} {1}'.format(dest_dir_js, dest_dir_css))
330 local('cp src/jquery.multiselect.js {0}/'.format(dest_dir_js))
331 local('cp src/jquery.multiselect.filter.js {0}/'.format(dest_dir_js))
332 local('cp jquery.multiselect.css {0}/'.format(dest_dir_css))
333 local('cp jquery.multiselect.filter.css {0}/'.format(dest_dir_css))
336 @recipe('MathJax')
337 def install_mathjax():
339 Install MathJax from Git
342 dest_dir = os.path.join(lib_dir(env.src_dir, 'js'), 'mathjax/')
343 mathjax_js = os.path.join(dest_dir, 'MathJax.js')
345 with lcd(os.path.join(env.ext_dir, 'mathjax')):
346 local('rm -rf {0}'.format(os.path.join(dest_dir)))
347 _cp_tree('unpacked/', dest_dir, exclude=["AM*", "MML*", "Accessible*", "Safe*"])
348 _cp_tree('images/', os.path.join(dest_dir, 'images'))
349 _cp_tree('fonts/', os.path.join(dest_dir, 'fonts'), exclude=["png"])
351 with open(mathjax_js, 'r') as f:
352 data = f.read()
353 # Uncomment 'isPacked = true' line
354 data = re.sub(r'//\s*(MathJax\.isPacked\s*=\s*true\s*;)', r'\1', data, re.MULTILINE)
356 with open(mathjax_js, 'w') as f:
357 f.write(data)
360 @recipe('PageDown')
361 def install_pagedown():
363 Install PageDown from Git (mirror!)
365 with lcd(os.path.join(env.ext_dir, 'pagedown')):
366 dest_dir = os.path.join(lib_dir(env.src_dir, 'js'), 'pagedown/')
367 local('mkdir -p {0}'.format(dest_dir))
368 local('cp *.js {0}'.format(dest_dir))
371 # Tasks
373 @task
374 def install(recipe_name):
376 Install a module given the recipe name
378 RECIPES[recipe_name]()
381 @task
382 def init_submodules(src_dir='.'):
384 Initialize submodules (fetch them from external Git repos)
387 print green("Initializing submodules")
388 with lcd(src_dir):
389 local('pwd')
390 local('git submodule update --init --recursive')
393 def _install_deps():
395 Install asset dependencies
397 print green("Installing asset dependencies...")
398 for recipe_name in RECIPES:
399 print cyan("Installing {0}".format(recipe_name))
400 install(recipe_name)
403 @task
404 def setup_deps(n_env=None, n_version=None, src_dir=None, system_node=None):
406 Setup (fetch and install) dependencies for Indico assets
409 src_dir = src_dir or env.src_dir
410 n_env = n_env or env.node_env_path
411 system_node = system_node.lower() in ('1', 'true') if system_node is not None else env.system_node
413 # initialize submodules if they haven't yet been
414 init_submodules(src_dir)
416 ext_dir = os.path.join(src_dir, 'ext_modules')
418 _check_present('curl')
420 with settings(node_env_path=n_env or os.path.join(ext_dir, 'node_env'),
421 node_version=n_version or env.node_version,
422 system_node=system_node,
423 src_dir=src_dir,
424 ext_dir=ext_dir):
426 if not system_node and not os.path.exists(n_env):
427 create_node_env()
429 with node_env():
430 local('npm install -g grunt-cli')
432 _install_deps()
435 @task
436 def clean_deps(src_dir=None):
438 Clean up generated files
441 for dtype in ASSET_TYPES:
442 _safe_rm('{0}/*'.format(lib_dir(src_dir or env.src_dir, dtype)), recursive=True)
445 @task
446 def cleanup(build_dir=None, force=False):
448 Clean up build environment
450 _safe_rm('{0}'.format(build_dir or env.build_dir), recursive=True, ask=(not force))
453 @task
454 def tarball(src_dir=None):
456 Create a source Indico distribution (tarball)
459 src_dir = src_dir or env.src_dir
461 make_docs(src_dir)
463 setup_deps(n_env=os.path.join(src_dir, 'ext_modules', 'node_env'),
464 src_dir=src_dir)
465 local('python setup.py -q sdist')
468 @task
469 def egg(py_versions=None):
471 Create a binary Indico distribution (egg)
474 for py_version in py_versions:
475 cmd_dir = os.path.join(env.pyenv_dir, 'versions', 'indico-build-{0}'.format(py_version), 'bin')
476 local('{0} setup.py -q bdist_egg'.format(os.path.join(cmd_dir, 'python')))
477 print green(local('ls -lah dist/', capture=True))
480 @task
481 def make_docs(src_dir=None, build_dir=None, force=False):
483 Generate Indico docs
486 src_dir = src_dir or env.src_dir
487 doc_src_dir = os.path.join(src_dir, 'doc')
489 if build_dir is None:
490 target_dir = os.path.join(src_dir, 'indico', 'htdocs', 'ihelp')
491 else:
492 target_dir = os.path.join(build_dir or env.build_dir, 'indico', 'htdocs', 'ihelp')
494 if not force:
495 print yellow("Checking if docs need to be generated... "),
496 if _find_most_recent(target_dir) > _find_most_recent(doc_src_dir):
497 print green("Nope.")
498 return
500 print red("Yes :(")
501 _check_present('pdflatex')
503 print green('Generating documentation')
504 with lcd(doc_src_dir):
505 for d in DOC_DIRS:
506 with lcd(d):
507 local('make html')
508 local('make latex')
509 local('rm -rf {0}/*'.format(os.path.join(target_dir, 'html')))
510 local('mv build/html/* {0}'.format(os.path.join(target_dir, 'html')))
512 with lcd(os.path.join('guides', 'build', 'latex')):
513 local('make all-pdf')
514 local('mv *.pdf {0}'.format(os.path.join(target_dir, 'pdf')))
516 print green('Cleaning up')
517 for d in DOC_DIRS:
518 with lcd(d):
519 local('make clean')
522 def _check_request_error(r):
523 if r.status_code >= 400:
524 j = r.json()
525 msg = j.get('message', DEFAULT_REQUEST_ERROR_MSG)
526 print red("ERROR: {0} ({1})".format(msg, r.status_code))
527 sys.exit(-2)
530 def _valid_github_credentials(auth):
531 url = "https://api.github.com/repos/{0}/{1}".format(env.github['org'], env.github['repo'])
532 r = requests.get(url, auth=(env.github['user'], auth))
533 if (r.status_code == 401) and (r.json().get('message') == 'Bad credentials'):
534 print red('Invalid Github credentials for user \'{0}\''.format(env.github['user']))
535 return False
537 return True
540 def _release_exists(tag_name, auth):
541 url = "https://api.github.com/repos/{0}/{1}/releases".format(env.github['org'], env.github['repo'])
542 r = requests.get(url, auth=(env.github['user'], auth))
543 _check_request_error(r)
544 parsed = r.json()
545 for release in parsed:
546 if release.get('tag_name') == tag_name:
547 rel_id = release.get('id')
548 return (True, rel_id, release)
550 return (False, 0, None)
553 def _asset_exists(rel_id, name, auth):
554 url = "https://api.github.com/repos/{0}/{1}/releases/{2}/assets" \
555 .format(env.github['org'], env.github['repo'], rel_id)
556 r = requests.get(url, auth=(env.github['user'], auth))
557 _check_request_error(r)
558 parsed = r.json()
559 for j in parsed:
560 if j.get('name') == name:
561 asset_id = j.get('id')
562 return (True, asset_id)
564 return (False, 0)
567 @task
568 def upload_github(build_dir=None, tag_name=None, auth_token=None,
569 overwrite=None, indico_version='master'):
571 build_dir = build_dir or env.build_dir
572 auth_token = auth_token or env.github['auth_token']
574 while (auth_token is None) or (not _valid_github_credentials(auth_token)):
575 auth_token = getpass.getpass(
576 'Insert the Github password/OAuth token for user \'{0}\': '.format(env.github['user']))
578 auth_creds = (env.github['user'], auth_token)
580 overwrite = overwrite or env.github['overwrite']
582 # Create a new release
583 tag_name = tag_name or indico_version
584 url = "https://api.github.com/repos/{0}/{1}/releases".format(env.github['org'], env.github['repo'])
585 payload = {
586 'tag_name': tag_name,
587 'target_commitish': indico_version,
588 'name': 'Indico {0}'.format(tag_name),
589 'draft': True
592 (exists, rel_id, release_data) = _release_exists(tag_name, auth_token)
594 if exists:
595 if overwrite is None:
596 overwrite = _yes_no_input('Release already exists, do you want to overwrite', 'n')
597 if overwrite:
598 release_id = rel_id
599 else:
600 return
601 else:
602 # We will need to get a new release id from github
603 r = requests.post(url, auth=auth_creds, data=json.dumps(payload))
604 _check_request_error(r)
605 release_data = r.json()
606 release_id = release_data.get('id')
608 # Upload binaries to the new release
609 binaries_dir = os.path.join(build_dir, 'indico', 'dist')
611 # awful way to handle this, but a regex seems like too much
612 url = release_data['upload_url'][:-7]
614 for f in os.listdir(binaries_dir):
616 # jump over hidden/system files
617 if f.startswith('.'):
618 continue
620 if os.path.isfile(os.path.join(binaries_dir, f)):
621 (exists, asset_id) = _asset_exists(release_id, f, auth_token)
623 if exists:
624 # delete previous version
625 del_url = "https://api.github.com/repos/{0}/{1}/releases/assets/{2}" \
626 .format(env.github['org'], env.github['repo'], asset_id)
627 r = requests.delete(del_url, auth=auth_creds)
628 _check_request_error(r)
630 with open(os.path.join(binaries_dir, f), 'rb') as ff:
631 data = ff.read()
632 extension = os.path.splitext(f)[1]
634 # upload eggs using zip mime type
635 if extension == '.gz':
636 headers = {'Content-Type': 'application/x-gzip'}
637 elif extension == '.egg':
638 headers = {'Content-Type': 'application/zip'}
640 headers['Accept'] = 'application/vnd.github.v3+json'
641 headers['Content-Length'] = len(data)
642 params = {'name': f}
644 print green("Uploading \'{0}\' to Github".format(f))
645 r = requests.post(url, auth=auth_creds, headers=headers, data=data, params=params, verify=False)
646 _check_request_error(r)
649 @task
650 def upload_ssh(build_dir=None, server_host=None, server_port=None,
651 ssh_user=None, ssh_key=None, dest_dir=None):
653 build_dir = build_dir or env.build_dir
654 server_host = server_host or env.ssh['host']
655 server_port = server_port or env.ssh['port']
656 ssh_user = ssh_user or env.ssh['user']
657 ssh_key = ssh_key or env.ssh['key']
658 dest_dir = dest_dir or env.ssh['dest_dir']
660 env.host_string = server_host + ':' + server_port
661 env.user = ssh_user
662 env.key_filename = ssh_key
664 binaries_dir = os.path.join(build_dir, 'indico', 'dist')
665 for f in os.listdir(binaries_dir):
666 if os.path.isfile(os.path.join(binaries_dir, f)):
667 _putl(os.path.join(binaries_dir, f), dest_dir)
670 @task
671 def _package_release(build_dir, py_versions, system_node):
672 # Build source tarball
673 with settings(system_node=system_node):
674 print green('Generating '), cyan('tarball')
675 tarball(build_dir)
677 # Build binaries (EGG)
678 print green('Generating '), cyan('eggs')
679 egg(py_versions)
682 @task
683 def package_release(py_versions=None, build_dir=None, system_node=False,
684 indico_version=None, upstream=None, tag_name=None,
685 github_auth=None, overwrite=None, ssh_server_host=None,
686 ssh_server_port=None, ssh_user=None, ssh_key=None,
687 ssh_dest_dir=None, no_clean=False, force_clean=False,
688 upload_to=None, build_here=False):
690 Create an Indico release - source and binary distributions
693 DEVELOP_REQUIRES = ['pojson>=0.4', 'termcolor', 'werkzeug', 'nodeenv', 'fabric',
694 'sphinx', 'repoze.sphinx.autointerface']
696 py_versions = py_versions.split('/') if py_versions else env.py_versions
697 upload_to = upload_to.split('/') if upload_to else []
699 build_dir = build_dir or env.build_dir
700 upstream = upstream or env.github['upstream']
702 ssh_server_host = ssh_server_host or env.ssh['host']
703 ssh_server_port = ssh_server_port or env.ssh['port']
704 ssh_user = ssh_user or env.ssh['user']
705 ssh_key = ssh_key or env.ssh['key']
706 ssh_dest_dir = ssh_dest_dir or env.ssh['dest_dir']
708 indico_version = indico_version or 'master'
710 local('mkdir -p {0}'.format(build_dir))
712 _check_pyenv(py_versions)
714 if build_here:
715 _package_release(os.path.dirname(__file__), py_versions, system_node)
716 else:
717 with lcd(build_dir):
718 if os.path.exists(os.path.join(build_dir, 'indico')):
719 print yellow("Repository seems to already exist.")
720 with lcd('indico'):
721 local('git fetch {0}'.format(upstream))
722 local('git reset --hard FETCH_HEAD')
723 if not no_clean:
724 local('git clean -df')
725 else:
726 local('git clone {0}'.format(upstream))
727 with lcd('indico'):
728 print green("Checking out branch \'{0}\'".format(indico_version))
729 local('git checkout {0}'.format(indico_version))
731 _package_release(os.path.join(build_dir, 'indico'), py_versions, system_node)
733 for u in upload_to:
734 if u == 'github':
735 upload_github(build_dir, tag_name, github_auth, overwrite, indico_version)
736 elif u == 'ssh':
737 upload_ssh(build_dir, ssh_server_host, ssh_server_port, ssh_user, ssh_key, ssh_dest_dir)
739 if not build_here and force_clean:
740 cleanup(build_dir, force=True)