Skip to content

Commit e163117

Browse files
feat: Add example-data download (#15)
* feat: Add example-data download * fix: pre-commit * maint: Run tests in CI * fix: Action name * fix: Add pytest dependency * fix: Test * fix: Add docstrings and improve test * test: Add exception testing * fix: Use string instead of *args * Update src/ansys/tools/example_download.py Co-authored-by: Roberto Pastor Muela <[email protected]> * fix: Comments * fix: Test and exception --------- Co-authored-by: Roberto Pastor Muela <[email protected]>
1 parent 0ba8b50 commit e163117

File tree

6 files changed

+327
-1
lines changed

6 files changed

+327
-1
lines changed

.github/workflows/cicd.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ jobs:
3838
operating-system: ${{ matrix.os }}
3939
python-version: ${{ matrix.python-version }}
4040
whitelist-license-check: "termcolor" # Has MIT license, but it's not recognized
41+
tests:
42+
name: Run tests
43+
runs-on: ubuntu-latest
44+
steps:
45+
- name: Run tests
46+
uses: ansys/actions/tests-pytest@v9
47+
with:
48+
library-name: ${{ env.PACKAGE_NAME }}
49+
python-version: ${{ env.MAIN_PYTHON_VERSION }}
4150

4251
package:
4352
name: Package library

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,4 @@ cython_debug/
172172

173173
# PyPI configuration file
174174
.pypirc
175+
.vscode

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ dependencies = []
2626

2727
[project.optional-dependencies]
2828

29-
tests = []
29+
tests = [
30+
"pytest==8.4.0",
31+
]
3032
doc = []
3133

3234
[project.urls]
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates.
2+
# SPDX-License-Identifier: MIT
3+
#
4+
#
5+
# Permission is hereby granted, free of charge, to any person obtaining a copy
6+
# of this software and associated documentation files (the "Software"), to deal
7+
# in the Software without restriction, including without limitation the rights
8+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
# copies of the Software, and to permit persons to whom the Software is
10+
# furnished to do so, subject to the following conditions:
11+
#
12+
# The above copyright notice and this permission notice shall be included in all
13+
# copies or substantial portions of the Software.
14+
#
15+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
# SOFTWARE.
22+
"""Module for downloading examples from example-data repository."""
23+
24+
from pathlib import Path
25+
import tempfile
26+
from threading import Lock
27+
from typing import Optional
28+
from urllib.parse import urljoin
29+
import urllib.request
30+
31+
__all__ = ["DownloadManager"]
32+
33+
BASE_URL = "https://github.com/ansys/example-data/raw/main"
34+
35+
36+
class DownloadManagerMeta(type):
37+
"""Provides a thread-safe implementation of ``Singleton``.
38+
39+
https://refactoring.guru/design-patterns/singleton/python/example#example-1.
40+
"""
41+
42+
_instances = {}
43+
_lock: Lock = Lock()
44+
45+
def __call__(cls, *args, **kwargs):
46+
"""Call to the class."""
47+
with cls._lock:
48+
if cls not in cls._instances:
49+
instance = super().__call__(*args, **kwargs)
50+
cls._instances[cls] = instance
51+
return cls._instances[cls]
52+
53+
54+
class DownloadManager(metaclass=DownloadManagerMeta):
55+
"""Manages downloads of example files.
56+
57+
Manages the download of example from the example-data
58+
repository https://github.com/ansys/example-data.
59+
"""
60+
61+
def __init__(self):
62+
"""Initialize the download manager."""
63+
self._downloads_list = []
64+
65+
def clear_download_cache(self):
66+
"""Remove downloaded example files from the local path."""
67+
for file in self._downloads_list:
68+
Path(file).unlink()
69+
self._downloads_list.clear()
70+
71+
def download_file(
72+
self, filename: str, directory: str, destination: Optional[str] = None, force: bool = False
73+
) -> str:
74+
"""Download an example file from the example data.
75+
76+
Parameters
77+
----------
78+
filename : str
79+
Name of the example file to download.
80+
destination : str, optional
81+
Path to download the example file to. The default
82+
is ``None``, in which case the default path for app data
83+
is used.
84+
force : bool, optional
85+
Whether to always download the example file. The default is
86+
``False``, in which case if the example file is cached, it
87+
is reused.
88+
directory : str
89+
Path under the PyAnsys Github examples repository.
90+
91+
Returns
92+
-------
93+
tuple[str, str]
94+
Tuple containing the filepath to use and the local filepath of the downloaded
95+
directory. The two are different in case of containers.
96+
97+
"""
98+
# Convert to Path object
99+
destination_path = Path(destination) if destination is not None else None
100+
101+
# If destination is not a dir, create it
102+
if destination_path is not None and not destination_path.is_dir():
103+
destination_path.mkdir(parents=True, exist_ok=True)
104+
105+
# Check if it was able to create the dir
106+
if destination_path is not None and not destination_path.is_dir():
107+
raise ValueError("Destination directory provided does not exist")
108+
109+
url = self._get_filepath_on_default_server(filename, directory)
110+
local_path = self._retrieve_data(url, filename, dest=destination, force=force)
111+
112+
# add path to downloaded files
113+
self._add_file(local_path)
114+
return local_path
115+
116+
def _add_file(self, file_path: str):
117+
"""Add the path for a downloaded example file to a list.
118+
119+
This list keeps track of where example files are
120+
downloaded so that a global cleanup of these files can be
121+
performed when the client is closed.
122+
123+
Parameters
124+
----------
125+
file_path : str
126+
Local path of the downloaded example file.
127+
"""
128+
self._downloads_list.append(file_path)
129+
130+
def _joinurl(self, base: str, directory: str) -> str:
131+
"""Join multiple paths to a base URL.
132+
133+
Parameters
134+
----------
135+
base : str
136+
Base URL to which the paths will be appended.
137+
138+
Returns
139+
-------
140+
str
141+
The joined URL with the base and paths.
142+
"""
143+
if base[-1] != "/":
144+
base += "/"
145+
return urljoin(base, directory)
146+
147+
def _get_filepath_on_default_server(self, filename: str, directory: str = None) -> str:
148+
"""Get the full URL of the file on the default repository.
149+
150+
Parameters
151+
----------
152+
filename : str
153+
Name of the file to download.
154+
directory : str, optional
155+
Path under the example-data repository.
156+
157+
Returns
158+
-------
159+
str
160+
Full URL of the file on the default repository.
161+
"""
162+
if directory:
163+
if directory[-1] != "/":
164+
directory += "/"
165+
return self._joinurl(BASE_URL, directory + filename)
166+
else:
167+
return self._joinurl(BASE_URL, filename)
168+
169+
def _retrieve_data(self, url: str, filename: str, dest: str = None, force: bool = False) -> str:
170+
"""Retrieve data from a URL and save it to a local file.
171+
172+
Parameters
173+
----------
174+
url : str
175+
The URL to download the file from.
176+
filename : str
177+
The name of the file to save the downloaded content as.
178+
dest : str, optional
179+
Destination path of the file, by default None
180+
force : bool, optional
181+
Force download to avoid cached examples, by default False
182+
183+
Returns
184+
-------
185+
str
186+
The local path where the file was saved.
187+
"""
188+
if dest is None:
189+
dest = tempfile.gettempdir() # Use system temp directory if no destination is provided
190+
local_path = Path(dest) / Path(filename).name
191+
if not force and Path.is_file(local_path):
192+
return local_path
193+
try:
194+
local_path, _ = urllib.request.urlretrieve(url, filename=local_path)
195+
except urllib.error.HTTPError:
196+
raise FileNotFoundError(f"Failed to download {filename} from {url}, file does not exist.")
197+
return local_path
198+
199+
200+
# Create a singleton instance of DownloadManager
201+
download_manager = DownloadManager()

tests/test_example_download.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates.
2+
# SPDX-License-Identifier: MIT
3+
#
4+
#
5+
# Permission is hereby granted, free of charge, to any person obtaining a copy
6+
# of this software and associated documentation files (the "Software"), to deal
7+
# in the Software without restriction, including without limitation the rights
8+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
# copies of the Software, and to permit persons to whom the Software is
10+
# furnished to do so, subject to the following conditions:
11+
#
12+
# The above copyright notice and this permission notice shall be included in all
13+
# copies or substantial portions of the Software.
14+
#
15+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
# SOFTWARE.
22+
"""Tests for example downloads."""
23+
24+
from pathlib import Path
25+
26+
import pytest
27+
28+
from ansys.tools.example_download import download_manager
29+
30+
31+
def test_download():
32+
"""Test downloading a file from the example repository."""
33+
filename = "11_blades_mode_1_ND_0.csv"
34+
directory = "pymapdl/cfx_mapping"
35+
36+
# Download the file
37+
local_path = download_manager.download_file(filename, directory)
38+
39+
assert Path.is_file(local_path)
40+
41+
download_manager.clear_download_cache()
42+
43+
assert not Path.is_file(local_path)
44+
45+
46+
def test_non_existent_file():
47+
"""Test downloading a non-existent file."""
48+
filename = "non_existent_file.txt"
49+
directory = "pymapdl/cfx_mapping"
50+
51+
# Attempt to download the non-existent file
52+
with pytest.raises(FileNotFoundError):
53+
download_manager.download_file(filename, directory)

tests/test_exceptions.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates.
2+
# SPDX-License-Identifier: MIT
3+
#
4+
#
5+
# Permission is hereby granted, free of charge, to any person obtaining a copy
6+
# of this software and associated documentation files (the "Software"), to deal
7+
# in the Software without restriction, including without limitation the rights
8+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
# copies of the Software, and to permit persons to whom the Software is
10+
# furnished to do so, subject to the following conditions:
11+
#
12+
# The above copyright notice and this permission notice shall be included in all
13+
# copies or substantial portions of the Software.
14+
#
15+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
# SOFTWARE.
22+
23+
"""Module for exception testing."""
24+
25+
from ansys.tools.exceptions import AnsysError, AnsysLogicError, AnsysTypeError
26+
27+
28+
def test_ansys_error():
29+
"""Test the base AnsysError exception."""
30+
try:
31+
raise AnsysError("This is a test error.")
32+
except AnsysError as e:
33+
assert str(e) == "This is a test error."
34+
assert e.message == "This is a test error."
35+
36+
37+
def test_ansys_type_error():
38+
"""Test the AnsysTypeError exception."""
39+
try:
40+
raise AnsysTypeError(expected_type="int", actual_type="str")
41+
except AnsysTypeError as e:
42+
assert str(e) == "Expected type int, but got str."
43+
assert e.expected_type == "int"
44+
assert e.actual_type == "str"
45+
46+
try:
47+
raise AnsysTypeError(expected_type=int, actual_type=str)
48+
except AnsysTypeError as e:
49+
assert str(e) == "Expected type int, but got str."
50+
assert e.expected_type == "int"
51+
assert e.actual_type == "str"
52+
53+
54+
def test_ansys_logic_error():
55+
"""Test the AnsysLogicError exception."""
56+
try:
57+
raise AnsysLogicError("This is a logic error.")
58+
except AnsysLogicError as e:
59+
assert str(e) == "This is a logic error."
60+
assert e.message == "This is a logic error."

0 commit comments

Comments
 (0)