imas-python 0.1.dev1618__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.

Potentially problematic release.


This version of imas-python might be problematic. Click here for more details.

Files changed (108) hide show
  1. imas/__init__.py +34 -0
  2. imas/__main__.py +17 -0
  3. imas/_util.py +260 -0
  4. imas/_version.py +16 -0
  5. imas/assets/IDSDef2MDSpreTree.xsl +161 -0
  6. imas/assets/IDS_fake_toplevel.xml +318 -0
  7. imas/assets/IDS_minimal.xml +37 -0
  8. imas/assets/IDS_minimal_2.xml +37 -0
  9. imas/assets/IDS_minimal_struct_array.xml +43 -0
  10. imas/assets/IDS_minimal_types.xml +169 -0
  11. imas/assets/ITER_134173_106_core_profiles.ids +2438 -0
  12. imas/assets/ITER_134173_106_equilibrium.ids +3172 -0
  13. imas/assets/README.md +5 -0
  14. imas/assets/core_profiles.ids +2438 -0
  15. imas/assets/equilibrium.ids +3172 -0
  16. imas/backends/__init__.py +12 -0
  17. imas/backends/db_entry_impl.py +122 -0
  18. imas/backends/imas_core/__init__.py +4 -0
  19. imas/backends/imas_core/al_context.py +391 -0
  20. imas/backends/imas_core/db_entry_al.py +405 -0
  21. imas/backends/imas_core/db_entry_helpers.py +200 -0
  22. imas/backends/imas_core/imas_interface.py +216 -0
  23. imas/backends/imas_core/mdsplus_model.py +367 -0
  24. imas/backends/imas_core/uda_support.py +56 -0
  25. imas/backends/netcdf/__init__.py +4 -0
  26. imas/backends/netcdf/db_entry_nc.py +176 -0
  27. imas/backends/netcdf/ids2nc.py +306 -0
  28. imas/backends/netcdf/nc2ids.py +302 -0
  29. imas/backends/netcdf/nc_metadata.py +357 -0
  30. imas/backends/netcdf/nc_validate.py +53 -0
  31. imas/command/cli.py +239 -0
  32. imas/command/db_analysis.py +290 -0
  33. imas/command/helpers.py +45 -0
  34. imas/command/timer.py +77 -0
  35. imas/db_entry.py +802 -0
  36. imas/dd_helpers.py +300 -0
  37. imas/dd_zip.py +297 -0
  38. imas/exception.py +107 -0
  39. imas/ids_base.py +149 -0
  40. imas/ids_convert.py +920 -0
  41. imas/ids_coordinates.py +400 -0
  42. imas/ids_data_type.py +115 -0
  43. imas/ids_defs.py +171 -0
  44. imas/ids_factory.py +108 -0
  45. imas/ids_identifiers.py +125 -0
  46. imas/ids_metadata.py +307 -0
  47. imas/ids_path.py +254 -0
  48. imas/ids_primitive.py +510 -0
  49. imas/ids_struct_array.py +233 -0
  50. imas/ids_structure.py +331 -0
  51. imas/ids_toplevel.py +381 -0
  52. imas/setup_logging.py +64 -0
  53. imas/test/test_all_dd_versions.py +28 -0
  54. imas/test/test_cli.py +44 -0
  55. imas/test/test_dbentry.py +104 -0
  56. imas/test/test_dd_helpers.py +134 -0
  57. imas/test/test_dd_zip.py +29 -0
  58. imas/test/test_deepcopy.py +25 -0
  59. imas/test/test_exception.py +12 -0
  60. imas/test/test_get_sample.py +388 -0
  61. imas/test/test_hash.py +188 -0
  62. imas/test/test_helpers.py +354 -0
  63. imas/test/test_identifiers.py +105 -0
  64. imas/test/test_ids2nc.py +166 -0
  65. imas/test/test_ids_ascii_data.py +39 -0
  66. imas/test/test_ids_convert.py +415 -0
  67. imas/test/test_ids_coordinates.py +165 -0
  68. imas/test/test_ids_data_type.py +40 -0
  69. imas/test/test_ids_factory.py +35 -0
  70. imas/test/test_ids_metadata.py +66 -0
  71. imas/test/test_ids_mixin.py +20 -0
  72. imas/test/test_ids_path.py +192 -0
  73. imas/test/test_ids_primitive.py +109 -0
  74. imas/test/test_ids_struct_array.py +89 -0
  75. imas/test/test_ids_structure.py +70 -0
  76. imas/test/test_ids_toplevel.py +55 -0
  77. imas/test/test_ids_validate.py +343 -0
  78. imas/test/test_latest_dd_autofill.py +74 -0
  79. imas/test/test_latest_dd_manual.py +38 -0
  80. imas/test/test_latest_dd_resample.py +102 -0
  81. imas/test/test_lazy_loading.py +201 -0
  82. imas/test/test_list_occurrences.py +82 -0
  83. imas/test/test_minimal.py +37 -0
  84. imas/test/test_minimal_conversion.py +44 -0
  85. imas/test/test_minimal_io.py +21 -0
  86. imas/test/test_minimal_struct_array_io.py +60 -0
  87. imas/test/test_minimal_types.py +436 -0
  88. imas/test/test_minimal_types_io.py +70 -0
  89. imas/test/test_minimal_types_io_automatic.py +66 -0
  90. imas/test/test_nbc_change.py +408 -0
  91. imas/test/test_nc_autofill.py +13 -0
  92. imas/test/test_nc_entry.py +28 -0
  93. imas/test/test_nc_metadata.py +153 -0
  94. imas/test/test_nc_validation.py +197 -0
  95. imas/test/test_snippets.py +25 -0
  96. imas/test/test_static_ids.py +39 -0
  97. imas/test/test_str_1d.py +99 -0
  98. imas/test/test_time_slicing.py +182 -0
  99. imas/test/test_uda_support.py +12 -0
  100. imas/test/test_util.py +209 -0
  101. imas/training.py +45 -0
  102. imas/util.py +526 -0
  103. imas_python-0.1.dev1618.dist-info/LICENSE.txt +165 -0
  104. imas_python-0.1.dev1618.dist-info/METADATA +288 -0
  105. imas_python-0.1.dev1618.dist-info/RECORD +108 -0
  106. imas_python-0.1.dev1618.dist-info/WHEEL +5 -0
  107. imas_python-0.1.dev1618.dist-info/entry_points.txt +3 -0
  108. imas_python-0.1.dev1618.dist-info/top_level.txt +1 -0
imas/dd_helpers.py ADDED
@@ -0,0 +1,300 @@
1
+ # This file is part of imas-python.
2
+ # You should have received the imas-python LICENSE file with this project.
3
+ """Helper functions to build IDSDef.xml"""
4
+
5
+ import logging
6
+ import os
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ from io import BytesIO
11
+ from pathlib import Path
12
+ from typing import Sequence, Tuple, Union
13
+ from urllib.request import urlopen
14
+ from zipfile import ZIP_DEFLATED, ZipFile
15
+
16
+ from packaging.version import Version as V
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _idsdef_zip_relpath = Path("imas/assets/IDSDef.zip")
21
+ _build_dir = Path("build")
22
+ _saxon_local_default_name = "saxon9he.jar" # For pre-3.30.0 builds
23
+ _saxon_regex = "saxon((.(?!test|xqj))*).jar" # Can be used in re.match
24
+
25
+
26
+ def prepare_data_dictionaries():
27
+ """Build IMAS IDSDef.xml files for each tagged version in the DD repository
28
+ 1. Search for saxon or download it
29
+ 2. Clone the DD repository (ask for user/pass unless ssh key access is available)
30
+ 3. Generate IDSDef.xml and rename to IDSDef_${version}.xml
31
+ 4. Zip all these IDSDefs together and include in wheel
32
+ """
33
+ from git import Repo
34
+
35
+ saxon_jar_path = get_saxon()
36
+ repo: Repo = get_data_dictionary_repo()
37
+ if repo:
38
+ newest_version_and_tag = (V("0"), None)
39
+ for tag in repo.tags:
40
+ version_and_tag = (V(str(tag)), tag)
41
+ if V(str(tag)) > V("3.21.1"):
42
+ newest_version_and_tag = max(newest_version_and_tag, version_and_tag)
43
+ logger.debug("Building data dictionary version %s", tag)
44
+ build_data_dictionary(repo, tag, saxon_jar_path)
45
+
46
+ logger.info("Creating zip file of DD versions")
47
+
48
+ if _idsdef_zip_relpath.is_file():
49
+ logger.warning("Overwriting '%s'", _idsdef_zip_relpath)
50
+
51
+ with ZipFile(
52
+ _idsdef_zip_relpath,
53
+ mode="w", # this needs w, since zip can have multiple same entries
54
+ compression=ZIP_DEFLATED,
55
+ ) as dd_zip:
56
+ for filename in _build_dir.glob("[0-9]*.xml"):
57
+ arcname = Path("data-dictionary").joinpath(*filename.parts[1:])
58
+ dd_zip.write(filename, arcname=arcname)
59
+ # Include identifiers from latest tag in zip file
60
+ repo.git.checkout(newest_version_and_tag[1], force=True)
61
+ # DD layout <= 4.0.0
62
+ for filename in Path("data-dictionary").glob("*/*identifier.xml"):
63
+ arcname = Path("identifiers").joinpath(*filename.parts[1:])
64
+ dd_zip.write(filename, arcname=arcname)
65
+ # DD layout > 4.0.0
66
+ for filename in Path("data-dictionary").glob("schemas/*/*identifier.xml"):
67
+ arcname = Path("identifiers").joinpath(*filename.parts[2:])
68
+ dd_zip.write(filename, arcname=arcname)
69
+
70
+
71
+ # pre 3.30.0 versions of the DD have the `saxon9he.jar` file path hardcoded
72
+ # in their makefiles. To be sure we can build everything, we link whatever
73
+ # saxon we can find to a local file called saxon9he.jar
74
+ def get_saxon() -> Path:
75
+ """Search for saxon*.jar and return the path or download it.
76
+ The DD build works by having Saxon in the CLASSPATH, called saxon9he.jar
77
+ until DD version 3.30.0. After 3.30.0 Saxon is found by the SAXONJARFILE env
78
+ variable. We will 'cheat' a little bit later by symlinking saxon9he.jar to
79
+ any version of saxon we found.
80
+
81
+ Check:
82
+ 1. CLASSPATH
83
+ 2. `which saxon`
84
+ 3. /usr/share/java/*
85
+ 4. or download it
86
+ """
87
+
88
+ local_saxon_path = Path.cwd() / _saxon_local_default_name
89
+ if local_saxon_path.exists():
90
+ logger.debug("Something already at '%s' not creating anew", local_saxon_path)
91
+ return local_saxon_path
92
+
93
+ saxon_jar_origin = Path(
94
+ find_saxon_classpath()
95
+ or find_saxon_bin()
96
+ or find_saxon_jar()
97
+ or download_saxon()
98
+ )
99
+ logger.info("Found Saxon JAR '%s'", saxon_jar_origin)
100
+ if saxon_jar_origin.name != _saxon_local_default_name:
101
+ try:
102
+ os.symlink(saxon_jar_origin, local_saxon_path)
103
+ except FileExistsError:
104
+ # Another process could have created the symlink while we were searching
105
+ logger.debug(
106
+ "Link '%s' exists, parallel process might've created it",
107
+ local_saxon_path,
108
+ )
109
+ return local_saxon_path
110
+ return saxon_jar_origin
111
+
112
+
113
+ def find_saxon_jar():
114
+ # This finds multiple versions on my system, but they are symlinked together.
115
+ # take the shortest one.
116
+ jars = [
117
+ path
118
+ for path in Path("/usr/share/java").rglob("*")
119
+ if re.match(_saxon_regex, path.name, flags=re.IGNORECASE)
120
+ ]
121
+
122
+ if jars:
123
+ saxon_jar_path = min(jars, key=lambda x: len(x.parts))
124
+ return saxon_jar_path
125
+
126
+
127
+ def find_saxon_classpath():
128
+ """Search JAVAs CLASSPATH for a Saxon .jar"""
129
+ classpath = os.environ.get("CLASSPATH", "")
130
+ for part in re.split(";|:", classpath):
131
+ if (
132
+ part.endswith(".jar")
133
+ and part.split("/")[-1].startswith("saxon")
134
+ and "test" not in part
135
+ and "xqj" not in part
136
+ ):
137
+ return part
138
+
139
+
140
+ def find_saxon_bin():
141
+ """Search for a saxon executable"""
142
+ saxon_bin = shutil.which("saxon")
143
+ if saxon_bin:
144
+ with open(saxon_bin, "r") as file:
145
+ for line in file:
146
+ saxon_jar_path = re.search("[^ ]*saxon[^ ]*jar", line)
147
+ if saxon_jar_path:
148
+ return saxon_jar_path.group(0)
149
+
150
+
151
+ def download_saxon():
152
+ """Downloads a zipfile containing Saxon and extract it to the current dir.
153
+ Return the full path to Saxon. This can be any Saxon version. Scripts that
154
+ wrap this should probably manipulate either the name of this file, and/or
155
+ the CLASSPATH"""
156
+
157
+ SAXON_PATH = "https://github.com/Saxonica/Saxon-HE/releases/download/SaxonHE10-9/SaxonHE10-9J.zip" # noqa: E501
158
+
159
+ resp = urlopen(SAXON_PATH, timeout=120.0)
160
+ zipfile = ZipFile(BytesIO(resp.read()))
161
+ # Zipfile has a list of the ZipInfos. Look inside for a Saxon jar
162
+ for file in zipfile.filelist:
163
+ if re.match(_saxon_regex, file.filename, flags=re.IGNORECASE):
164
+ path = zipfile.extract(file)
165
+ del zipfile
166
+ return path
167
+ raise FileNotFoundError(f"No Saxon jar found in given zipfile '{SAXON_PATH}'")
168
+
169
+
170
+ def get_data_dictionary_repo() -> Tuple[bool, bool]:
171
+ try:
172
+ import git # Import git here, the user might not have it!
173
+ except ModuleNotFoundError:
174
+ raise RuntimeError(
175
+ "Could not find 'git' module, try 'pip install gitpython'. \
176
+ Will not build Data Dictionaries!"
177
+ )
178
+
179
+ # We need the actual source code (for now) so grab it from ITER
180
+ dd_repo_path = "data-dictionary"
181
+
182
+ if "DD_DIRECTORY" in os.environ:
183
+ logger.info("Found DD_DIRECTORY, copying")
184
+ try:
185
+ shutil.copytree(os.environ["DD_DIRECTORY"], dd_repo_path)
186
+ except FileExistsError:
187
+ pass
188
+ else:
189
+ logger.info("Trying to pull data dictionary git repo from ITER")
190
+
191
+ # Set up a bare repo and fetch the data-dictionary repository in it
192
+ os.makedirs(dd_repo_path, exist_ok=True)
193
+ try:
194
+ repo = git.Repo(dd_repo_path)
195
+ except git.exc.InvalidGitRepositoryError:
196
+ repo = git.Repo.init(dd_repo_path)
197
+ logger.info("Set up local git repository {!s}".format(repo))
198
+
199
+ try:
200
+ origin = repo.remote()
201
+ except ValueError:
202
+ dd_repo_url = "https://github.com/iterorganization/imas-data-dictionary.git"
203
+ origin = repo.create_remote("origin", url=dd_repo_url)
204
+ logger.info("Set up remote '{!s}' linking to '{!s}'".format(origin, origin.url))
205
+
206
+ try:
207
+ origin.fetch(tags=True)
208
+ except git.exc.GitCommandError as ee:
209
+ logger.warning(
210
+ "Could not fetch tags from %s. Git reports:\n %s." "\nTrying to continue",
211
+ list(origin.urls),
212
+ ee,
213
+ )
214
+ else:
215
+ logger.info("Remote tags fetched")
216
+ return repo
217
+
218
+
219
+ def _run_data_dictionary(
220
+ args: Union[Sequence, str], tag: str, saxon_jar_path: str
221
+ ) -> int:
222
+ """Run in a Data Dictionary environment. Used e.g. to run the DD Makefile
223
+
224
+ Args:
225
+ args: The "args" argument directly passed to :func:`subprocess.run`,
226
+ e.g. ``["make", "clean"]``
227
+ tag: The DD version tag that will be printed on error
228
+ saxon_jar_path: The path to the saxon jar; Added to CLASSPATH and used
229
+ to generate the DD
230
+ """
231
+ env = os.environ.copy()
232
+ env["CLASSPATH"] = f"{saxon_jar_path}:{env.get('CLASSPATH', '')}"
233
+ result = subprocess.run(
234
+ args,
235
+ bufsize=0,
236
+ capture_output=True,
237
+ cwd=os.getcwd() + "/data-dictionary",
238
+ env=env,
239
+ text=True,
240
+ )
241
+
242
+ if result.returncode != 0:
243
+ logger.warning("Error making DD version %s, make reported:", tag)
244
+ logger.warning("CLASSPATH ='%s'", saxon_jar_path)
245
+ logger.warning("PATH = '%s'", os.environ.get("PATH", ""))
246
+ logger.warning("stdout = '%s'", result.stdout.strip())
247
+ logger.warning("stderr = '%s'", result.stderr.strip())
248
+ logger.warning("continuing without DD version %s", tag)
249
+ else:
250
+ logger.debug(
251
+ "Successful make for DD %s.\n-- Make stdout --\n%s\n-- Make stderr --\n%s",
252
+ tag,
253
+ result.stdout,
254
+ result.stderr,
255
+ )
256
+ return result.returncode
257
+
258
+
259
+ def build_data_dictionary(repo, tag: str, saxon_jar_path: str, rebuild=False) -> None:
260
+ """Build a single version of the data dictionary given by the tag argument
261
+ if the IDS does not already exist.
262
+
263
+ In the data-dictionary repository sometimes IDSDef.xml is stored
264
+ directly, in which case we do not call make.
265
+
266
+ Args:
267
+ repo: Repository object containing the DD source code
268
+ tag: The DD version tag that will be build
269
+ saxon_jar_path: The path to the saxon jar; Added to CLASSPATH and used
270
+ to generate the DD
271
+ rebuild: If true, overwrites existing pre-build tagged DD version
272
+ """
273
+ _build_dir.mkdir(exist_ok=True)
274
+ result_xml = _build_dir / f"{tag}.xml"
275
+
276
+ if result_xml.exists() and not rebuild:
277
+ logger.debug(f"XML for tag '{tag}' already exists, skipping")
278
+ return
279
+
280
+ repo.git.checkout(tag, force=True)
281
+ if _run_data_dictionary(["make", "clean"], tag, saxon_jar_path) != 0:
282
+ return
283
+ if _run_data_dictionary(["make", "IDSDef.xml"], tag, saxon_jar_path) != 0:
284
+ return
285
+
286
+ # copy and delete original instead of move (to follow symlink)
287
+ IDSDef = Path("data-dictionary/IDSDef.xml")
288
+ try:
289
+ shutil.copy(
290
+ IDSDef, # Hardcoded in access-layer makefile
291
+ result_xml,
292
+ follow_symlinks=True,
293
+ )
294
+ except shutil.SameFileError:
295
+ pass
296
+ IDSDef.unlink(missing_ok=True)
297
+
298
+
299
+ if __name__ == "__main__":
300
+ prepare_data_dictionaries()
imas/dd_zip.py ADDED
@@ -0,0 +1,297 @@
1
+ # This file is part of imas-python.
2
+ # You should have received the imas-python LICENSE file with this project.
3
+ """ Extract DD versions from a zip file.
4
+
5
+ The zip file contains files as
6
+ * `data-dictionary/3.30.0.xml`
7
+ * `data-dictionary/3.29.0.xml`
8
+
9
+ multiple paths are checked. See `ZIPFILE_LOCATIONS`.
10
+ First the environment variable IMASPY_DDZIP is checked.
11
+ If that exists and points to a file we will attempt to open it.
12
+ Then, IDSDef.zip is searched in site-packages, the current folder,
13
+ in .config/imas/ (`$$XDG_CONFIG_HOME`) and in
14
+ the assets/ folder within the imas-python package.
15
+
16
+ 1. `$$IMASPY_DDZIP`
17
+ 2. The virtual environment
18
+ 3. USER_BASE`imas/IDSDef.zip`
19
+ 4. All `site-packages/imas/IDSDef.zip`
20
+ 5. `./IDSDef.zip`
21
+ 6. `~/.config/imas/IDSDef.zip`
22
+ 7. `__file__/../../imas/assets/IDSDef.zip`
23
+
24
+ All files are checked, i.e. if your .config/imas/IDSDef.zip is outdated
25
+ the imas-python-packaged version will be used.
26
+
27
+ The `assets/IDSDef.zip` provided with the package can be updated
28
+ with the `python setup.py build_DD` command, which is also performed on install
29
+ if you have access to the ITER data-dictionary git repo.
30
+ Reinstalling imas thus also will give you access to the latest DD versions.
31
+ """
32
+ import logging
33
+ import os
34
+ import re
35
+ import xml.etree.ElementTree as ET
36
+ from contextlib import contextmanager, nullcontext
37
+ from functools import lru_cache
38
+ from pathlib import Path
39
+ from typing import Dict, Iterator, List, Tuple, Union
40
+ from zipfile import ZipFile
41
+
42
+ try:
43
+ from importlib.resources import as_file, files
44
+
45
+ try:
46
+ from importlib.resources.abc import Traversable
47
+ except ModuleNotFoundError: # Python 3.9/3.10 support
48
+ from importlib.abc import Traversable
49
+
50
+ except ImportError: # Python 3.8 support
51
+ from importlib_resources import as_file, files
52
+ from importlib_resources.abc import Traversable
53
+
54
+ from packaging.version import InvalidVersion, Version
55
+
56
+ import imas
57
+ from imas.exception import UnknownDDVersion
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+
62
+ def _get_xdg_config_dir():
63
+ """
64
+ Return the XDG config directory, according to the XDG base directory spec:
65
+
66
+ https://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
67
+ """
68
+ return os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
69
+
70
+
71
+ def _generate_zipfile_locations() -> Iterator[Union[Path, Traversable]]:
72
+ """Build a list of potential data dictionary locations.
73
+ We start with the path (if any) of the IMASPY_DDZIP env var.
74
+ Then we look for IDSDef.zip in the current folder, in the
75
+ default XDG config dir (~/.config/imas/IDSDef.zip) and
76
+ finally in the assets distributed with this package.
77
+ """
78
+ zip_name = "IDSDef.zip"
79
+
80
+ environ = os.environ.get("IMASPY_DDZIP")
81
+ if environ:
82
+ yield Path(environ).resolve()
83
+
84
+ yield Path(zip_name).resolve()
85
+ yield Path(_get_xdg_config_dir()).resolve() / "imas" / zip_name
86
+ yield files(imas) / "assets" / zip_name
87
+
88
+
89
+ def parse_dd_version(version: str) -> Version:
90
+ try:
91
+ return Version(version)
92
+ except InvalidVersion:
93
+ # This is probably a dev build of the DD, of which the version is obtained with
94
+ # `git describe` in the format X.Y.Z-<ncommits>-g<hash> with X.Y.Z the previous
95
+ # released version: try again after converting the first dash to a + and treat
96
+ # it like a `local` version specifier, which is recognized as newer.
97
+ # https://packaging.python.org/en/latest/specifications/version-specifiers/
98
+ return Version(version.replace("-", "+", 1))
99
+
100
+
101
+ # Expected use case is one, maximum two DD versions
102
+ # Cache is bigger than that: in pytest we currently use the following DD versions:
103
+ # - 3.22.0
104
+ # - 3.25.0
105
+ # - 3.28.0
106
+ # - 3.39.0
107
+ # - 4.0.0 (if available)
108
+ # - Environment default
109
+ # - IDS_fake_toplevel.xml
110
+ # - IDS_minimal.xml
111
+ # - IDS_minimal_2.xml
112
+ # - IDS_minimal_struct_array.xml
113
+ # - IDS_minimal_types.xml
114
+ _DD_CACHE_SIZE = 8
115
+ ZIPFILE_LOCATIONS = list(_generate_zipfile_locations())
116
+
117
+
118
+ def dd_etree(version=None, xml_path=None):
119
+ """Return the DD element tree corresponding to the provided dd_version or xml_file.
120
+
121
+ By default (``dd_version`` and ``dd_xml`` are not supplied), this will attempt
122
+ to get the version from the environment (``IMAS_VERSION``) and use the latest
123
+ available version as fallback.
124
+
125
+ You can also specify a specific DD version to use (e.g. "3.38.1") or point to a
126
+ specific data-dictionary XML file. These options are exclusive.
127
+
128
+ Args:
129
+ version: DD version string, e.g. "3.38.1".
130
+ xml_path: XML file containing data dictionary definition.
131
+ """
132
+ if version and xml_path:
133
+ raise ValueError("version and xml_path cannot be provided both.")
134
+ if not version and not xml_path:
135
+ # Figure out which DD version to use
136
+ if "IMAS_VERSION" in os.environ:
137
+ imas_version = os.environ["IMAS_VERSION"]
138
+ if imas_version in dd_xml_versions():
139
+ # Use bundled DD version when available
140
+ version = imas_version
141
+ elif "IMAS_PREFIX" in os.environ:
142
+ # Try finding the IDSDef.xml in this installation
143
+ imas_prefix = Path(os.environ["IMAS_PREFIX"]).resolve()
144
+ xml_file = imas_prefix / "include" / "IDSDef.xml"
145
+ if xml_file.exists():
146
+ xml_path = str(xml_file)
147
+ if not version and not xml_path:
148
+ logger.warning(
149
+ "Unable to load IMAS version %s, falling back to latest version.",
150
+ imas_version,
151
+ )
152
+ if not version and not xml_path:
153
+ # Use latest available from
154
+ version = latest_dd_version()
155
+ # Do the actual loading in a cached method:
156
+ return _load_etree(version, xml_path)
157
+
158
+
159
+ @lru_cache(_DD_CACHE_SIZE)
160
+ def _load_etree(version, xml_path):
161
+ if xml_path:
162
+ logger.info("Parsing data dictionary from file: %s", xml_path)
163
+ tree = ET.parse(xml_path)
164
+ else:
165
+ xml = get_dd_xml(version)
166
+ logger.info("Parsing data dictionary version %s", version)
167
+ tree = ET.ElementTree(ET.fromstring(xml))
168
+ return tree
169
+
170
+
171
+ @contextmanager
172
+ def _open_zipfile(path: Union[Path, Traversable]) -> Iterator[ZipFile]:
173
+ """Open a zipfile, given a Path or Traversable."""
174
+ if isinstance(path, Path):
175
+ ctx = nullcontext(path)
176
+ else:
177
+ ctx = as_file(path)
178
+ with ctx as file:
179
+ with ZipFile(file) as zipfile:
180
+ yield zipfile
181
+
182
+
183
+ @lru_cache
184
+ def _read_dd_versions() -> Dict[str, Tuple[Union[Path, Traversable], str]]:
185
+ """Traverse all possible DD zip files and return a map of known versions.
186
+
187
+ Returns:
188
+ version_map: version -> (zipfile path, filename)
189
+ """
190
+ versions = {}
191
+ xml_re = re.compile(r"^data-dictionary/([0-9.]+)\.xml$")
192
+ for path in ZIPFILE_LOCATIONS:
193
+ if not path.is_file():
194
+ continue
195
+ with _open_zipfile(path) as zipfile:
196
+ for fname in zipfile.namelist():
197
+ match = xml_re.match(fname)
198
+ if match:
199
+ version = match.group(1)
200
+ if version not in versions:
201
+ versions[version] = (path, fname)
202
+ if not versions:
203
+ raise RuntimeError(
204
+ "Could not find any data dictionary definitions. "
205
+ f"Looked in: {', '.join(map(repr, ZIPFILE_LOCATIONS))}."
206
+ )
207
+ return versions
208
+
209
+
210
+ @lru_cache
211
+ def _read_identifiers() -> Dict[str, Tuple[Union[Path, Traversable], str]]:
212
+ """Traverse all possible DD zip files and return a map of known identifiers.
213
+
214
+ Returns:
215
+ identifier_map: identifier -> (zipfile path, filename)
216
+ """
217
+ identifiers = {}
218
+ xml_re = re.compile(r"^identifiers/\w+/(\w+_identifier).xml$")
219
+ for path in ZIPFILE_LOCATIONS:
220
+ if not path.is_file():
221
+ continue
222
+ with _open_zipfile(path) as zipfile:
223
+ for fname in zipfile.namelist():
224
+ match = xml_re.match(fname)
225
+ if match:
226
+ identifier_name = match.group(1)
227
+ if identifier_name not in identifiers:
228
+ identifiers[identifier_name] = (path, fname)
229
+ return identifiers
230
+
231
+
232
+ @lru_cache
233
+ def dd_xml_versions() -> List[str]:
234
+ """Parse IDSDef.zip to find version numbers available"""
235
+
236
+ def sort_key(version):
237
+ try:
238
+ return parse_dd_version(version)
239
+ except InvalidVersion:
240
+ # Don't fail when a malformatted version is present in the DD zip
241
+ logger.error(
242
+ f"Could not convert DD XML version {version} to a Version.", exc_info=1
243
+ )
244
+ return Version(0)
245
+
246
+ return sorted(_read_dd_versions(), key=sort_key)
247
+
248
+
249
+ @lru_cache
250
+ def dd_identifiers() -> List[str]:
251
+ """Parse IDSDef.zip to find available identifiers"""
252
+
253
+ return sorted(_read_identifiers())
254
+
255
+
256
+ def get_dd_xml(version):
257
+ """Read XML file for the given data dictionary version."""
258
+ dd_versions = dd_xml_versions()
259
+ if version not in dd_versions:
260
+ raise UnknownDDVersion(version, dd_versions)
261
+ path, fname = _read_dd_versions()[version]
262
+ with _open_zipfile(path) as zipfile:
263
+ return zipfile.read(fname)
264
+
265
+
266
+ def get_dd_xml_crc(version):
267
+ """Given a version string, return its CRC checksum"""
268
+ # Note, by this time get_dd_xml is already called, so we don't need to check if the
269
+ # version is known
270
+ path, fname = _read_dd_versions()[version]
271
+ with _open_zipfile(path) as zipfile:
272
+ return zipfile.getinfo(fname).CRC
273
+
274
+
275
+ def get_identifier_xml(identifier_name):
276
+ """Get identifier XML for the given identifier name"""
277
+ path, fname = _read_identifiers()[identifier_name]
278
+ with _open_zipfile(path) as zipfile:
279
+ return zipfile.read(fname)
280
+
281
+
282
+ def print_supported_version_warning(version):
283
+ try:
284
+ if parse_dd_version(version) < imas.OLDEST_SUPPORTED_VERSION:
285
+ logger.warning(
286
+ "Version %s is below lowest supported version of %s.\
287
+ Proceed at your own risk.",
288
+ version,
289
+ imas.OLDEST_SUPPORTED_VERSION,
290
+ )
291
+ except InvalidVersion:
292
+ logging.warning("Ignoring version parsing error.", exc_info=1)
293
+
294
+
295
+ def latest_dd_version():
296
+ """Find the latest version in data-dictionary/IDSDef.zip"""
297
+ return dd_xml_versions()[-1]
imas/exception.py ADDED
@@ -0,0 +1,107 @@
1
+ # This file is part of imas-python.
2
+ # You should have received the imas-python LICENSE file with this project.
3
+ """Exception classes used in imas-python.
4
+ """
5
+
6
+ import difflib
7
+ import logging
8
+ from typing import TYPE_CHECKING, List
9
+
10
+ from imas.backends.imas_core import imas_interface as _imas_interface
11
+
12
+ if TYPE_CHECKING:
13
+ from imas.ids_factory import IDSFactory
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ # Expose ALException, which may be thrown by the lowlevel
20
+ if _imas_interface.has_imas:
21
+ ALException = _imas_interface.lowlevel.ALException
22
+ else:
23
+ ALException = None
24
+
25
+
26
+ class UnknownDDVersion(ValueError):
27
+ """Error raised when an unknown DD version is specified."""
28
+
29
+ def __init__(self, version: str, available: List[str], note: str = "") -> None:
30
+ close_matches = difflib.get_close_matches(version, available, n=1)
31
+ if close_matches:
32
+ suggestions = f"Did you mean {close_matches[0]!r}?"
33
+ else:
34
+ suggestions = f"Available versions are {', '.join(reversed(available))}"
35
+ super().__init__(
36
+ f"Data dictionary version {version!r} cannot be found. {suggestions}{note}"
37
+ )
38
+
39
+
40
+ class IDSNameError(ValueError):
41
+ """Error raised by DBEntry.get(_slice) when providing an invalid IDS name."""
42
+
43
+ def __init__(self, ids_name: str, factory: "IDSFactory") -> None:
44
+ suggestions = ""
45
+ close_matches = difflib.get_close_matches(ids_name, factory.ids_names(), n=1)
46
+ if close_matches:
47
+ suggestions = f" Did you mean {close_matches[0]!r}?"
48
+ super().__init__(f"IDS {ids_name!r} does not exist.{suggestions}")
49
+
50
+
51
+ class DataEntryException(RuntimeError):
52
+ """Error raised by DBEntry for unexpected data in the backend."""
53
+
54
+
55
+ class MDSPlusModelError(RuntimeError):
56
+ """Error raised when building MDS+ models."""
57
+
58
+ def __init__(self, msg: str) -> None:
59
+ super().__init__(f"Error building MDSplus data model: {msg}")
60
+
61
+
62
+ class LowlevelError(RuntimeError):
63
+ """Error raised when lowlevel returns nonzero status"""
64
+
65
+ def __init__(self, function: str, status: int):
66
+ super().__init__(
67
+ f"An Access Layer lowlevel operation ({function}) was unsuccessful "
68
+ f"({status=}). "
69
+ "More debug information should be available earlier in the program output."
70
+ )
71
+
72
+
73
+ class CoordinateLookupError(Exception):
74
+ """Error raised by IDSCoordinate.__getitem__ when a coordinate cannot be found."""
75
+
76
+
77
+ class ValidationError(Exception):
78
+ """Error raised by IDSToplevel.validate() to indicate the IDS is not valid."""
79
+
80
+
81
+ class CoordinateError(ValidationError):
82
+ """Error raised by ids.validate() to indicate a coordinate check has failed."""
83
+
84
+ def __init__(self, node, dimension, expected_size, coor_path):
85
+ """Create a new CoordinateError
86
+
87
+ Args:
88
+ node: IDSBase object that this coordinate error is raised for
89
+ dimension: (0-based) dimension with incorrect size
90
+ expected_size: size of the coordinate for the specified dimension
91
+ coor_path: path of the coordinate, may be None when a coordinate is of fixed
92
+ size (e.g. ``1...3``)
93
+ """
94
+ if coor_path is not None: # Error message when coordinate size doesnt match
95
+ details = (
96
+ f"its coordinate in dimension {dimension + 1} (`{coor_path}`) has "
97
+ f"size {expected_size}."
98
+ )
99
+ else:
100
+ details = f"dimension {dimension + 1} must have size {expected_size}."
101
+ super().__init__(
102
+ f"Element `{node._path}` has incorrect shape {node.shape}: {details}"
103
+ )
104
+
105
+
106
+ class InvalidNetCDFEntry(Exception):
107
+ """Error raised when loading an IDS from a NetCDF file that fails validation."""