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/__init__.py +1340 -0
- csspin/__main__.py +20 -0
- csspin/builtin/__init__.py +322 -0
- csspin/cli.py +711 -0
- csspin/schema.py +204 -0
- csspin/schema.yaml +134 -0
- csspin/tree.py +764 -0
- csspin-2.0.0.dist-info/METADATA +134 -0
- csspin-2.0.0.dist-info/RECORD +13 -0
- csspin-2.0.0.dist-info/WHEEL +5 -0
- csspin-2.0.0.dist-info/entry_points.txt +2 -0
- csspin-2.0.0.dist-info/licenses/LICENSE +176 -0
- csspin-2.0.0.dist-info/top_level.txt +1 -0
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"]
|