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
@@ -0,0 +1,216 @@
1
+ # This file is part of imas-python.
2
+ # You should have received the imas-python LICENSE file with this project.
3
+ """
4
+ Helper module for providing a version-independent interface to the Access Layer.
5
+
6
+ This module tries to abstract away most API incompatibilities between the supported
7
+ Access Layer versions (for example the rename of _ual_lowlevel to _al_lowlevel).
8
+ """
9
+ import inspect
10
+ import logging
11
+
12
+ from packaging.version import Version
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ # Import the Access Layer module
18
+ has_imas = True
19
+ try:
20
+ # First try to import imas_core, which is available since AL 5.2
21
+ from imas_core import _al_lowlevel as lowlevel
22
+ from imas_core import imasdef
23
+
24
+ # Enable throwing exceptions from the _al_lowlevel interface
25
+ enable_exceptions = getattr(lowlevel, "imas_core_config_enable_exceptions", None)
26
+ if enable_exceptions:
27
+ enable_exceptions()
28
+
29
+ except ImportError as exc:
30
+ imas = None
31
+ has_imas = False
32
+ imasdef = None
33
+ lowlevel = None
34
+ logger.critical(
35
+ "Could not import 'al_core': %s. Some functionality is not available.",
36
+ exc,
37
+ )
38
+
39
+
40
+ class LLInterfaceError(RuntimeError):
41
+ """Exception thrown when a method doesn't exist on the lowlevel interface."""
42
+
43
+
44
+ class LowlevelInterface:
45
+ """Compatibility object.
46
+
47
+ Provides a stable API for the rest of imas-python even when the
48
+ `imas.lowlevel` interface changes.
49
+
50
+ .. rubric:: Developer notes
51
+
52
+ - When initializing the singleton object, we determine the AL version and redefine
53
+ all methods that exist in the imported lowlevel module.
54
+ - If the lowlevel introduces new methods, we need to:
55
+
56
+ - Add a new method with the same name but prefix dropped (e.g. register_plugin
57
+ for lowlevel.al_register_plugin)
58
+ - The implementation of this method should provide a proper error message when
59
+ the method is called and the underlying lowlevel doesn't provide the
60
+ functionality. For instance ``raise self._minimal_version("5.0")``.
61
+
62
+ - If the lowlevel drops methods, we need to update the implementation fo the method
63
+ to provide a proper error message or a workaround.
64
+ - Renamed methods (if this will ever happen) are perhaps best handled in the
65
+ ``__init__`` by providing a mapping of new to old name, so far this was only
66
+ relevant for the ``ual_`` to ``al_`` rename.
67
+ """
68
+
69
+ def __init__(self, lowlevel):
70
+ self._lowlevel = lowlevel
71
+ self._al_version = None
72
+ self._al_version_str = ""
73
+ public_methods = [attr for attr in dir(self) if not attr.startswith("_")]
74
+
75
+ # AL not available
76
+ if self._lowlevel is None:
77
+ # Replace all our public methods by _imas_not_available
78
+ for method in public_methods:
79
+ setattr(self, method, self._imas_not_available)
80
+ return
81
+
82
+ # Lowlevel available, try to determine AL version
83
+ if hasattr(lowlevel, "get_al_version"):
84
+ # Introduced after 5.0.0
85
+ self._al_version_str = self._lowlevel.get_al_version()
86
+ self._al_version = Version(self._al_version_str)
87
+ elif hasattr(lowlevel, "al_read_data"):
88
+ # In AL 5.0.0, all `ual_` methods were renamed to `al_`
89
+ self._al_version_str = "5.0.0"
90
+ self._al_version = Version(self._al_version_str)
91
+ else:
92
+ # AL 4, don't try to determine in more detail
93
+ self._al_version_str = "4.?.?"
94
+ self._al_version = Version("4")
95
+ public_methods.remove("close_pulse")
96
+
97
+ if self._al_version < Version("5"):
98
+ method_prefix = "ual_"
99
+ else:
100
+ method_prefix = "al_"
101
+ # Overwrite all of our methods that are implemented in the lowlevel
102
+ for method in public_methods:
103
+ ll_method = getattr(lowlevel, method_prefix + method, None)
104
+ if ll_method is not None:
105
+ setattr(self, method, ll_method)
106
+
107
+ def _imas_not_available(self, *args, **kwargs):
108
+ raise RuntimeError(
109
+ "This function requires an imas installation, which is not available."
110
+ )
111
+
112
+ def _minimal_version(self, minversion):
113
+ return LLInterfaceError(
114
+ f"This function requires at least Access Layer version {minversion}, "
115
+ f"but the current version is {self._al_version_str}"
116
+ )
117
+
118
+ # AL 4 lowlevel API
119
+
120
+ def begin_pulse_action(self, backendID, shot, run, user, tokamak, version):
121
+ # Removed in AL5, compatibility handled in DBEntry
122
+ raise LLInterfaceError(f"{__name__} is not implemented")
123
+
124
+ def open_pulse(self, pulseCtx, mode, options):
125
+ # Removed in AL5, compatibility handled in DBEntry
126
+ raise LLInterfaceError(f"{__name__} is not implemented")
127
+
128
+ def close_pulse(self, pulseCtx, mode):
129
+ # options argument (mandatory in AL4) was removed in AL5
130
+ # This method is overwritten in AL5, but for AL4 we need to do this:
131
+ return lowlevel.ual_close_pulse(pulseCtx, mode, None)
132
+
133
+ def begin_global_action(self, pulseCtx, dataobjectname, rwmode, datapath=""):
134
+ # datapath was added in AL5 to support more efficient partial_get in the
135
+ # UDA backend. TODO: figure out if this is useful for lazy loading.
136
+ raise LLInterfaceError(f"{__name__} is not implemented")
137
+
138
+ def begin_slice_action(self, pulseCtx, dataobjectname, rwmode, time, interpmode):
139
+ raise LLInterfaceError(f"{__name__} is not implemented")
140
+
141
+ def end_action(self, ctx):
142
+ raise LLInterfaceError(f"{__name__} is not implemented")
143
+
144
+ def write_data(self, ctx, pyFieldPath, pyTimebasePath, inputData):
145
+ raise LLInterfaceError(f"{__name__} is not implemented")
146
+
147
+ def read_data(self, ctx, fieldPath, pyTimebasePath, ualDataType, dim):
148
+ raise LLInterfaceError(f"{__name__} is not implemented")
149
+
150
+ def delete_data(self, ctx, path):
151
+ raise LLInterfaceError(f"{__name__} is not implemented")
152
+
153
+ def begin_arraystruct_action(self, ctx, path, pyTimebase, size):
154
+ raise LLInterfaceError(f"{__name__} is not implemented")
155
+
156
+ def iterate_over_arraystruct(self, aosctx, step):
157
+ raise LLInterfaceError(f"{__name__} is not implemented")
158
+
159
+ # New methods added in AL 5.0
160
+
161
+ def build_uri_from_legacy_parameters(
162
+ self, backendID, pulse, run, user, tokamak, version, options
163
+ ):
164
+ raise self._minimal_version("5.0")
165
+
166
+ def begin_dataentry_action(self, uri, mode):
167
+ raise self._minimal_version("5.0")
168
+
169
+ def register_plugin(self, name):
170
+ raise self._minimal_version("5.0")
171
+
172
+ def unregister_plugin(self, name):
173
+ raise self._minimal_version("5.0")
174
+
175
+ def bind_plugin(self, path, name):
176
+ raise self._minimal_version("5.0")
177
+
178
+ def unbind_plugin(self, path, name):
179
+ raise self._minimal_version("5.0")
180
+
181
+ def bind_readback_plugins(self, ctx):
182
+ raise self._minimal_version("5.0")
183
+
184
+ def unbind_readback_plugins(self, ctx):
185
+ raise self._minimal_version("5.0")
186
+
187
+ def write_plugins_metadata(self, ctx):
188
+ raise self._minimal_version("5.0")
189
+
190
+ def setvalue_parameter_plugin(self, parameter_name, inputData, pluginName):
191
+ raise self._minimal_version("5.0")
192
+
193
+ # New methods added in AL 5.1
194
+
195
+ def get_occurrences(self, ctx, ids_name):
196
+ raise self._minimal_version("5.1")
197
+
198
+ def get_al_version(self):
199
+ return self._al_version_str
200
+
201
+ # New methods added in AL 5.4
202
+
203
+ def begin_timerange_action(
204
+ self, ctx, path, rwmode, tmin, tmax, dtime, interpolation_method
205
+ ):
206
+ raise self._minimal_version("5.4")
207
+
208
+
209
+ # Dummy documentation for interface:
210
+ for funcname in dir(LowlevelInterface):
211
+ func = getattr(LowlevelInterface, funcname)
212
+ if not funcname.startswith("_") and inspect.isfunction(func) and not func.__doc__:
213
+ func.__doc__ = f"Wrapper function for AL lowlevel method ``{funcname}``"
214
+
215
+ ll_interface = LowlevelInterface(lowlevel)
216
+ """imas-python <-> IMAS lowlevel interface"""
@@ -0,0 +1,367 @@
1
+ # Helper functions to create MDSPlus reference models
2
+ # and store them in a cache directory (.cache/imas/MDSPlus/name-HASH/)
3
+ """Module for generating and working with MDSplus models.
4
+ """
5
+
6
+ import errno
7
+ import getpass
8
+ import logging
9
+ import os
10
+ import re
11
+ import shutil
12
+ import tempfile
13
+ import time
14
+ import uuid
15
+ from pathlib import Path
16
+ from subprocess import CalledProcessError, check_output
17
+ from zlib import crc32
18
+
19
+ try:
20
+ from importlib.resources import as_file, files
21
+ except ImportError: # Python 3.8 support
22
+ from importlib_resources import as_file, files
23
+
24
+ import imas
25
+ from imas.dd_helpers import get_saxon
26
+ from imas.dd_zip import get_dd_xml, get_dd_xml_crc
27
+ from imas.exception import MDSPlusModelError
28
+ from imas.ids_factory import IDSFactory
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ MDSPLUS_MODEL_TIMEOUT = int(os.getenv("MDSPLUS_MODEL_TIMEOUT", "120"))
34
+
35
+
36
+ def safe_replace(src: Path, dst: Path) -> None:
37
+ """Replace a folder from ``src`` to ``dst``, overwriting `dst` if it is empty.
38
+
39
+ * Moves must be atomic. ``shutil.move()`` is not atomic.
40
+ Note that multiple threads may try to write to the cache at once,
41
+ so atomicity is required to ensure the serving on one thread doesn't
42
+ pick up a partially saved image from another thread.
43
+
44
+ * Moves must work across filesystems. Often temp directories and the
45
+ cache directories live on different filesystems. ``os.replace()`` can
46
+ throw errors if run across filesystems.
47
+
48
+ So we try ``os.replace()``, but if we detect a cross-filesystem copy, we
49
+ switch to ``shutil.move()`` with some wrappers to make it atomic.
50
+ """
51
+ # From https://alexwlchan.net/2019/03/atomic-cross-filesystem-moves-in-python/
52
+ try:
53
+ src.replace(dst)
54
+ except OSError as err:
55
+ if err.errno == errno.EXDEV:
56
+ # Generate a unique ID, and copy `<src>` to the target directory
57
+ # with a temporary name `<dst>.<ID>.tmp`. Because we're copying
58
+ # across a filesystem boundary, this initial copy may not be
59
+ # atomic. We intersperse a random UUID so if different processes
60
+ # are copying into `<dst>`, they don't overlap in their tmp copies.
61
+ copy_id = uuid.uuid4()
62
+ tmp_dst = dst.with_name(f"{dst.name}.{copy_id}.tmp")
63
+ shutil.copytree(src, tmp_dst)
64
+
65
+ # Then do an atomic replace onto the new name, and clean up the source path
66
+ try:
67
+ tmp_dst.replace(dst)
68
+ except OSError as err:
69
+ # if the folder is not empty, another process beat us, ignore error
70
+ if err.errno not in (errno.EEXIST, errno.ENOTEMPTY):
71
+ raise # otherwise raise the error
72
+ shutil.rmtree(src)
73
+ elif err.errno in (errno.EEXIST, errno.ENOTEMPTY):
74
+ # if the folder is not empty, another process beat us, ignore error
75
+ pass
76
+ else:
77
+ raise
78
+
79
+
80
+ def mdsplus_model_dir(factory: IDSFactory) -> str:
81
+ """
82
+ when given a version number this looks for the DD definition
83
+ of that version in the internal cache. Alternatively a filename
84
+ can be passed, which leads us to use that XML file to build an
85
+ MDSplus model directory.
86
+
87
+
88
+ Given a filename and xml contents create an xml
89
+ document for the mdsplus model by running a command like the below:
90
+
91
+ java net.sf.saxon.Transform -s:- -xsl: -o:${OUTPUT_FILE}
92
+
93
+ with ENV:
94
+ env={"CLASSPATH": saxon_jar_path, "PATH": os.environ.get("PATH", "")}
95
+
96
+ Args:
97
+ factory: IDSFactory indicating the DD version / XML to build models for.
98
+
99
+ Returns:
100
+ The path to the requested DD cache
101
+ """
102
+
103
+ # Calculate a checksum on the contents of a DD XML file to uniquely
104
+ # identify our cache files, and re-create them as-needed if the contents
105
+ # of the file change
106
+
107
+ if factory._xml_path is None: # Factory was created from version
108
+ version = factory.version
109
+ crc = get_dd_xml_crc(version)
110
+ xml_name = version + ".xml"
111
+ fname = "-"
112
+ else:
113
+ version = None
114
+ xml_name = Path(factory._xml_path).name
115
+ fname = factory._xml_path
116
+ with open(fname, "rb") as file:
117
+ crc = crc32(file.read())
118
+
119
+ cache_dir_name = "%s-%08x" % (xml_name, crc)
120
+ cache_dir_path = Path(_get_xdg_cache_dir()) / "imas" / "mdsplus" / cache_dir_name
121
+ # TODO: include hash or version of "IDSDef2MDSpreTree.xsl", which we should fetch
122
+ # from the access layer instead of provide ourselves, if we wish to be resilient to
123
+ # upgrades there (has happened early 2021 already once). of course, upgrades to the
124
+ # on-disk formats should be versioned and documented properly, so this should never
125
+ # happen again.
126
+
127
+ # There are multiple possible cases for the imas-python cache
128
+ # 1. The cache exist and can be used
129
+ # 2. The cache folder exists, and another process is creating it
130
+ # 3. The cache folder exists, but the process creating it has stopped
131
+ # 4. The cache folder does not exist and this process should make it
132
+ #
133
+ # As (cross)-filesystem operations can in principle collide, we use
134
+ # a statistically unique temp dir which we move with a special safe and
135
+ # atomic function if the generation successfully finished
136
+
137
+ fuuid = uuid.uuid4().hex
138
+ tmp_cache_dir_path = (
139
+ Path(tempfile.gettempdir())
140
+ / getpass.getuser()
141
+ / "imas"
142
+ / "mdsplus"
143
+ / f"{cache_dir_name}_{fuuid}"
144
+ )
145
+ if cache_dir_path.is_dir() and model_exists(cache_dir_path):
146
+ # Case 1: The model already exists on the right location, done!
147
+ generate_tmp_cache = False
148
+ elif cache_dir_path.is_dir() and not model_exists(cache_dir_path):
149
+ # The cache dir has been created, but not filled: case 3 or 4.
150
+ # We wait until it fills on its own (case 3)
151
+ logger.warning(
152
+ "Model dir %s exists but is empty. Waiting %ss for contents.",
153
+ cache_dir_path,
154
+ MDSPLUS_MODEL_TIMEOUT,
155
+ )
156
+ # If it timed out (case 4), we will create a new cache in this process
157
+ generate_tmp_cache = not wait_for_model(cache_dir_path)
158
+ # We expect cache_dir_path is empty when we need to generate
159
+ if generate_tmp_cache and len(os.listdir(cache_dir_path)) > 1:
160
+ if not model_exists(cache_dir_path):
161
+ logger.debug(
162
+ "Model directory %s contents: %s",
163
+ cache_dir_path,
164
+ os.listdir(cache_dir_path),
165
+ )
166
+ raise MDSPlusModelError(
167
+ "The imas-python cache directory is corrupted. Please clean the"
168
+ f" cache directory ({cache_dir_path}) and try again."
169
+ )
170
+ elif not cache_dir_path.is_dir() and not model_exists(cache_dir_path):
171
+ # The cache did not exist, we will create a new cache in this process
172
+ generate_tmp_cache = True
173
+ else:
174
+ raise RuntimeError("Programmer error, this case should never be true")
175
+
176
+ if generate_tmp_cache:
177
+ # create the empty directory to indicate we are building a new model
178
+ try:
179
+ cache_dir_path.mkdir(parents=True, exist_ok=True)
180
+ # ideally next we drop a timestamp so any successors can see how long they
181
+ # should wait
182
+ logger.info(
183
+ "Creating and caching MDSplus model at %s, this may take a while",
184
+ tmp_cache_dir_path,
185
+ )
186
+ create_model_ids_xml(tmp_cache_dir_path, fname, version)
187
+ create_mdsplus_model(tmp_cache_dir_path)
188
+
189
+ logger.info(
190
+ "MDSplus model at %s created, moving to %s",
191
+ tmp_cache_dir_path,
192
+ cache_dir_path,
193
+ )
194
+ safe_replace(tmp_cache_dir_path, cache_dir_path)
195
+
196
+ if not model_exists(cache_dir_path):
197
+ raise MDSPlusModelError(
198
+ "Unexpected error while generating MDSPlus model cache. "
199
+ "Please create a bug report."
200
+ )
201
+ except Exception as ee:
202
+ logger.error("Error creating MDSPlus file")
203
+ # remove cache directory so our successor does not spend time waiting
204
+ shutil.rmtree(cache_dir_path)
205
+ raise ee
206
+
207
+ return str(cache_dir_path)
208
+
209
+
210
+ def wait_for_model(cache_dir_path: Path) -> bool:
211
+ """Wait MDSPLUS_MODEL_TIMEOUT seconds until model appears in directory
212
+
213
+ Returns:
214
+ True if the cache folder is found, and false if the
215
+ wait loop timed out.
216
+ """
217
+ for _ in range(MDSPLUS_MODEL_TIMEOUT):
218
+ if model_exists(cache_dir_path):
219
+ return True
220
+ time.sleep(1)
221
+ else:
222
+ logger.warning(
223
+ "Timeout exceeded while waiting for MDSplus model, try overwriting"
224
+ )
225
+ return False
226
+
227
+
228
+ def model_exists(path: Path) -> bool:
229
+ """Given a path to an IDS model definition check if all components are there"""
230
+ return all(
231
+ map(
232
+ lambda f: os.path.isfile(path / f),
233
+ [
234
+ "ids.xml",
235
+ "ids_model.characteristics",
236
+ "ids_model.datafile",
237
+ "ids_model.tree",
238
+ "done.txt",
239
+ ],
240
+ )
241
+ )
242
+
243
+
244
+ def create_model_ids_xml(cache_dir_path, fname, version):
245
+ """Use saxon to compile an ids.xml suitable for creating an mdsplus model."""
246
+
247
+ try:
248
+ # we have to be careful to have the same version of this file as in the access
249
+ # layer:
250
+ with as_file(files(imas) / "assets" / "IDSDef2MDSpreTree.xsl") as xslfile:
251
+ check_output(
252
+ [
253
+ "java",
254
+ "net.sf.saxon.Transform",
255
+ "-s:" + str(fname),
256
+ "-o:" + str(Path(cache_dir_path) / "ids.xml"),
257
+ "DD_GIT_DESCRIBE=" + str(version or fname),
258
+ # if this is expected as git describe it might break
259
+ # if we just pass a filename
260
+ "AL_GIT_DESCRIBE=" + os.environ.get("AL_VERSION", "0.0.0"),
261
+ "-xsl:" + str(xslfile),
262
+ ],
263
+ input=get_dd_xml(version) if version else None,
264
+ env={"CLASSPATH": get_saxon(), "PATH": os.environ.get("PATH", "")},
265
+ )
266
+ except CalledProcessError as e:
267
+ if fname:
268
+ logger.error("Error making MDSPlus model IDS.xml for %s", fname)
269
+ else:
270
+ logger.error("Error making MDSplus model IDS.xml for %s", version)
271
+ raise e
272
+
273
+
274
+ def create_mdsplus_model(cache_dir_path: Path) -> None:
275
+ """Use jtraverser to compile a valid MDS model file."""
276
+ try:
277
+ # In newer versions of MDSplus, CompileTree is renamed to
278
+ # mds.jtraverser.CompileTree. Find out which to use:
279
+ jarfile = str(jTraverser_jar())
280
+ compiletree_class = check_output(
281
+ ["jar", "tf", jarfile, "CompileTree", "mds/jtraverser/CompileTree"]
282
+ ).decode("utf-8")
283
+ if not compiletree_class:
284
+ raise MDSPlusModelError(
285
+ f"Error making MDSplus model in {cache_dir_path}: "
286
+ "Could not determine CompileTree class in jTraverser.jar."
287
+ )
288
+ compiletree = compiletree_class.rpartition(".class")[0].replace("/", ".")
289
+ check_output(
290
+ [
291
+ "java",
292
+ "-Xms1g", # what do these do?
293
+ "-Xmx8g", # what do these do?
294
+ "-XX:+UseG1GC", # what do these do?
295
+ "-cp",
296
+ jarfile,
297
+ compiletree,
298
+ "ids",
299
+ ],
300
+ cwd=str(cache_dir_path),
301
+ env={
302
+ "PATH": os.environ.get("PATH", ""),
303
+ "LD_LIBRARY_PATH": os.environ.get("LD_LIBRARY_PATH", ""),
304
+ "ids_path": str(cache_dir_path),
305
+ },
306
+ )
307
+ # Touch a file to show that we have finished the model
308
+ (cache_dir_path / "done.txt").touch()
309
+ except CalledProcessError as e:
310
+ logger.error("Error making MDSPlus model in {path}", cache_dir_path)
311
+ raise e
312
+
313
+
314
+ def _get_xdg_cache_dir() -> str:
315
+ """
316
+ Return the XDG cache directory, according to the XDG base directory spec:
317
+
318
+ https://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
319
+ """
320
+ return os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
321
+
322
+
323
+ def jTraverser_jar() -> Path:
324
+ """Search a few common locations and CLASSPATH for jTraverser.jar
325
+ which is provided by MDSPlus."""
326
+ search_dirs = ["/usr/share/java"]
327
+
328
+ for component in os.environ.get("CLASSPATH", "").split(":"):
329
+ if component.endswith(".jar"):
330
+ if re.search(".*jTraverser.jar", component):
331
+ return component
332
+ else: # assume its a directory (strip any '*' suffix)
333
+ search_dirs.append(component.rstrip("*"))
334
+
335
+ files = []
336
+ for dir in search_dirs:
337
+ files += Path(dir).rglob("*")
338
+
339
+ jars = [path for path in files if path.name == "jTraverser.jar"]
340
+
341
+ if jars:
342
+ jar_path = min(jars, key=lambda x: len(x.parts))
343
+ return jar_path
344
+ else:
345
+ raise MDSPlusModelError("jTraverser.jar not found. Is MDSplus-Java available?")
346
+
347
+
348
+ def ensure_data_dir(user: str, tokamak: str, version: str, run: int) -> None:
349
+ """Ensure that a data dir exists with a similar algorithm that
350
+ the MDSplus backend uses to set the data path.
351
+ See also mdsplus_backend.cpp:751 (setDataEnv)"""
352
+ if user == "public":
353
+ if "IMAS_HOME" not in os.environ:
354
+ raise RuntimeError(
355
+ "Environment variable IMAS_HOME must be set to access "
356
+ "the public database."
357
+ )
358
+ dbdir = Path(os.environ["IMAS_HOME"]) / "shared" / "imasdb" / tokamak / version
359
+ elif user[0] == "/":
360
+ dbdir = Path(user) / tokamak / version
361
+ else:
362
+ dbdir = Path.home() / "public" / "imasdb" / tokamak / version
363
+
364
+ # Check subfolder based on run
365
+ assert 0 <= run <= 99_999
366
+ index = run // 10_000
367
+ (dbdir / str(index)).mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,56 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Union
4
+ from xml.etree import ElementTree as ET
5
+
6
+ from imas import dd_zip
7
+
8
+ from .mdsplus_model import _get_xdg_cache_dir
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def get_dd_version_from_idsdef_xml(path: Union[str, Path]) -> str:
14
+ """Parse the IDSDef.xml up to the point where the Data Dictionary version is set.
15
+
16
+ Returns:
17
+ The Data Dictionary version for the provided file, or None if the file cannot be
18
+ parsed / contains no Data Dictionary version.
19
+ """
20
+ try:
21
+ for _, elem in ET.iterparse(path):
22
+ if elem.tag == "version":
23
+ return elem.text
24
+ except OSError:
25
+ pass # File not found, etc.
26
+ except Exception:
27
+ logger.warning("Could not read DD version from file '%s'.", path, exc_info=True)
28
+ return None
29
+
30
+
31
+ def extract_idsdef(dd_version: str) -> str:
32
+ """Extract the IDSDef.xml for the given version and return its path.
33
+
34
+ The IDSDef.xml is extracted to the imas cache folder:
35
+
36
+ - If the file imas/uda/<version>.xml already exists, we assume it is correct
37
+ """
38
+ cache_dir_path = Path(_get_xdg_cache_dir()) / "imas" / "uda"
39
+ cache_dir_path.mkdir(parents=True, exist_ok=True) # ensure cache folder exists
40
+ idsdef_path = cache_dir_path / (dd_version + ".xml")
41
+
42
+ if idsdef_path.exists():
43
+ extract = False
44
+ # Check if the file is fine
45
+ if get_dd_version_from_idsdef_xml(idsdef_path) != dd_version:
46
+ # File is corrupt, I guess? We'll overwrite:
47
+ extract = True
48
+ else:
49
+ extract = True
50
+
51
+ if extract:
52
+ # Extract XML from the dd_zip and store
53
+ data = dd_zip.get_dd_xml(dd_version)
54
+ idsdef_path.write_bytes(data)
55
+
56
+ return str(idsdef_path)
@@ -0,0 +1,4 @@
1
+ # This file is part of imas-python.
2
+ # You should have received the imas-python LICENSE file with this project.
3
+ """NetCDF IO support for imas-python. Requires [netcdf] extra dependencies.
4
+ """