diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 26ad46553..22f0c57bf 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -348,7 +348,7 @@ jobs: PYTHONUNBUFFERED: 1 run: | . /env/bin/activate - mechanical-env pytest -m embedding_scripts -s --junitxml test_results_embedding_scripts${{ matrix.python-version }}.xml + mechanical-env pytest -m embedding_scripts -m cli -s --junitxml test_results_embedding_scripts${{ matrix.python-version }}.xml - name: Upload coverage results uses: actions/upload-artifact@v4 diff --git a/doc/changelog.d/735.added.md b/doc/changelog.d/735.added.md new file mode 100644 index 000000000..6ec3ecd87 --- /dev/null +++ b/doc/changelog.d/735.added.md @@ -0,0 +1 @@ +add feature flags to ansys-mechanical cli \ No newline at end of file diff --git a/doc/source/getting_started/running_mechanical.rst b/doc/source/getting_started/running_mechanical.rst index ee3a2bbe9..c82ccf15b 100644 --- a/doc/source/getting_started/running_mechanical.rst +++ b/doc/source/getting_started/running_mechanical.rst @@ -81,6 +81,9 @@ usage, type the following command: port number -i, --input-script TEXT Name of the input Python script. Cannot be mixed with -p + --features TEXT Beta feature flags to set, as a semicolon + delimited list. Options: ['MultistageHarmonic', + 'ThermalShells'] --exit Exit the application after running an input script. You can only use this command with --input-script argument (-i). The command diff --git a/pyproject.toml b/pyproject.toml index df9eddd68..6fd02491a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -137,6 +137,7 @@ markers = [ "remote_session_connect: tests that connect to Mechanical and work with gRPC server inside it", "minimum_version(num): tests that run if ansys-version is greater than or equal to the minimum version provided", "windows_only: tests that run if the testing platform is on Windows", + "cli: tests for the Command Line Interface", ] xfail_strict = true diff --git a/src/ansys/mechanical/core/embedding/appdata.py b/src/ansys/mechanical/core/embedding/appdata.py index 56c35622a..a79598abf 100644 --- a/src/ansys/mechanical/core/embedding/appdata.py +++ b/src/ansys/mechanical/core/embedding/appdata.py @@ -31,14 +31,17 @@ class UniqueUserProfile: """Create Unique User Profile (for AppData).""" - def __init__(self, profile_name): + def __init__(self, profile_name: str, dry_run: bool = False): """Initialize UniqueUserProfile class.""" self._default_profile = os.path.expanduser("~") self._location = os.path.join(self._default_profile, "PyMechanical-AppData", profile_name) + self._dry_run = dry_run self.initialize() def initialize(self) -> None: """Initialize the new profile location.""" + if self._dry_run: + return if self.exists(): self.cleanup() self.mkdirs() @@ -46,6 +49,8 @@ def initialize(self) -> None: def cleanup(self) -> None: """Cleanup unique user profile.""" + if self._dry_run: + return text = "The `private_appdata` option was used, but the following files were not removed: " message = [] diff --git a/src/ansys/mechanical/core/feature_flags.py b/src/ansys/mechanical/core/feature_flags.py new file mode 100644 index 000000000..96c11838a --- /dev/null +++ b/src/ansys/mechanical/core/feature_flags.py @@ -0,0 +1,51 @@ +# Copyright (C) 2022 - 2024 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Mechanical beta feature flags.""" + +import typing +import warnings + + +class FeatureFlags: + """Supported feature flag names.""" + + ThermalShells = "Mechanical.ThermalShells" + MultistageHarmonic = "Mechanical.MultistageHarmonic" + + +def get_feature_flag_names() -> typing.List[str]: + """Get the available feature flags.""" + return [x for x in dir(FeatureFlags) if "_" not in x] + + +def _get_flag_arg(flagname: str) -> str: + """Get the command line name for a given feature flag.""" + if hasattr(FeatureFlags, flagname): + return getattr(FeatureFlags, flagname) + warnings.warn(f"Using undocumented feature flag {flagname}") + return flagname + + +def get_command_line_arguments(flags: typing.List[str]): + """Get the command line arguments as an array for the given flags.""" + return ["-featureflags", ";".join([_get_flag_arg(flag) for flag in flags])] diff --git a/src/ansys/mechanical/core/run.py b/src/ansys/mechanical/core/run.py index 1ab27152d..4681df9d2 100644 --- a/src/ansys/mechanical/core/run.py +++ b/src/ansys/mechanical/core/run.py @@ -33,6 +33,9 @@ import click from ansys.mechanical.core.embedding.appdata import UniqueUserProfile +from ansys.mechanical.core.feature_flags import get_command_line_arguments, get_feature_flag_names + +DRY_RUN = False # TODO - add logging options (reuse env var based logging initialization) # TODO - add timeout @@ -82,6 +85,101 @@ def _run(args, env, check=False, display=False): return output +def _cli_impl( + project_file: str = None, + port: int = 0, + debug: bool = False, + input_script: str = None, + exe: str = None, + version: int = None, + graphical: bool = False, + show_welcome_screen: bool = False, + private_appdata: bool = False, + exit: bool = False, + features: str = None, +): + if project_file and input_script: + raise Exception("Cannot open a project file *and* run a script.") + + if (not graphical) and project_file: + raise Exception("Cannot open a project file in batch mode.") + + if port: + if project_file: + raise Exception("Cannot open in server mode with a project file.") + if input_script: + raise Exception("Cannot open in server mode with an input script.") + + args = [exe, "-DSApplet"] + if (not graphical) or (not show_welcome_screen): + args.append("-AppModeMech") + + if version < 232: + args.append("-nosplash") + args.append("-notabctrl") + + if not graphical: + args.append("-b") + + env: typing.Dict[str, str] = os.environ.copy() + if debug: + env["WBDEBUG_STOP"] = "1" + + if port: + args.append("-grpc") + args.append(str(port)) + + if project_file: + args.append("-file") + args.append(project_file) + + if input_script: + args.append("-script") + args.append(input_script) + + if (not graphical) and input_script: + exit = True + if version < 241: + warnings.warn( + "Please ensure ExtAPI.Application.Close() is at the end of your script. " + "Without this command, Batch mode will not terminate.", + stacklevel=2, + ) + + if exit and input_script and version >= 241: + args.append("-x") + + profile: UniqueUserProfile = None + if private_appdata: + new_profile_name = f"Mechanical-{os.getpid()}" + profile = UniqueUserProfile(new_profile_name, DRY_RUN) + profile.update_environment(env) + + if not DRY_RUN: + version_name = atp.SUPPORTED_ANSYS_VERSIONS[version] + if graphical: + mode = "Graphical" + else: + mode = "Batch" + print(f"Starting Ansys Mechanical version {version_name} in {mode} mode...") + if port: + # TODO - Mechanical doesn't write anything to the stdout in grpc mode + # when logging is off.. Ideally we let Mechanical write it, so + # the user only sees the message when the server is ready. + print(f"Serving on port {port}") + + if features is not None: + args.extend(get_command_line_arguments(features.split(";"))) + + if DRY_RUN: + return args, env + else: + _run(args, env, False, True) + + if private_appdata: + profile.cleanup() + + @click.command() @click.help_option("--help", "-h") @click.option( @@ -102,6 +200,13 @@ def _run(args, env, check=False, display=False): type=int, help="Start mechanical in server mode with the given port number", ) +@click.option( + "--features", + type=str, + default=None, + help=f"Beta feature flags to set, as a semicolon delimited list.\ + Options: {get_feature_flag_names()}", +) @click.option( "-i", "--input-script", @@ -156,6 +261,7 @@ def cli( show_welcome_screen: bool, private_appdata: bool, exit: bool, + features: str, ): """CLI tool to run mechanical. @@ -167,80 +273,19 @@ def cli( Starting Ansys Mechanical version 2023R2 in graphical mode... """ - if project_file and input_script: - raise Exception("Cannot open a project file *and* run a script.") - - if (not graphical) and project_file: - raise Exception("Cannot open a project file in batch mode.") - - if port: - if project_file: - raise Exception("Cannot open in server mode with a project file.") - if input_script: - raise Exception("Cannot open in server mode with an input script.") - exe = atp.get_mechanical_path(allow_input=False, version=revision) version = atp.version_from_path("mechanical", exe) - version_name = atp.SUPPORTED_ANSYS_VERSIONS[version] - - args = [exe, "-DSApplet"] - if (not graphical) or (not show_welcome_screen): - args.append("-AppModeMech") - - if version < 232: - args.append("-nosplash") - args.append("-notabctrl") - - if graphical: - mode = "Graphical" - else: - mode = "Batch" - args.append("-b") - - if debug: - os.environ["WBDEBUG_STOP"] = "1" - - if port: - args.append("-grpc") - args.append(str(port)) - - if project_file: - args.append("-file") - args.append(project_file) - - if input_script: - args.append("-script") - args.append(input_script) - - if (not graphical) and input_script: - exit = True - if version < 241: - warnings.warn( - "Please ensure ExtAPI.Application.Close() is at the end of your script. " - "Without this command, Batch mode will not terminate.", - stacklevel=2, - ) - - if exit and input_script and version >= 241: - args.append("-x") - - profile: UniqueUserProfile = None - env: typing.Dict[str, str] = None - if private_appdata: - env = os.environ.copy() - new_profile_name = f"Mechanical-{os.getpid()}" - profile = UniqueUserProfile(new_profile_name) - profile.update_environment(env) - - print(f"Starting Ansys Mechanical version {version_name} in {mode} mode...") - if port: - # TODO - Mechanical doesn't write anything to the stdout in grpc mode - # when logging is off.. Ideally we let Mechanical write it, so - # the user only sees the message when the server is ready. - print(f"Serving on port {port}") - - _run(args, env, False, True) - - if private_appdata: - profile.cleanup() + return _cli_impl( + project_file, + port, + debug, + input_script, + exe, + version, + graphical, + show_welcome_screen, + private_appdata, + exit, + features, + ) diff --git a/tests/conftest.py b/tests/conftest.py index 68988027c..dfa2e5f54 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,7 +38,7 @@ from ansys.mechanical.core.errors import MechanicalExitedError from ansys.mechanical.core.examples import download_file from ansys.mechanical.core.misc import get_mechanical_bin -from ansys.mechanical.core.run import _run +import ansys.mechanical.core.run # to run tests with multiple markers # pytest -q --collect-only -m "remote_session_launch" @@ -176,7 +176,7 @@ def run_subprocess(): def func(args, env=None, check: bool = None): if check is None: check = _CHECK_PROCESS_RETURN_CODE - stdout, stderr = _run(args, env, check) + stdout, stderr = ansys.mechanical.core.run._run(args, env, check) return stdout, stderr return func @@ -189,6 +189,13 @@ def rootdir(): yield base.parent +@pytest.fixture() +def disable_cli(): + ansys.mechanical.core.run.DRY_RUN = True + yield + ansys.mechanical.core.run.DRY_RUN = False + + @pytest.fixture() def test_env(): """Create a virtual environment scoped to the test.""" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 000000000..047942d40 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,154 @@ +# Copyright (C) 2022 - 2024 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import os + +import pytest + +from ansys.mechanical.core.run import _cli_impl + + +@pytest.mark.cli +def test_cli_default(disable_cli): + args, env = _cli_impl(exe="AnsysWBU.exe", version=241) + assert os.environ == env + assert "-AppModeMech" in args + assert "-b" in args + assert "-DSApplet" in args + assert "AnsysWBU.exe" in args + + +@pytest.mark.cli +def test_cli_debug(disable_cli): + _, env = _cli_impl(exe="AnsysWBU.exe", version=241, debug=True) + assert "WBDEBUG_STOP" in env + + +@pytest.mark.cli +def test_cli_graphical(disable_cli): + args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, graphical=True) + assert "-b" not in args + + +@pytest.mark.cli +def test_cli_appdata(disable_cli): + _, env = _cli_impl(exe="AnsysWBU.exe", version=241, private_appdata=True) + var_to_compare = "TEMP" if os.name == "nt" else "HOME" + assert os.environ[var_to_compare] != env[var_to_compare] + + +@pytest.mark.cli +def test_cli_errors(disable_cli): + # can't mix project file and input script + with pytest.raises(Exception): + _cli_impl( + exe="AnsysWBU.exe", + version=241, + project_file="foo.mechdb", + input_script="foo.py", + graphical=True, + ) + # project file only works in graphical mode + with pytest.raises(Exception): + _cli_impl(exe="AnsysWBU.exe", version=241, project_file="foo.mechdb") + # can't mix port and project file + with pytest.raises(Exception): + _cli_impl(exe="AnsysWBU.exe", version=241, project_file="foo.mechdb", port=11) + # can't mix port and input script + with pytest.raises(Exception): + _cli_impl(exe="AnsysWBU.exe", version=241, input_script="foo.py", port=11) + + +@pytest.mark.cli +def test_cli_appmode(disable_cli): + args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, show_welcome_screen=True, graphical=True) + assert "-AppModeMech" not in args + + +@pytest.mark.cli +def test_cli_232(disable_cli): + args, _ = _cli_impl(exe="AnsysWBU.exe", version=231) + assert "-nosplash" in args + assert "-notabctrl" in args + + +@pytest.mark.cli +def test_cli_port(disable_cli): + args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, port=11) + assert "-grpc" in args + assert "11" in args + + +@pytest.mark.cli +def test_cli_project(disable_cli): + args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, project_file="foo.mechdb", graphical=True) + assert "-file" in args + assert "foo.mechdb" in args + + +@pytest.mark.cli +def test_cli_script(disable_cli): + args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, input_script="foo.py", graphical=True) + assert "-script" in args + assert "foo.py" in args + + +@pytest.mark.cli +def test_cli_features(disable_cli): + with pytest.warns(UserWarning): + args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, features="a;b;c") + assert "-featureflags" in args + assert "a;b;c" in args + args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, features="MultistageHarmonic") + assert "Mechanical.MultistageHarmonic" in args + + +@pytest.mark.cli +def test_cli_exit(disable_cli): + + # Regardless of version, `exit` does nothing on its own + args, _ = _cli_impl(exe="AnsysWBU.exe", version=232, exit=True) + assert "-x" not in args + + args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, exit=True) + assert "-x" not in args + + # On versions earlier than 2024R1, `exit` throws a warning but does nothing + with pytest.warns(UserWarning): + args, _ = _cli_impl(exe="AnsysWBU.exe", version=232, exit=True, input_script="foo.py") + assert "-x" not in args + + # In UI mode, exit must be manually specified + args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, input_script="foo.py", graphical=True) + assert "-x" not in args + + # In batch mode, exit is implied + args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, input_script="foo.py") + assert "-x" in args + + # In batch mode, exit can be explicitly passed + args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, exit=True, input_script="foo.py") + assert "-x" in args + + # In batch mode, exit can not be disabled + args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, exit=False, input_script="foo.py") + assert "-x" in args