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,816 @@
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 gettext import ngettext
18
+ import importlib.metadata
19
+ import keyword
20
+ import os
21
+ from pathlib import Path
22
+ import re
23
+ import shlex
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+ import traceback
28
+ import webbrowser
29
+
30
+ import click
31
+ from dotenv import dotenv_values, load_dotenv
32
+
33
+ from ansys.saf.cli._config.const import (
34
+ DEFAULT_SOLUTION_DISPLAY_NAME,
35
+ DEFAULT_SOLUTION_NAME,
36
+ DEFAULT_SOLUTION_NAMESPACE,
37
+ DEFAULT_STEP_NAME,
38
+ DEFAULT_TEMPLATE_NAME,
39
+ DEFAULT_UI_FRAMEWORK,
40
+ GLOW_API_PORT,
41
+ GLOW_DEBUG,
42
+ GLOW_LOGGING_LEVEL,
43
+ GLOW_SOLUTION_DEFINITION,
44
+ GLOW_UI_DEBUG,
45
+ GLOW_UI_MODULE,
46
+ GLOW_UI_PORT,
47
+ PORTAL_UI_PORT,
48
+ SAF_CLI_EXTERNAL_URL,
49
+ SAF_DESKTOP_LOG_TO_FILES,
50
+ UI_FRAMEWORKS,
51
+ )
52
+ from ansys.saf.cli._database.manager import SolutionDatabaseManager
53
+ from ansys.saf.cli._database.models import SolutionRegistry
54
+ from ansys.saf.cli._solutions.add_step import (
55
+ add_step_to_solution,
56
+ check_template_cli_compatibility,
57
+ step_name_exists,
58
+ )
59
+ from ansys.saf.cli._solutions.environment import InstallError, setup_environment
60
+ from ansys.saf.cli._solutions.plugins import list_available_templates, resolve_template
61
+ from ansys.saf.cli._solutions.scaffolding import create_solution
62
+ from ansys.saf.cli._utilities.archiver import archive_solution
63
+ from ansys.saf.cli._utilities.backup import BackupManager
64
+ from ansys.saf.cli._utilities.solution_modules import (
65
+ create_solution_environment,
66
+ find_solution_display_name,
67
+ find_solution_root_dir,
68
+ get_exec_from_solution_venv,
69
+ get_main_module,
70
+ get_solution_venv_bin_dir,
71
+ resolve_solution_main_file,
72
+ )
73
+
74
+ LIST_UI_FRAMEWORKS = "\n- ".join(UI_FRAMEWORKS)
75
+ UI_PROMPT = f"""What is the UI framework of the Solution?\n- {LIST_UI_FRAMEWORKS}\nChoose UI framework"""
76
+
77
+
78
+ def _validate_namespace_root(_ctx: click.Context, _param: click.Parameter, value: str) -> str:
79
+ """Validate namespace root parameter for Click callback."""
80
+
81
+ value = value.strip()
82
+ if not value:
83
+ raise click.BadParameter("Namespace cannot be empty or just whitespace.")
84
+
85
+ if value.startswith(".") or value.endswith("."):
86
+ raise click.BadParameter("Namespace must not start or end with a dot.")
87
+
88
+ if ".." in value:
89
+ raise click.BadParameter("Namespace must not contain empty segments (for example: a..b).")
90
+
91
+ segment_pattern = re.compile(r"^[a-z_][a-z0-9_]*$")
92
+ for segment in value.split("."):
93
+ if segment != segment.lower():
94
+ raise click.BadParameter(f"Namespace segment '{segment}' must be lowercase.")
95
+ if keyword.iskeyword(segment):
96
+ raise click.BadParameter(f"Namespace segment '{segment}' is a Python keyword and is not allowed.")
97
+ if segment and segment[0].isdigit():
98
+ raise click.BadParameter(f"Namespace segment '{segment}' must start with a letter or underscore.")
99
+ if not segment_pattern.fullmatch(segment):
100
+ raise click.BadParameter(
101
+ f"Namespace segment '{segment}' can contain only lowercase letters, numbers, and underscores.",
102
+ )
103
+
104
+ return value
105
+
106
+
107
+ def _set_env_var_if_not_none(name: str, value: int | bool | str | None = None, env: dict[str, str] | None = None):
108
+ environment = env or os.environ
109
+ if value is not None:
110
+ environment[name] = str(value)
111
+
112
+
113
+ def _resolve_input_solution(solution: str) -> tuple[SolutionRegistry, str]:
114
+ db_manager = SolutionDatabaseManager()
115
+ solution_root_dir = find_solution_root_dir(db_manager, solution)
116
+ solution_main_file = resolve_solution_main_file(solution_root_dir)
117
+ main_module = get_main_module(solution_main_file)
118
+ if stored_solution := db_manager.get_solution_by_root_dir(solution_root_dir):
119
+ return stored_solution, main_module
120
+ else:
121
+ solution_display_name = find_solution_display_name(solution_main_file) or solution_root_dir.name
122
+ new_solution = SolutionRegistry(
123
+ name=solution_root_dir.name,
124
+ root_dir=solution_root_dir,
125
+ display_name=solution_display_name,
126
+ )
127
+ db_manager.store_solution(new_solution)
128
+ return new_solution, main_module
129
+
130
+
131
+ def _resolve_effective_env_file(solution_root_dir: Path, env_file: Path | None) -> Path | None:
132
+ default_env_file = solution_root_dir / ".env"
133
+ return env_file or (default_env_file if default_env_file.is_file() else None)
134
+
135
+
136
+ def _get_solution_runtime_modules(main_module: str) -> tuple[str, str]:
137
+ solution_module_root = main_module.removesuffix(".main")
138
+ return f"{solution_module_root}.solution.definition", f"{solution_module_root}.ui.app"
139
+
140
+
141
+ def _set_solution_runtime_env_vars(solution_env: dict[str, str], main_module: str, env_file: Path | None):
142
+ """Set GLOW_SOLUTION_DEFINITION and GLOW_UI_MODULE environment variables.
143
+
144
+ For backward compatibility with existing solutions that may not define these values in .env,
145
+ derive defaults from the resolved main module. Allows explicit overrides from environment or .env.
146
+ """
147
+ solution_definition_default, ui_module_default = _get_solution_runtime_modules(main_module)
148
+ env_file_values: dict[str, str] = {}
149
+ if env_file and env_file.is_file():
150
+ env_file_values = {k: str(v) for k, v in dotenv_values(env_file).items() if v is not None}
151
+
152
+ solution_definition = (
153
+ solution_env.get(GLOW_SOLUTION_DEFINITION)
154
+ or env_file_values.get(GLOW_SOLUTION_DEFINITION)
155
+ or solution_definition_default
156
+ )
157
+ ui_module = solution_env.get(GLOW_UI_MODULE) or env_file_values.get(GLOW_UI_MODULE) or ui_module_default
158
+
159
+ solution_env[GLOW_SOLUTION_DEFINITION] = solution_definition
160
+ solution_env[GLOW_UI_MODULE] = ui_module
161
+
162
+
163
+ def _get_executable_absolute_path(executable: str, solution_root_dir: Path) -> Path:
164
+ solution_venv_bin_dir = get_solution_venv_bin_dir(solution_root_dir)
165
+ # We could not find a way to force binaries such as python or pip to resolve to the solution's environment,
166
+ # so we force it by prepending the solution's environment bin directory.
167
+ if (solution_venv_bin_dir / executable).is_file():
168
+ return solution_venv_bin_dir / executable
169
+ elif (solution_venv_bin_dir / f"{executable}.exe").is_file():
170
+ return solution_venv_bin_dir / f"{executable}.exe"
171
+ else:
172
+ raise FileNotFoundError(f"Executable '{executable}' not found in solution '{solution_root_dir.name}'.")
173
+
174
+
175
+ def _generic_exception_handler(e: Exception):
176
+ click.secho(f"Error: {e}\n{traceback.format_exc()}", err=True, fg="red")
177
+ sys.exit(1)
178
+
179
+
180
+ @click.group(invoke_without_command=True)
181
+ @click.option("--version", is_flag=True, help="Display the SAF-CLI version.")
182
+ @click.pass_context
183
+ def saf(ctx: click.Context, version: bool):
184
+ """Utility script for solutions."""
185
+ try:
186
+ if version:
187
+ click.secho(importlib.metadata.version("ansys-saf-cli"), fg="cyan")
188
+ sys.exit(0)
189
+ elif ctx.invoked_subcommand is None:
190
+ click.secho(ctx.get_help())
191
+ sys.exit(0)
192
+ except Exception as e:
193
+ _generic_exception_handler(e)
194
+
195
+
196
+ # By default, click first prompts for all options, and then validates the arguments. This causes an issue where an user
197
+ # may do something like "saf <command> extra-invalid-argument" and only after providing all the prompted inputs, they
198
+ # receive an error about the invalid argument. To avoid this, we create a custom Command class that first checks for
199
+ # extra arguments before prompting for inputs.
200
+ class CommandWithPromptsNoArgs(click.Command):
201
+ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
202
+ original_args = list(args)
203
+ # The following code snippet is copied from click.Command.parse_args
204
+ parser = self.make_parser(ctx)
205
+ _, args, _ = parser.parse_args(args=args)
206
+ if args and not ctx.allow_extra_args and not ctx.resilient_parsing:
207
+ ctx.fail(
208
+ ngettext(
209
+ "Got unexpected extra argument ({args})",
210
+ "Got unexpected extra arguments ({args})",
211
+ len(args),
212
+ ).format(args=" ".join(map(str, args))),
213
+ )
214
+ return super().parse_args(ctx, original_args)
215
+
216
+
217
+ @saf.command(short_help="Create a new solution", cls=CommandWithPromptsNoArgs)
218
+ @click.option(
219
+ "--solution-name",
220
+ prompt="What is the solution name?",
221
+ default=DEFAULT_SOLUTION_NAME,
222
+ help="The name of the solution to be created.",
223
+ type=str,
224
+ )
225
+ @click.option(
226
+ "--solution-display-name",
227
+ prompt="What is the solution display name?",
228
+ default=DEFAULT_SOLUTION_DISPLAY_NAME,
229
+ help="Name of the solution in the user interface.",
230
+ type=str,
231
+ )
232
+ @click.option(
233
+ "--ui-framework",
234
+ type=click.Choice(UI_FRAMEWORKS),
235
+ prompt=UI_PROMPT,
236
+ show_choices=False,
237
+ default=DEFAULT_UI_FRAMEWORK,
238
+ help="Type of solution UI.",
239
+ )
240
+ @click.option(
241
+ "--namespace",
242
+ default=DEFAULT_SOLUTION_NAMESPACE,
243
+ prompt="What is the solution namespace? (e.g. myorg.apps)",
244
+ help=(
245
+ "Provide a dot-separated namespace using lowercase letters, numbers, or underscores. "
246
+ "Example: my_namespace.project"
247
+ ),
248
+ type=str,
249
+ callback=_validate_namespace_root,
250
+ )
251
+ def new(solution_name: str, solution_display_name: str, ui_framework: str, namespace: str):
252
+ """Create a new Solution in the current working directory."""
253
+ solution_created: bool = False
254
+ solution_root_dir: Path | None = None
255
+ try:
256
+ # solution_name will be used for the name of the python project directory (solution_root_dir).
257
+ # SolutionRegistry contains validation for solution_name to check that the resulting path is valid.
258
+ # In the same way, when registering a solution based on its solution_root_dir path, we extract the solution name
259
+ # from the directory name.
260
+ solution_root_dir = Path.cwd() / solution_name
261
+ if solution_root_dir.exists():
262
+ raise FileExistsError(f"A file or directory already exists at {solution_root_dir}")
263
+
264
+ # create model before creating the solution to validate inputs
265
+ solution = SolutionRegistry(name=solution_name, root_dir=solution_root_dir, display_name=solution_display_name)
266
+
267
+ # Let's load the db before creating the solution, so that we fail fast in case something is wrong with it.
268
+ db_manager = SolutionDatabaseManager()
269
+ create_solution(solution_name, solution_display_name, ui_framework, namespace)
270
+ solution_created = True
271
+ db_manager.store_solution(solution)
272
+ except Exception as e:
273
+ if solution_root_dir and solution_root_dir.is_dir() and solution_created:
274
+ shutil.rmtree(solution_root_dir)
275
+ _generic_exception_handler(e)
276
+
277
+
278
+ @saf.command(short_help="Create the virtual environment and install the dependencies of a solution")
279
+ @click.argument("solution", type=str, default="")
280
+ @click.option(
281
+ "-d",
282
+ "--dependencies",
283
+ type=str,
284
+ help=(
285
+ "List of dependency groups to install separated by comas. Example: tests,doc,style. [default: desktop,ui,doc]"
286
+ ),
287
+ )
288
+ @click.option(
289
+ "-F",
290
+ "--force-clear-all",
291
+ is_flag=True,
292
+ default=False,
293
+ help="Clean-up the workspace. Delete existing .venv, .poetry/.venv, .poetry/.cache and poetry.lock.",
294
+ )
295
+ @click.option(
296
+ "-f",
297
+ "--force-clear",
298
+ is_flag=True,
299
+ default=False,
300
+ help="Clean-up the workspace. Delete existing .venv., .poetry/.venv and .poetry/.cache.",
301
+ )
302
+ @click.option(
303
+ "--env-file",
304
+ type=click.Path(exists=True, file_okay=True, path_type=Path),
305
+ help="Load environment variables from this file. [default: .env in the solution root directory]",
306
+ )
307
+ def install(
308
+ solution: str,
309
+ dependencies: str | None,
310
+ force_clear_all: bool,
311
+ force_clear: bool,
312
+ env_file: Path | None,
313
+ ):
314
+ """Create the virtual environment and install the dependencies of a solution
315
+
316
+ SOLUTION: The name of a registered Solution, or the path to a solution directory (relative or absolute).
317
+ When specifying a path, it must point to the root dir of the solution, which contains the src directory. If left
318
+ empty, the current working directory is assumed to be the root dir of a solution. When using a path or left empty,
319
+ if the resolved directory is a valid solution directory, the solution is registered in the database.
320
+ """
321
+ try:
322
+ stored_solution, main_module = _resolve_input_solution(solution)
323
+ dependency_groups = dependencies.split(",") if dependencies else None
324
+ workspace_clear = "hard" if force_clear_all else "soft" if force_clear else None
325
+ default_env_file = stored_solution.root_dir / ".env"
326
+ env_file = env_file or (default_env_file if default_env_file.is_file() else None)
327
+ setup_environment(
328
+ solution_root_dir=stored_solution.root_dir,
329
+ solution_main_module_name=main_module,
330
+ dependency_groups=dependency_groups,
331
+ workspace_clear=workspace_clear,
332
+ env_file=env_file,
333
+ )
334
+ except InstallError as ie:
335
+ click.secho(str(ie), err=True, fg="red")
336
+ sys.exit(1)
337
+ except Exception as e:
338
+ _generic_exception_handler(e)
339
+
340
+
341
+ @saf.command(short_help="List stored solutions")
342
+ def solutions():
343
+ """List all the Solutions stored in the database. Solutions marked as invalid are not shown."""
344
+ try:
345
+ db_manager = SolutionDatabaseManager()
346
+ solutions_by_name = db_manager.get_solutions_grouped_by_name()
347
+ if not solutions_by_name:
348
+ click.secho("No solutions found.", fg="yellow")
349
+ return
350
+ for solution_name, solutions in solutions_by_name.items():
351
+ click.secho(f"{solution_name}", fg="cyan", bold=True)
352
+ for solution in solutions:
353
+ click.secho(f" Root directory: {solution.root_dir}", fg="green")
354
+ click.secho(f" Display Name: {solution.display_name}", fg="green")
355
+ click.secho()
356
+ except Exception as e:
357
+ _generic_exception_handler(e)
358
+
359
+
360
+ @saf.command(short_help="List installed templates")
361
+ @click.option("--steps", is_flag=True, help="List only steps.")
362
+ @click.option("--solutions", is_flag=True, help="List only solutions.")
363
+ @click.option("--verbose", is_flag=True, help="Enable verbose output.")
364
+ def templates(steps: bool, solutions: bool, verbose: bool):
365
+ """List all the templates installed in the system."""
366
+ try:
367
+ if steps and solutions:
368
+ raise click.UsageError("Cannot use both --steps and --solutions flags together.")
369
+ list_available_templates(steps_only=steps, solutions_only=solutions, verbose=verbose)
370
+ except Exception as e:
371
+ _generic_exception_handler(e)
372
+
373
+
374
+ @saf.command(short_help="Run a solution from source code.")
375
+ @click.argument("solution", type=str, default="")
376
+ @click.option(
377
+ "--portal",
378
+ is_flag=True,
379
+ default=False,
380
+ help="Run the solution with Portal. If set, the portal UI will open instead of the solution UI.",
381
+ )
382
+ @click.option("--no-ui", is_flag=True, default=False, help="Run the solution without the UI server.")
383
+ @click.option(
384
+ "--browser",
385
+ is_flag=True,
386
+ default=False,
387
+ help="Run the solution without webview, opening the UI in the browser.",
388
+ )
389
+ @click.option("--streamlit-ui", is_flag=True, default=False, help="Run the solution with Streamlit UI framework.")
390
+ @click.option(
391
+ "--loglevel",
392
+ envvar=GLOW_LOGGING_LEVEL,
393
+ default=None,
394
+ type=str,
395
+ help="Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL.",
396
+ )
397
+ @click.option("--debug", envvar=GLOW_DEBUG, is_flag=True, default=False, help="Run the solution in debug mode.")
398
+ @click.option(
399
+ "--ui-debugger",
400
+ envvar=GLOW_UI_DEBUG,
401
+ is_flag=True,
402
+ default=False,
403
+ help="Enable debugpy.listen in UI process and disable Dash/Flask debug functionality.",
404
+ )
405
+ @click.option(
406
+ "--portal-ui-port",
407
+ envvar=PORTAL_UI_PORT,
408
+ type=int,
409
+ help="The port on which the portal is running. [default: random]",
410
+ )
411
+ @click.option(
412
+ "--solution-api-port",
413
+ envvar=GLOW_API_PORT,
414
+ type=int,
415
+ help="The port on which the solution api is running. [default: random]",
416
+ )
417
+ @click.option(
418
+ "--solution-ui-port",
419
+ envvar=GLOW_UI_PORT,
420
+ type=int,
421
+ help="The port on which the solution ui is running. [default: random]",
422
+ )
423
+ @click.option(
424
+ "--project",
425
+ default=None,
426
+ type=str,
427
+ help="Display name of the project to open or create. This option cannot be used if Portal is running.",
428
+ )
429
+ @click.option(
430
+ "--env-file",
431
+ type=click.Path(exists=True, file_okay=True, path_type=Path),
432
+ help="Load environment variables from this file. [default: .env in the solution root directory]",
433
+ )
434
+ @click.option(
435
+ "--no-automatic-project-migration",
436
+ is_flag=True,
437
+ default=False,
438
+ help="Disable automatic schema migration for old projects.",
439
+ )
440
+ @click.option(
441
+ "--log-to-files",
442
+ envvar=SAF_DESKTOP_LOG_TO_FILES,
443
+ is_flag=True,
444
+ default=False,
445
+ help="Log to files and disable OTEL dashboard.",
446
+ )
447
+ def run(
448
+ solution: str,
449
+ portal: bool,
450
+ no_ui: bool,
451
+ browser: bool,
452
+ streamlit_ui: bool,
453
+ loglevel: str | None,
454
+ debug: bool,
455
+ ui_debugger: bool,
456
+ portal_ui_port: int | None,
457
+ solution_api_port: int | None,
458
+ solution_ui_port: int | None,
459
+ project: str | None,
460
+ env_file: Path | None,
461
+ no_automatic_project_migration: bool,
462
+ log_to_files: bool,
463
+ ):
464
+ """Run a Solution.
465
+
466
+ SOLUTION: The name of a registered Solution, or the path to a solution directory (relative or absolute).
467
+ When specifying a path, it must point to the root dir of the solution, which contains the src directory. If left
468
+ empty, the current working directory is assumed to be the root dir of a solution. When using a path or left empty,
469
+ if the resolved directory is a valid solution directory, the solution is registered in the database.
470
+ """
471
+ try:
472
+ stored_solution, main_module = _resolve_input_solution(solution)
473
+ env_file = _resolve_effective_env_file(stored_solution.root_dir, env_file)
474
+
475
+ solution_env = create_solution_environment(stored_solution.root_dir)
476
+ _set_solution_runtime_env_vars(solution_env, main_module, env_file)
477
+ _set_env_var_if_not_none(GLOW_LOGGING_LEVEL, loglevel, solution_env)
478
+ if debug: # don't overwrite env var if user has not set the flag explicitly, bool variables are always not None
479
+ _set_env_var_if_not_none(GLOW_DEBUG, debug, solution_env)
480
+ if ui_debugger:
481
+ _set_env_var_if_not_none(GLOW_UI_DEBUG, ui_debugger, solution_env)
482
+ _set_env_var_if_not_none(PORTAL_UI_PORT, portal_ui_port, solution_env)
483
+ _set_env_var_if_not_none(GLOW_API_PORT, solution_api_port, solution_env)
484
+ _set_env_var_if_not_none(GLOW_UI_PORT, solution_ui_port, solution_env)
485
+
486
+ executable = get_exec_from_solution_venv(stored_solution.root_dir, "python")
487
+ cmd: list[str] = [
488
+ executable.as_posix(),
489
+ "-m",
490
+ "ansys.saf.desktop.orchestrator",
491
+ "--solution-main-module-name",
492
+ f"{main_module}",
493
+ ]
494
+ cmd += ["--no-ui"] if no_ui else []
495
+ cmd += ["--portal"] if portal else []
496
+ cmd += ["--browser"] if browser else []
497
+ cmd += ["--streamlit-ui"] if streamlit_ui else []
498
+ cmd += ["--project-display-name", project] if project else []
499
+ cmd += ["--env-file", env_file.resolve().as_posix()] if env_file else []
500
+ cmd += ["--no-automatic-project-migration"] if no_automatic_project_migration else []
501
+ cmd += ["--log-to-files"] if log_to_files else []
502
+ subprocess.run(cmd, env=solution_env, cwd=stored_solution.root_dir, check=True)
503
+ except Exception as e:
504
+ _generic_exception_handler(e)
505
+
506
+
507
+ @saf.command(short_help="Execute a command using the solution's python environment")
508
+ @click.argument("solution", type=str, default="")
509
+ @click.argument("command", type=str, default="", required=True)
510
+ @click.option(
511
+ "--cwd",
512
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
513
+ help="Directory in which the command will be executed. [default: solution root directory]",
514
+ )
515
+ @click.option(
516
+ "--env-file",
517
+ type=click.Path(exists=True, file_okay=True, path_type=Path),
518
+ help="Load environment variables from this file. [default: .env in the solution root directory]",
519
+ )
520
+ def execute(solution: str, command: str, cwd: Path | None, env_file: Path | None = None):
521
+ try:
522
+ # If a single argument is passed it is interpreted as the command and it will be executed in the
523
+ # current working directory's solution, if any.
524
+ if not command:
525
+ command = solution
526
+ solution = ""
527
+
528
+ stored_solution, main_module = _resolve_input_solution(solution)
529
+
530
+ cmd = shlex.split(command)
531
+ executable = cmd[0]
532
+ executable_abs_path = _get_executable_absolute_path(executable, stored_solution.root_dir)
533
+
534
+ cwd = cwd or stored_solution.root_dir
535
+
536
+ env_file = _resolve_effective_env_file(stored_solution.root_dir, env_file)
537
+ if env_file and env_file.is_file():
538
+ click.secho(f"Environment variables loaded from {env_file.resolve()}")
539
+ load_dotenv(env_file)
540
+ solution_env = create_solution_environment(stored_solution.root_dir)
541
+ _set_solution_runtime_env_vars(solution_env, main_module=main_module, env_file=env_file)
542
+
543
+ subprocess.run([executable_abs_path.as_posix()] + cmd[1:], cwd=cwd, env=solution_env, check=True)
544
+ except Exception as e:
545
+ _generic_exception_handler(e)
546
+
547
+
548
+ @saf.command(short_help="Archive a solution's source code.")
549
+ @click.argument("solution", type=str, default="")
550
+ @click.option(
551
+ "--filename",
552
+ default=None,
553
+ type=str,
554
+ help="The name of the archive file to create. If not provided, the solution name is used.",
555
+ )
556
+ @click.option(
557
+ "--path",
558
+ default=None,
559
+ type=str,
560
+ help="The path to a directory where to save the solution. If not provided, the current parent directory is used.",
561
+ )
562
+ @click.option(
563
+ "--extension",
564
+ default=None,
565
+ type=str,
566
+ help="The extension of the archive file to create. If not provided, .saf will be used.",
567
+ )
568
+ def archive(
569
+ solution: str,
570
+ filename: str | None,
571
+ path: Path | None,
572
+ extension: str | None,
573
+ ):
574
+ """Create a .saf archive from the given solution.
575
+
576
+ SOLUTION: The name of a registered Solution, or the path to a solution directory (relative or absolute).
577
+ When specifying a path, it must point to the root dir of the solution, which contains the src directory. If left
578
+ empty, the current working directory is assumed to be the root dir of a solution. When using a path or left empty,
579
+ if the resolved directory is a valid solution directory, the solution is registered in the database.
580
+ """
581
+ try:
582
+ stored_solution, _ = _resolve_input_solution(solution)
583
+
584
+ archive_solution(
585
+ solution_root_dir=stored_solution.root_dir,
586
+ archive_name=filename or stored_solution.name,
587
+ archive_path=Path(path) if path else None,
588
+ archive_extension=extension,
589
+ )
590
+ except Exception as e:
591
+ _generic_exception_handler(e)
592
+
593
+
594
+ @saf.command(short_help="Open SAF documentation in a web browser.")
595
+ def doc():
596
+ """Opens SAF documentation in a web browser."""
597
+ try:
598
+ webbrowser.open(SAF_CLI_EXTERNAL_URL)
599
+ except Exception as e:
600
+ _generic_exception_handler(e)
601
+
602
+
603
+ @saf.command(short_help="Package the solution for distribution.")
604
+ @click.argument("solution", type=str, default="")
605
+ @click.option(
606
+ "--display-console-window",
607
+ is_flag=True,
608
+ default=False,
609
+ help="Display the console window when running the solution.",
610
+ )
611
+ @click.option("--encrypt", is_flag=True, default=False, help="Encrypt the solution code before packaging.")
612
+ @click.option(
613
+ "--encryption-file",
614
+ help="The file containing the list of files to encrypt.",
615
+ default="",
616
+ )
617
+ @click.option("--encryption-key", help="The encryption key to use for obfuscation.", default="")
618
+ @click.option(
619
+ "--no-executable",
620
+ is_flag=True,
621
+ default=False,
622
+ help="Do not create an executable installer for the solution.",
623
+ )
624
+ @click.option(
625
+ "--executable-as-dir",
626
+ is_flag=True,
627
+ default=False,
628
+ help=(
629
+ "Bundle the executable in a directory containing all the supporting files. "
630
+ "This is mandatory if the executable file size exceeds the pyinstaller limit of 4GB."
631
+ ),
632
+ )
633
+ @click.option("--obfuscate", is_flag=True, default=False, help="Obfuscate the solution code before packaging.")
634
+ @click.option(
635
+ "--offline-package",
636
+ is_flag=True,
637
+ default=False,
638
+ help="Create a package that installs without internet access.",
639
+ )
640
+ @click.option(
641
+ "--exclude-python",
642
+ is_flag=True,
643
+ default=False,
644
+ help="Do not ship the Python interpreter with the packaged solution.",
645
+ )
646
+ @click.option(
647
+ "--python-version",
648
+ help="The version of Python to use for the solution.",
649
+ default="",
650
+ )
651
+ @click.option(
652
+ "--force-python-from-source",
653
+ is_flag=True,
654
+ default=False,
655
+ help="Force building Python from source instead of downloading it from NuGet packages (Windows only).",
656
+ )
657
+ @click.option(
658
+ "--solution-entry-point",
659
+ help=(
660
+ "The main solution entry point to start the solution. "
661
+ "Module should be a dotted import ('ansys.solutions.mysoln.main'). "
662
+ "If not provided, the solution will be run with the default entry point."
663
+ ),
664
+ default="",
665
+ )
666
+ @click.option("--github-token", help="Personal Access Token for downloading dependencies from GitHub.", default="")
667
+ @click.option("--solution-ui-framework", help="The UI framework used in the solution.", default="")
668
+ @click.option("--no-glow", is_flag=True, default=False, help="Add this flag if the solution does not use SAF/GLOW.")
669
+ @click.option(
670
+ "--env-file",
671
+ type=click.Path(exists=True, file_okay=True, path_type=Path),
672
+ help="Load environment variables from this file. [default: .env in the solution root directory]",
673
+ )
674
+ def build(
675
+ solution: str,
676
+ display_console_window: bool,
677
+ encrypt: bool,
678
+ encryption_file: str,
679
+ encryption_key: str,
680
+ no_executable: bool,
681
+ executable_as_dir: bool,
682
+ obfuscate: bool,
683
+ offline_package: bool,
684
+ exclude_python: bool,
685
+ python_version: str,
686
+ force_python_from_source: bool,
687
+ solution_entry_point: str,
688
+ github_token: str,
689
+ solution_ui_framework: str,
690
+ no_glow: bool,
691
+ env_file: Path | None = None,
692
+ ):
693
+ """Package the solution for distribution.
694
+
695
+ This command will package the solution into a single executable file for distribution. The solution is
696
+ packaged as a standalone executable that can be run on any machine without needing to install Python or any
697
+ dependencies. The solution is packaged with all its dependencies and the Python interpreter.
698
+
699
+ The solution is packaged using PyInstaller. The packaged solution is saved in the dist directory of the solution.
700
+
701
+ SOLUTION: The name of a registered Solution, or the path to a solution directory (relative or absolute).
702
+ When specifying a path, it must point to the root dir of the solution, which contains the src directory. If left
703
+ empty, the current working directory is assumed to be the root dir of a solution. When using a path or left empty,
704
+ if the resolved directory is a valid solution directory, the solution is registered in the database.
705
+ """
706
+ try:
707
+ stored_solution, main_module = _resolve_input_solution(solution)
708
+ env_file = _resolve_effective_env_file(stored_solution.root_dir, env_file)
709
+
710
+ executable = get_exec_from_solution_venv(stored_solution.root_dir, "python")
711
+ cmd: list[str] = [
712
+ executable.as_posix(),
713
+ "-m",
714
+ "ansys.saf.desktop.installer",
715
+ ]
716
+ cmd += ["--display-console-window"] if display_console_window else []
717
+ cmd += ["--encrypt"] if encrypt else []
718
+ cmd += ["--encryption-file", encryption_file] if encryption_file else []
719
+ cmd += ["--encryption-key", encryption_key] if encryption_key else []
720
+ cmd += ["--no-executable"] if no_executable else []
721
+ cmd += ["--executable-as-dir"] if executable_as_dir else []
722
+ cmd += ["--obfuscate"] if obfuscate else []
723
+ cmd += ["--offline-package"] if offline_package else []
724
+ cmd += ["--exclude-python"] if exclude_python else []
725
+ cmd += ["--python-version", python_version] if python_version else []
726
+ cmd += ["--force-python-from-source"] if force_python_from_source else []
727
+ cmd += ["--solution-entry-point", solution_entry_point] if solution_entry_point else []
728
+ cmd += ["--github-token", github_token] if github_token else []
729
+ cmd += ["--solution-ui-framework", solution_ui_framework] if solution_ui_framework else []
730
+ cmd += ["--no-glow"] if no_glow else []
731
+ cmd += ["--env-file", env_file.resolve().as_posix()] if env_file else []
732
+
733
+ solution_env = create_solution_environment(stored_solution.root_dir)
734
+ _set_solution_runtime_env_vars(solution_env, main_module, env_file)
735
+ subprocess.run(cmd, cwd=stored_solution.root_dir, env=solution_env, check=True)
736
+ except Exception as e:
737
+ _generic_exception_handler(e)
738
+
739
+
740
+ @saf.command(short_help="Add a new step to the solution, with or without associated UI.")
741
+ @click.argument("solution", type=str, default="")
742
+ @click.option(
743
+ "--step-name",
744
+ prompt="What is the Step name?",
745
+ default=DEFAULT_STEP_NAME,
746
+ help="The name of the step to add to the solution. It is assumed that the step name is provided in snake_case.",
747
+ type=str,
748
+ )
749
+ @click.option(
750
+ "--ui-framework",
751
+ type=click.Choice(UI_FRAMEWORKS),
752
+ prompt=UI_PROMPT,
753
+ show_choices=False,
754
+ default=DEFAULT_UI_FRAMEWORK,
755
+ help="Type of solution UI.",
756
+ )
757
+ @click.option(
758
+ "--template",
759
+ prompt="What is the Step template name?",
760
+ default=DEFAULT_TEMPLATE_NAME,
761
+ help="The name of the step template to be used.",
762
+ type=str,
763
+ )
764
+ def add_step(
765
+ solution: str,
766
+ step_name: str,
767
+ ui_framework: str,
768
+ template: str,
769
+ ):
770
+ """Add a new step to the solution, with or without associated UI.
771
+
772
+ SOLUTION: The name of a registered Solution, or the path to a solution directory (relative or absolute).
773
+ When specifying a path, it must point to the root dir of the solution, which contains the src directory. If left
774
+ empty, the current working directory is assumed to be the root dir of a solution. When using a path or left empty,
775
+ if the resolved directory is a valid solution directory, the solution is registered in the database.
776
+ """
777
+ try:
778
+ stored_solution, solution_main_module = _resolve_input_solution(solution)
779
+ # expected format: <namespace>.<solution_name>.main
780
+ # e.g., saf.solutions.my_solution.main or org.apps.my_solution.main
781
+ solution_package_name = solution_main_module.split(".")[-2]
782
+ namespace_root = ".".join(solution_main_module.split(".")[:-2])
783
+ saf_step_template = resolve_template(template)
784
+ if saf_step_template.type != "step":
785
+ raise ValueError(f"Template '{template}' is of type '{saf_step_template.type}', expected 'step'")
786
+
787
+ ctx = click.get_current_context()
788
+ step_name_source = ctx.get_parameter_source("step_name")
789
+
790
+ if step_name_source == click.core.ParameterSource.PROMPT:
791
+ while step_name_exists(stored_solution.root_dir, solution_package_name, step_name):
792
+ click.secho(
793
+ f"A step with name '{step_name}' already exists in solution '{stored_solution.name}'.",
794
+ fg="yellow",
795
+ )
796
+ step_name = click.prompt("Please enter a different Step name", default=DEFAULT_STEP_NAME)
797
+ elif step_name_exists(stored_solution.root_dir, solution_package_name, step_name):
798
+ raise ValueError(f"Step name '{step_name}' is already taken in solution '{stored_solution.name}'.")
799
+
800
+ check_template_cli_compatibility(stored_solution.root_dir, saf_step_template)
801
+
802
+ with BackupManager(stored_solution.root_dir, solution_package_name, namespace_root):
803
+ add_step_to_solution(
804
+ stored_solution.name,
805
+ stored_solution.root_dir,
806
+ solution_package_name,
807
+ step_name,
808
+ ui_framework,
809
+ saf_step_template,
810
+ )
811
+ except Exception as e:
812
+ _generic_exception_handler(e)
813
+
814
+
815
+ def entry_point():
816
+ saf(obj={})