csspin-python 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.
@@ -0,0 +1,970 @@
1
+ # -*- mode: python; coding: utf-8 -*-
2
+ #
3
+ # Copyright (C) 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-few-public-methods,missing-class-docstring
19
+
20
+ """``python``
21
+ ==========
22
+
23
+ This plugin provisions the requested version of the Python
24
+ programming languages.
25
+
26
+ On Linux and macOS, Python is installed by compiling from source
27
+ (implying, that Python's build requirements must be installed). On
28
+ Windows, pre-built binaries are downloaded using `nuget`.
29
+
30
+ If a user has `pyenv <https://github.com/pyenv/pyenv>`_ installed it
31
+ can be activated by setting ``python.user_pyenv`` in
32
+ :file:`global.yaml`.
33
+
34
+ To skip provisioning of Python and use an already installed version,
35
+ :py:data:`python.use` can be set to the name or the full path of an
36
+ interpreter:
37
+
38
+ .. code-block:: console
39
+
40
+ spin -p python.use=/usr/local/bin/python ...
41
+
42
+ Note: `spin` will install or update certain packages of that
43
+ interpreter, thus write access is required.
44
+
45
+ Tasks
46
+ -----
47
+
48
+ .. click:: csspin_python:python
49
+ :prog: spin python
50
+
51
+ .. click:: csspin_python:python:wheel
52
+ :prog: spin python:wheel
53
+
54
+ .. click:: csspin_python:env
55
+ :prog: spin env
56
+
57
+ Properties
58
+ ----------
59
+
60
+ * :py:data:`python.version` -- must be set to choose the
61
+ required Python version
62
+ * :py:data:`python.interpreter` -- path to the Python interpreter
63
+
64
+ Note: don't use these properties when using `virtualenv`, they will
65
+ point to the base installation.
66
+
67
+ """
68
+
69
+ import abc
70
+ import configparser
71
+ import logging
72
+ import os
73
+ import re
74
+ import shutil
75
+ import sys
76
+ from subprocess import check_output
77
+ from textwrap import dedent, indent
78
+
79
+ from click.exceptions import Abort
80
+ from csspin import (
81
+ EXPORTS,
82
+ Command,
83
+ Memoizer,
84
+ Path,
85
+ Verbosity,
86
+ argument,
87
+ backtick,
88
+ cd,
89
+ config,
90
+ die,
91
+ download,
92
+ echo,
93
+ error,
94
+ exists,
95
+ get_requires,
96
+ info,
97
+ interpolate1,
98
+ memoizer,
99
+ mkdir,
100
+ namespaces,
101
+ normpath,
102
+ readtext,
103
+ rmtree,
104
+ setenv,
105
+ sh,
106
+ task,
107
+ warn,
108
+ writetext,
109
+ )
110
+ from csspin.tree import ConfigTree
111
+
112
+ defaults = config(
113
+ build_wheels=["{spin.project_root}"],
114
+ pyenv=config(
115
+ url="https://github.com/pyenv/pyenv.git",
116
+ path="{spin.data}/pyenv",
117
+ cache="{spin.data}/pyenv_cache",
118
+ python_build="{python.pyenv.path}/plugins/python-build/bin/python-build",
119
+ ),
120
+ user_pyenv=False,
121
+ nuget=config(
122
+ url="https://dist.nuget.org/win-x86-commandline/latest/nuget.exe",
123
+ exe="{spin.data}/nuget.exe",
124
+ source="https://api.nuget.org/v3/index.json",
125
+ ),
126
+ version=None,
127
+ use=None,
128
+ inst_dir=(
129
+ "{spin.data}/python/{python.version}"
130
+ if sys.platform != "win32"
131
+ else "{spin.data}/python/python.{python.version}/tools"
132
+ ),
133
+ interpreter=(
134
+ "{python.inst_dir}/bin/python{platform.exe}"
135
+ if sys.platform != "win32"
136
+ else "{python.inst_dir}/python{platform.exe}"
137
+ ),
138
+ venv="{spin.spin_dir}/venv",
139
+ memo="{python.venv}/spininfo.memo",
140
+ bindir="{python.venv}/bin" if sys.platform != "win32" else "{python.venv}",
141
+ scriptdir=(
142
+ "{python.venv}/bin" if sys.platform != "win32" else "{python.venv}/Scripts"
143
+ ),
144
+ python="{python.scriptdir}/python{platform.exe}",
145
+ provisioner=None,
146
+ provisioner_memo="{spin.spin_dir}/python_provisioner.memo",
147
+ current_package=config(
148
+ install=True,
149
+ extras=[],
150
+ ),
151
+ index_url="https://pypi.org/simple",
152
+ requires=config(
153
+ python=["build", "wheel"],
154
+ system=config(
155
+ debian=config(
156
+ apt=[
157
+ "build-essential",
158
+ "curl",
159
+ "git",
160
+ "libbz2-dev",
161
+ "libffi-dev",
162
+ "libkrb5-dev",
163
+ "liblzma-dev",
164
+ "libncursesw5-dev",
165
+ "libreadline-dev",
166
+ "libsqlite3-dev",
167
+ "libssl-dev",
168
+ "libxml2-dev",
169
+ "libxmlsec1-dev",
170
+ "make",
171
+ "xz-utils",
172
+ "zlib1g-dev",
173
+ ]
174
+ )
175
+ ),
176
+ ),
177
+ )
178
+
179
+
180
+ @task()
181
+ def python(args):
182
+ """Run the Python interpreter used for this projects."""
183
+ sh("python", *args)
184
+
185
+
186
+ @task("python:wheel", when="package")
187
+ def wheel(
188
+ cfg,
189
+ paths: argument(type=str, nargs=-1, required=False), # noqa: F722
190
+ ):
191
+ """Build a wheel of the current project and any additional wheels."""
192
+ setenv(PIP_INDEX_URL=cfg.python.index_url)
193
+ search_paths = paths or cfg.python.build_wheels
194
+ for build_path in {Path(path).absolute() for path in search_paths}:
195
+ try:
196
+ echo("Building PEP 517-like wheel")
197
+ sh(
198
+ "python",
199
+ "-m",
200
+ "build",
201
+ "-w",
202
+ build_path,
203
+ "-o",
204
+ "{spin.project_root}/dist",
205
+ )
206
+ except Abort:
207
+ echo("Building does not seem to work, use legacy setup.py style")
208
+ with cd(build_path):
209
+ sh(
210
+ "python",
211
+ "setup.py",
212
+ None if cfg.verbosity > Verbosity.NORMAL else "-v" "build",
213
+ "-b",
214
+ "{spin.project_root}/build",
215
+ "bdist_wheel",
216
+ "-d",
217
+ "{spin.project_root}/dist",
218
+ )
219
+
220
+
221
+ @task()
222
+ def env():
223
+ """Generate command to activate the virtual environment"""
224
+ if sys.platform == "win32":
225
+ # Don't care about cmd
226
+ print(normpath("{python.scriptdir}", "activate.ps1"))
227
+ else:
228
+ print(f". {normpath('{python.scriptdir}', 'activate')}")
229
+
230
+
231
+ def pyenv_install(cfg):
232
+ """Install and setup the virtual environment using pyenv"""
233
+ with namespaces(cfg.python):
234
+ if cfg.python.user_pyenv:
235
+ info("Using your existing pyenv installation ...")
236
+ sh(f"pyenv install --skip-existing {cfg.python.version}")
237
+ cfg.python.interpreter = backtick("pyenv which python --nosystem").strip()
238
+ else:
239
+ info("Installing Python {version} to {inst_dir}")
240
+ # For Linux/macOS using the 'python-build' plugin from
241
+ # pyenv is by far the most robust way to install a
242
+ # version of Python.
243
+ if not exists("{pyenv.path}"):
244
+ sh(f"git clone {cfg.python.pyenv.url} {cfg.python.pyenv.path}")
245
+ else:
246
+ with cd(cfg.python.pyenv.path):
247
+ sh("git pull")
248
+ # we should set
249
+ setenv(PYTHON_BUILD_CACHE_PATH=mkdir(cfg.python.pyenv.cache))
250
+ setenv(PYTHON_CFLAGS="-DOPENSSL_NO_COMP")
251
+ try:
252
+ sh(
253
+ f"{cfg.python.pyenv.python_build} {cfg.python.version} {cfg.python.inst_dir}"
254
+ )
255
+ except Abort:
256
+ error("Failed to build the Python interpreter - removing it")
257
+ rmtree(cfg.python.inst_dir)
258
+ raise
259
+
260
+
261
+ def nuget_install(cfg):
262
+ """Install the virtual environment using nuget"""
263
+ if not exists(cfg.python.nuget.exe):
264
+ download(cfg.python.nuget.url, cfg.python.nuget.exe)
265
+ setenv(NUGET_HTTP_CACHE_PATH=cfg.spin.data / "nugetcache")
266
+ sh(
267
+ cfg.python.nuget.exe,
268
+ "install",
269
+ "-verbosity",
270
+ "quiet",
271
+ "-o",
272
+ cfg.spin.data / "python",
273
+ "python",
274
+ "-version",
275
+ cfg.python.version,
276
+ "-source",
277
+ cfg.python.nuget.source,
278
+ )
279
+ sh(f"{cfg.python.interpreter} -m ensurepip --upgrade")
280
+ sh(
281
+ cfg.python.interpreter,
282
+ "-mpip",
283
+ None if cfg.verbosity > Verbosity.NORMAL else "-q",
284
+ "install",
285
+ "-U",
286
+ "pip",
287
+ "wheel",
288
+ "packaging",
289
+ )
290
+
291
+
292
+ def provision(cfg: ConfigTree) -> None:
293
+ """Provision the python plugin"""
294
+ with memoizer(cfg.python.provisioner_memo) as memo:
295
+ if cfg.python.provisioner is None:
296
+ cfg.python.provisioner = SimpleProvisioner()
297
+ if not memo.check(cfg.python.provisioner):
298
+ memo.add(cfg.python.provisioner)
299
+
300
+ info("Checking {python.interpreter}")
301
+ if not shutil.which(cfg.python.interpreter):
302
+ info("Provisioning '{python.interpreter}'")
303
+ cfg.python.provisioner.provision_python(cfg)
304
+
305
+ venv_provision(cfg)
306
+
307
+ cfg.python.site_packages = get_site_packages(interpreter=cfg.python.python)
308
+
309
+
310
+ def configure(cfg):
311
+ """Configure the python plugin"""
312
+ if not cfg.python.version and not cfg.python.use:
313
+ die(
314
+ "Please choose a version in spinfile.yaml by setting python.version"
315
+ " or pass a local interpreter via python.use."
316
+ )
317
+
318
+ if cfg.python.use:
319
+ if cfg.python.version:
320
+ warn("python.version will be ignored, using '{python.use}' instead.")
321
+ cfg.python.interpreter = cfg.python.use
322
+
323
+ elif cfg.python.user_pyenv:
324
+ setenv(PYENV_VERSION="{python.version}")
325
+ try:
326
+ cfg.python.interpreter = backtick(
327
+ "pyenv which python --nosystem",
328
+ check=False,
329
+ silent=not cfg.verbosity > Verbosity.NORMAL,
330
+ ).strip()
331
+ except Exception: # pylint: disable=broad-exception-caught # nosec
332
+ warn(
333
+ "The desired interpreter is not available within the"
334
+ " user's pyenv installation."
335
+ )
336
+
337
+ if exists(cfg.python.python):
338
+ cfg.python.site_packages = get_site_packages(interpreter=cfg.python.python)
339
+
340
+
341
+ def init(cfg):
342
+ """Initialize the python plugin"""
343
+ if not cfg.python.use:
344
+ logging.debug("Checking for %s", cfg.python.interpreter)
345
+ if not exists(cfg.python.interpreter):
346
+ die(
347
+ f"Python {cfg.python.version} has not been provisioned for this"
348
+ " project. You might want to run spin with the 'provision'"
349
+ " task."
350
+ )
351
+ venv_init(cfg)
352
+
353
+
354
+ # We won't activate more than once.
355
+ ACTIVATED = False
356
+
357
+
358
+ def venv_init(cfg):
359
+ """Activate the virtual environment"""
360
+ global ACTIVATED # pylint: disable=global-statement
361
+ if os.environ.get("VIRTUAL_ENV", "") != cfg.python.venv and not ACTIVATED:
362
+ activate_this = cfg.python.scriptdir / "activate_this.py"
363
+ if not exists(activate_this):
364
+ die(
365
+ f"{cfg.python.venv} does not exist. You may want to provision"
366
+ " it using 'spin provision'"
367
+ )
368
+ if sys.platform == "win32":
369
+ echo(f"{cfg.python.scriptdir}\\activate.ps1")
370
+ else:
371
+ echo(f". {cfg.python.scriptdir}/activate")
372
+ with open(activate_this, encoding="utf-8") as file:
373
+ exec( # pylint: disable=exec-used # nosec
374
+ file.read(), {"__file__": activate_this}
375
+ )
376
+ ACTIVATED = True
377
+
378
+
379
+ def patch_activate(schema):
380
+ """Patch the activate script"""
381
+ if exists(schema.activatescript):
382
+ setters = []
383
+ resetters = set()
384
+ old_value_setters = set()
385
+ for name, value in EXPORTS:
386
+ value = schema.interpolate_environ_value(value)
387
+ setters.append(schema.setpattern.format(name=name, value=value))
388
+ resetters.add(schema.resetpattern.format(name=name))
389
+ old_value_setters.add(schema.old_env_pattern.format(name=name))
390
+ resetters = "\n".join(resetters)
391
+ setters = "\n".join(setters)
392
+ old_value_setters = "\n".join(old_value_setters)
393
+ original = readtext(schema.activatescript)
394
+ if schema.patchmarker not in original:
395
+ shutil.copyfile(
396
+ interpolate1(f"{schema.activatescript}"),
397
+ interpolate1(f"{schema.activatescript}.bak"),
398
+ )
399
+ info(f"Patching {schema.activatescript}")
400
+ # Removing the byte order marker (BOM) ensures the absence of those in
401
+ # the final scripts. BOMs in executables are not fully supported in
402
+ # Powershell.
403
+ original = (
404
+ readtext(f"{schema.activatescript}.bak").encode("utf-8").decode("utf-8-sig")
405
+ )
406
+ for repl in schema.replacements:
407
+ original = original.replace(repl[0], repl[1])
408
+ newscript = schema.script.format(
409
+ patchmarker=schema.patchmarker,
410
+ original=original,
411
+ resetters=resetters,
412
+ old_value_setters=old_value_setters,
413
+ setters=setters,
414
+ )
415
+ writetext(f"{schema.activatescript}", newscript)
416
+
417
+
418
+ class ActivateScriptPatcher(abc.ABC):
419
+ @staticmethod
420
+ @abc.abstractmethod
421
+ def interpolate_environ_value(value):
422
+ """
423
+ Translate value so the script can handle uninterpolated "{ENVVAR}" literals in value
424
+
425
+ Example:
426
+ # Assume the following subset of os.environ
427
+ os.environ = {
428
+ "PATH": "/bin:/usr/bin",
429
+ "COMPILER_PATHS": "/compiler/A/bin:/compiler/B/bin",
430
+ }
431
+
432
+ # Now, setenv has been called with
433
+ # setenv(PATH="{python.scriptdir}:{COMPILER_PATHS}:{PATH}") thus the
434
+ # value of ``PATH`` in ``EXPORTS`` equals "/venv/bin:{COMPILER_PATHS}:{PATH}" as
435
+ # ``COMPILER_PATHS`` and ``PATH`` haven't been interpolated yet.
436
+ interpolate_environ_value(value) => /venv/bin:/compiler/A/bin:/compiler/B/bin:/bin:/usr/bin
437
+ """
438
+ return value
439
+
440
+
441
+ class BashActivate(ActivateScriptPatcher):
442
+ patchmarker = "\n## Patched by csspin_python.python\n"
443
+ activatescript = Path("{python.scriptdir}") / "activate"
444
+ replacements = [
445
+ ("deactivate", "origdeactivate"),
446
+ ]
447
+ old_env_pattern = dedent(
448
+ """
449
+ if [ -z ${{{name}+x}} ]; then
450
+ export _OLD_SPIN_UNSET{name}=""
451
+ else
452
+ export _OLD_SPIN_VALUE{name}="${name}"
453
+ fi
454
+ """
455
+ )
456
+ setpattern = dedent(
457
+ """
458
+ {name}="{value}"
459
+ export {name}
460
+ """
461
+ )
462
+ resetpattern = indent(
463
+ dedent(
464
+ """
465
+ if ! [ -z "${{_OLD_SPIN_VALUE{name}+_}}" ] ; then
466
+ {name}="$_OLD_SPIN_VALUE{name}"
467
+ export {name}
468
+ unset _OLD_SPIN_VALUE{name}
469
+ fi
470
+ if ! [ -z "${{_OLD_SPIN_UNSET{name}+_}}" ] ; then
471
+ unset {name}
472
+ unset _OLD_SPIN_UNSET{name}
473
+ fi
474
+ """
475
+ ),
476
+ prefix=" ",
477
+ )
478
+ script = dedent(
479
+ """
480
+ {patchmarker}
481
+ {original}
482
+ deactivate () {{
483
+ {resetters}
484
+ if [ ! "${{1-}}" = "nondestructive" ] ; then
485
+ # Self destruct!
486
+ unset -f deactivate
487
+ origdeactivate
488
+ fi
489
+ }}
490
+
491
+ deactivate nondestructive
492
+ {old_value_setters}
493
+ {setters}
494
+
495
+ # The hash command must be called to get it to forget past
496
+ # commands. Without forgetting past commands the $PATH changes
497
+ # we made may not be respected
498
+ hash -r 2>/dev/null
499
+ """
500
+ )
501
+
502
+ @staticmethod
503
+ def interpolate_environ_value(value):
504
+ if not value:
505
+ return ""
506
+ keys = re.findall(r"{(?P<key>\w+?)}", value)
507
+ for key in keys:
508
+ if key in os.environ:
509
+ value = value.replace(f"{{{key}}}", f"${key}")
510
+ return value
511
+
512
+
513
+ class PowershellActivate(ActivateScriptPatcher):
514
+ patchmarker = "\n## Patched by csspin_python.python\n"
515
+ activatescript = Path("{python.scriptdir}") / "activate.ps1"
516
+ replacements = [
517
+ ("deactivate", "origdeactivate"),
518
+ ]
519
+ old_env_pattern = (
520
+ "New-Variable -Scope global -Name _OLD_SPIN_{name} -Value $env:{name}"
521
+ )
522
+ setpattern = dedent(
523
+ """
524
+ $env:{name} = "{value}"
525
+ """
526
+ )
527
+ resetpattern = indent(
528
+ dedent(
529
+ """
530
+ if (Test-Path variable:_OLD_SPIN_{name}) {{
531
+ $env:{name} = $variable:_OLD_SPIN_{name}
532
+ Remove-Variable "_OLD_SPIN_{name}" -Scope global
533
+ }}
534
+ """
535
+ ),
536
+ prefix=" ",
537
+ )
538
+ script = dedent(
539
+ """
540
+ {patchmarker}
541
+ {original}
542
+ function global:deactivate([switch] $NonDestructive) {{
543
+ {resetters}
544
+ if (!$NonDestructive) {{
545
+ Remove-Item function:deactivate
546
+ origdeactivate
547
+ }}
548
+ }}
549
+
550
+ deactivate -nondestructive
551
+ {old_value_setters}
552
+ {setters}
553
+ """
554
+ )
555
+
556
+ @staticmethod
557
+ def interpolate_environ_value(value):
558
+ if not value:
559
+ return ""
560
+ keys = re.findall(r"{(?P<key>\w+?)}", value)
561
+ for key in keys:
562
+ if key in os.environ:
563
+ value = value.replace(f"{{{key}}}", f"$env:{key}")
564
+ return value
565
+
566
+
567
+ class BatchActivate(ActivateScriptPatcher):
568
+ patchmarker = "\nREM Patched by csspin_python.python\n"
569
+ activatescript = Path("{python.scriptdir}") / "activate.bat"
570
+ replacements = ()
571
+ old_env_pattern = dedent(
572
+ """
573
+ if defined _OLD_SPIN_VALUE_{name} goto ENDIFSPIN{name}1
574
+ if defined _OLD_SPIN_UNSET_{name} goto ENDIFSPIN{name}2
575
+ if defined {name} goto ENDIFSPIN{name}3
576
+ goto ENDIFSPIN{name}4
577
+ :ENDIFSPIN{name}1
578
+ set "{name}=%_OLD_SPIN_VALUE_{name}%"
579
+ set "_OLD_SPIN_VALUE_{name}=%{name}%"
580
+ goto ENDIFSPIN{name}5
581
+ :ENDIFSPIN{name}2
582
+ set "{name}="
583
+ set "_OLD_SPIN_UNSET_{name}= "
584
+ goto ENDIFSPIN{name}5
585
+ :ENDIFSPIN{name}3
586
+ set "_OLD_SPIN_VALUE_{name}=%{name}%"
587
+ goto ENDIFSPIN{name}5
588
+ :ENDIFSPIN{name}4
589
+ set "_OLD_SPIN_UNSET_{name}= "
590
+ goto ENDIFSPIN{name}5
591
+ :ENDIFSPIN{name}5
592
+ """
593
+ )
594
+ setpattern = 'set "{name}={value}"'
595
+ resetpattern = ""
596
+ script = dedent(
597
+ """
598
+ @echo off
599
+ {patchmarker}
600
+ {original}
601
+ {old_value_setters}
602
+ {setters}
603
+ """
604
+ )
605
+
606
+ @staticmethod
607
+ def interpolate_environ_value(value):
608
+ if not value:
609
+ return ""
610
+ keys = re.findall(r"{(?P<key>\w+?)}", value)
611
+ for key in keys:
612
+ if key in os.environ:
613
+ value = value.replace(f"{{{key}}}", f"%{key}%")
614
+ return value
615
+
616
+
617
+ class BatchDeactivate(ActivateScriptPatcher):
618
+ patchmarker = "\nREM Patched by csspin_python.python\n"
619
+ activatescript = Path("{python.scriptdir}") / "deactivate.bat"
620
+ replacements = ()
621
+ old_env_pattern = ""
622
+ setpattern = ""
623
+ resetpattern = dedent(
624
+ """
625
+ if defined _OLD_SPIN_VALUE_{name} goto ENDIFVSPIN{name}1
626
+ if defined _OLD_SPIN_UNSET_{name} goto ENDIFVSPIN{name}2
627
+ :ENDIFVSPIN{name}1
628
+ set "{name}=%_OLD_SPIN_VALUE_{name}%"
629
+ set _OLD_SPIN_VALUE_{name}=
630
+ goto ENDIFVSPIN{name}0
631
+ :ENDIFVSPIN{name}2
632
+ set {name}=
633
+ set _OLD_SPIN_UNSET_{name}=
634
+ goto ENDIFVSPIN{name}0
635
+ :ENDIFVSPIN{name}0
636
+ """
637
+ )
638
+ script = dedent(
639
+ """
640
+ @echo off
641
+ {patchmarker}
642
+ {original}
643
+ {resetters}
644
+ """
645
+ )
646
+
647
+
648
+ class PythonActivate(ActivateScriptPatcher):
649
+ patchmarker = "# Patched by csspin_python.python\n"
650
+ activatescript = Path("{python.scriptdir}") / "activate_this.py"
651
+ replacements = ()
652
+ old_env_pattern = ""
653
+ setpattern = 'os.environ["{name}"] = fr"{value}"'
654
+ resetpattern = ""
655
+ script = dedent(
656
+ """
657
+ {patchmarker}
658
+ {original}
659
+ {setters}
660
+ """
661
+ )
662
+
663
+ @staticmethod
664
+ def interpolate_environ_value(value):
665
+ if not value:
666
+ return ""
667
+ keys = re.findall(r"{(?P<key>\w+?)}", value)
668
+ for key in keys:
669
+ if key in os.environ:
670
+ value = value.replace(f"{{{key}}}", f"{{os.environ['{key}']}}")
671
+ return value
672
+
673
+
674
+ def get_site_packages(interpreter):
675
+ """Return the path to the virtual environments site-packages."""
676
+ return Path(
677
+ check_output(
678
+ [
679
+ interpolate1(interpreter),
680
+ "-c",
681
+ 'import sysconfig; print(sysconfig.get_path("purelib"))',
682
+ ],
683
+ )
684
+ .decode()
685
+ .strip(),
686
+ )
687
+
688
+
689
+ def finalize_provision(cfg):
690
+ """Patching the activate scripts and preparing the site-packages"""
691
+ cfg.python.provisioner.install(cfg)
692
+
693
+ for schema in (
694
+ BashActivate,
695
+ BatchActivate,
696
+ BatchDeactivate,
697
+ PowershellActivate,
698
+ PythonActivate,
699
+ ):
700
+ patch_activate(schema)
701
+
702
+ setenv_path = str(cfg.python.site_packages / "_set_env.pth")
703
+ info(f"Create {setenv_path}")
704
+ pthline = interpolate1(
705
+ "import os; "
706
+ "bindir=r'{python.bindir}'; "
707
+ "os.environ['PATH'] = "
708
+ "os.environ['PATH'] if bindir in os.environ['PATH'] "
709
+ "else os.pathsep.join((bindir, os.environ['PATH']))\n"
710
+ )
711
+ writetext(setenv_path, pthline)
712
+
713
+
714
+ class ProvisionerProtocol:
715
+ """An implementation of this protocol is used to provision
716
+ dependencies to a virtual environment.
717
+
718
+ Separate plugins, can implement this interface and overwrite
719
+ cfg.python.provisioner.
720
+
721
+ .. note::
722
+ The provisioner will be memoized, so make sure it works with ``pickle.dumps``.
723
+ """
724
+
725
+ # noinspection PyMethodMayBeStatic
726
+ def provision_python(self, cfg: ConfigTree) -> None:
727
+ """Provision the project's python interpreter"""
728
+ if sys.platform == "win32":
729
+ nuget_install(cfg)
730
+ else:
731
+ # Everything else (Linux and macOS) uses pyenv
732
+ pyenv_install(cfg)
733
+
734
+ # noinspection PyMethodMayBeStatic
735
+ def provision_venv(self, cfg: ConfigTree) -> None:
736
+ """Provision the virtual environment of the project"""
737
+ # virtualenv is guaranteed to be available like this
738
+ # as we declared it as one of spin's dependencies
739
+ cmd = [
740
+ sys.executable,
741
+ "-mvirtualenv",
742
+ None if cfg.verbosity > Verbosity.NORMAL else "-q",
743
+ ]
744
+ virtualenv = Command(*cmd)
745
+ # do not download seeds, since we update pip later anyway
746
+ # add the plugins directory to the PYTHONPATH so that virtualenv will be found
747
+ virtualenv(
748
+ "-p",
749
+ cfg.python.interpreter,
750
+ cfg.python.venv,
751
+ env={"PYTHONPATH": cfg.spin.spin_dir / "plugins"},
752
+ )
753
+
754
+ def prerequisites(self, cfg: ConfigTree) -> None:
755
+ """Provide requirements for the provisioning strategy."""
756
+
757
+ def lock(self, cfg: ConfigTree) -> None:
758
+ """Lock the project's dependencies."""
759
+
760
+ def add(self, cfg: ConfigTree, req: str, devpackage: bool = False) -> None:
761
+ """Add an extra dependency (incl. development ones)."""
762
+
763
+ def lock_extras(self, cfg: ConfigTree) -> None:
764
+ """Lock the extra dependencies."""
765
+
766
+ def sync(self, cfg: ConfigTree) -> None:
767
+ """Synchronize the environment with the locked dependencies."""
768
+
769
+ def install(self, cfg: ConfigTree) -> None:
770
+ """Install the project itself."""
771
+
772
+ # noinspection PyMethodMayBeStatic
773
+ def cleanup(self, cfg: ConfigTree) -> None:
774
+ """Cleanup the provisioned environment"""
775
+ rmtree(cfg.python.venv)
776
+
777
+
778
+ class SimpleProvisioner(ProvisionerProtocol):
779
+ """The simplest Python dependency provisioner using pip.
780
+
781
+ This provisioner will never uninstall dependencies that are no
782
+ longer required.
783
+ """
784
+
785
+ def __init__(self):
786
+ self.requirements = set()
787
+ self.devpackages = set()
788
+ self.m = Memoizer("{python.memo}")
789
+
790
+ def prerequisites(self, cfg):
791
+ # We'll need pip
792
+ sh(
793
+ "python",
794
+ "-mpip",
795
+ None if cfg.verbosity > Verbosity.NORMAL else "-q",
796
+ "--disable-pip-version-check",
797
+ "install",
798
+ "--index-url",
799
+ cfg.python.index_url,
800
+ "-U",
801
+ "pip",
802
+ )
803
+
804
+ def lock(self, cfg):
805
+ """Noop"""
806
+
807
+ def add(self, cfg, req, devpackage=False):
808
+ # Add the requirement or devpackage if not already there.
809
+ if not self.m.check(req):
810
+ lst = self.devpackages if devpackage else self.requirements
811
+ lst.add(req)
812
+
813
+ def sync(self, cfg):
814
+ self.__execute_installation(
815
+ self.requirements,
816
+ None if cfg.verbosity > Verbosity.NORMAL else "-q",
817
+ cfg.python.index_url,
818
+ )
819
+
820
+ def install(self, cfg):
821
+ quietflag = None if cfg.verbosity > Verbosity.NORMAL else "-q"
822
+ self.__execute_installation(self.devpackages, quietflag, cfg.python.index_url)
823
+
824
+ # If there is a setup.py, make an editable install (which
825
+ # transitively also installs runtime dependencies of the project).
826
+ if cfg.python.current_package.install and any(
827
+ (exists("setup.py"), exists("setup.cfg"), exists("pyproject.toml"))
828
+ ):
829
+ cmd = [
830
+ "pip",
831
+ quietflag,
832
+ "--disable-pip-version-check",
833
+ "install",
834
+ "--index-url",
835
+ cfg.python.index_url,
836
+ "-e",
837
+ ]
838
+ if cfg.python.current_package.extras:
839
+ cmd.append(f".[{','.join(cfg.python.current_package.extras)}]")
840
+ else:
841
+ cmd.append(".")
842
+ sh(*cmd)
843
+
844
+ # Verify dependency compatibility of installed packages
845
+ pip_check = sh(
846
+ "pip",
847
+ "--disable-pip-version-check",
848
+ "check",
849
+ check=False,
850
+ capture_output=True,
851
+ )
852
+ if pip_check.returncode:
853
+ die(pip_check.stdout)
854
+
855
+ def _split(self, reqset):
856
+ """to pass whitespace-less args to sh()"""
857
+ reqlist = []
858
+ for req in reqset:
859
+ reqlist.extend(req.split())
860
+ return reqlist
861
+
862
+ def __execute_installation(self, packages, quietflag, index_url):
863
+ """Install packages that are not yet memoized"""
864
+ if to_install := {package for package in packages if not self.m.check(package)}:
865
+ sh(
866
+ "pip",
867
+ quietflag,
868
+ "--disable-pip-version-check",
869
+ "install",
870
+ "--index-url",
871
+ index_url,
872
+ *self._split(to_install),
873
+ )
874
+ for package in to_install:
875
+ self.m.add(package)
876
+ self.m.save()
877
+
878
+
879
+ def venv_provision(cfg): # pylint: disable=too-many-branches,missing-function-docstring
880
+ fresh_env = False
881
+
882
+ info("Checking venv '{python.venv}'")
883
+ if not exists(cfg.python.venv):
884
+ info("Provisioning venv '{python.venv}'")
885
+ cfg.python.provisioner.provision_venv(cfg)
886
+ fresh_env = True
887
+
888
+ # This sets PATH to the venv
889
+ init(cfg)
890
+
891
+ # Always create a pip conf with at least the packageserver index_url as content
892
+ if sys.platform == "win32":
893
+ pipconf = cfg.python.venv / "pip.ini"
894
+ else:
895
+ pipconf = cfg.python.venv / "pip.conf"
896
+
897
+ _create_pipconf(cfg, pipconf)
898
+
899
+ # Establish the prerequisites
900
+ if fresh_env:
901
+ cfg.python.provisioner.prerequisites(cfg)
902
+
903
+ # Plugins can define a 'venv_hook' function, to give them a
904
+ # chance to do something with the virtual environment just
905
+ # being provisioned (e.g. preparing the venv by adding pth
906
+ # files or by adding packages with other installers like
907
+ # easy_install).
908
+ for plugin in cfg.spin.topo_plugins:
909
+ plugin_module = cfg.loaded[plugin]
910
+ hook = getattr(plugin_module, "venv_hook", None)
911
+ if hook is not None:
912
+ logging.debug(f"{plugin_module.__name__}.venv_hook()")
913
+ hook(cfg)
914
+
915
+ cfg.python.provisioner.lock(cfg)
916
+
917
+ # Install packages required by the project ('requirements')
918
+ for req in cfg.python.get("requirements", []):
919
+ cfg.python.provisioner.add(cfg, interpolate1(req))
920
+
921
+ # Install development packages required by the project ('devpackages')
922
+ for pkgspec in cfg.python.get("devpackages", []):
923
+ cfg.python.provisioner.add(cfg, interpolate1(pkgspec), True)
924
+
925
+ # Install packages required by plugins used
926
+ # ('<plugin>.requires.python')
927
+ for plugin in cfg.spin.topo_plugins:
928
+ plugin_module = cfg.loaded[plugin]
929
+ for req in get_requires(plugin_module.defaults, "python"):
930
+ cfg.python.provisioner.add(cfg, interpolate1(req))
931
+
932
+ cfg.python.provisioner.lock_extras(cfg)
933
+ cfg.python.provisioner.sync(cfg)
934
+
935
+
936
+ def cleanup(cfg: ConfigTree) -> None:
937
+ """Remove directories and files generated by the python plugin."""
938
+ with memoizer(cfg.python.provisioner_memo) as memo:
939
+ for provisioner in memo.items():
940
+ try:
941
+ provisioner.cleanup(cfg)
942
+ except Exception as err: # pylint: disable=broad-exception-caught
943
+ warn(
944
+ "Cleaning up the python environment of provisioner class "
945
+ f"'{provisioner.__class__.__name__}' failed: {err}"
946
+ )
947
+ memo.clear()
948
+ rmtree(cfg.python.provisioner_memo)
949
+ for path in cfg.python.build_wheels:
950
+ current_path = Path(interpolate1(path))
951
+ rmtree(current_path / "build")
952
+ rmtree(current_path / "dist")
953
+ for filename in os.listdir(current_path):
954
+ if filename.endswith(".egg-info") or filename.endswith(".dist-info"):
955
+ rmtree(current_path / filename)
956
+
957
+
958
+ def _create_pipconf(cfg, configfile):
959
+ # pip cannot handle a section being defined twice, so let's insert the
960
+ # index_url ourselves and create the configfile
961
+ config_parser = configparser.ConfigParser()
962
+ config_parser.read_string(cfg.python.pipconf)
963
+ if not config_parser.has_section("global"):
964
+ config_parser.add_section("global")
965
+ if not (
966
+ "index_url" in config_parser["global"] or "index-url" in config_parser["global"]
967
+ ):
968
+ config_parser["global"]["index_url"] = cfg.python.index_url
969
+ with open(configfile, mode="w", encoding="utf-8") as fd:
970
+ config_parser.write(fd)