diff --git a/doc/source/conf.py b/doc/source/conf.py index ffe438126..ef633da33 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -60,6 +60,7 @@ "StaticMixer_001.cfx", "StaticMixer_001.def", ], + "python_uv": ["project_setup.py", "exec_script.py", "eval.py"], } sys.path.append(os.path.abspath(os.path.dirname(__file__))) diff --git a/doc/source/examples/ex_python_uv.rst b/doc/source/examples/ex_python_uv.rst new file mode 100644 index 000000000..2f142674c --- /dev/null +++ b/doc/source/examples/ex_python_uv.rst @@ -0,0 +1,171 @@ +.. _example_python_uv: + +.. note:: + + Go to the `bottom of this page`_ to download the ZIP file for the uv example. + +Run arbitrary Python scripts on HPS +=================================== + +This example shows how to run arbitrary Python scripts. It uses +the `uv `__ package to generate the required +environments on the fly. + +The example sets up a project that plots ``sin(x)`` using NumPy and +Matplotlib and then saves the figure to a file. + +The metadata header present in the ``eval.py`` script, which defines the +dependencies, enables uv to take care of the environment setup: + +.. code:: python + + # /// script + # requires-python = "==3.12" + # dependencies = [ + # "numpy", + # "matplotlib" + # ] + # /// + +For more information, see `Inline script metadata +`__ +in the *Python Packaging User Guide* and `Running a script with dependencies `__ +in the uv documentation. + +Prerequisites +============= + +For the example to run, uv must be installed and registered +on the scaler/evaluator. For installation instructions, see +`Installing uv `__ +in the uv documentation. + +Once uv is installed, the package must be registered in the +scaler/evaluator with the following properties: + +.. list-table:: + :header-rows: 1 + + * - Property + - Value + * - Name + - uv + * - Version + - 0.7.19 + * - Installation Path + - /path/to/uv + * - Executable + - /path/to/uv/bin/uv + +Be sure to adjust the version to the one you have installed. + +Define a custom cache directory +------------------------------- + +The preceding steps set up uv with the cache located in its default location +in the user home directory (~/.cache/uv). Depending on your +situation, you might prefer a different cache location, such as a shared +directory accessible to all evaluators. To define a custom uv +cache directory, add the following environment variable to the +uv package registration in the scaler/evaluator: + +.. list-table:: + :header-rows: 1 + + * - Environment Variable + - Value + * - UV_CACHE_DIR + - /path/to/custom/uv/cache/dir + +Create offline air-gapped setups +-------------------------------- + +If internet is not available, you can create offline air-gapped setups for uv +using one of these options: + +* Pre-populate the uv cache with all desired dependencies. +* Provide a local Python package index and set uv to use it. For + more information, see + `Package indexes `__ + in the uv documentation. This index can then sit in a shared location, + with node-local caching applied. +* Use pre-generated virtual environments. For more information, see + `uv venv `__ in the + uv documentation. + +To turn off network access, you can either set the +``UV_OFFLINE`` environment variable or use the ``--offline`` flag with +many uv commands. + +Run the example +=============== + +To run the example, execute the ``project_setup.py`` script:: + + uv run project_setup.py + +This command sets up a project with a number +of jobs. Each job generates a ``plot.png`` file. + +Options +------- + +The example supports the following command line arguments: + +.. list-table:: + :header-rows: 1 + + * - Flag + - Example + - Description + * - ``-U``, ``--url`` + - ``--url=https://localhost:8443/hps`` + - URL of the target HPS instance + * - ``-u``, ``--username`` + - ``--username=repuser`` + - Username to log into HPS + * - ``-p``, ``--password`` + - ``--password=topSecret`` + - Password to log into HPS + * - ``-j``, ``--num-jobs`` + - ``--num-jobs=10`` + - Number of jobs to generate + +Files +----- + +Descriptions follow of the relevant example files. + +The project creation script, ``project_setup.py``, handles all communication with the +HPS instance, defines the project, and generates the jobs. + +.. literalinclude:: ../../../examples/python_uv/project_setup.py + :language: python + :lines: 23- + :caption: project_setup.py + +The script ``eval.py``, which is evaluated on HPS, contains the code to plot a sine and then save +the figure. + +.. literalinclude:: ../../../examples/python_uv/eval.py + :language: python + :lines: 23- + :caption: eval.py + +The execution script, ``exec_script.py``, uses uv to run the evaluation script. + +.. literalinclude:: ../../../examples/python_uv/exec_script.py + :language: python + :lines: 23- + :caption: exec_script.py + +Download the ZIP file for the uv example and use +a tool such as 7-Zip to extract the files. + +.. _bottom of this page: + +.. button-link:: ../_downloads/python_uv.zip + :color: black + :expand: + + Download ZIP file \ No newline at end of file diff --git a/doc/source/examples/index.rst b/doc/source/examples/index.rst index 3e6f5f54f..99d41f08f 100644 --- a/doc/source/examples/index.rst +++ b/doc/source/examples/index.rst @@ -35,6 +35,7 @@ one :download:`ZIP file <../../../build/pyhps_examples.zip>`. ex_fluent_nozzle ex_cfx_static_mixer ex_python_two_bar + ex_python_uv .. list-table:: :header-rows: 1 @@ -59,9 +60,11 @@ one :download:`ZIP file <../../../build/pyhps_examples.zip>`. - Submit a CFX solve job to the HPS server using an execution script. * - :ref:`example_python_two_bar` - Create an HPS project that solves a two-bar truss problem with Python. + * - :ref:`example_python_uv` + - Run arbitrary Python scripts on HPS using uv. A link to download the required resources is available on each example page. If desired, -you can download the required resources for all examples through the link below. +you can download the required resources for all examples using the following link. .. _bottom of this page: diff --git a/doc/styles/config/vocabularies/ANSYS/accept.txt b/doc/styles/config/vocabularies/ANSYS/accept.txt index 9f40ae5f3..5a3f700d4 100644 --- a/doc/styles/config/vocabularies/ANSYS/accept.txt +++ b/doc/styles/config/vocabularies/ANSYS/accept.txt @@ -9,10 +9,14 @@ isort [Kk]eycloak Koenig marshmallow_oneofschema +Matplotlib +NumPy Mises postprocess [Pp]ydantic pytest subpackage +uv +venv von Wintermantel \ No newline at end of file diff --git a/examples/python_uv/eval.py b/examples/python_uv/eval.py new file mode 100644 index 000000000..abaae2b80 --- /dev/null +++ b/examples/python_uv/eval.py @@ -0,0 +1,50 @@ +# Copyright (C) 2022 - 2025 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. + +# /// script +# requires-python = "==3.12" +# dependencies = [ +# "numpy", +# "matplotlib" +# ] +# /// + + +import matplotlib.pyplot as plt +import numpy as np + +if __name__ == "__main__": + # Generate plot + ts = np.linspace(0.0, 10.0, 100) + ys = np.sin(ts) + + fig, ax = plt.subplots() + ax.plot(ts, ys) + ax.set_xlabel("Time [s]") + ax.set_ylabel("Displacement [cm]") + plt.savefig("plot.png", dpi=200) + + # Uncomment to enable venv cleanup in exec script, see execution script for details + # import json + # import sys + # with open("output_parameters.json", "w") as out_file: + # json.dump({"exe": sys.executable}, out_file, indent=4) diff --git a/examples/python_uv/exec_script.py b/examples/python_uv/exec_script.py new file mode 100644 index 000000000..bccecdfb1 --- /dev/null +++ b/examples/python_uv/exec_script.py @@ -0,0 +1,88 @@ +# Copyright (C) 2022 - 2025 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. + +"""Simple execution script for Python with uv. + +Command formed: uv run +""" + +import json +import os +import shutil + +from ansys.rep.common.logging import log +from ansys.rep.evaluator.task_manager import ApplicationExecution + + +class PythonExecution(ApplicationExecution): + def execute(self): + log.info("Start uv execution script") + + # Identify files + script_file = next((f for f in self.context.input_files if f["name"] == "eval"), None) + assert script_file, "Python script file missing" + output_filename = "output_parameters.json" + + # Identify applications + app_uv = next((a for a in self.context.software if a["name"] == "uv"), None) + assert app_uv, "Cannot find app uv" + + # Add " around exe if needed for Windows + exes = {"uv": app_uv["executable"]} + for k, v in exes.items(): + if " " in v and not v.startswith('"'): + exes[k] = f'"{v}"' # noqa + + # Pass env vars correctly + env = dict(os.environ) + env.update(self.context.environment) + + ## Run evaluation script + cmd = f"{exes['uv']} run {script_file['path']}" + self.run_and_capture_output(cmd, shell=True, env=env) + + # Read eval.py output parameters + output_parameters = {} + try: + log.debug(f"Loading output parameters from {output_filename}") + with open(output_filename) as out_file: + output_parameters = json.load(out_file) + self.context.parameter_values.update(output_parameters) + log.debug(f"Loaded output parameters: {output_parameters}") + except Exception as ex: + log.info("No output parameters found.") + log.debug(f"Failed to read output_parameters from file: {ex}") + + # If exe path is in out params, delete the venv folder to avoid runaway uv venv cache + # See https://github.com/astral-sh/uv/issues/13431 + if "exe" in output_parameters.keys(): + try: + venv_cache = os.path.abspath(os.path.join(output_parameters["exe"], "..", "..")) + if os.path.exists(venv_cache): + log.debug(f"Cleaning venv cache at {venv_cache}...") + shutil.rmtree(venv_cache) + else: + log.debug(f"Venv cache path {venv_cache} does not exist.") + except Exception as ex: + log.debug(f"Couldn't clean venv cache at {venv_cache}: {ex}") + + log.info("End Python execution script") diff --git a/examples/python_uv/project_setup.py b/examples/python_uv/project_setup.py new file mode 100644 index 000000000..46afac654 --- /dev/null +++ b/examples/python_uv/project_setup.py @@ -0,0 +1,151 @@ +# Copyright (C) 2022 - 2025 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. + +# /// script +# requires-python = "==3.10" +# dependencies = [ +# "ansys-hps-client", +# "packaging" +# ] +# /// + +"""Python with uv example.""" + +import argparse +import logging +import os + +from ansys.hps.client import Client, HPSError +from ansys.hps.client.jms import ( + File, + JmsApi, + Job, + JobDefinition, + Project, + ProjectApi, + ResourceRequirements, + Software, + SuccessCriteria, + TaskDefinition, +) + +log = logging.getLogger(__name__) + + +def create_project(client, num_jobs): + log.debug("=== Create Project") + jms_api = JmsApi(client) + proj = jms_api.create_project( + Project( + name=f"Python UV example - {num_jobs} jobs", + priority=1, + active=True, + ), + replace=True, + ) + project_api = ProjectApi(client, proj.id) + + log.debug("=== Define Files") + cwd = os.path.dirname(__file__) + # Input Files + files = [ + File( + name="eval", + evaluation_path="eval.py", + type="text/plain", + src=os.path.join(cwd, "eval.py"), + ), + File( + name="exec_script", + evaluation_path="exec_script.py", + type="text/plain", + src=os.path.join(cwd, "exec_script.py"), + ), + File( + name="plot", + evaluation_path="plot.png", + type="image/png", + collect=True, + ), + ] + files = project_api.create_files(files) + file_ids = {f.name: f.id for f in files} + + log.debug("=== Define Task") + task_def = TaskDefinition( + name="plotting", + software_requirements=[Software(name="uv")], + resource_requirements=ResourceRequirements( + num_cores=0.5, + memory=100 * 1024 * 1024, # 100 MB + disk_space=10 * 1024 * 1024, # 10 MB + ), + execution_level=0, + max_execution_time=500.0, + use_execution_script=True, + execution_script_id=file_ids["exec_script"], + execution_command="%executable% run %file:eval%", + input_file_ids=[file_ids["eval"]], + output_file_ids=[file_ids["plot"]], + success_criteria=SuccessCriteria( + return_code=0, + require_all_output_files=True, + ), + ) + task_defs = project_api.create_task_definitions([task_def]) + + print("== Define Job") + job_def = JobDefinition( + name="JobDefinition.1", active=True, task_definition_ids=[task_defs[0].id] + ) + job_def = project_api.create_job_definitions([job_def])[0] + log.debug(f"== Create {num_jobs} Jobs") + jobs = [] + for i in range(num_jobs): + jobs.append(Job(name=f"Job.{i}", eval_status="pending", job_definition_id=job_def.id)) + project_api.create_jobs(jobs) + log.info(f"Created project '{proj.name}', ID='{proj.id}'") + return proj + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("-j", "--num-jobs", type=int, default=10) + parser.add_argument("-U", "--url", default="https://localhost:8443/hps") + parser.add_argument("-u", "--username", default="repuser") + parser.add_argument("-p", "--password", default="repuser") + args = parser.parse_args() + + logger = logging.getLogger() + logging.basicConfig(format="%(message)s", level=logging.DEBUG) + + try: + log.info("Connect to HPC Platform Services") + client = Client(url=args.url, username=args.username, password=args.password) + log.info(f"HPS URL: {client.url}") + proj = create_project( + client=client, + num_jobs=args.num_jobs, + ) + + except HPSError as e: + log.error(str(e))