ansys-saf-cli 4.1.dev0__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.
Files changed (143) hide show
  1. ansys/saf/cli/__init__.py +15 -0
  2. ansys/saf/cli/_cli/__init__.py +15 -0
  3. ansys/saf/cli/_cli/main.py +816 -0
  4. ansys/saf/cli/_cli/py.typed +0 -0
  5. ansys/saf/cli/_config/__init__.py +15 -0
  6. ansys/saf/cli/_config/const.py +45 -0
  7. ansys/saf/cli/_config/py.typed +0 -0
  8. ansys/saf/cli/_database/__init__.py +15 -0
  9. ansys/saf/cli/_database/manager.py +94 -0
  10. ansys/saf/cli/_database/models.py +53 -0
  11. ansys/saf/cli/_database/py.typed +0 -0
  12. ansys/saf/cli/_solutions/__init__.py +15 -0
  13. ansys/saf/cli/_solutions/add_step.py +256 -0
  14. ansys/saf/cli/_solutions/dependencies.py +162 -0
  15. ansys/saf/cli/_solutions/environment.py +1145 -0
  16. ansys/saf/cli/_solutions/plugins.py +254 -0
  17. ansys/saf/cli/_solutions/py.typed +0 -0
  18. ansys/saf/cli/_solutions/scaffolding.py +73 -0
  19. ansys/saf/cli/_solutions/templates/__init__.py +15 -0
  20. ansys/saf/cli/_solutions/templates/py.typed +0 -0
  21. ansys/saf/cli/_solutions/templates/solution/cookiecutter.json +18 -0
  22. ansys/saf/cli/_solutions/templates/solution/hooks/post_gen_project.py +54 -0
  23. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/.codespell.ignore +2 -0
  24. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/.devcontainer/devcontainer.json +26 -0
  25. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/.env +5 -0
  26. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/.flake8 +8 -0
  27. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/.github/labeler.yml +9 -0
  28. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/.github/labels.yml +27 -0
  29. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/.github/workflows/build-release.yml +175 -0
  30. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/.github/workflows/label.yml +89 -0
  31. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/.gitignore +137 -0
  32. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/.pre-commit-config.yaml +48 -0
  33. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/.release-please-manifest.json +3 -0
  34. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/.reuse/templates/copyright.jinja2 +1 -0
  35. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/.vscode/extensions.json +8 -0
  36. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/.vscode/launch.json +28 -0
  37. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/AUTHORS +12 -0
  38. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/CHANGELOG.md +1 -0
  39. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/CODEOWNERS +3 -0
  40. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/CODE_OF_CONDUCT.md +65 -0
  41. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/CONTRIBUTING.md +2 -0
  42. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/CONTRIBUTORS.md +9 -0
  43. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/LICENSE.rst +41 -0
  44. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/README.md +69 -0
  45. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/deployments/Dockerfile +43 -0
  46. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/deployments/README.md +13 -0
  47. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/deployments/distributed-deployment-template/.env +27 -0
  48. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/deployments/distributed-deployment-template/compose.yaml +132 -0
  49. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/deployments/standalone/.env +20 -0
  50. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/deployments/standalone/compose.yaml +118 -0
  51. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/deployments/standalone-with-hps/.env +28 -0
  52. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/deployments/standalone-with-hps/compose.yaml +146 -0
  53. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/deployments/standalone-with-minerva/.env +25 -0
  54. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/deployments/standalone-with-minerva/compose.yaml +122 -0
  55. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/doc/.vale.ini +28 -0
  56. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/doc/Makefile +30 -0
  57. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/doc/changelog.d/changelog_template.jinja +17 -0
  58. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/doc/make.bat +46 -0
  59. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/doc/source/_static/css/custom.css +8 -0
  60. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/doc/source/_static/images/repository-banner.png +0 -0
  61. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/doc/source/_templates/README.md +1 -0
  62. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/doc/source/changelog.rst +12 -0
  63. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/doc/source/conf.py +253 -0
  64. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/doc/source/examples.rst +8 -0
  65. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/doc/source/getting_started/desktop_installation.rst +165 -0
  66. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/doc/source/getting_started/docker_installation.rst +68 -0
  67. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/doc/source/index.rst +51 -0
  68. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/doc/source/user_guide/index.rst +6 -0
  69. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/doc/styles/config/vocabularies/ANSYS/accept.txt +5 -0
  70. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/doc/styles/config/vocabularies/ANSYS/reject.txt +0 -0
  71. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/examples/README.md +1 -0
  72. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/lock_files/dash/poetry.lock +7621 -0
  73. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/lock_files/no_ui/poetry.lock +7173 -0
  74. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/minerva/cli/README.md +3 -0
  75. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/minerva/oauth/App_Data/Certificates/README.md +3 -0
  76. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/minerva/oauth/oauth.config +9 -0
  77. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/pyproject.toml +192 -0
  78. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/release-please-config.json +9 -0
  79. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/sonar-project.properties +12 -0
  80. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/__init__.py +3 -0
  81. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/main.py +19 -0
  82. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/portal_assets/application.svg +36 -0
  83. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/portal_assets/description.json +3 -0
  84. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/portal_assets/project.svg +1 -0
  85. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/solution/definition.py +24 -0
  86. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/solution/first_step.py +34 -0
  87. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/solution/method_assets/README.md +1 -0
  88. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/solution/scripts/README.md +1 -0
  89. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/solution/scripts/assets/README.md +1 -0
  90. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/solution/second_step.py +12 -0
  91. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/app.py +30 -0
  92. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/css/all.css +9 -0
  93. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/css/bootstrap.min.css +6 -0
  94. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/css/style.css +47 -0
  95. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/icons/dark/carbon--ibm-engineering-workflow-mgmt.svg +1 -0
  96. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/icons/dark/carbon--return.svg +1 -0
  97. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/icons/dark/game-icons--crossed-air-flows.svg +1 -0
  98. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/icons/dark/material-symbols--home.svg +1 -0
  99. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/icons/dark/radix-icons--moon.svg +1 -0
  100. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/icons/dark/radix-icons--sun.svg +1 -0
  101. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/icons/dark/streamline--startup-solid.svg +1 -0
  102. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/icons/dark/teenyicons--doc-solid.svg +1 -0
  103. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/icons/light/carbon--ibm-engineering-workflow-mgmt.svg +1 -0
  104. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/icons/light/carbon--return.svg +1 -0
  105. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/icons/light/game-icons--crossed-air-flows.svg +1 -0
  106. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/icons/light/material-symbols--home.svg +1 -0
  107. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/icons/light/radix-icons--moon.svg +1 -0
  108. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/icons/light/radix-icons--sun.svg +1 -0
  109. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/icons/light/streamline--startup-solid.svg +1 -0
  110. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/icons/light/teenyicons--doc-solid.svg +1 -0
  111. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/images/README.md +1 -0
  112. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/images/workflow-placeholder.png +0 -0
  113. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/installer/README.md +5 -0
  114. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/logos/dark/placeholder_logo.png +0 -0
  115. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/logos/light/placeholder_logo.png +0 -0
  116. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/orchestrator/README.md +3 -0
  117. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/pywebview/README.md +3 -0
  118. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/assets/scripts/README.md +1 -0
  119. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/components/README.md +1 -0
  120. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/pages/about_page.py +71 -0
  121. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/pages/first_page.py +147 -0
  122. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/pages/page.py +367 -0
  123. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/src/{{ cookiecutter.__solution_namespace_path }}/{{cookiecutter.__solution_module_name}}/ui/pages/second_page.py +32 -0
  124. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/tests/common_test_files/README.md +1 -0
  125. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/tests/conftest.py +37 -0
  126. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/tests/unit/test_solution_api.py +42 -0
  127. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/tests/unit/test_solution_ui.py +30 -0
  128. ansys/saf/cli/_solutions/templates/solution/{{cookiecutter.__solution_name}}/tox.ini +56 -0
  129. ansys/saf/cli/_utilities/__init__.py +15 -0
  130. ansys/saf/cli/_utilities/archiver.py +67 -0
  131. ansys/saf/cli/_utilities/backup.py +181 -0
  132. ansys/saf/cli/_utilities/conversion.py +64 -0
  133. ansys/saf/cli/_utilities/platform.py +26 -0
  134. ansys/saf/cli/_utilities/py.typed +0 -0
  135. ansys/saf/cli/_utilities/solution_modules.py +173 -0
  136. ansys/saf/cli/integrations/solutions_manager.py +64 -0
  137. ansys/saf/cli/py.typed +0 -0
  138. ansys_saf_cli-4.1.dev0.dist-info/METADATA +201 -0
  139. ansys_saf_cli-4.1.dev0.dist-info/RECORD +143 -0
  140. ansys_saf_cli-4.1.dev0.dist-info/WHEEL +4 -0
  141. ansys_saf_cli-4.1.dev0.dist-info/entry_points.txt +3 -0
  142. ansys_saf_cli-4.1.dev0.dist-info/licenses/AUTHORS +12 -0
  143. ansys_saf_cli-4.1.dev0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,1145 @@
1
+ # Copyright (C) 2026 ANSYS, Inc. and/or its affiliates.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ from contextlib import contextmanager
18
+ import os
19
+ from pathlib import Path
20
+ import platform
21
+ import re
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ from typing import Any
27
+ from urllib.parse import urlparse
28
+
29
+ import click
30
+ from dotenv import load_dotenv
31
+ from packaging.markers import Marker
32
+ import requests
33
+ from requests.auth import HTTPBasicAuth
34
+ import tomlkit
35
+
36
+ from ansys.saf.cli._config.const import (
37
+ WORKSPACE_CLEAR_OPTIONS,
38
+ )
39
+ from ansys.saf.cli._utilities.solution_modules import get_exec_from_solution_venv
40
+
41
+
42
+ class InstallError(Exception):
43
+ """Custom exception for install errors.
44
+ Errors of this type are not internal issues with
45
+ the saf install command but are instead issues
46
+ relating to the user's environment or input.
47
+ """
48
+
49
+
50
+ # Path definition ---------------------------------------------------------------------------------------------------
51
+
52
+
53
+ # leave as relative paths, they are resolved after setup_environment changes the working dir to the solution_root_dir
54
+ PYPROJECT_FILE = Path("pyproject.toml")
55
+ VENV_DIR = Path(".venv")
56
+ POETRY_LOCK_FILE = Path("poetry.lock")
57
+ POETRY_VENV_DIR = Path(".poetry") / ".venv"
58
+ POETRY_CACHE_DIR = Path(".poetry") / ".cache"
59
+ POETRY_PYTHON_EXEC = (
60
+ POETRY_VENV_DIR / "Scripts" / "python.exe" if platform.system() == "Windows" else POETRY_VENV_DIR / "bin" / "python"
61
+ )
62
+ POETRY_POETRY_EXEC = (
63
+ POETRY_VENV_DIR / "Scripts" / "poetry.exe" if platform.system() == "Windows" else POETRY_VENV_DIR / "bin" / "poetry"
64
+ )
65
+ PYTHON_EXEC = VENV_DIR / "Scripts" / "python.exe" if platform.system() == "Windows" else VENV_DIR / "bin" / "python"
66
+ POETRY_EXEC = VENV_DIR / "Scripts" / "poetry.exe" if platform.system() == "Windows" else VENV_DIR / "bin" / "poetry"
67
+
68
+
69
+ # Console prints ----------------------------------------------------------------------------------------------------
70
+
71
+
72
+ def _print_main_header(text: str, max_length: int = 100) -> None:
73
+ """Display main header."""
74
+ for _ in range(max_length):
75
+ print("=", end="")
76
+ print()
77
+ print(text)
78
+ for _ in range(max_length):
79
+ print("=", end="")
80
+ print()
81
+ print()
82
+
83
+
84
+ def _print_section_header(text: str, max_length: int = 100) -> None:
85
+ """Display a section header in the console."""
86
+ section_header = ""
87
+ if len(text) < max_length:
88
+ section_header = text + " "
89
+ for _ in range(len(text), max_length):
90
+ section_header += "-"
91
+ else:
92
+ section_header = text
93
+ print(section_header)
94
+ print()
95
+
96
+
97
+ def _print_input_value(input_str: str, value: str, separator: str = ":", separator_position: int = 60) -> None:
98
+ """Print input value in console."""
99
+ if len(input_str) < separator_position:
100
+ text = input_str
101
+ for _ in range(len(text), separator_position):
102
+ text += " "
103
+ text += separator + " " + value
104
+ else:
105
+ text = input_str + " " + separator + " " + value
106
+ print(text)
107
+
108
+
109
+ def _print_inputs_summary(
110
+ dependency_groups: list[str],
111
+ build_system_version: str,
112
+ optional_dependency_groups: list[str],
113
+ ) -> None:
114
+ """Display a summary of the inputs."""
115
+ print(f"OS : {platform.system()}")
116
+ print(f"Python version : {_get_python_version()}")
117
+ print("Virtual environment name : .venv")
118
+ for dependency_group in optional_dependency_groups:
119
+ _print_input_value(
120
+ f"{dependency_group} dependencies",
121
+ "yes" if dependency_group in dependency_groups else "no",
122
+ separator=":",
123
+ separator_position=37,
124
+ )
125
+ print("Dependency management system : poetry")
126
+ print(f"Dependency management system version : {build_system_version}")
127
+ print()
128
+
129
+
130
+ # Checks ------------------------------------------------------------------------------------------------------------
131
+
132
+
133
+ def _check_python_version(configuration: dict[str, Any]) -> None: # noqa: C901
134
+ """Check if current Python is consistent with the python specifications from the build system."""
135
+ # Initialize lower/upper versions
136
+ lower_version, upper_version, lower_bound_symbol, upper_bound_symbol = None, None, None, None
137
+ lower_specification, upper_specification, single_specification = None, None, None
138
+ # Read Python version constraint
139
+ if (
140
+ "tool" not in configuration
141
+ or "poetry" not in configuration["tool"]
142
+ or "dependencies" not in configuration["tool"]["poetry"]
143
+ or "python" not in configuration["tool"]["poetry"]["dependencies"]
144
+ ):
145
+ raise RuntimeError("Python dependency not specified in pyproject.")
146
+ python_compatibility = configuration["tool"]["poetry"]["dependencies"]["python"].replace(" ", "")
147
+ # Split lower/upper version constraint
148
+ if "," in python_compatibility:
149
+ lower_specification, upper_specification = (
150
+ python_compatibility.split(",")[0],
151
+ python_compatibility.split(",")[1],
152
+ )
153
+ elif python_compatibility.startswith(">"):
154
+ lower_specification, upper_specification = python_compatibility, None
155
+ elif python_compatibility.startswith("<"):
156
+ lower_specification, upper_specification = None, python_compatibility
157
+ else:
158
+ single_specification = python_compatibility
159
+
160
+ if single_specification:
161
+ version = _get_version_from_python_specification(single_specification)
162
+ sign = _get_sign_from_python_specification(single_specification)
163
+ if sign != "==":
164
+ raise Exception("Unable to interpret python version specification.")
165
+ marker = Marker(f"python_full_version {sign} '{version}'")
166
+ if not marker.evaluate():
167
+ raise Exception(f"Python version must be equal to {version}.")
168
+ return
169
+ error_message = ""
170
+ lower_marker: Marker | None = None
171
+ upper_marker: Marker | None = None
172
+ if lower_specification:
173
+ lower_version = _get_version_from_python_specification(lower_specification)
174
+ lower_bound_symbol = _get_sign_from_python_specification(lower_specification)
175
+ lower_marker = Marker(f"python_full_version {lower_bound_symbol} '{lower_version}'")
176
+ error_message += (
177
+ f"Python version must be {lower_bound_symbol} {lower_version}. " if not lower_marker.evaluate() else ""
178
+ )
179
+ if upper_specification:
180
+ upper_version = _get_version_from_python_specification(upper_specification)
181
+ upper_bound_symbol = _get_sign_from_python_specification(upper_specification)
182
+ upper_marker = Marker(f"python_full_version {upper_bound_symbol} '{upper_version}'")
183
+ error_message += (
184
+ f"Python version must be {upper_bound_symbol} {upper_version}." if not upper_marker.evaluate() else ""
185
+ )
186
+ if error_message:
187
+ raise Exception(error_message)
188
+
189
+
190
+ def _check_virtual_environment_exists() -> bool:
191
+ """Check if an install exists already."""
192
+ return VENV_DIR.is_dir()
193
+
194
+
195
+ def set_user_level_environment_variable(variable_name: str, value: str) -> None:
196
+ """Set environment variable at the user level for persistence."""
197
+ os.environ[variable_name] = value
198
+
199
+ variable_name_formatted = click.style(variable_name, fg="cyan", bold=True)
200
+
201
+ if platform.system() == "Windows":
202
+ try:
203
+ subprocess.run(
204
+ ["setx", variable_name, value],
205
+ check=True,
206
+ capture_output=True,
207
+ text=True,
208
+ )
209
+ click.echo(
210
+ f"Environment variable {variable_name_formatted} set permanently for the current Windows user",
211
+ )
212
+ except subprocess.CalledProcessError:
213
+ click.echo(f"Warning: Could not set permanent environment variable {variable_name_formatted}")
214
+ else:
215
+ home_dir = Path.home()
216
+ profile_files = [
217
+ home_dir / ".bash_profile",
218
+ home_dir / ".bashrc",
219
+ home_dir / ".profile",
220
+ ]
221
+
222
+ export_line = f'export {variable_name}="{value}"\n'
223
+
224
+ for profile_file in profile_files:
225
+ if profile_file.exists():
226
+ try:
227
+ content = profile_file.read_text()
228
+ if f"export {variable_name}=" not in content:
229
+ with profile_file.open("a") as f:
230
+ f.write(export_line)
231
+ click.echo(f"Environment variable {variable_name_formatted} added to {profile_file}")
232
+ break
233
+ except OSError:
234
+ continue
235
+ else:
236
+ try:
237
+ bashrc = home_dir / ".bashrc"
238
+ with bashrc.open("a") as f:
239
+ f.write(export_line)
240
+ click.echo(f"Created {bashrc} and added {variable_name_formatted}")
241
+ except OSError:
242
+ click.echo(f"Warning: Could not set permanent environment variable {variable_name_formatted}")
243
+
244
+
245
+ def _prompt_for_credentials(source: dict[str, Any], is_first_attempt: bool) -> tuple[str, str]:
246
+ """Interactively prompt user for PyPI credentials."""
247
+ source_name = source["name"]
248
+ source_url = source.get("url", "")
249
+
250
+ if is_first_attempt:
251
+ click.echo(f"\nMissing credentials for private PyPI source: {click.style(source_name, fg='cyan', bold=True)}")
252
+ click.echo(f"URL: {source_url}")
253
+
254
+ click.echo()
255
+
256
+ username = click.prompt(
257
+ f"Enter username for {click.style(source_name, fg='cyan', bold=True)}",
258
+ type=str,
259
+ show_default=False,
260
+ ).strip()
261
+
262
+ password = click.prompt(
263
+ f"Enter password/token for {click.style(source_name, fg='cyan', bold=True)}",
264
+ type=str,
265
+ hide_input=True,
266
+ show_default=False,
267
+ ).strip()
268
+
269
+ return username, password
270
+
271
+
272
+ def _get_netrc_path():
273
+ netrc_raw_path = os.getenv("NETRC")
274
+ if netrc_raw_path is None:
275
+ netrc_file_name = "_netrc" if platform.system() == "Windows" else ".netrc"
276
+ netrc_path = Path.home() / netrc_file_name
277
+ else:
278
+ netrc_path = Path(netrc_raw_path)
279
+ return netrc_path
280
+
281
+
282
+ def _get_credentials_from_netrc(source: dict[str, Any]) -> tuple[Path, str, str, str] | None:
283
+ hostname = _get_parsed_url_from_source(source).hostname
284
+
285
+ if hostname is None:
286
+ return None
287
+
288
+ netrc_path = _get_netrc_path()
289
+
290
+ if not netrc_path.is_file():
291
+ return None
292
+
293
+ try:
294
+ with netrc_path.open("r") as netrc_file:
295
+ netrc_content = netrc_file.read()
296
+
297
+ machine_pattern = re.compile(
298
+ r"default\s+login\s+(\S+)\s+password\s+(\S+)",
299
+ re.IGNORECASE,
300
+ )
301
+ match = machine_pattern.search(netrc_content)
302
+
303
+ if match:
304
+ raise InstallError(
305
+ "The netrc file contains the 'default' keyword which is not supported by pypi. "
306
+ f"The netrc file is located at: {netrc_path}",
307
+ )
308
+
309
+ machine_pattern = re.compile(
310
+ rf"machine\s+{re.escape(hostname)}\s+login\s+(\S+)\s+password\s+(\S+)",
311
+ re.IGNORECASE,
312
+ )
313
+ match = machine_pattern.search(netrc_content)
314
+
315
+ if match:
316
+ netrc_username, netrc_password = match.groups()
317
+ return netrc_path, hostname, netrc_username, netrc_password
318
+
319
+ except InstallError:
320
+ raise
321
+ except Exception as e:
322
+ click.secho(f"Warning: Could not read .netrc file at {netrc_path}: {e}", err=True, fg="yellow")
323
+
324
+ return None
325
+
326
+
327
+ def _check_credentials_consistent_with_netrc(source: dict[str, Any], username: str, password: str) -> bool:
328
+ """Check for consistency between provided credentials and .netrc file."""
329
+
330
+ credentials = _get_credentials_from_netrc(source)
331
+
332
+ if credentials is None:
333
+ return True # No .netrc entry to compare against
334
+
335
+ netrc_path, hostname, netrc_username, netrc_password = credentials
336
+ if netrc_username != username or netrc_password != password:
337
+ raise InstallError(
338
+ f"Error: Credentials in poetry environment variables for {hostname} do not match those in {netrc_path}.",
339
+ )
340
+
341
+ return True
342
+
343
+
344
+ def validate_credentials(
345
+ source: dict[str, Any],
346
+ username: str,
347
+ password: str,
348
+ certificate: str | None = None,
349
+ verify: bool = True,
350
+ ) -> bool:
351
+ """
352
+ Validate the provided username and password against the remote PyPI server specified in the source.
353
+
354
+ Args:
355
+ source (dict[str, Any]): The source dictionary containing the PyPI server URL.
356
+ username (str): The username to authenticate with.
357
+ password (str): The password to authenticate with.
358
+
359
+ Returns:
360
+ bool: True if credentials are valid, False if unauthorized. Exits on other errors.
361
+ """
362
+ url = str(source["url"])
363
+
364
+ try:
365
+ response = requests.get(
366
+ url + "/JUNK",
367
+ auth=HTTPBasicAuth(username, password),
368
+ timeout=(10, 10),
369
+ verify=certificate if verify else False,
370
+ )
371
+ except requests.exceptions.SSLError as e:
372
+ raise InstallError(
373
+ f"SSL certificate validation failed for {_get_source_name_formatted(source)}. "
374
+ "Please check the provided certificate.",
375
+ ) from e
376
+ except requests.RequestException as e:
377
+ raise InstallError(f"Unable to reach {_get_source_name_formatted(source)} on {url}") from e
378
+
379
+ if response.status_code == 401:
380
+ return False
381
+ elif response.status_code in (200, 404):
382
+ return True
383
+
384
+ raise InstallError(
385
+ f"Unexpected response from {_get_source_name_formatted(source)}, Code is {response.status_code}",
386
+ )
387
+
388
+
389
+ def _get_parsed_url_from_source(source: dict[str, Any]):
390
+ parsed_url = urlparse(str(source["url"]))
391
+ return parsed_url
392
+
393
+
394
+ def _can_use_credentials_from_environment_variables_or_netrc(
395
+ source: dict[str, Any],
396
+ username_var: str,
397
+ password_var: str,
398
+ certificate_var: str,
399
+ ) -> bool:
400
+ username = os.environ.get(username_var)
401
+ password = os.environ.get(password_var)
402
+ certificate, certificate_verification_disabled = _read_certificate_from_environment_variable(certificate_var)
403
+
404
+ if username and password:
405
+ _check_credentials_consistent_with_netrc(source, username, password)
406
+
407
+ click.echo(f"Validating credentials for {_get_source_name_formatted(source)}...")
408
+ if validate_credentials(
409
+ source,
410
+ username,
411
+ password,
412
+ certificate=certificate,
413
+ verify=not certificate_verification_disabled,
414
+ ):
415
+ click.secho(f"Credentials for {source['name']} are valid.", fg="green")
416
+ return True
417
+ else:
418
+ click.secho(f"Invalid credentials for {source['name']}", err=True, fg="red")
419
+ else:
420
+ credentials = _get_credentials_from_netrc(source)
421
+ if credentials is not None:
422
+ netrc_path, _, username, password = credentials
423
+ source_name_formatted = _get_source_name_formatted(source)
424
+ if validate_credentials(
425
+ source,
426
+ username,
427
+ password,
428
+ certificate=certificate,
429
+ verify=not certificate_verification_disabled,
430
+ ):
431
+ click.secho(f"Using credentials from {netrc_path} for {source_name_formatted}.", fg="green")
432
+ return True
433
+ else:
434
+ raise InstallError(
435
+ f"Unable to access private PyPI source {source_name_formatted}. "
436
+ "Poetry credential environment variables are not set "
437
+ f"and invalid credentials appear in {netrc_path}",
438
+ )
439
+
440
+ return False
441
+
442
+
443
+ def _check_private_sources(configuration: dict[str, Any]) -> None:
444
+ """Check and interactively manage private PyPI credentials."""
445
+ private_sources = configuration["tool"]["poetry"].get("source", [])
446
+ credentials_updated, certificate_updated = False, False
447
+
448
+ for source in private_sources:
449
+ if source["name"].lower() == "pypi":
450
+ source["url"] = "https://pypi.org/simple"
451
+
452
+ click.secho(
453
+ f"Checking access to {_get_source_name_formatted(source)}...",
454
+ )
455
+
456
+ source_name_slug = source["name"].upper().replace("-", "_")
457
+ certificate_var = f"POETRY_CERTIFICATES_{source_name_slug}_CERT"
458
+
459
+ certificate_updated = _prompt_validate_and_set_certificates(source, certificate_var) or certificate_updated
460
+
461
+ if source["name"].lower() == "pypi":
462
+ click.echo()
463
+ continue
464
+
465
+ username_var = f"POETRY_HTTP_BASIC_{source_name_slug}_USERNAME"
466
+ password_var = f"POETRY_HTTP_BASIC_{source_name_slug}_PASSWORD"
467
+
468
+ if not _can_use_credentials_from_environment_variables_or_netrc(
469
+ source,
470
+ username_var,
471
+ password_var,
472
+ certificate_var,
473
+ ):
474
+ credentials_updated = (
475
+ _prompt_validate_and_set_credentials(source, username_var, password_var, certificate_var)
476
+ or credentials_updated
477
+ )
478
+
479
+ click.echo()
480
+
481
+ if credentials_updated or certificate_updated:
482
+ click.echo()
483
+ click.secho(
484
+ "IMPORTANT: Environment variables have been updated.\n"
485
+ "Please restart your terminal/IDE for the changes to take effect:\n"
486
+ " - If using VS Code: Close ALL instances and restart VS Code\n"
487
+ " - If using Command Prompt/PowerShell: Close and reopen your terminal\n"
488
+ " - If using Linux/macOS terminal: Start a new terminal session or run 'source ~/.bashrc'\n"
489
+ "Then run the install command again to continue with the setup.",
490
+ fg="yellow",
491
+ bold=True,
492
+ )
493
+ sys.exit(0)
494
+
495
+
496
+ def _prompt_validate_and_set_credentials(
497
+ source: dict[str, Any],
498
+ username_var: str,
499
+ password_var: str,
500
+ certificate_var: str,
501
+ ):
502
+ attempt = 0
503
+ credentials_updated = False
504
+
505
+ certificate, certificate_verification_disabled = _read_certificate_from_environment_variable(certificate_var)
506
+
507
+ while True:
508
+ try:
509
+ attempt += 1
510
+ new_username, new_password = _prompt_for_credentials(source, bool(attempt))
511
+ click.echo("Validating credentials...")
512
+ if validate_credentials(
513
+ source,
514
+ new_username,
515
+ new_password,
516
+ certificate=certificate,
517
+ verify=not certificate_verification_disabled,
518
+ ):
519
+ set_user_level_environment_variable(username_var, new_username)
520
+ set_user_level_environment_variable(password_var, new_password)
521
+ credentials_updated = True
522
+
523
+ click.secho(f"Successfully configured credentials for {source['name']}", fg="green")
524
+ break
525
+ else:
526
+ click.secho(
527
+ "Invalid credentials. Please check your username and password/token and try again.",
528
+ err=True,
529
+ fg="red",
530
+ )
531
+ except click.Abort as e:
532
+ raise InstallError(
533
+ f"\nSetup cancelled. Install will fail without valid credentials for {source['name']}",
534
+ ) from e
535
+ except KeyboardInterrupt as e:
536
+ raise InstallError(
537
+ f"\nSetup interrupted. Install will fail without valid credentials for {source['name']}",
538
+ ) from e
539
+
540
+ return credentials_updated
541
+
542
+
543
+ def _prompt_validate_and_set_certificates(source: dict[str, Any], certificate_var: str):
544
+ """Prompt, validate and set SSL certificate for a given source."""
545
+ certificate, certificate_verification_disabled = _read_certificate_from_environment_variable(certificate_var)
546
+
547
+ if certificate_verification_disabled:
548
+ click.secho(
549
+ f"SSL certificate verification is currently disabled for {_get_source_name_formatted(source)}. "
550
+ "Note that this is not recommended as it can pose security risks.",
551
+ fg="yellow",
552
+ )
553
+ return False
554
+ elif not is_valid_certificate(source, certificate):
555
+ click.secho(
556
+ f"An SSL certificate is required to access the {_get_source_name_formatted(source)} source. "
557
+ "This is likely because you are in a corporate network with SSL interception "
558
+ "or because the source uses a self-signed certificate.",
559
+ )
560
+ else:
561
+ return False
562
+
563
+ while True:
564
+ try:
565
+ new_certificate = _prompt_for_certificate(source)
566
+ set_user_level_environment_variable(certificate_var, new_certificate)
567
+ certificate = new_certificate
568
+ except click.Abort as e:
569
+ raise InstallError(
570
+ f"\nSetup cancelled. Install will fail without a valid SSL certificate for {source['name']}",
571
+ ) from e
572
+ except KeyboardInterrupt as e:
573
+ raise InstallError(
574
+ f"\nSetup interrupted. Install will fail without a valid SSL certificate for {source['name']}",
575
+ ) from e
576
+
577
+ if not is_valid_certificate(source, certificate):
578
+ click.secho(
579
+ "Invalid certificate. Please check the provided certificate and try again.",
580
+ err=True,
581
+ fg="red",
582
+ )
583
+ else:
584
+ break
585
+
586
+ click.secho(f"Successfully configured certificate for {source['name']}", fg="green")
587
+ return True
588
+
589
+
590
+ def _prompt_for_certificate(source: dict[str, Any]):
591
+ """Interactively prompt user for PyPI certificate."""
592
+ source_name = source["name"]
593
+
594
+ while True:
595
+ certificate = click.prompt(
596
+ f"Enter the path to a valid SSL certificate for {click.style(source_name, fg='cyan', bold=True)}",
597
+ type=str,
598
+ show_default=False,
599
+ ).strip()
600
+ if Path(certificate).is_file():
601
+ break
602
+ else:
603
+ click.secho("Invalid file path. Please enter a valid path to the SSL certificate.", err=True, fg="red")
604
+
605
+ return certificate
606
+
607
+
608
+ def is_valid_certificate(source: dict[str, Any], certificate: str | None) -> bool:
609
+ """
610
+ Validate the provided SSL certificate against the remote PyPI server specified in the source.
611
+
612
+ Args:
613
+ source (dict[str, Any]): The source dictionary containing the PyPI server URL.
614
+ certificate_path (str | None): The path to the SSL certificate to validate.
615
+
616
+ Returns:
617
+ bool: True if certificate is valid, False otherwise.
618
+ """
619
+ url = str(source["url"])
620
+
621
+ try:
622
+ requests.get(
623
+ url + "/JUNK",
624
+ verify=certificate or True,
625
+ timeout=(10, 10),
626
+ )
627
+ return True
628
+ except (requests.exceptions.SSLError, requests.RequestException):
629
+ return False
630
+
631
+
632
+ def _get_source_name_formatted(source: dict[str, Any]):
633
+ return click.style(source["name"], fg="cyan", bold=True)
634
+
635
+
636
+ def _read_certificate_from_environment_variable(certificate_var: str) -> tuple[str | None, bool]:
637
+ """Read certificate path from environment variable and check if certificate verification is disabled."""
638
+ certificate = os.environ.get(certificate_var)
639
+
640
+ if certificate: # noqa: SIM102
641
+ if certificate.lower() == "false":
642
+ return None, True
643
+
644
+ return certificate, False
645
+
646
+
647
+ # General-purpose ---------------------------------------------------------------------------------------------------
648
+
649
+
650
+ def _read_integers_from_string(string: str) -> list[str]:
651
+ """Scan a string and extract integers. The regex matches any digit character (0-9)."""
652
+ return re.findall(r"\d+", string)
653
+
654
+
655
+ def _get_python_version() -> str:
656
+ """Get Python version."""
657
+ return f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
658
+
659
+
660
+ def _set_pip_command(
661
+ package_name: str,
662
+ package_version: str = "*",
663
+ method: str = "install",
664
+ pypi_url: str = "https://pypi.org/simple",
665
+ private_pypi_token_name: str = "",
666
+ no_deps: bool = False,
667
+ python_executable: str = sys.executable,
668
+ ) -> list[str]:
669
+ """Make pip command."""
670
+ package_name += f"=={package_version}" if package_version != "*" else ""
671
+ command = [python_executable, "-m", "pip", method, package_name]
672
+ # Add part related to private PyPI source
673
+ if pypi_url != "https://pypi.org/simple":
674
+ # Check if token is available
675
+ if not private_pypi_token_name:
676
+ raise Exception(f"No token specified for private PyPI server {pypi_url}.")
677
+ # Check if the environment variable associated to the private PyPI token exists
678
+ if (private_pypi_token := os.getenv(private_pypi_token_name)) is None:
679
+ raise Exception(f"Environment variable {private_pypi_token_name} does not exist.")
680
+ # Split absolute URL
681
+ parsed_url = urlparse(pypi_url)
682
+ # Update command line
683
+ command += ["-i", f"{parsed_url.scheme}://PAT:{private_pypi_token}@{parsed_url.netloc + parsed_url.path}"]
684
+ # Add no-deps option
685
+ if no_deps:
686
+ command += ["--no-deps"]
687
+
688
+ return command
689
+
690
+
691
+ def _get_python_package(
692
+ package_name: str,
693
+ package_version: str = "*",
694
+ method: str = "install",
695
+ pypi_url: str = "https://pypi.org/simple",
696
+ private_pypi_token_name: str = "",
697
+ no_deps: bool = False,
698
+ python_executable: str = sys.executable,
699
+ ) -> None:
700
+ """
701
+ Install or download a python package using PIP.
702
+
703
+ Parameters
704
+ ----------
705
+ package_name: str
706
+ Name of the package
707
+ package_version : str
708
+ Version of the package.
709
+ method : str
710
+ Get method: ``download`` or ``install``
711
+ pypi_url : str
712
+ URL of the PyPI server.
713
+ private_pypi_token_name : str
714
+ Name of the environment variable holding the token to access to the PyPI.
715
+ python_executable : str
716
+ Path to the Python executable.
717
+
718
+ Returns
719
+ -------
720
+ None
721
+ """
722
+ # Set pip command
723
+ command = _set_pip_command(
724
+ package_name=package_name,
725
+ package_version=package_version,
726
+ method=method,
727
+ pypi_url=pypi_url,
728
+ private_pypi_token_name=private_pypi_token_name,
729
+ no_deps=no_deps,
730
+ python_executable=python_executable,
731
+ )
732
+ # Run pip command
733
+ process = subprocess.run(
734
+ command,
735
+ check=False,
736
+ capture_output=True,
737
+ text=True,
738
+ )
739
+ if process.returncode != 0:
740
+ print(process.stdout)
741
+ print(process.stderr)
742
+ raise Exception("Command failed with error.")
743
+
744
+
745
+ def _create_virtual_environment(
746
+ virtual_environment_exists: bool,
747
+ workspace_clear: str | None,
748
+ venv: str = VENV_DIR.name,
749
+ ) -> None:
750
+ """Create a virtual environment."""
751
+ print("Create virtual environment")
752
+ if virtual_environment_exists and not workspace_clear:
753
+ print("Skipped\n")
754
+ return
755
+
756
+ if platform.system() == "Linux":
757
+ subprocess.run([sys.executable, "-m", "pip", "install", "virtualenv"], check=True)
758
+ subprocess.run([sys.executable, "-m", "venv", venv], check=True)
759
+ print()
760
+
761
+
762
+ @contextmanager
763
+ def _exit_virtual_environment():
764
+ # this is necessary to trick poetry into thinking it is not in a virtual environment. Otherwise, even if we execute
765
+ # the poetry in the solution's venv with the CWD where the solution's pyproject.toml is located,
766
+ # it will install the packages in the caller's venv (saf-cli)
767
+ venv_path = os.environ.pop("VIRTUAL_ENV", None)
768
+ yield
769
+ if venv_path is not None:
770
+ os.environ["VIRTUAL_ENV"] = venv_path
771
+
772
+
773
+ @contextmanager
774
+ def _switch_cwd(solution_root_dir: Path):
775
+ working_directory = Path.cwd()
776
+ try:
777
+ os.chdir(solution_root_dir)
778
+ yield
779
+ finally:
780
+ os.chdir(working_directory)
781
+
782
+
783
+ # Dependency Management System (Build System) -----------------------------------------------------------------------
784
+
785
+
786
+ def _get_version_from_python_specification(specification: str) -> str:
787
+ """Read the version."""
788
+ return ".".join(_read_integers_from_string(specification))
789
+
790
+
791
+ def _get_sign_from_python_specification(specification: str) -> str:
792
+ """Read the mathematical symbol expressing a constraint on the version."""
793
+ first_part = specification.split(".")[0]
794
+ sign = "".join([i for i in first_part if not i.isdigit()])
795
+ if sign == "":
796
+ sign = "=="
797
+ return sign
798
+
799
+
800
+ def _check_poetry_plugin_export_is_required(build_system_version: str, python_interpreter: str) -> bool:
801
+ if int(build_system_version[0]) < 2:
802
+ return False
803
+ result = subprocess.run(
804
+ [python_interpreter, "-m", "pip", "show", "poetry-plugin-export"],
805
+ capture_output=True,
806
+ text=True,
807
+ )
808
+ return result.returncode != 0
809
+
810
+
811
+ def _install_build_system(
812
+ virtual_environment_exists: bool,
813
+ workspace_clear: str | None,
814
+ build_system_version: str,
815
+ ) -> None:
816
+ """Install build system."""
817
+ print("Install dependency management system.")
818
+ if virtual_environment_exists and not workspace_clear:
819
+ print("Skipped\n")
820
+ return
821
+
822
+ # We create a separate virtual environment for poetry, as it's recommended in its documentation:
823
+ # `Poetry should always be installed in a dedicated virtual environment to isolate it from the rest of your system.`
824
+ # See: https://python-poetry.org/docs/#installation
825
+ print()
826
+ _create_virtual_environment(
827
+ virtual_environment_exists,
828
+ workspace_clear,
829
+ venv=POETRY_VENV_DIR.as_posix(),
830
+ )
831
+ _get_python_package(
832
+ "poetry",
833
+ build_system_version,
834
+ method="install",
835
+ python_executable=POETRY_PYTHON_EXEC.absolute().as_posix(),
836
+ )
837
+ # poetry-plugin-export is currently included as a required plugin in the pyproject.toml of the solution.
838
+ # However, if saf install is run in an old solution, we need to make sure the plugin is installed when
839
+ # using poetry >=2.
840
+ if _check_poetry_plugin_export_is_required(build_system_version, POETRY_PYTHON_EXEC.absolute().as_posix()):
841
+ _get_python_package(
842
+ "poetry-plugin-export>=1.8",
843
+ method="install",
844
+ python_executable=POETRY_PYTHON_EXEC.absolute().as_posix(),
845
+ )
846
+ if not POETRY_POETRY_EXEC.is_file():
847
+ raise RuntimeError(f"Poetry executable not found at {POETRY_POETRY_EXEC}.")
848
+ if int(build_system_version[0]) < 2:
849
+ print("Downgrade virtualenv in the poetry virtual environment.")
850
+ _get_python_package(
851
+ "virtualenv",
852
+ "20.30.0", # virtualenv >= 20.31.0 is incompatible with poetry < 2
853
+ method="install",
854
+ python_executable=POETRY_PYTHON_EXEC.absolute().as_posix(),
855
+ )
856
+ # Create a file symbolic link from the virtual environment (.venv) that the build system (poetry) manages
857
+ # to the build system executable (poetry.exe) in the poetry virtual environment (.poetry/.venv). This
858
+ # ensures that the correct poetry is accessible when the managed virtual environment is activated
859
+
860
+ if platform.system() == "Windows":
861
+ subprocess.run(
862
+ [
863
+ "powershell",
864
+ "-Command",
865
+ "New-Item",
866
+ "-ItemType",
867
+ "HardLink",
868
+ "-Path",
869
+ f"'{POETRY_EXEC.absolute().as_posix()}'",
870
+ "-Target",
871
+ f"'{POETRY_POETRY_EXEC.absolute().as_posix()}'",
872
+ ],
873
+ check=True,
874
+ )
875
+ elif platform.system() == "Linux":
876
+ subprocess.run(
877
+ [
878
+ "ln",
879
+ "-sf",
880
+ POETRY_POETRY_EXEC.absolute().as_posix(),
881
+ POETRY_EXEC.absolute().as_posix(),
882
+ ],
883
+ check=True,
884
+ )
885
+ print()
886
+
887
+
888
+ def _configure_build_system(
889
+ virtual_environment_exists: bool,
890
+ workspace_clear: str | None,
891
+ ) -> None:
892
+ """Configure the build system to enable connection to private sources."""
893
+ print("Configure dependency management system.")
894
+ if virtual_environment_exists and not workspace_clear:
895
+ print("Skipped\n")
896
+ return
897
+
898
+ _configure_poetry()
899
+ print()
900
+
901
+
902
+ def _configure_poetry() -> None:
903
+ """Configure Poetry."""
904
+ subprocess.run(
905
+ [
906
+ POETRY_EXEC.absolute().as_posix(),
907
+ "config",
908
+ "cache-dir",
909
+ POETRY_CACHE_DIR.absolute().as_posix(),
910
+ "--local",
911
+ ],
912
+ check=True,
913
+ )
914
+
915
+
916
+ # Specifics ---------------------------------------------------------------------------------------------------------
917
+
918
+
919
+ def _clear_workspace(workspace_clear: str | None) -> None:
920
+ """Remove residual items form previous installation (like venv directory, lock file ...)."""
921
+ if not workspace_clear:
922
+ print("Skip workspace clear")
923
+ print()
924
+ return
925
+
926
+ if VENV_DIR.is_dir():
927
+ print("Delete existing virtual environment '.venv'")
928
+ shutil.rmtree(VENV_DIR)
929
+ if POETRY_VENV_DIR.is_dir():
930
+ print("Delete existing poetry virtual environment")
931
+ shutil.rmtree(POETRY_VENV_DIR)
932
+ if POETRY_CACHE_DIR.is_dir():
933
+ print("Delete existing poetry cache")
934
+ shutil.rmtree(POETRY_CACHE_DIR)
935
+ if workspace_clear == "hard":
936
+ if POETRY_LOCK_FILE.is_file():
937
+ print("Delete existing poetry lock file")
938
+ POETRY_LOCK_FILE.unlink()
939
+ else:
940
+ print("Skip deleting poetry lock file")
941
+ print()
942
+
943
+
944
+ def _resolve_dependency_groups(
945
+ dependency_groups: list[str] | None,
946
+ optional_dependency_groups: list[str],
947
+ ) -> list[str]:
948
+ if not dependency_groups:
949
+ default_dependency_groups = [
950
+ dependency_group
951
+ for dependency_group in ["desktop", "ui", "doc", "build"]
952
+ if dependency_group in optional_dependency_groups
953
+ ]
954
+ return default_dependency_groups
955
+ if "all" in dependency_groups:
956
+ return optional_dependency_groups
957
+ for dependency_group in dependency_groups:
958
+ if dependency_group not in optional_dependency_groups:
959
+ raise ValueError(f"Invalid dependency group: {dependency_group}")
960
+ return dependency_groups
961
+
962
+
963
+ def _run_poetry_install(args: list[str]) -> None:
964
+ with _exit_virtual_environment():
965
+ try:
966
+ subprocess.run(
967
+ [POETRY_POETRY_EXEC.absolute().as_posix()] + args,
968
+ check=True,
969
+ text=True,
970
+ stderr=subprocess.PIPE,
971
+ )
972
+
973
+ except subprocess.CalledProcessError as e:
974
+ if "Authorization error" in e.stderr or "401 Client Error:" in e.stderr:
975
+ raise Exception(
976
+ "Poetry install failed due to authorization issues with a private PyPI source. "
977
+ "Please verify each username and password/token for private PyPI sources. ",
978
+ ) from e
979
+ else:
980
+ raise Exception(
981
+ f"Poetry install failed with an unexpected error. {e.stderr}",
982
+ ) from e
983
+
984
+
985
+ def _get_optional_dependency_groups(configuration: dict[str, Any]) -> list[str]:
986
+ optional_dependency_groups = []
987
+ if "group" in configuration["tool"]["poetry"]:
988
+ optional_dependency_groups = list(configuration["tool"]["poetry"]["group"])
989
+ return optional_dependency_groups
990
+
991
+
992
+ def _install_dependencies(dependency_groups: list[str]) -> None:
993
+ """Install optional requirements (doc, tests, build or style)."""
994
+ # Install standard optional dependency groups
995
+ print(f"Install the following dependency groups: {', '.join(dependency_groups)}")
996
+ dependency_groups_str = ",".join(dependency_groups)
997
+ poetry_cmd = ["install"] + (["--with", dependency_groups_str] if dependency_groups_str else [])
998
+ _run_poetry_install(poetry_cmd)
999
+ print()
1000
+
1001
+
1002
+ def _install_dotnet_linux_dependencies():
1003
+ """Install .NET for Linux."""
1004
+ print("Install dotnet dependencies")
1005
+ if platform.system() != "Linux":
1006
+ print("Skipped\n")
1007
+ return
1008
+
1009
+ subprocess.run(
1010
+ """
1011
+ set -xe \
1012
+ && wget https://dot.net/v1/dotnet-install.sh \
1013
+ && chmod +x dotnet-install.sh \
1014
+ && ./dotnet-install.sh --install-dir /home/$USER/.dotnet --version 8.0.8 --runtime aspnetcore \
1015
+ && grep -qxF "DOTNET_ROOT=/home/$USER/.dotnet" /home/$USER/.bash_profile \
1016
+ || echo DOTNET_ROOT=/home/$USER/.dotnet >> /home/$USER/.bash_profile \
1017
+ && grep -qxF "PATH=\\$PATH:/home/$USER/.dotnet" /home/$USER/.bash_profile \
1018
+ || echo "PATH=\\$PATH:/home/$USER/.dotnet" >> /home/$USER/.bash_profile \
1019
+ && echo "\nsource /home/$USER/.bash_profile" >> ./.venv/bin/activate \
1020
+ && rm dotnet-install.sh
1021
+ """,
1022
+ check=True,
1023
+ shell=True,
1024
+ )
1025
+ print()
1026
+
1027
+
1028
+ def _preload_solution_main_module(
1029
+ solution_python_exec: Path,
1030
+ solution_main_module_name: str,
1031
+ selected_dependency_groups: list[str],
1032
+ optional_dependency_groups: list[str],
1033
+ ) -> None:
1034
+ """Import the main module of the solution to speed up first saf run call."""
1035
+ print("Preload solution package")
1036
+ if "ui" in optional_dependency_groups and "ui" not in selected_dependency_groups:
1037
+ print("Skipped: UI is not in the selected dependency groups.\n")
1038
+ return
1039
+ try:
1040
+ subprocess.run(
1041
+ [solution_python_exec.absolute().as_posix(), "-c", f"import {solution_main_module_name}"],
1042
+ stderr=subprocess.PIPE,
1043
+ text=True,
1044
+ check=True,
1045
+ )
1046
+ except subprocess.CalledProcessError as e:
1047
+ raise RuntimeError(f"Importing the solution's main module failed: {e.stderr}") from e
1048
+ print()
1049
+
1050
+
1051
+ def setup_environment(
1052
+ solution_root_dir: Path,
1053
+ solution_main_module_name: str,
1054
+ dependency_groups: list[str] | None = None,
1055
+ workspace_clear: str | None = None,
1056
+ env_file: Path | None = None,
1057
+ ) -> None:
1058
+ """Sequence of operations leading to the complete Python ecosystem."""
1059
+ if workspace_clear is not None and workspace_clear not in WORKSPACE_CLEAR_OPTIONS:
1060
+ raise ValueError(f"Invalid workspace clear option: {workspace_clear}")
1061
+
1062
+ start_time = time.time()
1063
+
1064
+ # Initialize setup ------------------------------------------------------------------------------------------------
1065
+
1066
+ with _switch_cwd(solution_root_dir):
1067
+ configuration = tomlkit.loads(PYPROJECT_FILE.read_bytes()).unwrap()
1068
+
1069
+ if env_file is not None:
1070
+ print(f"Environment variables loaded from {env_file.resolve()}")
1071
+ load_dotenv(env_file)
1072
+
1073
+ _check_private_sources(configuration)
1074
+
1075
+ _check_python_version(configuration)
1076
+
1077
+ virtual_environment_exists = _check_virtual_environment_exists()
1078
+
1079
+ optional_dependency_groups = _get_optional_dependency_groups(configuration)
1080
+ selected_dependency_groups = _resolve_dependency_groups(dependency_groups, optional_dependency_groups)
1081
+
1082
+ build_system_version = configuration.get("build-system-requirements", {}).get("build-system-version", None)
1083
+ if not build_system_version:
1084
+ raise RuntimeError("No build system version found in the configuration file.")
1085
+
1086
+ _print_main_header("Setup Environment")
1087
+
1088
+ _print_inputs_summary(
1089
+ selected_dependency_groups,
1090
+ build_system_version,
1091
+ optional_dependency_groups,
1092
+ )
1093
+
1094
+ # Clear workspace ----------------------------------------------------------------------------------------------
1095
+
1096
+ _print_section_header("Clear workspace", max_length=100)
1097
+
1098
+ _clear_workspace(workspace_clear)
1099
+
1100
+ # Setup virtual environment ------------------------------------------------------------------------------------
1101
+
1102
+ _print_section_header("Setup virtual environment", max_length=100)
1103
+
1104
+ _create_virtual_environment(virtual_environment_exists, workspace_clear)
1105
+
1106
+ # Setup dependency management system ---------------------------------------------------------------------------
1107
+
1108
+ _print_section_header("Setup dependency management system", max_length=100)
1109
+
1110
+ _install_build_system(
1111
+ virtual_environment_exists,
1112
+ workspace_clear,
1113
+ build_system_version,
1114
+ )
1115
+
1116
+ _configure_build_system(
1117
+ virtual_environment_exists,
1118
+ workspace_clear,
1119
+ )
1120
+
1121
+ # Install dependencies -----------------------------------------------------------------------------------------
1122
+
1123
+ _print_section_header("Install dependencies", max_length=100)
1124
+
1125
+ _install_dependencies(selected_dependency_groups)
1126
+
1127
+ _install_dotnet_linux_dependencies()
1128
+
1129
+ # Preload installed solution package so that the first execution with saf run is faster.
1130
+ solution_python_exec = get_exec_from_solution_venv(solution_root_dir, "python")
1131
+ _preload_solution_main_module(
1132
+ solution_python_exec,
1133
+ solution_main_module_name,
1134
+ selected_dependency_groups,
1135
+ optional_dependency_groups,
1136
+ )
1137
+
1138
+ # Finish -------------------------------------------------------------------------------------------------------
1139
+
1140
+ _print_section_header("Summary", max_length=100)
1141
+
1142
+ elapsed_time = (time.time() - start_time) / 60
1143
+
1144
+ print("You are all set!")
1145
+ print(f"Execution time: {elapsed_time:.1f} minutes.")