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.
- imas/__init__.py +34 -0
- imas/__main__.py +17 -0
- imas/_util.py +260 -0
- imas/_version.py +16 -0
- imas/assets/IDSDef2MDSpreTree.xsl +161 -0
- imas/assets/IDS_fake_toplevel.xml +318 -0
- imas/assets/IDS_minimal.xml +37 -0
- imas/assets/IDS_minimal_2.xml +37 -0
- imas/assets/IDS_minimal_struct_array.xml +43 -0
- imas/assets/IDS_minimal_types.xml +169 -0
- imas/assets/ITER_134173_106_core_profiles.ids +2438 -0
- imas/assets/ITER_134173_106_equilibrium.ids +3172 -0
- imas/assets/README.md +5 -0
- imas/assets/core_profiles.ids +2438 -0
- imas/assets/equilibrium.ids +3172 -0
- imas/backends/__init__.py +12 -0
- imas/backends/db_entry_impl.py +122 -0
- imas/backends/imas_core/__init__.py +4 -0
- imas/backends/imas_core/al_context.py +391 -0
- imas/backends/imas_core/db_entry_al.py +405 -0
- imas/backends/imas_core/db_entry_helpers.py +200 -0
- imas/backends/imas_core/imas_interface.py +216 -0
- imas/backends/imas_core/mdsplus_model.py +367 -0
- imas/backends/imas_core/uda_support.py +56 -0
- imas/backends/netcdf/__init__.py +4 -0
- imas/backends/netcdf/db_entry_nc.py +176 -0
- imas/backends/netcdf/ids2nc.py +306 -0
- imas/backends/netcdf/nc2ids.py +302 -0
- imas/backends/netcdf/nc_metadata.py +357 -0
- imas/backends/netcdf/nc_validate.py +53 -0
- imas/command/cli.py +239 -0
- imas/command/db_analysis.py +290 -0
- imas/command/helpers.py +45 -0
- imas/command/timer.py +77 -0
- imas/db_entry.py +802 -0
- imas/dd_helpers.py +300 -0
- imas/dd_zip.py +297 -0
- imas/exception.py +107 -0
- imas/ids_base.py +149 -0
- imas/ids_convert.py +920 -0
- imas/ids_coordinates.py +400 -0
- imas/ids_data_type.py +115 -0
- imas/ids_defs.py +171 -0
- imas/ids_factory.py +108 -0
- imas/ids_identifiers.py +125 -0
- imas/ids_metadata.py +307 -0
- imas/ids_path.py +254 -0
- imas/ids_primitive.py +510 -0
- imas/ids_struct_array.py +233 -0
- imas/ids_structure.py +331 -0
- imas/ids_toplevel.py +381 -0
- imas/setup_logging.py +64 -0
- imas/test/test_all_dd_versions.py +28 -0
- imas/test/test_cli.py +44 -0
- imas/test/test_dbentry.py +104 -0
- imas/test/test_dd_helpers.py +134 -0
- imas/test/test_dd_zip.py +29 -0
- imas/test/test_deepcopy.py +25 -0
- imas/test/test_exception.py +12 -0
- imas/test/test_get_sample.py +388 -0
- imas/test/test_hash.py +188 -0
- imas/test/test_helpers.py +354 -0
- imas/test/test_identifiers.py +105 -0
- imas/test/test_ids2nc.py +166 -0
- imas/test/test_ids_ascii_data.py +39 -0
- imas/test/test_ids_convert.py +415 -0
- imas/test/test_ids_coordinates.py +165 -0
- imas/test/test_ids_data_type.py +40 -0
- imas/test/test_ids_factory.py +35 -0
- imas/test/test_ids_metadata.py +66 -0
- imas/test/test_ids_mixin.py +20 -0
- imas/test/test_ids_path.py +192 -0
- imas/test/test_ids_primitive.py +109 -0
- imas/test/test_ids_struct_array.py +89 -0
- imas/test/test_ids_structure.py +70 -0
- imas/test/test_ids_toplevel.py +55 -0
- imas/test/test_ids_validate.py +343 -0
- imas/test/test_latest_dd_autofill.py +74 -0
- imas/test/test_latest_dd_manual.py +38 -0
- imas/test/test_latest_dd_resample.py +102 -0
- imas/test/test_lazy_loading.py +201 -0
- imas/test/test_list_occurrences.py +82 -0
- imas/test/test_minimal.py +37 -0
- imas/test/test_minimal_conversion.py +44 -0
- imas/test/test_minimal_io.py +21 -0
- imas/test/test_minimal_struct_array_io.py +60 -0
- imas/test/test_minimal_types.py +436 -0
- imas/test/test_minimal_types_io.py +70 -0
- imas/test/test_minimal_types_io_automatic.py +66 -0
- imas/test/test_nbc_change.py +408 -0
- imas/test/test_nc_autofill.py +13 -0
- imas/test/test_nc_entry.py +28 -0
- imas/test/test_nc_metadata.py +153 -0
- imas/test/test_nc_validation.py +197 -0
- imas/test/test_snippets.py +25 -0
- imas/test/test_static_ids.py +39 -0
- imas/test/test_str_1d.py +99 -0
- imas/test/test_time_slicing.py +182 -0
- imas/test/test_uda_support.py +12 -0
- imas/test/test_util.py +209 -0
- imas/training.py +45 -0
- imas/util.py +526 -0
- imas_python-0.1.dev1618.dist-info/LICENSE.txt +165 -0
- imas_python-0.1.dev1618.dist-info/METADATA +288 -0
- imas_python-0.1.dev1618.dist-info/RECORD +108 -0
- imas_python-0.1.dev1618.dist-info/WHEEL +5 -0
- imas_python-0.1.dev1618.dist-info/entry_points.txt +3 -0
- 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)
|