forked from bioconda/bioconda-utils
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbuild.py
More file actions
448 lines (390 loc) · 18.2 KB
/
build.py
File metadata and controls
448 lines (390 loc) · 18.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
"""
Package Builder
"""
import subprocess as sp
from collections import defaultdict, namedtuple
import itertools
import logging
import os
import sys
import time
from typing import List, Optional
from bioconda_utils.skiplist import Skiplist
from bioconda_utils.build_failure import BuildFailureRecord
from bioconda_utils.githandler import GitHandler
import conda
from conda.exports import UnsatisfiableError
from conda_build.exceptions import DependencyNeedsBuildingError
import networkx as nx
import pandas
from ruamel_yaml import YAML
from . import utils
from . import docker_utils
from . import pkg_test
from . import upload
from . import lint
from . import graph
logger = logging.getLogger(__name__)
#: Result tuple for builds comprising success status and list of docker images
BuildResult = namedtuple("BuildResult", ["success", "mulled_images"])
def conda_build_purge() -> None:
"""Calls conda build purge and optionally conda clean
``conda clean --all`` is called if we haveless than 300 MB free space
on the current disk.
"""
utils.run(["conda", "build", "purge"], mask=False)
free_mb = utils.get_free_space()
if free_mb < 300:
logger.info("CLEANING UP PACKAGE CACHE (free space: %iMB).", free_mb)
utils.run(["conda", "clean", "--all"], mask=False)
logger.info("CLEANED UP PACKAGE CACHE (free space: %iMB).",
utils.get_free_space())
def build(recipe: str, pkg_paths: List[str] = None,
testonly: bool = False, mulled_test: bool = True,
channels: List[str] = None,
docker_builder: docker_utils.RecipeBuilder = None,
raise_error: bool = False,
linter=None,
mulled_conda_image: str = pkg_test.MULLED_CONDA_IMAGE,
record_build_failure: bool = False,
dag: Optional[nx.DiGraph] = None,
skiplist_leafs: bool = False) -> BuildResult:
"""
Build a single recipe for a single env
Arguments:
recipe: Path to recipe
pkg_paths: List of paths to expected packages
testonly: Only run the tests described in the meta.yaml
mulled_test: Run tests in minimal docker container
channels: Channels to include via the ``--channel`` argument to
conda-build. Higher priority channels should come first.
docker_builder : docker_utils.RecipeBuilder object
Use this docker builder to build the recipe, copying over the built
recipe to the host's conda-bld directory.
raise_error: Instead of returning a failed build result, raise the
error instead. Used for testing.
linter: Linter to use for checking recipes
record_build_failure: If True, record build failures in a file next to the meta.yaml
dag: optional nx.DiGraph with dependency information
skiplist_leafs: If True, blacklist leaf packages that fail to build
"""
if record_build_failure and not dag:
raise ValueError("record_build_failure requires dag to be set")
if linter:
logger.info('Linting recipe %s', recipe)
linter.clear_messages()
if linter.lint([recipe]):
logger.error('\n\nThe recipe %s failed linting. See '
'https://bioconda.github.io/contributor/linting.html for details:\n\n%s\n',
recipe, linter.get_report())
return BuildResult(False, None)
logger.info("Lint checks passed")
# Copy env allowing only whitelisted vars
whitelisted_env = {
k: str(v)
for k, v in os.environ.items()
if utils.allowed_env_var(k, docker_builder is not None)
}
logger.info("BUILD START %s", recipe)
args = ['--override-channels']
if testonly:
args += ["--test"]
else:
args += ["--no-anaconda-upload"]
for channel in channels or ['local']:
args += ['-c', channel]
logger.debug('Build and Channel Args: %s', args)
# Even though there may be variants of the recipe that will be built, we
# will only be checking attributes that are independent of variants (pkg
# name, version, noarch, whether or not an extended container was used)
meta = utils.load_first_metadata(recipe, finalize=False)
is_noarch = bool(meta.get_value('build/noarch', default=False))
use_base_image = meta.get_value('extra/container', {}).get('extended-base', False)
if use_base_image:
base_image = 'quay.io/bioconda/base-glibc-debian-bash:2.1.0'
else:
base_image = 'quay.io/bioconda/base-glibc-busybox-bash:2.1.0'
build_failure_record = BuildFailureRecord(recipe)
build_failure_record_existed_before_build = build_failure_record.exists()
if build_failure_record_existed_before_build:
# remove record to avoid that it is leaked into the package
build_failure_record.remove()
try:
if docker_builder is not None:
docker_builder.build_recipe(recipe_dir=os.path.abspath(recipe),
build_args=' '.join(args),
env=whitelisted_env,
noarch=is_noarch)
# Use presence of expected packages to check for success
if (docker_builder.pkg_dir is not None):
platform = utils.RepoData.native_platform()
subfolder = utils.RepoData.platform2subdir(platform)
conda_build_config = utils.load_conda_build_config(platform=subfolder)
pkg_paths = [p.replace(conda_build_config.output_folder, docker_builder.pkg_dir) for p in pkg_paths]
for pkg_path in pkg_paths:
if not os.path.exists(pkg_path):
logger.error(
"BUILD FAILED: the built package %s "
"cannot be found", pkg_path)
return BuildResult(False, None)
else:
conda_build_cmd = [utils.bin_for('conda'), 'mambabuild']
# - Temporarily reset os.environ to avoid leaking env vars
# - Also pass filtered env to run()
# - Point conda-build to meta.yaml, to avoid building subdirs
with utils.sandboxed_env(whitelisted_env):
cmd = conda_build_cmd + args
for config_file in utils.get_conda_build_config_files():
cmd += [config_file.arg, config_file.path]
cmd += [os.path.join(recipe, 'meta.yaml')]
with utils.Progress():
utils.run(cmd, mask=False)
logger.info('BUILD SUCCESS %s',
' '.join(os.path.basename(p) for p in pkg_paths))
if record_build_failure:
# Success, hence the record is obsolete. Remove it.
if build_failure_record_existed_before_build:
# record is already removed (see above), but change has to be committed
build_failure_record.commit_and_push_changes()
except (docker_utils.DockerCalledProcessError, sp.CalledProcessError) as exc:
logger.error('BUILD FAILED %s', recipe)
if record_build_failure:
store_build_failure_record(recipe, exc.output, meta, dag, skiplist_leafs)
if raise_error:
raise exc
return BuildResult(False, None)
if mulled_test:
logger.info('TEST START via mulled-build %s', recipe)
mulled_images = []
for pkg_path in pkg_paths:
try:
pkg_test.test_package(pkg_path, base_image=base_image,
conda_image=mulled_conda_image)
except sp.CalledProcessError:
logger.error('TEST FAILED: %s', recipe)
return BuildResult(False, None)
logger.info("TEST SUCCESS %s", recipe)
mulled_images.append(pkg_test.get_image_name(pkg_path))
return BuildResult(True, mulled_images)
return BuildResult(True, None)
def store_build_failure_record(recipe, output, meta, dag, skiplist_leafs):
"""
Write the exception to a file next to the meta.yaml
"""
pkg_name = meta.meta["package"]["name"]
is_leaf = graph.is_leaf(dag, pkg_name)
build_failure_record = BuildFailureRecord(recipe)
# if recipe is a leaf (i.e. not used by others as dependency)
# we can automatically blacklist it if desired
build_failure_record.fill(log=output, skiplist=skiplist_leafs and is_leaf)
build_failure_record.write()
build_failure_record.commit_and_push_changes()
def remove_cycles(dag, name2recipes, failed, skip_dependent):
nodes_in_cycles = set()
for cycle in list(nx.simple_cycles(dag)):
logger.error('BUILD ERROR: dependency cycle found: %s', cycle)
nodes_in_cycles.update(cycle)
for name in sorted(nodes_in_cycles):
cycle_fail_recipes = sorted(name2recipes[name])
logger.error('BUILD ERROR: cannot build recipes for %s since '
'it cyclically depends on other packages in the '
'current build job. Failed recipes: %s',
name, cycle_fail_recipes)
failed.extend(cycle_fail_recipes)
for node in nx.algorithms.descendants(dag, name):
if node not in nodes_in_cycles:
skip_dependent[node].extend(cycle_fail_recipes)
return dag.subgraph(name for name in dag if name not in nodes_in_cycles)
def get_subdags(dag, n_workers, worker_offset):
if n_workers > 1 and worker_offset >= n_workers:
raise ValueError(
"n-workers is less than the worker-offset given! "
"Either decrease --n-workers or decrease --worker-offset!")
# Get connected subdags and sort by nodes
if n_workers > 1:
root_nodes = sorted([k for (k, v) in dag.in_degree() if v == 0])
nodes = set()
found = set()
for idx, root_node in enumerate(root_nodes):
# Flatten the nested list
children = itertools.chain(*nx.dfs_successors(dag, root_node).values())
# This is the only obvious way of ensuring that all nodes are included
# in exactly 1 subgraph
found.add(root_node)
if idx % n_workers == worker_offset:
nodes.add(root_node)
for child in children:
if child not in found:
nodes.add(child)
found.add(child)
else:
for child in children:
found.add(child)
subdags = dag.subgraph(list(nodes))
logger.info("Building and testing sub-DAGs %i in each group of %i, which is %i packages", worker_offset, n_workers, len(subdags.nodes()))
else:
subdags = dag
return subdags
def build_recipes(recipe_folder: str, config_path: str, recipes: List[str],
mulled_test: bool = True, testonly: bool = False,
force: bool = False,
docker_builder: docker_utils.RecipeBuilder = None,
label: str = None,
anaconda_upload: bool = False,
mulled_upload_target=None,
check_channels: List[str] = None,
do_lint: bool = None,
lint_exclude: List[str] = None,
n_workers: int = 1,
worker_offset: int = 0,
keep_old_work: bool = False,
mulled_conda_image: str = pkg_test.MULLED_CONDA_IMAGE,
record_build_failures: bool = False,
skiplist_leafs: bool = False):
"""
Build one or many bioconda packages.
Arguments:
recipe_folder: Directory containing possibly many, and possibly nested, recipes.
config_path: Path to config file
packages: Glob indicating which packages should be considered. Note that packages
matching the glob will still be filtered out by any blacklists
specified in the config.
mulled_test: If true, test the package in a minimal container.
testonly: If true, only run test.
force: If true, build the recipe even though it would otherwise be filtered out.
docker_builder: If specified, use to build all recipes
label: If specified, use to label uploaded packages on anaconda. Default is "main" label.
anaconda_upload: If true, upload the package(s) to anaconda.org.
mulled_upload_target: If specified, upload the mulled docker image to the given target
on quay.io.
check_channels: Channels to check to see if packages already exist in them.
Defaults to every channel in the config file except "defaults".
do_lint: Whether to run linter
lint_exclude: List of linting functions to exclude.
n_workers: The number of parallel instances of bioconda-utils being run. The
sub-DAGs are then split into groups of n_workers size.
worker_offset: If n_workers is >1, then every worker_offset within a given group of
sub-DAGs will be processed.
keep_old_work: Do not remove anything from environment, even after successful build and test.
"""
if not recipes:
logger.info("Nothing to be done.")
return True
config = utils.load_config(config_path)
blacklist = Skiplist(config, recipe_folder)
# get channels to check
if check_channels is None:
if config['channels']:
check_channels = [c for c in config['channels'] if c != "defaults"]
else:
check_channels = []
# setup linting
if do_lint:
always_exclude = ('build_number_needs_bump',)
if not lint_exclude:
lint_exclude = always_exclude
else:
lint_exclude = tuple(set(lint_exclude) | set(always_exclude))
linter = lint.Linter(config, recipe_folder, lint_exclude)
else:
linter = None
failed = []
dag, name2recipes = graph.build(recipes, config=config_path, blacklist=blacklist)
if not dag:
logger.info("Nothing to be done.")
return True
skip_dependent = defaultdict(list)
dag = remove_cycles(dag, name2recipes, failed, skip_dependent)
subdag = get_subdags(dag, n_workers, worker_offset)
if not subdag:
logger.info("Nothing to be done.")
return True
logger.info("%i recipes to build and test: \n%s", len(subdag), "\n".join(subdag.nodes()))
recipe2name = {}
for name, recipe_list in name2recipes.items():
for recipe in recipe_list:
recipe2name[recipe] = name
recipes = [(recipe, recipe2name[recipe])
for package in nx.topological_sort(subdag)
for recipe in name2recipes[package]]
built_recipes = []
skipped_recipes = []
failed_uploads = []
for recipe, name in recipes:
if name in skip_dependent:
logger.info('BUILD SKIP: skipping %s because it depends on %s '
'which had a failed build.',
recipe, skip_dependent[name])
skipped_recipes.append(recipe)
continue
logger.info('Determining expected packages for %s', recipe)
try:
pkg_paths = utils.get_package_paths(recipe, check_channels, force=force)
except utils.DivergentBuildsError as exc:
logger.error('BUILD ERROR: packages with divergent build strings in repository '
'for recipe %s. A build number bump is likely needed: %s',
recipe, exc)
failed.append(recipe)
for pkg in nx.algorithms.descendants(subdag, name):
skip_dependent[pkg].append(recipe)
continue
except (UnsatisfiableError, DependencyNeedsBuildingError) as exc:
logger.error('BUILD ERROR: could not determine dependencies for recipe %s: %s',
recipe, exc)
failed.append(recipe)
for pkg in nx.algorithms.descendants(subdag, name):
skip_dependent[pkg].append(recipe)
continue
if not pkg_paths:
logger.info("Nothing to be done for recipe %s", recipe)
continue
res = build(recipe=recipe,
pkg_paths=pkg_paths,
testonly=testonly,
mulled_test=mulled_test,
channels=config['channels'],
docker_builder=docker_builder,
linter=linter,
mulled_conda_image=mulled_conda_image,
dag=dag,
record_build_failure=record_build_failures,
skiplist_leafs=skiplist_leafs)
if not res.success:
failed.append(recipe)
for pkg in nx.algorithms.descendants(subdag, name):
skip_dependent[pkg].append(recipe)
else:
built_recipes.append(recipe)
if not testonly:
if anaconda_upload:
for pkg in pkg_paths:
if not upload.anaconda_upload(pkg, label=label):
failed_uploads.append(pkg)
if mulled_upload_target:
for img in res.mulled_images:
upload.mulled_upload(img, mulled_upload_target)
docker_utils.purgeImage(mulled_upload_target, img)
# remove traces of the build
if not keep_old_work:
conda_build_purge()
if failed or failed_uploads:
logger.error('BUILD SUMMARY: of %s recipes, '
'%s failed and %s were skipped. '
'Details of recipes and environments follow.',
len(recipes), len(failed), len(skipped_recipes))
if built_recipes:
logger.error('BUILD SUMMARY: while the entire build failed, '
'the following recipes were built successfully:\n%s',
'\n'.join(built_recipes))
for recipe in failed:
logger.error('BUILD SUMMARY: FAILED recipe %s', recipe)
for name, dep in skip_dependent.items():
logger.error('BUILD SUMMARY: SKIPPED recipe %s '
'due to failed dependencies %s', name, dep)
if failed_uploads:
logger.error('UPLOAD SUMMARY: the following packages failed to upload:\n%s',
'\n'.join(failed_uploads))
return False
logger.info("BUILD SUMMARY: successfully built %s of %s recipes",
len(built_recipes), len(recipes))
return True