csspin 2.0.0__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.
csspin/cli.py ADDED
@@ -0,0 +1,711 @@
1
+ # -*- mode: python; coding: utf-8 -*-
2
+ #
3
+ # Copyright 2020 CONTACT Software GmbH
4
+ # https://www.contact-software.com/
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Spin automates the provisioning of tools and other development
19
+ requirements and provides shrink-wrapped project workflows.
20
+
21
+ Spin requires a ``spinfile.yaml`` in the project's top-level directory. It can
22
+ be started from anywhere in the project tree, and searches the path up for
23
+ ``spinfile.yaml``. Subcommands are provided by "plugins" declared in
24
+ ``spinfile.yaml``.
25
+
26
+ Spin also leverages `click's <https://click.palletsprojects.com/en/8.0.x/>`_
27
+ feature set, enabling the usage of environment variables instead of CLI flags
28
+ and options (see :ref:`environment-as-input-channel-label`).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import importlib
34
+ import importlib.metadata as importlib_metadata
35
+ import os
36
+ import sys
37
+ from site import addsitedir
38
+ from traceback import format_exc
39
+ from types import ModuleType
40
+ from typing import TYPE_CHECKING, Any, Generator, Iterable
41
+
42
+ import click
43
+ import packaging.version
44
+ from path import Path
45
+
46
+ from csspin import (
47
+ Verbosity,
48
+ cd,
49
+ config,
50
+ debug,
51
+ die,
52
+ exists,
53
+ get_requires,
54
+ get_tree,
55
+ interpolate1,
56
+ memoizer,
57
+ mkdir,
58
+ obfuscate,
59
+ readyaml,
60
+ schema,
61
+ secrets,
62
+ set_tree,
63
+ setenv,
64
+ sh,
65
+ toporun,
66
+ tree,
67
+ warn,
68
+ writetext,
69
+ )
70
+ from csspin.tree import ConfigTree
71
+
72
+ if TYPE_CHECKING:
73
+ from typing import Callable
74
+
75
+
76
+ # These are the basic defaults for the top-level configuration
77
+ # tree. Sections and values will be added by loading plugins and
78
+ # reading the project configuration file (spinfile.yaml).
79
+ DEFAULTS = config(
80
+ spin=config(
81
+ spinfile="spinfile.yaml",
82
+ data=Path("{SPIN_DATA}"),
83
+ config=Path("{SPIN_CONFIG}"),
84
+ extra_index=None,
85
+ version=importlib_metadata.version("csspin"),
86
+ ),
87
+ platform=config(
88
+ exe=".exe" if sys.platform == "win32" else "",
89
+ kind=sys.platform,
90
+ ),
91
+ )
92
+
93
+ # Props and dump will only be used in finalize_cfg_tree,
94
+ # which can be called from various places.
95
+ # To make it work, let's define them globally.
96
+ PROP: list[str] = []
97
+ PREPEND_PROP: list[str] = []
98
+ APPEND_PROP: list[str] = []
99
+ DUMP = False
100
+
101
+
102
+ def find_spinfile(spinfile: str | None) -> str | None:
103
+ """Find a file 'spinfile' by walking up the directory tree."""
104
+ cwd = os.getcwd()
105
+ if spinfile is None:
106
+ spinfile = DEFAULTS.spin.spinfile
107
+ spinfile_ = spinfile
108
+ while not os.path.exists(spinfile_):
109
+ cwd_ = os.path.dirname(cwd)
110
+ if cwd_ == cwd:
111
+ break
112
+ cwd = cwd_
113
+ spinfile_ = os.path.join(cwd, spinfile)
114
+ if os.path.exists(spinfile_):
115
+ return os.path.abspath(spinfile_)
116
+ return None
117
+
118
+
119
+ def load_plugin(
120
+ cfg: tree.ConfigTree,
121
+ import_spec: str,
122
+ may_fail: bool = False,
123
+ indent: str = " ",
124
+ ) -> ModuleType | None:
125
+ """Recursively load a plugin module.
126
+
127
+ Load the plugin given by 'import_spec' and its dependencies
128
+ specified in the module-level configuration key 'requires' (list
129
+ of absolute or relative import specs).
130
+
131
+ """
132
+ debug(f"{indent}import plugin {import_spec}")
133
+
134
+ mod = full_name = None
135
+ try:
136
+ # Invalidate the caches before dynamically importing modules that were
137
+ # created after the interpreter was started.
138
+ importlib.invalidate_caches()
139
+ mod = importlib.import_module(import_spec)
140
+ full_name = mod.__name__
141
+ except ModuleNotFoundError:
142
+ if may_fail:
143
+ # We tolerate this only in context of cleanup, where imports may not
144
+ # succeed.
145
+ warn(
146
+ f"Plugin {import_spec} could not be loaded, it may need to be"
147
+ " provisioned"
148
+ )
149
+ else:
150
+ debug(format_exc())
151
+ die(
152
+ f"Plugin {import_spec} could not be loaded, it may need to be"
153
+ " provisioned"
154
+ )
155
+
156
+ if full_name and full_name not in cfg.loaded:
157
+ # This plugin module has not been imported so far --
158
+ # initialize it and recursively load dependencies
159
+
160
+ plugin_defaults = getattr(mod, "defaults", config())
161
+ # The subtree is either the module name for the plugin
162
+ # (excluding the package prefix), or __name__, if that is set.
163
+ settings_name = plugin_defaults.get("__name__", full_name.split(".")[-1])
164
+ debug(f"{indent}add subtree {settings_name}")
165
+ plugin_config_tree = cfg.setdefault(settings_name, config())
166
+ if not isinstance(plugin_config_tree, ConfigTree):
167
+ die(
168
+ f"The configuration of {import_spec} is invalid."
169
+ " Please check its configuration in spinfile.yaml"
170
+ " and global.yaml."
171
+ )
172
+
173
+ if not import_spec.startswith("csspin."):
174
+ try:
175
+ # Load the plugin specific schema for non-builtin plugins
176
+ plugin_name = import_spec.split(".")[-1]
177
+ debug(f"{indent}loading {plugin_name}_schema.yaml")
178
+ plugin_schema = schema.schema_load( # type: ignore[attr-defined]
179
+ os.path.join(
180
+ os.path.dirname(mod.__file__), # type: ignore[union-attr,arg-type]
181
+ f"{plugin_name}_schema.yaml",
182
+ )
183
+ ).properties[plugin_name]
184
+
185
+ # Assign the schema to the plugins sub-tree as well as to the
186
+ # global schema.
187
+ plugin_config_tree.schema = plugin_schema
188
+ # fmt: off
189
+ cfg._ConfigTree__schema.properties[settings_name] = ( # pylint: disable=protected-access
190
+ plugin_schema
191
+ )
192
+ # fmt: on
193
+
194
+ tree.tree_merge(plugin_config_tree, plugin_schema.get_default())
195
+ except FileNotFoundError:
196
+ debug(format_exc())
197
+ warn(f"Plugin {import_spec} does not provide a schema.")
198
+ except KeyError:
199
+ debug(format_exc())
200
+ warn(f"Plugin {import_spec} does not provide a valid schema.")
201
+
202
+ if plugin_defaults:
203
+ tree.tree_update(
204
+ plugin_config_tree,
205
+ plugin_defaults, # type: ignore[arg-type]
206
+ keep=interpolate1("{spin.spinfile}"),
207
+ )
208
+ elif plugin_defaults:
209
+ tree.tree_update(
210
+ plugin_config_tree,
211
+ plugin_defaults, # type: ignore[arg-type]
212
+ keep=interpolate1("{spin.spinfile}"),
213
+ )
214
+ tree.tree_apply_directives(plugin_config_tree)
215
+
216
+ cfg.loaded[full_name] = mod
217
+ dependencies = set()
218
+ for requirement in get_requires(plugin_config_tree, "spin"):
219
+ if plugin := load_plugin(
220
+ cfg, requirement, may_fail=may_fail, indent=indent + " "
221
+ ):
222
+ # Only depend on installed plugins; This is sufficient here,
223
+ # since a plugin can only be absent if may_fail is set.
224
+ dependencies.add(plugin)
225
+
226
+ plugin_config_tree._requires = [ # pylint: disable=protected-access
227
+ dep.__name__
228
+ for dep in dependencies # pylint: disable=protected-access # noqa: E501
229
+ ]
230
+ mod.defaults = plugin_config_tree # type: ignore[union-attr]
231
+ return mod
232
+
233
+
234
+ def reverse_toposort(nodes: Iterable, graph: dict) -> list:
235
+ """Topologically sort nodes according to graph, which is a dict
236
+ mapping nodes to dependencies.
237
+ """
238
+ graph = dict(graph) # don't destroy the input
239
+ counts = {n: 0 for n in nodes}
240
+ for targets in graph.values():
241
+ for n in targets:
242
+ counts[n] += 1
243
+ result = [] # type: ignore[var-annotated]
244
+ independent = {n for n in nodes if counts[n] == 0}
245
+ while independent:
246
+ n = independent.pop()
247
+ result.insert(0, n)
248
+ for m in graph.pop(n, ()):
249
+ counts[m] -= 1
250
+ if counts[m] == 0:
251
+ independent.add(m)
252
+ if graph:
253
+ die("dependency graph has at least one cycle")
254
+
255
+ return result
256
+
257
+
258
+ # This is a click-style decorator that adds the basic command line
259
+ # options of spin to a click.command or click.group; we have this
260
+ # separately here, because we'll use it twice: a) for 'cli' below,
261
+ # which is our bootstrap command, and b) for 'commands' which is the
262
+ # actual click group that collects sub commands from plugins. 'cli'
263
+ # uses these options to find the the configuration file, the project
264
+ # root and plugin directory, and then loads the plugins. 'commands'
265
+ # just has the same options, but doesn't use them except for
266
+ # generating the help text.
267
+ def base_options(fn: Callable) -> Callable:
268
+ decorators = [
269
+ click.option(
270
+ "--version",
271
+ "version",
272
+ is_flag=True,
273
+ help="Display spin's version and exit.",
274
+ ),
275
+ click.option(
276
+ "--help",
277
+ is_flag=True,
278
+ help="Display spin's help and exit.",
279
+ ),
280
+ click.option(
281
+ "--change-directory",
282
+ "-C",
283
+ "cwd",
284
+ type=click.Path(file_okay=False, exists=True),
285
+ help=(
286
+ "Change directory before doing anything else. "
287
+ "In this case, the configuration file "
288
+ "(spinfile.yaml) is expected to live in the "
289
+ "directory changed to."
290
+ ),
291
+ ),
292
+ click.option(
293
+ "--env",
294
+ "envbase",
295
+ type=click.Path(file_okay=False, exists=False),
296
+ help=(
297
+ "Set an alternative directory for the environment instead of the name"
298
+ " computed by spin (SPIN_ENVBASE)."
299
+ ),
300
+ ),
301
+ click.option(
302
+ "-f",
303
+ "spinfile",
304
+ default=None,
305
+ type=click.Path(dir_okay=False, exists=False),
306
+ help=(
307
+ "An alternative name for the configuration file. "
308
+ "This can be a relative or absolute path when "
309
+ "used without -C."
310
+ ),
311
+ ),
312
+ click.option(
313
+ "--quiet",
314
+ "-q",
315
+ is_flag=True,
316
+ default=False,
317
+ help=(
318
+ "Be more quiet. By default, spin will echo commands as they are"
319
+ " executed. With -q, no commands will be shown. The quiet option is"
320
+ " also available to plugins via {quiet}. Some plugins will pass on the"
321
+ " (equivalent of a) quiet option to the commands they call."
322
+ ),
323
+ ),
324
+ click.option(
325
+ "--verbose",
326
+ "-v",
327
+ count=True,
328
+ default=0,
329
+ help=(
330
+ "Be more verbose. By default, spin will generate no output, except the"
331
+ " commands it executes. Using -v, the verbosity is increased."
332
+ ),
333
+ ),
334
+ click.option(
335
+ "--dump",
336
+ is_flag=True,
337
+ default=False,
338
+ help=(
339
+ "Dump the configuration tree before processing starts. This is useful"
340
+ " to analyze problems with spinfile.yaml and for plugin developers."
341
+ ),
342
+ ),
343
+ click.option(
344
+ "--prepend-properties",
345
+ "--pp",
346
+ multiple=True,
347
+ help=(
348
+ "Prepend to a setting in spin's configuration tree, using"
349
+ " 'property=value'. This only works for prepending to lists."
350
+ " Example: spin --pp pytest.opts=\"-m 'not slow'\""
351
+ ),
352
+ ),
353
+ click.option(
354
+ "--append-properties",
355
+ "--ap",
356
+ multiple=True,
357
+ help=(
358
+ "Append to a setting in spin's configuration tree, using"
359
+ " 'property=value'. This only works for appending to lists."
360
+ " Example: spin --ap pytest.opts=['-vv', '']"
361
+ ),
362
+ ),
363
+ click.option(
364
+ "-p",
365
+ "properties",
366
+ multiple=True,
367
+ help=(
368
+ "Override a setting in spin's configuration tree, using"
369
+ " 'property=value'. This only works for string properties. Example:"
370
+ " spin -p python.version=3.9.6 ..."
371
+ ),
372
+ ),
373
+ ]
374
+ for decorator in decorators:
375
+ fn = decorator(fn)
376
+ return fn
377
+
378
+
379
+ class GroupWithAliases(click.Group):
380
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
381
+ click.Group.__init__(self, *args, **kwargs)
382
+ self._aliases: dict = {}
383
+
384
+ def register_alias(self, alias: str, cmd_object: click.Command) -> None:
385
+ self._aliases[alias] = cmd_object
386
+
387
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command:
388
+ cmd = click.Group.get_command(self, ctx, cmd_name)
389
+ if cmd is None:
390
+ cmd = self._aliases.get(cmd_name, None)
391
+ return cmd
392
+
393
+
394
+ NOENV_COMMANDS = set()
395
+
396
+
397
+ def register_noenv(cmdname: str) -> None:
398
+ NOENV_COMMANDS.add(cmdname)
399
+
400
+
401
+ _nested = False
402
+
403
+
404
+ @click.command(cls=GroupWithAliases, help=__doc__)
405
+ @click.pass_context
406
+ @base_options # required for the documentation build to find CLI references
407
+ def commands(ctx: click.Context, **kwargs: Any) -> None:
408
+ global _nested # pylint: disable=global-statement
409
+ ctx.obj = get_tree()
410
+ if not _nested:
411
+ if ctx.invoked_subcommand not in NOENV_COMMANDS:
412
+ toporun(ctx.obj, "init")
413
+ _nested = True
414
+
415
+
416
+ @click.command(
417
+ context_settings={
418
+ "allow_extra_args": True,
419
+ # Override the default help option name -- we want click to
420
+ # use the help of the main command group 'commands', not from
421
+ # this boilerplate entry point.
422
+ "help_option_names": ["--hidden-help-option"],
423
+ "auto_envvar_prefix": "SPIN",
424
+ "allow_interspersed_args": False,
425
+ },
426
+ )
427
+ @base_options
428
+ @click.pass_context
429
+ def cli( # type: ignore[return] # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-return-statements # noqa: E501
430
+ ctx: click.Context,
431
+ version: packaging.version.Version | None,
432
+ help: bool, # pylint: disable=W0622
433
+ cwd: str,
434
+ envbase: str,
435
+ spinfile: str,
436
+ quiet: bool,
437
+ verbose: int,
438
+ dump: bool,
439
+ properties: tuple,
440
+ prepend_properties: tuple,
441
+ append_properties: tuple,
442
+ ) -> int | None:
443
+ if version:
444
+ print(importlib_metadata.version("csspin"))
445
+ return 0
446
+ if quiet:
447
+ verbose = -1
448
+ elif ctx.args and ctx.args[0] in ("env", "system-provision"):
449
+ # In case of 'env' and 'system-provision' we want spin to be quiet.
450
+ quiet = True
451
+ verbose = -1
452
+
453
+ global DUMP # pylint: disable=global-statement
454
+ PROP.extend(properties)
455
+ PREPEND_PROP.extend(prepend_properties)
456
+ APPEND_PROP.extend(append_properties)
457
+ DUMP = dump
458
+
459
+ verbosity = Verbosity(verbose)
460
+ # We want to honor the '--quiet' and '--verbose' flags early, even if
461
+ # the configuration tree has not yet been created, as subsequent
462
+ # code uses 'echo' and/or 'log'.
463
+ get_tree().verbosity = verbosity
464
+
465
+ # Find a project file and load it.
466
+ if cwd:
467
+ cd(cwd)
468
+ _spinfile = find_spinfile(spinfile)
469
+ if not _spinfile:
470
+ if help:
471
+ warn("No configuration file found")
472
+ commands.main(args=ctx.args)
473
+ return None
474
+ elif spinfile:
475
+ die(f"{spinfile} not found")
476
+ else:
477
+ die("No configuration file found")
478
+ spinfile = _spinfile # type: ignore[assignment]
479
+
480
+ cfg = load_minimal_tree(
481
+ spinfile=spinfile,
482
+ cwd=cwd,
483
+ envbase=envbase,
484
+ verbosity=verbosity,
485
+ setenvs=not help,
486
+ )
487
+
488
+ if ctx.args and ctx.args[0] in ("cleanup", "provision", "system-provision"):
489
+ # Special case for tasks that modify the config tree themselves.
490
+ commands.main(ctx.args)
491
+ return None
492
+ try:
493
+ load_plugins_into_tree(cfg)
494
+ except ModuleNotFoundError as exc:
495
+ if help:
496
+ commands.main(args=ctx.args)
497
+ return None
498
+ die(exc)
499
+
500
+ finalize_cfg_tree(cfg)
501
+ mkdir("{spin.data}")
502
+
503
+ if help:
504
+ # If help should be printed, we do so with exit-code 0
505
+ print(commands.get_help(ctx))
506
+ return None
507
+
508
+ if dump and not ctx.args:
509
+ # Otherwise help would be displayed right after the dump.
510
+ return None
511
+
512
+ # Invoke the main command group, which by now has all the
513
+ # sub-commands from the plugins.
514
+ commands.main(args=ctx.args)
515
+
516
+
517
+ def find_plugin_packages(cfg: tree.ConfigTree) -> Generator:
518
+ # Packages that are required to load plugins are identified by
519
+ # the keys in dict-valued list items of the 'plugins' setting
520
+ if not isinstance(cfg.plugin_packages, list):
521
+ die("'plugin_packages' configuration is invalid!")
522
+ yield from cfg.plugin_packages
523
+
524
+
525
+ def yield_plugin_import_specs(cfg: tree.ConfigTree) -> Generator:
526
+ if not isinstance(cfg.plugins, list):
527
+ die("'plugins' configuration is invalid!")
528
+
529
+ for item in cfg.plugins:
530
+ if isinstance(item, dict):
531
+ for package, modules in item.items():
532
+ for module in modules:
533
+ yield f"{package}.{module}"
534
+ else:
535
+ yield item
536
+
537
+
538
+ def load_minimal_tree( # pylint: disable=too-many-locals,too-many-arguments
539
+ spinfile: str | Path,
540
+ cwd: str = "",
541
+ envbase: str | None = None,
542
+ verbosity: Verbosity = Verbosity.NORMAL,
543
+ setenvs: bool = True,
544
+ ) -> tree.ConfigTree:
545
+ """
546
+ Create a minimal ConfigTree from the provided spinfile and the global config
547
+ file. Will also load the project-local and built-in plugins.
548
+ """
549
+ get_tree().verbosity = verbosity
550
+ debug(f"Loading {spinfile}")
551
+ spinschema = schema.schema_load(Path(__file__).dirname() / "schema.yaml")
552
+
553
+ cfg = spinschema.get_default() # type: ignore[call-arg]
554
+ set_tree(cfg)
555
+ cfg.verbosity = verbosity
556
+ cfg.schema = spinschema
557
+ userdata = readyaml(spinfile) if spinfile else config()
558
+ if not userdata:
559
+ die("The spinfile seems to be invalid!")
560
+ tree.tree_update(cfg, userdata)
561
+ tree.tree_merge(cfg, DEFAULTS)
562
+
563
+ # Merge user-specific globals if they exist
564
+ if (
565
+ not os.getenv("SPIN_DISABLE_GLOBAL_YAML")
566
+ and (spin_global := interpolate1(Path("{SPIN_CONFIG}/global.yaml")))
567
+ and exists(spin_global)
568
+ ):
569
+ user_settings = readyaml(spin_global)
570
+ if user_settings:
571
+ debug(f"Merging user settings from {os.path.normpath(spin_global)}")
572
+ tree.tree_update(cfg, user_settings)
573
+
574
+ if envbase:
575
+ cfg.spin.spin_dir = Path(envbase).absolute() / ".spin"
576
+
577
+ cfg.spin.spinfile = Path(spinfile)
578
+ cfg.spin.project_root = Path(cfg.spin.spinfile).absolute().normpath().dirname()
579
+ if not cfg.spin.project_name:
580
+ cfg.spin.project_name = cfg.spin.project_root.basename()
581
+ cfg.spin.launch_dir = Path().cwd().relpath(cfg.spin.project_root)
582
+ cfg.spin.spin_dir = interpolate1(Path(cfg.spin.spin_dir)).absolute() # type: ignore[union-attr]
583
+
584
+ if not cwd:
585
+ cd(cfg.spin.project_root)
586
+
587
+ if not exists(cfg.spin.spin_dir):
588
+ mkdir(cfg.spin.spin_dir)
589
+ writetext(
590
+ cfg.spin.spin_dir / ".gitignore",
591
+ "# Created by spin automatically\n*\n",
592
+ )
593
+
594
+ if setenvs:
595
+ setenv(**cfg.environment)
596
+ cfg.loaded = config()
597
+ debug("loading project plugins:")
598
+ load_plugin(cfg, "csspin.builtin")
599
+ tree.tree_apply_certain(cfg, ("plugins", "plugin_paths", "plugin_packages"))
600
+ return cfg # type: ignore[no-any-return]
601
+
602
+
603
+ def load_plugins_into_tree(cfg: tree.ConfigTree, cleanup: bool = False) -> None:
604
+ """
605
+ Update the tree by loading the installed plugin-packages and the globally
606
+ available plugins.
607
+ """
608
+ if not isinstance(cfg.plugin_paths, list):
609
+ die("'plugin_paths' configuration is invalid!")
610
+
611
+ for localpath in cfg.plugin_paths:
612
+ localabs = interpolate1(cfg.spin.project_root / localpath)
613
+ if not exists(localabs):
614
+ die(f"Plugin path {localabs} doesn't exist")
615
+ addsitedir(localabs)
616
+
617
+ # Load plugins. "Plugins" are not plugin packages, but modules
618
+ # which we expect to live in plugin packages. Afterwards
619
+ # 'cfg.loaded' will be a mapping from plugin names to module
620
+ # objects.
621
+ addsitedir(cfg.spin.spin_dir / "plugins")
622
+
623
+ for import_spec in yield_plugin_import_specs(cfg):
624
+ load_plugin(cfg, import_spec, may_fail=cleanup)
625
+
626
+ # Create a topologically sorted list of the plugins by their
627
+ # dependencies, which have been stored in "_requires" by
628
+ # load_plugin. This will be used later by 'toporun' to run
629
+ # initialization functions in order (e.g. a tool like 'flake8'
630
+ # requires a virtualenv where it can be installed; the virtualenv
631
+ # is provided by the 'virtualenv' plugin, which in turn requires
632
+ # 'python', which provides a Python installation).
633
+ nodes = cfg.loaded.keys()
634
+ graph = {n: getattr(mod.defaults, "_requires", []) for n, mod in cfg.loaded.items()}
635
+ cfg.spin.topo_plugins = reverse_toposort(nodes, graph)
636
+
637
+
638
+ def finalize_cfg_tree(cfg: tree.ConfigTree) -> None:
639
+ """Load the configuration and plugins from ``spinfile`` and build the tree.
640
+
641
+ The user's global spinfile is used to extend the built tree.
642
+
643
+ If ``provision`` is set, plugins will be provisioned.
644
+ """
645
+ tree.tree_ensure_descriptors(cfg)
646
+ tree.tree_inherit_internal(cfg)
647
+
648
+ tree.tree_update_properties(
649
+ cfg,
650
+ PROP,
651
+ PREPEND_PROP,
652
+ APPEND_PROP,
653
+ )
654
+ # Run 'configure' hooks of plugins
655
+ toporun(cfg, "configure")
656
+
657
+ # Interpolate values of the configuration tree and enforce their types
658
+ tree.tree_sanitize(cfg)
659
+
660
+ secrets.update(tree.tree_extract_secrets(cfg))
661
+
662
+ if DUMP:
663
+ print(obfuscate(tree.tree_dump(cfg)))
664
+
665
+
666
+ def install_plugin_packages(cfg: tree.ConfigTree) -> None:
667
+ """Install plugin packages which are not yet installed and extend the
668
+ configuration tree.
669
+ """
670
+ mkdir(plugin_dir := interpolate1(Path("{spin.spin_dir}") / "plugins"))
671
+
672
+ # To be able to do editable installs to plugin dir, we have to
673
+ # temporarily set PYTHONPATH, to let the pip subprocess
674
+ # believe plugin_dir is in sys.path. But we must be careful to
675
+ # unset it before calling anything else -- see below!
676
+ old_python_path = os.environ.get("PYTHONPATH", None)
677
+ os.environ["PYTHONPATH"] = plugin_dir
678
+
679
+ cmd = [
680
+ f"{sys.executable}",
681
+ "-mpip",
682
+ "install",
683
+ "-q" if cfg.verbosity < Verbosity.INFO else None,
684
+ "--disable-pip-version-check",
685
+ "--upgrade",
686
+ "-t",
687
+ plugin_dir,
688
+ "--index-url",
689
+ "{spin.index_url}",
690
+ ]
691
+
692
+ if cfg.spin.extra_index:
693
+ cmd.extend(["--extra-index-url", cfg.spin.extra_index])
694
+
695
+ # Install all missing plugin-packages at once to avoid pip's dependency
696
+ # resolver to fail without exit-zero, while using the "-t" (target) option
697
+ # pointing to the plugin directory.
698
+ with memoizer(plugin_dir / "packages.memo") as m: # type: ignore[operator]
699
+ if to_be_installed := set(find_plugin_packages(cfg)):
700
+ args = list(cmd)
701
+ for pkg in to_be_installed:
702
+ args.extend(pkg.split())
703
+ sh(*args)
704
+ for pkg in to_be_installed:
705
+ m.add(pkg)
706
+
707
+ # Now remove PYTHONPATH and make plugin a pth-enabled part of sys.path
708
+ if old_python_path:
709
+ os.environ["PYTHONPATH"] = old_python_path
710
+ else:
711
+ del os.environ["PYTHONPATH"]