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 ADDED
@@ -0,0 +1,1340 @@
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
+ # pylint: disable=too-many-lines
19
+
20
+ """This is the plugin API of spin. It contains functions and classes
21
+ that are necessary for plugins to register themselves with spin,
22
+ e.g. :py:func:`task`, and convenience APIs that aim to simplify plugin
23
+ implementation.
24
+
25
+ spin's task management (aka subcommands) is just a thin wrapper on top
26
+ of the venerable `package click
27
+ <https://click.palletsprojects.com/en/8.0.x/>`_, so to create any
28
+ slightly advanced command line interfaces for plugins you want to make
29
+ yourself comfortable with click's documentation.
30
+
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from enum import IntEnum
36
+ from typing import TYPE_CHECKING, Iterable, Type
37
+
38
+ import packaging.version
39
+ import platformdirs.unix
40
+
41
+ if TYPE_CHECKING:
42
+ from typing import Any, Callable, Generator
43
+ from csspin.tree import ConfigTree
44
+ from csspin.cli import GroupWithAliases
45
+
46
+ import collections
47
+ import inspect
48
+ import os
49
+ import pickle
50
+ import re
51
+ import shlex
52
+ import shutil
53
+ import subprocess
54
+ import sys
55
+ import urllib.request
56
+ from contextlib import contextmanager
57
+ from traceback import format_exc
58
+
59
+ import click
60
+ import packaging
61
+ import platformdirs
62
+ from path import Path
63
+
64
+ __all__ = [
65
+ "debug",
66
+ "echo",
67
+ "info",
68
+ "warn",
69
+ "error",
70
+ "cd",
71
+ "copy",
72
+ "confirm",
73
+ "exists",
74
+ "mkdir",
75
+ "rmtree",
76
+ "die",
77
+ "Command",
78
+ "sh",
79
+ "backtick",
80
+ "setenv",
81
+ "readbytes",
82
+ "writebytes",
83
+ "readtext",
84
+ "writetext",
85
+ "appendtext",
86
+ "persist",
87
+ "unpersist",
88
+ "memoizer",
89
+ "namespaces",
90
+ "interpolate1",
91
+ "interpolate",
92
+ "config",
93
+ "readyaml",
94
+ "download",
95
+ "argument",
96
+ "option",
97
+ "task",
98
+ "group",
99
+ "invoke",
100
+ "toporun",
101
+ "Path",
102
+ "Memoizer",
103
+ "EXPORTS",
104
+ ]
105
+
106
+ secrets: set[str] = set()
107
+
108
+
109
+ def obfuscate(msg: Iterable[str] | str) -> list[str] | str:
110
+ mask = "*******"
111
+
112
+ if isinstance(msg, str):
113
+ new_msg = msg
114
+ for secret in secrets:
115
+ new_msg = new_msg.replace(secret, mask)
116
+ return new_msg
117
+
118
+ elif isinstance(msg, Iterable):
119
+ msg_list: list[str] = []
120
+ for string in msg:
121
+ for secret in secrets:
122
+ string = string.replace(secret, mask)
123
+ msg_list.append(string)
124
+ return msg_list
125
+
126
+
127
+ def echo(*msg: str, resolve: bool = False, **kwargs: Any) -> None:
128
+ """Print a message to the console by joining the positional arguments
129
+ `msg` with spaces.
130
+
131
+ `echo` is meant for messages that explain to the user what spin is doing
132
+ (e.g. *echoing* commands launched). It will remain silent though when ``spin``
133
+ is run with the ``--quiet`` flag. If the parameter ``resolve`` is set to
134
+ ``True``, the arguments are interpolated against the configuration tree.
135
+
136
+ `echo` supports the same keyword arguments as Click's :py:func:`click.echo`.
137
+
138
+ """
139
+ if CONFIG.verbosity > Verbosity.QUIET:
140
+ if resolve:
141
+ msg = interpolate(msg) # type: ignore[assignment]
142
+ msg = obfuscate(msg) # type: ignore[assignment]
143
+ click.echo(click.style("spin: ", fg="green"), nl=False)
144
+ click.echo(click.style(" ".join(msg), bold=True), **kwargs)
145
+
146
+
147
+ def info(*msg: str, **kwargs: Any) -> None:
148
+ """Print a message to the console by joining the positional arguments
149
+ `msg` with spaces.
150
+
151
+ Arguments are interpolated against the configuration tree. `info`
152
+ will remain silent unless ``spin`` is run with the ``--verbose``
153
+ flag. `info` is meant for messages that provide additional details.
154
+
155
+ `info` supports the same keyword arguments as Click's
156
+ :py:func:`click.echo`.
157
+
158
+ """
159
+ if CONFIG.verbosity > Verbosity.NORMAL:
160
+ msg = interpolate(msg) # type: ignore[assignment]
161
+ msg = obfuscate(msg) # type: ignore[assignment]
162
+ click.echo(click.style("spin: ", fg="green"), nl=False)
163
+ click.echo(" ".join(msg), **kwargs)
164
+
165
+
166
+ def debug(*msg: str, resolve: bool = False, **kwargs: Any) -> None:
167
+ """Print a message to the console by joining the positional arguments
168
+ `msg` with spaces.
169
+
170
+ Arguments are interpolated against the configuration tree if ``resolve``
171
+ evaluates to ``True``. `debug` will remain silent unless ``spin`` is run
172
+ with the ``-vv`` flag. `debug` is meant for messages that provide internal
173
+ details.
174
+
175
+ `debug` supports the same keyword arguments as Click's
176
+ :py:func:`click.echo`.
177
+
178
+ """
179
+ if CONFIG.verbosity > Verbosity.INFO:
180
+ if resolve:
181
+ msg = interpolate(msg) # type: ignore[assignment]
182
+ msg = obfuscate(msg) # type: ignore[assignment]
183
+ click.echo(click.style("spin: debug: ", fg="white", dim=True), nl=False)
184
+ click.echo(" ".join(msg), **kwargs)
185
+
186
+
187
+ def warn(*msg: str, **kwargs: Any) -> None:
188
+ """Print a warning message to the console by joining the positional
189
+ arguments `msg` with spaces.
190
+
191
+ Arguments are interpolated against the configuration tree. The
192
+ output is written to standard error.
193
+
194
+ `warn` supports the same keyword arguments as Click's
195
+ :py:func:`click.echo`.
196
+
197
+ """
198
+ msg = interpolate(msg) # type: ignore[assignment]
199
+ msg = obfuscate(msg) # type: ignore[assignment]
200
+ click.echo(click.style("spin: warning: ", fg="yellow"), nl=False, err=True)
201
+ click.echo(" ".join(msg), err=True, **kwargs)
202
+
203
+
204
+ def error(*msg: str, resolve: bool = True, **kwargs: Any) -> None:
205
+ """Print an error message to the console by joining the positional
206
+ arguments `msg` with spaces.
207
+
208
+ Arguments are interpolated against the configuration tree if `resolve`
209
+ evaluates to `True`. The output is written to standard error.
210
+
211
+ `error` supports the same keyword arguments as Click's
212
+ :py:func:`click.echo`.
213
+
214
+ """
215
+ if resolve:
216
+ msg = interpolate(msg) # type: ignore[assignment]
217
+ msg = obfuscate(msg) # type: ignore[assignment]
218
+ click.echo(click.style("spin: error: ", fg="red"), nl=False, err=True)
219
+ click.echo(" ".join(msg), err=True, **kwargs)
220
+
221
+
222
+ def confirm(*msg: str, resolve: bool = True, **kwargs: Any) -> bool:
223
+ """Prompt user for confirmation.
224
+
225
+ Arguments are interpolated against the configuration tree if `resolve`
226
+ evaluates to `True`. The output is written to standard out.
227
+
228
+ `confirm` supports the same keyword arguments as Click's
229
+ :py:func:`click.confirm`.
230
+
231
+ """
232
+ if resolve:
233
+ msg = interpolate(msg) # type: ignore[assignment]
234
+ msg = obfuscate(msg) # type: ignore[assignment]
235
+ click.echo(click.style("spin: ", fg="yellow"), nl=False)
236
+ return click.confirm(" ".join(msg), **kwargs)
237
+
238
+
239
+ class Verbosity(IntEnum):
240
+ """
241
+ :py:class:`enum.IntEnum` defining four verbosity levels:
242
+
243
+ * ``QUIET``: Outputs only warnings and errors via :py:func:`csspin.warn()` and
244
+ :py:func:`csspin.error()`.
245
+ * ``NORMAL``: Outputs the normal amount of verbosity, extending the quiet
246
+ level by enabling :py:func:`csspin.echo()`.
247
+ * ``INFO``: Extends normal verbosity to enable :py:func:`csspin.info()`.
248
+ * ``DEBUG``: Extends info verbosity to enable debug messages via
249
+ :py:func:`csspin.debug()`.
250
+ """
251
+
252
+ QUIET = -1
253
+ NORMAL = 0
254
+ INFO = 1
255
+ DEBUG = 2
256
+
257
+
258
+ class DirectoryChanger:
259
+ """A simple class to change the current directory.
260
+
261
+ Change directory on construction, and restore the cwd when used as
262
+ a context manager. Noop if we're already in the wanted directory.
263
+ """
264
+
265
+ def __init__(self: DirectoryChanger, path: str | Path) -> None:
266
+ """Change directory."""
267
+ path = interpolate1(path)
268
+ self._cwd = os.getcwd()
269
+ if not os.path.samefile(path, self._cwd):
270
+ echo("cd", path)
271
+ os.chdir(path)
272
+
273
+ def __enter__(self: DirectoryChanger) -> None:
274
+ """Nop."""
275
+
276
+ def __exit__(self: DirectoryChanger, *args: Any) -> None:
277
+ """Change back to where we came from."""
278
+ if not os.path.samefile(self._cwd, os.getcwd()):
279
+ os.chdir(self._cwd)
280
+
281
+
282
+ def cd(path: str | Path) -> DirectoryChanger:
283
+ """Change directory.
284
+
285
+ The `path` argument is interpolated against the configuration
286
+ tree.
287
+
288
+ `cd` can be used either as a function or as a context
289
+ manager. When used as a context manager, the working directory is
290
+ changed back to what it was before the ``with`` block.
291
+
292
+ You can do this:
293
+
294
+ >>> cd("{spin.project_root}")
295
+
296
+ ... or that:
297
+
298
+ >>> with cd("{spin.project_root}"):
299
+ <do something in this directory>
300
+
301
+ """
302
+ return DirectoryChanger(path)
303
+
304
+
305
+ def exists(path: str | Path) -> bool:
306
+ """Check whether `path` exists. The argument is interpolated against
307
+ the configuration tree.
308
+
309
+ """
310
+ path = interpolate1(path)
311
+ return os.path.exists(path)
312
+
313
+
314
+ def normpath(*args: str | Path) -> str:
315
+ """Interpolate and return a normalized path as str"""
316
+ return os.path.normpath(os.path.join(*interpolate(args))) # type: ignore[no-any-return]
317
+
318
+
319
+ def abspath(*args: str | Path) -> str:
320
+ """Interpolate and return an absolute path as str"""
321
+ return os.path.abspath(normpath(*args))
322
+
323
+
324
+ def mkdir(path: str | Path) -> str:
325
+ """Ensure that `path` exists.
326
+
327
+ If necessary, directories are recursively created to make `path`
328
+ available. The argument is interpolated against the configuration
329
+ tree.
330
+
331
+ """
332
+ if not exists(path := interpolate1(Path(path))):
333
+ echo("mkdir", path)
334
+ os.makedirs(path)
335
+ return path
336
+
337
+
338
+ def rmtree(path: str | Path) -> None:
339
+ """Recursively remove `path` and everything it contains.
340
+ Can also remove single files. The argument
341
+ is interpolated against the configuration tree.
342
+
343
+ Obviously, this should be used with care.
344
+
345
+ """
346
+ if (path := interpolate1(path)) and not exists(path):
347
+ return
348
+ if sys.platform == "win32":
349
+ echo(f"rm {path} -recurse -force")
350
+ else:
351
+ echo(f"rm -rf {path}")
352
+
353
+ if (path := Path(path)).is_dir():
354
+ path.rmtree()
355
+ else:
356
+ path.remove()
357
+
358
+
359
+ def mv(source: str | Path, target: str | Path) -> None:
360
+ """Move a file or directory recursively from `source` to `target` in case
361
+ the `target` exists, otherwise rename `source` to `target`.
362
+
363
+ """
364
+ if not exists((source := str(interpolate1(source)))):
365
+ die(f"{source} does not exist!")
366
+ target = str(interpolate1(target))
367
+
368
+ if sys.platform == "win32":
369
+ echo(f"move-item -path {source} -destination {target}")
370
+ else:
371
+ echo(f"mv {source} {target}")
372
+
373
+ shutil.move(source, target)
374
+
375
+
376
+ def copy(source: str | Path, target: str | Path) -> None:
377
+ """Copy a file or directory recursively from `source` to `target` in case
378
+ the `target` exists.
379
+
380
+ """
381
+ if not exists((source := Path(interpolate1(source)).absolute())):
382
+ die(f"{source} does not exist!")
383
+ target = Path(interpolate1(target)).absolute()
384
+
385
+ source_is_dir = source.is_dir()
386
+ if sys.platform == "win32":
387
+ opts = "-recurse" if source_is_dir else ""
388
+ echo(f"copy-item -path {source} -destination {target} {opts}")
389
+ else:
390
+ opts = "-r " if source_is_dir else ""
391
+ echo(f"cp {opts}{source} {target}")
392
+
393
+ if source_is_dir:
394
+ source.copytree(
395
+ (target / source.basename()).mkdir_p(),
396
+ dirs_exist_ok=True,
397
+ )
398
+ else:
399
+ source.copy2(target)
400
+
401
+
402
+ def die(*msg: Any, resolve: bool = True) -> None:
403
+ """Terminates ``spin`` with a non-zero return code and print the error
404
+ message `msg`.
405
+
406
+ Arguments are interpolated against the configuration tree if `resolve`
407
+ evaluates to `True`.
408
+
409
+ """
410
+ if resolve:
411
+ msg = interpolate(msg) # type: ignore[assignment]
412
+ error(*msg, resolve=False)
413
+ raise click.Abort(msg)
414
+
415
+
416
+ class Command:
417
+ """Create a function that is a shrink-wrapped shell command.
418
+
419
+ The callable returned behaves like :py:func:`sh`, accepting
420
+ additional arguments for the wrapper command as positional
421
+ parameters. All positional arguments are interpolated against the
422
+ configuration tree.
423
+
424
+ Example:
425
+
426
+ >>> install = Command("pip", "install")
427
+ >>> install("spin")
428
+
429
+ """
430
+
431
+ def __init__(self: Command, *cmd: str) -> None:
432
+ self._cmd = list(cmd)
433
+
434
+ def append(self: Command, item: str) -> None:
435
+ self._cmd.append(item)
436
+
437
+ def __call__(
438
+ self: Command, *args: str, **kwargs: Any
439
+ ) -> subprocess.CompletedProcess | None:
440
+ cmd = self._cmd + list(args)
441
+ return sh(*cmd, **kwargs)
442
+
443
+
444
+ def sh(*cmd: Any, **kwargs: Any) -> subprocess.CompletedProcess | None:
445
+ """Run a program by building a command line from `cmd`.
446
+
447
+ When multiple positional arguments are given, each is treated as
448
+ one element of the command. When just one positional argument is
449
+ used, `sh` assumes it to be a single command and splits it into
450
+ multiple arguments using :py:func:`shlex.split`. The `cmd`
451
+ arguments are interpolated against the configuration tree. When
452
+ `silent` is ``False``, the resulting command line will be
453
+ echoed. When `shell` is ``True``, the command line is passed to
454
+ the system's shell.
455
+
456
+ Other keyword arguments are passed into
457
+ :py:func:`subprocess.run`.
458
+
459
+ All positional arguments are interpolated against the
460
+ configuration tree.
461
+
462
+ >>> sh("ls", "{HOME}")
463
+
464
+ """
465
+ cmd = interpolate(cmd) # type: ignore[assignment]
466
+ shell = kwargs.pop("shell", len(cmd) == 1)
467
+ check = kwargs.pop("check", True)
468
+ env = argenv = kwargs.pop("env", None)
469
+ if env:
470
+ process_env = dict(os.environ)
471
+ process_env.update(env)
472
+ env = process_env
473
+
474
+ executable = None
475
+ if sys.platform == "win32":
476
+ if len(cmd) == 1:
477
+ cmd = shlex.split(cmd[0].replace("\\", "\\\\"))
478
+ if not shell:
479
+ executable = shutil.which(cmd[0])
480
+
481
+ if not kwargs.pop("silent", False):
482
+
483
+ def quote(arg: str) -> str:
484
+ if len(cmd) > 1 and " " in arg:
485
+ return f"'{arg}'"
486
+ return arg
487
+
488
+ echo(" ".join(quote(c) for c in cmd))
489
+
490
+ message = "Command '{cmd_}' failed with exit status {returncode}."
491
+ cmd_ = cmd if isinstance(cmd, str) else subprocess.list2cmdline(cmd) # type: ignore[unreachable] # noqa: E501
492
+ try:
493
+ debug(
494
+ f"subprocess.run({cmd}, shell={shell}, check={check}, env={argenv},"
495
+ f" executable={executable}, kwargs={kwargs}",
496
+ )
497
+ cpi = subprocess.run(
498
+ cmd, shell=shell, check=check, env=env, executable=executable, **kwargs
499
+ )
500
+ except FileNotFoundError as ex:
501
+ debug(format_exc())
502
+ die(str(ex))
503
+ except subprocess.CalledProcessError as ex:
504
+ debug(format_exc())
505
+ if check:
506
+ die(message.format(cmd_=cmd_, returncode=ex.returncode))
507
+ cpi = subprocess.CompletedProcess(args=cmd, returncode=ex.returncode)
508
+
509
+ if not check and cpi.returncode:
510
+ warn(message.format(cmd_=cmd, returncode=cpi.returncode))
511
+
512
+ return cpi
513
+
514
+
515
+ def backtick(*cmd: str, **kwargs: Any) -> str:
516
+ kwargs["stdout"] = subprocess.PIPE
517
+ cpi = sh(*cmd, **kwargs)
518
+ return cpi.stdout.decode() # type: ignore[no-any-return,union-attr]
519
+
520
+
521
+ #: EXPORTS is a list that contains all (key, value) tuples of environment variables
522
+ #: that got set or unset via :py:func:`csspin.setenv` during the current spin execution.
523
+ #:
524
+ #: The ``value`` of a given element is already fully interpolated, except for
525
+ #: parts that look like environment variables. So any plugin using ``EXPORTS`` is able
526
+ #: to lazily evaluate the value of a variable in cases, where it has been set
527
+ #: multiple times.
528
+ #:
529
+ #: A case where that's relevant can be seen in the following example:
530
+ #:
531
+ #: Example:
532
+ #:
533
+ #: >>> os.environ.getenv("PATH")
534
+ #: "/usr/bin:/bin"
535
+ #: >>> setenv(PATH="{spin.project_root}/bin:{PATH}")
536
+ #: >>> setenv(PATH="{python.scriptdir}:{PATH}")
537
+ #: >>> EXPORTS
538
+ #: [("PATH", "/home/foo/project/bin:{PATH}"), ("PATH", "/home/foo/project/.spin/venv/bin:{PATH})]
539
+ #:
540
+ #: As can be seen, the real value of ``PATH`` should be
541
+ #: ``"/home/foo/project/.spin/venv/bin:/home/foo/project/bin:"/usr/bin:/bin"``,
542
+ #: which any plugin could now generate.
543
+ EXPORTS: list[tuple[str, str]] = []
544
+
545
+
546
+ def setenv(*args: Any, **kwargs: Any) -> None:
547
+ """Set or unset one or more environment variables. The values of
548
+ keyword arguments are interpolated against the configuration tree.
549
+
550
+ Passing ``None`` as a value removes the environment variable.
551
+
552
+ Variables that have been set during or before ``configure()`` will be
553
+ patched into the activation scripts of the Python virtual environment.
554
+
555
+ >>> setenv(FOO="{spin.foo}", BAR="{bar.options}")
556
+
557
+ """
558
+
559
+ def _value_replacement_for_echoing(value: str) -> str:
560
+ keys = re.findall(r"{(?P<key>\w+?)}", value)
561
+ if sys.platform == "win32":
562
+ for key in keys:
563
+ if key in os.environ:
564
+ value = value.replace(f"{{{key}}}", f"$env:{key}")
565
+ else:
566
+ for key in keys:
567
+ if key in os.environ:
568
+ value = value.replace(f"{{{key}}}", f"${key}")
569
+ return value
570
+
571
+ for key, value in kwargs.items():
572
+ if value is None:
573
+ if not args:
574
+ if sys.platform == "win32":
575
+ echo(f"$env:{key}=$null", resolve=False)
576
+ else:
577
+ echo(f"unset {key}", resolve=False)
578
+ os.environ.pop(key, None)
579
+ EXPORTS.append((key, ""))
580
+ else:
581
+ interpolated_value = interpolate1(value)
582
+ exports_value = interpolate1(value, interpolate_environ=False)
583
+ if not args:
584
+ value_to_print = _value_replacement_for_echoing(exports_value)
585
+ if sys.platform == "win32":
586
+ echo(f'$env:{key}="{value_to_print}"', resolve=False)
587
+ else:
588
+ echo(f"export {key}={value_to_print}", resolve=False)
589
+ else:
590
+ echo(args[0])
591
+ os.environ[key] = interpolated_value
592
+ EXPORTS.append((key, exports_value))
593
+
594
+
595
+ def _read_file(fn: str | Path, mode: str) -> str | bytes:
596
+ fn = interpolate1(fn)
597
+ with open(fn, mode, encoding="utf-8" if "b" not in mode else None) as f:
598
+ return f.read() # type: ignore[no-any-return]
599
+
600
+
601
+ def readlines(fn: str | Path) -> list[str]:
602
+ fn = interpolate1(fn)
603
+ with open(fn, "r", encoding="utf-8") as f:
604
+ return f.readlines()
605
+
606
+
607
+ def writelines(fn: str | Path, lines: str) -> None:
608
+ fn = interpolate1(fn)
609
+ with open(fn, "w", encoding="utf-8") as f:
610
+ f.writelines(lines)
611
+
612
+
613
+ def _write_file(fn: str | Path, mode: str, data: bytes | str) -> int:
614
+ fn = interpolate1(fn)
615
+ with open(fn, mode, encoding="utf-8" if "b" not in mode else None) as f:
616
+ return f.write(data)
617
+
618
+
619
+ def readbytes(fn: str | Path) -> bytes:
620
+ """`readbytes` reads binary data. The file name argument is
621
+ interpolated against the configuration tree.
622
+
623
+ """
624
+ return _read_file(fn, "rb") # type: ignore[return-value]
625
+
626
+
627
+ def writebytes(fn: str | Path, data: bytes) -> int:
628
+ """Write `data`` to the file named `fn`.
629
+
630
+ Data is binary data (`bytes`). The file name argument is
631
+ interpolated against the configuration tree.
632
+
633
+ """
634
+ return _write_file(fn, "wb", data)
635
+
636
+
637
+ def readtext(fn: str | Path) -> str:
638
+ """Read an UTF8 encoded text from the file 'fn'.
639
+
640
+ The file name argument is interpolated against the configuration
641
+ tree.
642
+
643
+ """
644
+ return _read_file(fn, "r") # type: ignore[return-value]
645
+
646
+
647
+ def writetext(fn: str | Path, data: str) -> int:
648
+ """Write `data`, which is text (Unicode object of type `str`) to the
649
+ file named `fn`.
650
+
651
+ The file name argument is interpolated against the configuration tree.
652
+
653
+ """
654
+ return _write_file(fn, "w", data)
655
+
656
+
657
+ def appendtext(fn: str | Path, data: str) -> int:
658
+ """Append `data`, which is text (Unicode object of type `str`) to the
659
+ file named `fn`.
660
+
661
+ The file name argument is interpolated against the configuration tree.
662
+
663
+ """
664
+ return _write_file(fn, "a", data)
665
+
666
+
667
+ def persist(fn: str | Path, data: Type[object]) -> int:
668
+ """Persist the Python object(s) in `data` using :py:mod:`pickle`."""
669
+ return writebytes(fn, pickle.dumps(data))
670
+
671
+
672
+ def unpersist(fn: str, default: Any | None = None) -> Any | None:
673
+ """Load pickled Python object(s) from the file `fn`."""
674
+ try:
675
+ return pickle.loads(readbytes(fn))
676
+ except FileNotFoundError:
677
+ return default
678
+
679
+
680
+ class Memoizer:
681
+ """Maintain a persistent base of simple facts.
682
+
683
+ Facts are loaded from file `fn`. The argument is interpolated
684
+ against the configuration tree. If `fn` does not exist, there are
685
+ no facts.
686
+
687
+ The `Memoizer` class stores and retrieves Python objects from the
688
+ binary file named `fn`. The argument is interpolated against the
689
+ configuration tree. `Memoizer` can be used to keep a simple
690
+ "database". Spin internally uses Memoizers for e.g. keeping track
691
+ of packages installed in a virtual environment.
692
+
693
+ To ease the handling in `spin` scripts, there also is context
694
+ manager called `memoizer` (note the lower case "m"). The context
695
+ manager retrieves the database from the file and saves it back
696
+ when the context is closed::
697
+
698
+ >>> with memoizer(fn) as m:
699
+ ... if m.check("test"): ...
700
+
701
+ There are *no* precautions for simultaneous access from multiple
702
+ processes, writes will likely silently become lost.
703
+
704
+ """
705
+
706
+ def __init__(self: Memoizer, fn: str | Path) -> None:
707
+ self._fn = fn
708
+ self._items = unpersist(fn, [])
709
+
710
+ def check(self: Memoizer, item: Iterable) -> bool:
711
+ """Checks whether `item` is stored in the memoizer."""
712
+ return item in self._items # type: ignore[operator]
713
+
714
+ def clear(self: Memoizer) -> None:
715
+ """Remove all items"""
716
+ self._items = []
717
+
718
+ def items(self: Memoizer) -> Iterable:
719
+ return self._items # type: ignore[return-value]
720
+
721
+ def add(self: Memoizer, item: Any) -> None:
722
+ """Add `item` to the memoizer."""
723
+ self._items.append(item) # type: ignore[union-attr]
724
+ self.save()
725
+
726
+ def save(self: Memoizer) -> None:
727
+ """Persist the current state of the memoizer.
728
+
729
+ This is done automatically when using `memoizer` as a context
730
+ manager.
731
+
732
+ """
733
+ persist(self._fn, self._items) # type: ignore[arg-type]
734
+
735
+
736
+ @contextmanager
737
+ def memoizer(fn: str) -> Generator:
738
+ """Context manager for creating a :py:class:`Memoizer` that
739
+ automatically saves the fact base.
740
+
741
+ >>> with memoizer("facts.memo") as m:
742
+ ... m.add("fact1")
743
+ ... m.add("fact2")
744
+
745
+ """
746
+ m = Memoizer(fn)
747
+ yield m
748
+ m.save()
749
+
750
+
751
+ NSSTACK = []
752
+
753
+
754
+ @contextmanager
755
+ def namespaces(*nslist: str) -> Generator:
756
+ """Add namespaces for interpolation."""
757
+ for ns in nslist:
758
+ NSSTACK.append(ns)
759
+ yield
760
+ for _ in nslist:
761
+ NSSTACK.pop()
762
+
763
+
764
+ # On Windows platformdirs.user_config_dir and platformdirs.user_data_dir
765
+ # both point to %LOCALAPPDATA%, that's why we need /data and /config subfolders
766
+ os.environ["SPIN_CONFIG"] = os.environ.get(
767
+ "SPIN_CONFIG",
768
+ Path.joinpath(
769
+ platformdirs.user_config_dir(),
770
+ "spin",
771
+ "config" if sys.platform == "win32" else "",
772
+ ).normpath(),
773
+ )
774
+ os.environ["SPIN_DATA"] = os.environ.get(
775
+ "SPIN_DATA",
776
+ Path.joinpath(
777
+ platformdirs.user_data_dir(), "spin", "data" if sys.platform == "win32" else ""
778
+ ).normpath(),
779
+ )
780
+
781
+
782
+ def interpolate1(
783
+ literal: str | Path, *extra_dicts: dict, interpolate_environ: bool = True
784
+ ) -> str | Path:
785
+ """
786
+ Interpolate a string or path against the configuration tree and the
787
+ environment.
788
+
789
+ Example:
790
+
791
+ >>> interpolate1("{SHELL}")
792
+ '/usr/bin/zsh'
793
+
794
+ If literal is not a string or path, it will be converted to a string prior
795
+ interpolating.
796
+
797
+ To avoid interpolation for literals or specific parts of a literal, curly
798
+ braces can be used to escape curly braces, like regular f-string
799
+ interpolation.
800
+
801
+ Example:
802
+
803
+ >>> interpolate1(
804
+ ... '{{"header": {{"language": "en", "data": "{SPIN_DATA}"}}}}'
805
+ ... )
806
+ '{"header": {"language": "en", "data": "/home/developer/.local/share/spin"}}'
807
+
808
+ It may be necessary to omit the interpolation against the environment, in that case
809
+ the parameter ``interpolate_environ`` can be set to ``False``.
810
+
811
+ Example:
812
+
813
+ >>> interpolate1("{spin.version} and {PATH}", interpolate_environ=False)
814
+ "1.0.2.dev5 and {PATH}"
815
+
816
+ .. Attention:: **Do not use** :py:func:`csspin.interpolate1` **in a plugins'
817
+ top-level**, as the one can't rely on the configuration tree at import time
818
+ of the module.
819
+
820
+ .. code-block:: python
821
+ :caption: Negative example: How not to use :py:func:`csspin.interpolate1`
822
+ :linenos:
823
+
824
+ from csspin import config, interpolate1
825
+
826
+ defaults = config(key=interpolate1("{some.property}"))
827
+
828
+ .. Attention:: If the interpolated property is not set, not NoneType but "None" as a string is returned.
829
+
830
+ """
831
+ is_path = isinstance(literal, Path)
832
+ literal = str(literal)
833
+ seen = set()
834
+ previous = None
835
+
836
+ where_to_look = collections.ChainMap(
837
+ {"config": CONFIG},
838
+ CONFIG,
839
+ os.environ if interpolate_environ else {},
840
+ *extra_dicts,
841
+ *NSSTACK, # type: ignore[arg-type]
842
+ )
843
+
844
+ while previous != literal:
845
+ # Interpolate until we reach a fixpoint -- this allows for
846
+ # nested variables.
847
+ if literal in seen:
848
+ die(
849
+ f"Could not interpolate '{literal}' due to RecursionError.",
850
+ resolve=False,
851
+ )
852
+ seen.add(previous := literal)
853
+
854
+ # We need to protect double braces by doubling them, because
855
+ # .format() converts {{}} to {} undependent of if it interpolated
856
+ # something within these braces or not.
857
+ literal = literal.replace("}}", "}}}}").replace("{{", "{{{{")
858
+ try:
859
+ if interpolate_environ:
860
+ literal = literal.format_map(where_to_look)
861
+ else:
862
+ # When not interpolating the environ, we need to escape
863
+ # sub-literals that look like environment variables.
864
+ literal = re.sub(r"({\w+})", r"{\1}", literal)
865
+ literal = literal.format_map(where_to_look)
866
+ literal = re.sub(r"{({\w+})}", r"\1", literal)
867
+ except KeyError as ex:
868
+ error_key = str(ex)[1:-1]
869
+ die(f"Cannot interpolate '{{{error_key}}}' in {literal}.", resolve=False)
870
+ except AttributeError as ex:
871
+ error_key = str(ex).replace("No property ", "")[1:-1]
872
+ die(f"Cannot interpolate '{{{error_key}}}' in {literal}.", resolve=False)
873
+ literal = literal.replace("{{", "{").replace("}}", "}")
874
+ return Path(literal).normpath() if is_path else literal
875
+
876
+
877
+ def interpolate(literals: Iterable, *extra_dicts: dict) -> list:
878
+ """
879
+ Interpolate an iterable of hashable items against the configuration tree.
880
+ """
881
+ out = []
882
+ for literal in literals:
883
+ # We allow None, which gets filtered out here, to enable
884
+ # simple argument configuration, e.g. something like:
885
+ # sh("...", "-q" if cfg.quiet else None, ...)
886
+ if literal is not None:
887
+ out.append(interpolate1(literal, *extra_dicts))
888
+ return out
889
+
890
+
891
+ def config(*args: Any | None, **kwargs: Any) -> ConfigTree:
892
+ """`config` creates a configuration subtree:
893
+
894
+ >>> config(a="alpha", b="beta)
895
+ {"a": "alpha", "b": "beta}
896
+
897
+ Plugins use `config` to declare their ``defaults`` tree.
898
+
899
+ """
900
+
901
+ from csspin.tree import ConfigTree
902
+
903
+ return ConfigTree(*args, **kwargs, __ofs_frames__=1) # type: ignore[arg-type]
904
+
905
+
906
+ def readyaml(fname: str | Path) -> ConfigTree:
907
+ """Read a YAML file."""
908
+ from csspin.tree import tree_load
909
+
910
+ fname = interpolate1(fname)
911
+ return tree_load(fname)
912
+
913
+
914
+ def download(url: str, location: str | Path) -> None:
915
+ """Download data from ``url`` to ``location``."""
916
+ url, location = interpolate((url, location))
917
+ dirname = os.path.dirname(location)
918
+ mkdir(dirname)
919
+ echo(f"Download {url} -> {location} ...")
920
+
921
+ with urllib.request.urlopen(url) as response:
922
+ data = response.read()
923
+ writebytes(location, data)
924
+
925
+
926
+ # This is the global configuration tree.
927
+ CONFIG = config()
928
+
929
+
930
+ def get_tree() -> ConfigTree:
931
+ """Return the global configuration tree."""
932
+ return CONFIG
933
+
934
+
935
+ def set_tree(cfg: ConfigTree) -> ConfigTree:
936
+ # Intentionally undocumented
937
+ global CONFIG # pylint: disable=global-statement
938
+ CONFIG = cfg
939
+ return cfg
940
+
941
+
942
+ def argument(**kwargs: Any) -> Callable:
943
+ """Annotations task arguments.
944
+
945
+ This works just like :py:func:`click.argument`, accepting all the
946
+ same parameters. Example:
947
+
948
+ .. code-block:: python
949
+ :linenos:
950
+
951
+ @task()
952
+ def mytask(outfile: argument(type="...", help="...")):
953
+ foo("do something")
954
+
955
+ """
956
+
957
+ def wrapper(param_name: str) -> Callable:
958
+ return click.argument(param_name, **kwargs)
959
+
960
+ return wrapper
961
+
962
+
963
+ def option(*args: Any, **kwargs: Any) -> Callable:
964
+ """Annotations for task options.
965
+
966
+ This works just like :py:func:`click.option`, accepting the same
967
+ parameters. Example:
968
+
969
+ .. code-block:: python
970
+ :linenos:
971
+
972
+ @task()
973
+ def mytask(
974
+ outfile: option(
975
+ "-o",
976
+ "outfile",
977
+ default="-",
978
+ type=click.File("w"),
979
+ help="... usage information ...",
980
+ ),
981
+ ):
982
+ foo("do something")
983
+
984
+ """
985
+
986
+ def wrapper(param_name: str) -> Callable:
987
+ return click.option(*args, **kwargs)
988
+
989
+ return wrapper
990
+
991
+
992
+ def task(*args: Any, **kwargs: Any) -> Callable:
993
+ """Decorator that creates a task. This is a wrapper around Click's
994
+ :py:func:`click.command` decorator, with some extras:
995
+
996
+ * a string keyword argument ``when`` adds the task to the list of
997
+ commands to run using :py:func:`invoke`
998
+
999
+ * `aliases` is a list of aliases for the command (e.g. "tests" is
1000
+ an alias for "test")
1001
+
1002
+ * ``noenv=True`` registers the command as a global command, that
1003
+ can run without a provisioned environment
1004
+
1005
+ `task` introspects the signature of the decorated function and
1006
+ handles certain argument names automatically:
1007
+
1008
+ * ``ctx`` will pass the :py:class:`Click context object
1009
+ <click.Context>` into the task; this is rarely useful for spin
1010
+ tasks
1011
+
1012
+ * ``cfg`` will automatically pass the configuration tree; this is
1013
+ very useful most of the time, except for the simplest of tasks
1014
+
1015
+ * ``args`` will simply pass through all command line arguments
1016
+ by using the ``ignore_unknown_options`` and
1017
+ ``allow_extra_args`` options of the Click context; this is
1018
+ often used for tasks that launch a specific command line tool
1019
+ to enable arbitrary arguments
1020
+
1021
+
1022
+ All other arguments to the task must be annotated with either
1023
+ :py:func:`option` or :py:func:`argument`. They both support the
1024
+ same arguments as the corresponding decorators
1025
+ :py:func:`click.option` and :py:func:`click.argument`.
1026
+
1027
+ A simple example:
1028
+
1029
+ .. code-block:: python
1030
+ :linenos:
1031
+
1032
+ @task()
1033
+ def simple_task(cfg, args):
1034
+ foo("do something")
1035
+
1036
+ This would make ``simple_task`` available as a new subcommand of
1037
+ spin.
1038
+
1039
+ More elaborate examples can be found in the built-in plugins
1040
+ shipping with spin.
1041
+
1042
+ """
1043
+
1044
+ # Import cli here, to avoid an import cycle
1045
+ from csspin import cli # pylint: disable=cyclic-import
1046
+
1047
+ def task_wrapper(
1048
+ fn: Callable,
1049
+ group: GroupWithAliases = cli.commands, # type: ignore[assignment]
1050
+ ) -> Callable:
1051
+ task_object = fn
1052
+ pass_context = False
1053
+ context_settings = config()
1054
+ sig = inspect.signature(fn)
1055
+ param_names = list(sig.parameters.keys())
1056
+ if param_names and "ctx" in param_names:
1057
+ pass_context = True
1058
+ task_object = click.pass_context(fn)
1059
+ param_names.pop(param_names.index("ctx"))
1060
+ pass_config = False
1061
+ for pn in param_names:
1062
+ if pn == "cfg":
1063
+ pass_config = True
1064
+ continue
1065
+ if pn == "args":
1066
+ context_settings.ignore_unknown_options = True
1067
+ context_settings.allow_extra_args = True
1068
+ task_object = click.argument("args", nargs=-1)(task_object)
1069
+ continue
1070
+ param = sig.parameters[pn]
1071
+ task_object = param.annotation(pn)(task_object)
1072
+ hook = kwargs.pop("when", None)
1073
+ aliases = kwargs.pop("aliases", [])
1074
+ noenv = kwargs.pop("noenv", False)
1075
+ group = kwargs.pop("group", group)
1076
+ task_object = group.command(*args, **kwargs, context_settings=context_settings)(
1077
+ task_object
1078
+ )
1079
+ if noenv:
1080
+ cli.register_noenv(task_object.name)
1081
+ if group != cli.commands: # pylint: disable=comparison-with-callable
1082
+ task_object.full_name = " ".join((group.name, task_object.name)) # type: ignore[attr-defined]
1083
+ else:
1084
+ task_object.full_name = task_object.name # type: ignore[attr-defined]
1085
+ if hook:
1086
+ cfg = get_tree()
1087
+ hook_tree = cfg.spin.get("hooks", config())
1088
+ hooks = hook_tree.setdefault(hook, [])
1089
+ hooks.append(task_object)
1090
+ for alias in aliases:
1091
+ group.register_alias(alias, task_object)
1092
+
1093
+ def regular_callback(*args: Any, **kwargs: Any) -> Any:
1094
+ ensure(task_object) # type: ignore[arg-type]
1095
+ return fn(*args, **kwargs)
1096
+
1097
+ def alternate_callback(*args: Any, **kwargs: Any) -> Any:
1098
+ ensure(task_object) # type: ignore[arg-type]
1099
+ return fn(get_tree(), *args, **kwargs)
1100
+
1101
+ if pass_config and pass_context:
1102
+ task_object.callback = click.pass_context(alternate_callback)
1103
+ elif pass_config:
1104
+ task_object.callback = alternate_callback
1105
+ elif pass_context:
1106
+ task_object.callback = click.pass_context(regular_callback)
1107
+ else:
1108
+ task_object.callback = regular_callback
1109
+ task_object.__doc__ = fn.__doc__
1110
+ return task_object
1111
+
1112
+ return task_wrapper
1113
+
1114
+
1115
+ def group(*args: Any, **kwargs: Any) -> Callable:
1116
+ """Decorator for task groups, to create nested commands.
1117
+
1118
+ This works like :py:class:`click.Group`, but additionally supports
1119
+ subcommand aliases, that can be set via the `aliases` keyword
1120
+ argument to :py:func:`task`. Example:
1121
+
1122
+ .. code-block:: python
1123
+
1124
+ @group()
1125
+ def foo():
1126
+ pass
1127
+
1128
+
1129
+ @foo.task()
1130
+ def bar():
1131
+ pass
1132
+
1133
+ The above example creates a ``spin foo bar`` command.
1134
+
1135
+ """
1136
+ from csspin import cli
1137
+
1138
+ def group_decorator(fn: str | Path) -> Callable:
1139
+
1140
+ noenv = kwargs.pop("noenv", False)
1141
+ kwargs["cls"] = cli.GroupWithAliases
1142
+ grp = cli.commands.group(*args, **kwargs)(click.pass_context(fn)) # type: ignore[attr-defined]
1143
+ if noenv:
1144
+ cli.register_noenv(grp.name)
1145
+
1146
+ def subtask(*args: Any, **kwargs: Any) -> Callable:
1147
+ def task_decorator(fn: str | Path) -> click.Command:
1148
+ cmd = task(*args, **kwargs, group=grp)(fn)
1149
+ return cmd # type: ignore[no-any-return]
1150
+
1151
+ return task_decorator
1152
+
1153
+ grp.task = subtask
1154
+ return grp # type: ignore[no-any-return]
1155
+
1156
+ return group_decorator
1157
+
1158
+
1159
+ def getmtime(fn: str | Path) -> float:
1160
+ """Get the modification of file `fn`.
1161
+
1162
+ `fn` is interpolated against the configuration tree.
1163
+
1164
+ """
1165
+ return os.path.getmtime(interpolate1(fn))
1166
+
1167
+
1168
+ def is_up_to_date(target: str | Path, sources: Iterable[str | Path]) -> bool:
1169
+ """Check whether `target` exists and is newer than all of the
1170
+ `sources`.
1171
+
1172
+ """
1173
+ if not exists(target):
1174
+ return False
1175
+ if not isinstance(sources, Iterable):
1176
+ die( # type: ignore[unreachable]
1177
+ f"Can't check if {target} is up to date, since 'sources' is not iterable."
1178
+ )
1179
+ target_mtime = getmtime(target)
1180
+ source_mtimes = [getmtime(src) for src in sources] + [0.0]
1181
+ return target_mtime >= max(source_mtimes)
1182
+
1183
+
1184
+ def run_script(script: str | list, env: dict | None = None) -> None:
1185
+ """Run a list of shell commands."""
1186
+ if isinstance(script, str) or not isinstance(script, Iterable):
1187
+ script = [str(script)]
1188
+ for line in script:
1189
+ sh(line, shell=True, env=env)
1190
+
1191
+
1192
+ def run_spin(script: str | list) -> None:
1193
+ """Run a list of spin commands."""
1194
+ from csspin.cli import commands
1195
+
1196
+ if isinstance(script, str) or not isinstance(script, Iterable):
1197
+ script = [str(script)]
1198
+
1199
+ for line in script:
1200
+ line = shlex.split(line.replace("\\", "\\\\"))
1201
+ try:
1202
+ echo("spin", " ".join(line), resolve=True)
1203
+ commands(line)
1204
+ except SystemExit as exc:
1205
+ if exc.code: # pylint: disable=using-constant-test
1206
+ raise
1207
+
1208
+
1209
+ def get_sources(tree: ConfigTree) -> list:
1210
+ sources = tree.get("sources", [])
1211
+ if not isinstance(sources, list):
1212
+ sources = [sources]
1213
+ return sources # type: ignore[no-any-return]
1214
+
1215
+
1216
+ def build_target(cfg: ConfigTree, target: str, phony: bool = False) -> None:
1217
+ info(f"target '{target}'{' (phony)' if phony else ''}")
1218
+ if (target_def := cfg.build_rules.get(target, None)) is None:
1219
+ if not exists(target) and not phony:
1220
+ die(
1221
+ f"Sorry, I don't know how to produce '{target}'. You may want to"
1222
+ " add a rule to your spinfile.yaml in the 'build_rules'"
1223
+ " section."
1224
+ )
1225
+ return
1226
+
1227
+ sources = get_sources(target_def)
1228
+ # First, build preconditions
1229
+ if sources:
1230
+ for source in sources:
1231
+ build_target(cfg, source, False)
1232
+ if not phony:
1233
+ if not is_up_to_date(target, sources):
1234
+ info(f"build '{target}'")
1235
+ script = target_def.get("script", [])
1236
+ spinscript = target_def.get("spin", [])
1237
+ run_script(script)
1238
+ run_spin(spinscript)
1239
+ else:
1240
+ info(f"{target} is up to date")
1241
+
1242
+
1243
+ def ensure(command: click.Command) -> None:
1244
+ # Check 'command_name' for dependencies declared under
1245
+ # 'build_rules', and make sure to produce it. This is used
1246
+ # internally and intentionally undocumented.
1247
+ debug(f"checking preconditions for {command}")
1248
+ cfg = get_tree()
1249
+ build_target(cfg, f"task {command.full_name}", phony=True) # type: ignore[attr-defined]
1250
+
1251
+
1252
+ def invoke(hook: str, *args: Any, **kwargs: Any) -> None:
1253
+ '''``invoke()`` invokes the tasks that have the ``when`` hook
1254
+ `hook`. As an example, here is the implementation of **test**:
1255
+
1256
+ .. code-block:: python
1257
+
1258
+ @task(aliases=["tests"])
1259
+ def test(cfg, coverage: option("--coverage", "coverage", is_flag=True)):
1260
+ """Run all tests defined in this project"""
1261
+ invoke("test", coverage=coverage)
1262
+
1263
+ The way a task that uses `invoke` is invoking other tasks is part of the
1264
+ call interface contract: *all* tasks initialized like ``@task(when="test")``
1265
+ *must* support the ``coverage`` argument as part of their Python function
1266
+ signature (albeit not necessarily the same command line flag
1267
+ ``--coverage``).
1268
+ '''
1269
+ ctx = click.get_current_context()
1270
+ cfg = get_tree()
1271
+
1272
+ if not (hooks := cfg.spin.hooks.setdefault(hook, [])):
1273
+ warn(f"No tasks found for hook '{hook}'")
1274
+ return
1275
+ n_hooks = len(hooks)
1276
+
1277
+ info(
1278
+ f"{hook} hook will invoke the following tasks: "
1279
+ + ", ".join([f"'{h.full_name}'" for h in hooks])
1280
+ )
1281
+
1282
+ for i, task_object in enumerate(hooks):
1283
+ prefix = f"{hook} ({i + 1}/{n_hooks}) -"
1284
+
1285
+ echo(f"{prefix} calling '{task_object.full_name}'")
1286
+ # Filter kwargs so that plugins don't need to provide
1287
+ # options, just for being able to get called by a workflow.
1288
+ task_opts = [
1289
+ param.name
1290
+ for param in task_object.params
1291
+ if isinstance(param, click.Option)
1292
+ ]
1293
+ pass_opts = {k: v for k, v in kwargs.items() if k in task_opts}
1294
+
1295
+ ctx.invoke(task_object, *args, **pass_opts)
1296
+ info(f"{prefix} '{task_object.full_name}' done")
1297
+
1298
+
1299
+ def toporun(cfg: ConfigTree, *fn_names: Any, reverse: bool = False) -> None:
1300
+ """Run plugin functions named in 'fn_names' in topological order."""
1301
+ plugins = cfg.spin.topo_plugins
1302
+ if reverse:
1303
+ plugins = reversed(plugins)
1304
+ for func_name in fn_names:
1305
+ debug(f"toporun: {func_name}")
1306
+ for pi_name in plugins:
1307
+ if pi_name == "csspin.builtin" and func_name in ("cleanup", "provision"):
1308
+ # Don't run the hook in spin.builtin, it's a task there and not
1309
+ # considered a plugin's hook.
1310
+ continue
1311
+ pi_mod = cfg.loaded[pi_name]
1312
+ initf = getattr(pi_mod, func_name, None)
1313
+ if initf:
1314
+ debug(f" {pi_name}.{func_name}()")
1315
+ initf(cfg)
1316
+
1317
+
1318
+ def main(*args: Any, **kwargs: Any) -> None:
1319
+ from csspin.cli import cli
1320
+
1321
+ if not args:
1322
+ args = None # type: ignore[assignment]
1323
+ cli.main(args, **kwargs) # type: ignore[arg-type]
1324
+
1325
+
1326
+ def _main(*args: Any, **kwargs: Any) -> None:
1327
+ return main(*args, standalone_mode=True, **kwargs)
1328
+
1329
+
1330
+ def parse_version(verstr: str) -> packaging.version.Version:
1331
+ """Parse a version string."""
1332
+ return packaging.version.parse(verstr)
1333
+
1334
+
1335
+ def get_requires(tree: ConfigTree, keyname: str) -> ConfigTree | list:
1336
+ """Access the 'requires.<keyname>' property in a subtree. Return [] if
1337
+ not there.
1338
+ """
1339
+ requires = tree.get("requires", config())
1340
+ return requires.get(keyname, []) # type: ignore[no-any-return]