fromager 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fromager/__main__.py ADDED
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import argparse
4
+ import collections
5
+ import csv
6
+ import functools
7
+ import itertools
8
+ import json
9
+ import logging
10
+ import os
11
+ import pathlib
12
+ import sys
13
+
14
+ from packaging.requirements import Requirement
15
+
16
+ from . import (context, finders, overrides, sdist, server, settings, sources,
17
+ wheels)
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ TERSE_LOG_FMT = '%(message)s'
22
+ VERBOSE_LOG_FMT = '%(levelname)s:%(name)s:%(lineno)d: %(message)s'
23
+
24
+
25
+ def main():
26
+ parser = _get_argument_parser()
27
+ args = parser.parse_args(sys.argv[1:])
28
+
29
+ # Configure console and log output.
30
+ stream_handler = logging.StreamHandler()
31
+ stream_handler.setLevel(logging.DEBUG if args.verbose else logging.INFO)
32
+ stream_formatter = logging.Formatter(VERBOSE_LOG_FMT if args.verbose else TERSE_LOG_FMT)
33
+ stream_handler.setFormatter(stream_formatter)
34
+ logging.getLogger().addHandler(stream_handler)
35
+ if args.log_file:
36
+ # Always log to the file at debug level
37
+ file_handler = logging.FileHandler(args.log_file)
38
+ file_handler.setLevel(logging.DEBUG)
39
+ file_formatter = logging.Formatter(VERBOSE_LOG_FMT)
40
+ file_handler.setFormatter(file_formatter)
41
+ logging.getLogger().addHandler(file_handler)
42
+ # We need to set the overall logger level to debug and allow the
43
+ # handlers to filter messages at their own level.
44
+ logging.getLogger().setLevel(logging.DEBUG)
45
+
46
+ try:
47
+ args.func(args)
48
+ except Exception as err:
49
+ logger.exception(err)
50
+ raise
51
+
52
+
53
+ def _get_argument_parser():
54
+ parser = argparse.ArgumentParser('fromager')
55
+ parser.add_argument('-v', '--verbose', action='store_true', default=False,
56
+ help='report more detail to the console')
57
+ parser.add_argument('--log-file', default='',
58
+ help='save detailed report of actions to file')
59
+ parser.add_argument('-o', '--sdists-repo', default='sdists-repo',
60
+ help='location to manage source distributions [%(default)s]')
61
+ parser.add_argument('-w', '--wheels-repo', default='wheels-repo',
62
+ help='location to manage wheel repository [%(default)s]')
63
+ parser.add_argument('-t', '--work-dir', default=os.environ.get('WORKDIR', 'work-dir'),
64
+ help='location to manage working files, including builds [%(default)s]')
65
+ parser.add_argument('-p', '--patches-dir', default='overrides/patches',
66
+ help='location of files for patching source before building [%(default)s]')
67
+ parser.add_argument('-e', '--envs-dir', default='overrides/envs',
68
+ help='location of environment override files [%(default)s]')
69
+ parser.add_argument('--settings-file', default='overrides/settings.yaml',
70
+ help='location of the application settings file [%(default)s]')
71
+ parser.add_argument('--wheel-server-url',
72
+ help='URL for the wheel server for builds')
73
+ parser.add_argument('--no-cleanup', dest='cleanup', default=True, action='store_false',
74
+ help='do not remove working files when a build completes successfully')
75
+ parser.add_argument('--variant', default='cpu',
76
+ help='the build variant name [%(default)s]')
77
+
78
+ subparsers = parser.add_subparsers(title='commands', dest='command')
79
+
80
+ parser_bootstrap = subparsers.add_parser(
81
+ 'bootstrap',
82
+ help='recursively build packages and their dependencies',
83
+ )
84
+ parser_bootstrap.set_defaults(func=do_bootstrap)
85
+ parser_bootstrap.add_argument('--requirements-file', '-r', action='append', default=[],
86
+ dest='requirements_files',
87
+ help='a pip requirements file')
88
+ parser_bootstrap.add_argument('toplevel', nargs='*',
89
+ help='a requirements specification for a package')
90
+
91
+ parser_download = subparsers.add_parser(
92
+ 'download-source-archive',
93
+ help='download the source code archive for one version of one package',
94
+ )
95
+ parser_download.set_defaults(func=do_download_source_archive)
96
+ parser_download.add_argument('dist_name',
97
+ help='the name of the distribution')
98
+ parser_download.add_argument('dist_version',
99
+ help='the version of the distribution')
100
+ parser_download.add_argument('sdist_server_url',
101
+ help='the URL for a PyPI-compatible package index hosting sdists')
102
+
103
+ parser_prepare_source = subparsers.add_parser(
104
+ 'prepare-source',
105
+ help='ensure the source code is in a form ready for building a distribution',
106
+ )
107
+ parser_prepare_source.set_defaults(func=do_prepare_source)
108
+ parser_prepare_source.add_argument('dist_name',
109
+ help='the name of the distribution')
110
+ parser_prepare_source.add_argument('dist_version',
111
+ help='the version of the distribution')
112
+
113
+ parser_prepare_build = subparsers.add_parser(
114
+ 'prepare-build',
115
+ help='set up build environment to build the package',
116
+ )
117
+ parser_prepare_build.set_defaults(func=do_prepare_build)
118
+ parser_prepare_build.add_argument('dist_name',
119
+ help='the name of the distribution')
120
+ parser_prepare_build.add_argument('dist_version',
121
+ help='the version of the distribution')
122
+
123
+ parser_build = subparsers.add_parser(
124
+ 'build',
125
+ help='build a wheel',
126
+ )
127
+ parser_build.set_defaults(func=do_build)
128
+ parser_build.add_argument('dist_name',
129
+ help='the name of the distribution')
130
+ parser_build.add_argument('dist_version',
131
+ help='the version of the distribution')
132
+
133
+ parser_canonicalize = subparsers.add_parser(
134
+ 'canonicalize',
135
+ help='convert a package name to its canonical form for use in override paths',
136
+ )
137
+ parser_canonicalize.set_defaults(func=do_canonicalize)
138
+ parser_canonicalize.add_argument('toplevel', nargs='+',
139
+ help='names of distributions to convert')
140
+
141
+ parser_csv = subparsers.add_parser(
142
+ 'build-order-csv',
143
+ help='convert build order files to CSV',
144
+ )
145
+ parser_csv.set_defaults(func=do_build_order_csv)
146
+ parser_csv.add_argument('build_order_file', default='work-dir/build-order.json', nargs='?',
147
+ help='the build-order.json files to convert')
148
+ parser_csv.add_argument('--output', '-o',
149
+ help='write the output to a named file (defaults to console)')
150
+
151
+ parser_graph = subparsers.add_parser(
152
+ 'build-order-graph',
153
+ help='convert build-order.json files to a dot graph showing dependencies',
154
+ )
155
+ parser_graph.set_defaults(func=do_build_order_graph)
156
+ parser_graph.add_argument('build_order_file', nargs='+',
157
+ help='the build-order.json files to convert')
158
+ parser_graph.add_argument('--output', '-o',
159
+ help='write the output to a named file (defaults to console)')
160
+
161
+ parser_summary = subparsers.add_parser(
162
+ 'build-order-summary',
163
+ help='report commonalities and differences between build order files',
164
+ )
165
+ parser_summary.set_defaults(func=do_build_order_summary)
166
+ parser_summary.add_argument('build_order_file', nargs='+',
167
+ help='the build-order.json files to examine')
168
+ parser_summary.add_argument('--output', '-o',
169
+ help='write the output to a named CSV file (defaults to console)')
170
+
171
+ return parser
172
+
173
+
174
+ def requires_context(f):
175
+ "Decorate f() to add WorkContext argument before calling it."
176
+ @functools.wraps(f)
177
+ def provides_context(args):
178
+ ctx = context.WorkContext(
179
+ settings=settings.load(args.settings_file),
180
+ patches_dir=args.patches_dir,
181
+ envs_dir=args.envs_dir,
182
+ sdists_repo=args.sdists_repo,
183
+ wheels_repo=args.wheels_repo,
184
+ work_dir=args.work_dir,
185
+ wheel_server_url=args.wheel_server_url,
186
+ cleanup=args.cleanup,
187
+ variant=args.variant,
188
+ )
189
+ ctx.setup()
190
+ return f(args, ctx)
191
+ return provides_context
192
+
193
+
194
+ def _get_requirements_from_args(args):
195
+ to_build = []
196
+ to_build.extend(args.toplevel)
197
+ for filename in args.requirements_files:
198
+ with open(filename, 'r') as f:
199
+ for line in f:
200
+ useful, _, _ = line.partition('#')
201
+ useful = useful.strip()
202
+ logger.debug('line %r useful %r', line, useful)
203
+ if not useful:
204
+ continue
205
+ to_build.append(useful)
206
+ return to_build
207
+
208
+
209
+ @requires_context
210
+ def do_bootstrap(args, ctx):
211
+ server.start_wheel_server(ctx)
212
+
213
+ to_build = _get_requirements_from_args(args)
214
+ if not to_build:
215
+ raise RuntimeError('Pass a requirement specificiation or use -r to pass a requirements file')
216
+ logger.debug('bootstrapping %s', to_build)
217
+ for toplevel in to_build:
218
+ sdist.handle_requirement(ctx, Requirement(toplevel))
219
+
220
+ # If we put pre-built wheels in the downloads directory, we should
221
+ # remove them so we can treat that directory as a source of wheels
222
+ # to upload to an index.
223
+ for prebuilt_wheel in ctx.wheels_prebuilt.glob('*.whl'):
224
+ filename = ctx.wheels_downloads / prebuilt_wheel.name
225
+ if filename.exists():
226
+ logger.info(f'removing prebuilt wheel {prebuilt_wheel.name} from download cache')
227
+ filename.unlink()
228
+
229
+
230
+ @requires_context
231
+ def do_download_source_archive(args, ctx):
232
+ req = Requirement(f'{args.dist_name}=={args.dist_version}')
233
+ logger.info('downloading source archive for %s from %s', req, args.sdist_server_url)
234
+ filename, _ = sources.download_source(ctx, req, [args.sdist_server_url])
235
+ print(filename)
236
+
237
+
238
+ @requires_context
239
+ def do_prepare_source(args, ctx):
240
+ req = Requirement(f'{args.dist_name}=={args.dist_version}')
241
+ logger.info('preparing source directory for %s', req)
242
+ sdists_downloads = pathlib.Path(args.sdists_repo) / 'downloads'
243
+ source_filename = finders.find_sdist(sdists_downloads, req, args.dist_version)
244
+ if source_filename is None:
245
+ dir_contents = []
246
+ for ext in ['*.tar.gz', '*.zip']:
247
+ dir_contents.extend(str(e) for e in sdists_downloads.glob(ext))
248
+ raise RuntimeError(
249
+ f'Cannot find sdist for {req.name} version {args.dist_version} in {sdists_downloads} among {dir_contents}'
250
+ )
251
+ # FIXME: Does the version need to be a Version instead of str?
252
+ source_root_dir = sources.prepare_source(ctx, req, source_filename, args.dist_version)
253
+ print(source_root_dir)
254
+
255
+
256
+ def _find_source_root_dir(work_dir, req, dist_version):
257
+ source_root_dir = finders.find_source_dir(pathlib.Path(work_dir), req, dist_version)
258
+ if source_root_dir:
259
+ return source_root_dir
260
+ work_dir_contents = list(str(e) for e in work_dir.glob('*'))
261
+ raise RuntimeError(
262
+ f'Cannot find source directory for {req.name} version {dist_version} among {work_dir_contents}'
263
+ )
264
+
265
+
266
+ @requires_context
267
+ def do_prepare_build(args, ctx):
268
+ server.start_wheel_server(ctx)
269
+ req = Requirement(f'{args.dist_name}=={args.dist_version}')
270
+ source_root_dir = _find_source_root_dir(pathlib.Path(args.work_dir), req, args.dist_version)
271
+ logger.info('preparing build environment for %s', req)
272
+ sdist.prepare_build_environment(ctx, req, source_root_dir)
273
+
274
+
275
+ @requires_context
276
+ def do_build(args, ctx):
277
+ req = Requirement(f'{args.dist_name}=={args.dist_version}')
278
+ logger.info('building for %s', req)
279
+ source_root_dir = _find_source_root_dir(pathlib.Path(args.work_dir), req, args.dist_version)
280
+ build_env = wheels.BuildEnvironment(ctx, source_root_dir.parent, None)
281
+ wheel_filename = wheels.build_wheel(ctx, req, source_root_dir, build_env)
282
+ print(wheel_filename)
283
+
284
+
285
+ def do_canonicalize(args):
286
+ for name in args.toplevel:
287
+ print(overrides.pkgname_to_override_module(name))
288
+
289
+
290
+ def do_build_order_csv(args):
291
+ fields = [
292
+ ('dist', 'Distribution Name'),
293
+ ('version', 'Version'),
294
+ ('req', 'Original Requirement'),
295
+ ('type', 'Dependency Type'),
296
+ ('prebuilt', 'Pre-built Package'),
297
+ ('order', 'Build Order'),
298
+ ('why', 'Dependency Chain'),
299
+ ]
300
+ headers = {n: v for n, v in fields}
301
+ fieldkeys = [f[0] for f in fields]
302
+ fieldnames = [f[1] for f in fields]
303
+
304
+ build_order = []
305
+ with open(args.build_order_file, 'r') as f:
306
+ for i, entry in enumerate(json.load(f), 1):
307
+ # Add an order column, not in the original source file, in
308
+ # case someone wants to sort the output on another field.
309
+ entry['order'] = i
310
+ # Replace the short keys with the longer human-readable
311
+ # headers we want in the CSV output.
312
+ new_entry = {headers[f]: entry[f] for f in fieldkeys}
313
+ # Reformat the why field
314
+ new_entry['Dependency Chain'] = ' '.join(
315
+ f'-{dep_type}-> {Requirement(req).name}({version})'
316
+ for dep_type, req, version
317
+ in entry['why']
318
+ )
319
+ build_order.append(new_entry)
320
+
321
+ if args.output:
322
+ outfile = open(args.output, 'w')
323
+ else:
324
+ outfile = sys.stdout
325
+
326
+ try:
327
+ writer = csv.DictWriter(outfile, fieldnames=fieldnames, quoting=csv.QUOTE_NONNUMERIC)
328
+ writer.writeheader()
329
+ writer.writerows(build_order)
330
+ finally:
331
+ if args.output:
332
+ outfile.close()
333
+
334
+
335
+ def do_build_order_summary(args):
336
+ dist_to_input_file = collections.defaultdict(dict)
337
+ for filename in args.build_order_file:
338
+ with open(filename, 'r') as f:
339
+ build_order = json.load(f)
340
+ for step in build_order:
341
+ key = overrides.pkgname_to_override_module(step['dist'])
342
+ dist_to_input_file[key][filename] = step['version']
343
+
344
+ if args.output:
345
+ outfile = open(args.output, 'w')
346
+ else:
347
+ outfile = sys.stdout
348
+
349
+ # The build order files are organized in directories named for the
350
+ # image. Pull those names out of the files given.
351
+ image_column_names = tuple(
352
+ pathlib.Path(filename).parent.name
353
+ for filename in args.build_order_file
354
+ )
355
+
356
+ writer = csv.writer(outfile, quoting=csv.QUOTE_NONNUMERIC)
357
+ writer.writerow(("Distribution Name",) + image_column_names + ("Same Version",))
358
+ for dist, present_in_files in sorted(dist_to_input_file.items()):
359
+ all_versions = set()
360
+ row = [dist]
361
+ for filename in args.build_order_file:
362
+ v = present_in_files.get(filename, "")
363
+ row.append(v)
364
+ if v:
365
+ all_versions.add(v)
366
+ row.append(len(all_versions) == 1)
367
+ writer.writerow(row)
368
+
369
+ if args.output:
370
+ outfile.close()
371
+
372
+
373
+ def do_build_order_graph(args):
374
+
375
+ def fmt_req(req, version):
376
+ req = Requirement(req)
377
+ name = overrides.pkgname_to_override_module(req.name)
378
+ return f'{name}{"[" + ",".join(req.extras) + "]" if req.extras else ""}=={version}'
379
+
380
+ def new_node(req):
381
+ if req not in nodes:
382
+ nodes[req] = {
383
+ 'nid': 'node' + str(next(node_ids)),
384
+ 'prebuilt': False,
385
+ }
386
+ return nodes[req]
387
+
388
+ def update_node(req, prebuilt=False):
389
+ node_details = new_node(req)
390
+ if (not node_details['prebuilt']) and prebuilt:
391
+ node_details['prebuilt'] = True
392
+ return req
393
+
394
+ # Track unique ids for nodes since the labels may not be
395
+ # syntactically correct.
396
+ node_ids = itertools.count(1)
397
+ # Map formatted requirement text to node details
398
+ nodes = {}
399
+ edges = []
400
+
401
+ for filename in args.build_order_file:
402
+ with open(filename, 'r') as f:
403
+ build_order = json.load(f)
404
+
405
+ for step in build_order:
406
+ update_node(fmt_req(step['dist'], step['version']), prebuilt=step['prebuilt'])
407
+ try:
408
+ why = step['why']
409
+ if len(why) == 0:
410
+ # should not happen
411
+ continue
412
+ elif len(why) == 1:
413
+ # Lone node requiring nothing to build.
414
+ pass
415
+ else:
416
+ parent_info = why[0]
417
+ for child_info in why[1:]:
418
+ parent = update_node(fmt_req(parent_info[1], parent_info[2]))
419
+ child = update_node(fmt_req(child_info[1], child_info[2]))
420
+ edge = (parent, child)
421
+ # print(edge, nodes[edge[0]], nodes[edge[1]])
422
+ if edge not in edges:
423
+ edges.append(edge)
424
+ parent_info = child_info
425
+ except Exception as err:
426
+ raise Exception(f'Error processing {filename} at {step}') from err
427
+
428
+ if args.output:
429
+ outfile = open(args.output, 'w')
430
+ else:
431
+ outfile = sys.stdout
432
+ try:
433
+
434
+ outfile.write('digraph {\n')
435
+
436
+ # Determine some nodes with special characteristics
437
+ all_nodes = set(n['nid'] for n in nodes.values())
438
+ # left = set(nodes[p]['nid'] for p, _ in edges)
439
+ right = set(nodes[c]['nid'] for _, c in edges)
440
+ # Toplevel nodes have no incoming connections
441
+ toplevel_nodes = all_nodes - right
442
+ # Leaves have no outgoing connections
443
+ # leaves = all_nodes - left
444
+
445
+ for req, node_details in nodes.items():
446
+ nid = node_details['nid']
447
+
448
+ node_attrs = [('label', req)]
449
+ if node_details['prebuilt']:
450
+ node_attrs.extend([
451
+ ('style', 'filled'),
452
+ ('color', 'darkred'),
453
+ ('fontcolor', 'white'),
454
+ ('tooltip', 'pre-built package'),
455
+ ])
456
+ elif nid in toplevel_nodes:
457
+ node_attrs.extend([
458
+ ('style', 'filled'),
459
+ ('color', 'darkgreen'),
460
+ ('fontcolor', 'white'),
461
+ ('tooltip', 'toplevel package'),
462
+ ])
463
+ node_attr_text = ','.join('%s="%s"' % a for a in node_attrs)
464
+
465
+ outfile.write(f' {nid} [{node_attr_text}];\n')
466
+
467
+ outfile.write('\n')
468
+ if len(toplevel_nodes) > 1:
469
+ outfile.write(' /* toplevel nodes should all be at the same level */\n')
470
+ outfile.write(' {rank=same; %s;}\n\n' % " ".join(toplevel_nodes))
471
+ # if len(leaves) > 1:
472
+ # outfile.write(' /* leaf nodes should all be at the same level */\n')
473
+ # outfile.write(' {rank=same; %s;}\n\n' % " ".join(leaves))
474
+
475
+ for parent_req, child_req in edges:
476
+ parent_node = nodes[parent_req]
477
+ parent_nid = parent_node['nid']
478
+ child_node = nodes[child_req]
479
+ child_nid = child_node['nid']
480
+ outfile.write(f' {parent_nid} -> {child_nid};\n')
481
+
482
+ outfile.write('}\n')
483
+ finally:
484
+ if args.output:
485
+ outfile.close()
486
+
487
+
488
+ if __name__ == '__main__':
489
+ main()
fromager/context.py ADDED
@@ -0,0 +1,108 @@
1
+ import json
2
+ import logging
3
+ import pathlib
4
+ from urllib.parse import urlparse
5
+
6
+ from packaging.utils import canonicalize_name
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class WorkContext:
12
+
13
+ def __init__(self,
14
+ settings,
15
+ patches_dir,
16
+ envs_dir,
17
+ sdists_repo,
18
+ wheels_repo,
19
+ work_dir,
20
+ wheel_server_url,
21
+ cleanup=True,
22
+ variant='cpu'):
23
+ self.settings = settings
24
+ self.patches_dir = pathlib.Path(patches_dir).absolute()
25
+ self.envs_dir = pathlib.Path(envs_dir).absolute()
26
+ self.sdists_repo = pathlib.Path(sdists_repo).absolute()
27
+ self.sdists_downloads = self.sdists_repo / 'downloads'
28
+ self.wheels_repo = pathlib.Path(wheels_repo).absolute()
29
+ self.wheels_build = self.wheels_repo / 'build'
30
+ self.wheels_downloads = self.wheels_repo / 'downloads'
31
+ self.wheels_prebuilt = self.wheels_repo / 'prebuilt'
32
+ self.wheel_server_dir = self.wheels_repo / 'simple'
33
+ self.work_dir = pathlib.Path(work_dir).absolute()
34
+ self.wheel_server_url = wheel_server_url
35
+ self.cleanup = cleanup
36
+ self.variant = variant
37
+
38
+ self._build_order_filename = self.work_dir / 'build-order.json'
39
+
40
+ # Push items onto the stack as we start to resolve their
41
+ # dependencies so at the end we have a list of items that need to
42
+ # be built in order.
43
+ self._build_stack = []
44
+ self._build_requirements = set()
45
+
46
+ # Track requirements we've seen before so we don't resolve the
47
+ # same dependencies over and over and so we can break cycles in
48
+ # the dependency list. The key is the requirements spec, rather
49
+ # than the package, in case we do have multiple rules for the same
50
+ # package.
51
+ self._seen_requirements = set()
52
+
53
+ @property
54
+ def pip_wheel_server_args(self):
55
+ args = ['--index-url', self.wheel_server_url]
56
+ parsed = urlparse(self.wheel_server_url)
57
+ if parsed.scheme != 'https':
58
+ args = args + [
59
+ '--trusted-host', parsed.hostname
60
+ ]
61
+ return args
62
+
63
+ def _resolved_key(self, req, version):
64
+ return (canonicalize_name(req.name), tuple(sorted(req.extras)), str(version))
65
+
66
+ def mark_as_seen(self, req, version):
67
+ logger.debug('remembering seen sdist %s', self._resolved_key(req, version))
68
+ self._seen_requirements.add(self._resolved_key(req, version))
69
+
70
+ def has_been_seen(self, req, version):
71
+ return self._resolved_key(req, version) in self._seen_requirements
72
+
73
+ def add_to_build_order(self, req_type, req, version, why, prebuilt=False):
74
+ # We only care if this version of this package has been built,
75
+ # and don't want to trigger building it twice. The "extras"
76
+ # value, included in the _resolved_key() output, can confuse
77
+ # that so we ignore itand build our own key using just the
78
+ # name and version.
79
+ key = (canonicalize_name(req.name), str(version))
80
+ if key in self._build_requirements:
81
+ return
82
+ logger.info(f'adding {key} to build order')
83
+ self._build_requirements.add(key)
84
+ info = {
85
+ 'type': req_type,
86
+ 'req': str(req),
87
+ 'dist': canonicalize_name(req.name),
88
+ 'version': str(version),
89
+ 'why': why,
90
+ 'prebuilt': prebuilt,
91
+ }
92
+ self._build_stack.append(info)
93
+ with open(self._build_order_filename, 'w') as f:
94
+ # Set default=str because the why value includes
95
+ # Requirement and Version instances that can't be
96
+ # converted to JSON without help.
97
+ json.dump(self._build_stack, f, indent=2, default=str)
98
+
99
+ def setup(self):
100
+ # The work dir must already exist, so don't try to create it.
101
+ # Use os.makedirs() to create the others in case the paths
102
+ # already exist.
103
+ for p in [self.work_dir,
104
+ self.sdists_repo, self.sdists_downloads,
105
+ self.wheels_repo, self.wheels_downloads, self.wheels_prebuilt]:
106
+ if not p.exists():
107
+ logger.debug('creating %s', p)
108
+ p.mkdir(parents=True)