asyncmd 0.3.2__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.
- asyncmd/__init__.py +18 -0
- asyncmd/_config.py +26 -0
- asyncmd/_version.py +75 -0
- asyncmd/config.py +203 -0
- asyncmd/gromacs/__init__.py +16 -0
- asyncmd/gromacs/mdconfig.py +351 -0
- asyncmd/gromacs/mdengine.py +1127 -0
- asyncmd/gromacs/utils.py +197 -0
- asyncmd/mdconfig.py +440 -0
- asyncmd/mdengine.py +100 -0
- asyncmd/slurm.py +1199 -0
- asyncmd/tools.py +86 -0
- asyncmd/trajectory/__init__.py +25 -0
- asyncmd/trajectory/convert.py +577 -0
- asyncmd/trajectory/functionwrapper.py +556 -0
- asyncmd/trajectory/propagate.py +937 -0
- asyncmd/trajectory/trajectory.py +1103 -0
- asyncmd/utils.py +148 -0
- asyncmd-0.3.2.dist-info/LICENSE +232 -0
- asyncmd-0.3.2.dist-info/METADATA +179 -0
- asyncmd-0.3.2.dist-info/RECORD +23 -0
- asyncmd-0.3.2.dist-info/WHEEL +5 -0
- asyncmd-0.3.2.dist-info/top_level.txt +1 -0
asyncmd/gromacs/utils.py
ADDED
@@ -0,0 +1,197 @@
|
|
1
|
+
# This file is part of asyncmd.
|
2
|
+
#
|
3
|
+
# asyncmd is free software: you can redistribute it and/or modify
|
4
|
+
# it under the terms of the GNU General Public License as published by
|
5
|
+
# the Free Software Foundation, either version 3 of the License, or
|
6
|
+
# (at your option) any later version.
|
7
|
+
#
|
8
|
+
# asyncmd is distributed in the hope that it will be useful,
|
9
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
10
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
11
|
+
# GNU General Public License for more details.
|
12
|
+
#
|
13
|
+
# You should have received a copy of the GNU General Public License
|
14
|
+
# along with asyncmd. If not, see <https://www.gnu.org/licenses/>.
|
15
|
+
import os
|
16
|
+
import logging
|
17
|
+
import aiofiles.os
|
18
|
+
|
19
|
+
from ..trajectory.trajectory import Trajectory
|
20
|
+
from .mdconfig import MDP
|
21
|
+
|
22
|
+
|
23
|
+
logger = logging.getLogger(__name__)
|
24
|
+
|
25
|
+
|
26
|
+
def nstout_from_mdp(mdp: MDP, traj_type: str = "TRR") -> int:
|
27
|
+
"""
|
28
|
+
Get minimum number of steps between outputs for trajectories from MDP.
|
29
|
+
|
30
|
+
Parameters
|
31
|
+
----------
|
32
|
+
mdp : MDP
|
33
|
+
Config object from which the output step should be read.
|
34
|
+
traj_type : str, optional
|
35
|
+
Trajectory format for which output step should be read, "XTC" or "TRR",
|
36
|
+
by default "TRR".
|
37
|
+
|
38
|
+
Returns
|
39
|
+
-------
|
40
|
+
int
|
41
|
+
Minimum number of steps between two writes.
|
42
|
+
|
43
|
+
Raises
|
44
|
+
------
|
45
|
+
ValueError
|
46
|
+
Raised when an unknown trajectory format `traj_type` is given.
|
47
|
+
ValueError
|
48
|
+
Raised when the given MDP would result in no output for the given
|
49
|
+
trajectory format `traj_type`.
|
50
|
+
"""
|
51
|
+
if traj_type.upper() == "TRR":
|
52
|
+
keys = ["nstxout", "nstvout", "nstfout"]
|
53
|
+
elif traj_type.upper() == "XTC":
|
54
|
+
keys = ["nstxout-compressed", "nstxtcout"]
|
55
|
+
else:
|
56
|
+
raise ValueError("traj_type must be one of 'TRR' or 'XTC'.")
|
57
|
+
|
58
|
+
vals = []
|
59
|
+
for k in keys:
|
60
|
+
try:
|
61
|
+
# need to check for 0 (== no output!) in case somone puts the
|
62
|
+
# defaults (or reads an mdout.mdp where gmx lists all the defaults)
|
63
|
+
v = mdp[k]
|
64
|
+
if v == 0:
|
65
|
+
v = float("inf")
|
66
|
+
vals += [v]
|
67
|
+
except KeyError:
|
68
|
+
# not set, defaults to 0, so we ignore it
|
69
|
+
pass
|
70
|
+
|
71
|
+
nstout = min(vals, default=None)
|
72
|
+
if (nstout is None) or (nstout == float("inf")):
|
73
|
+
raise ValueError(f"The MDP you passed results in no {traj_type} "
|
74
|
+
+"trajectory output.")
|
75
|
+
return nstout
|
76
|
+
|
77
|
+
|
78
|
+
async def get_all_traj_parts(folder: str, deffnm: str,
|
79
|
+
traj_type: str = "TRR") -> "list[Trajectory]":
|
80
|
+
"""
|
81
|
+
Find and return a list of trajectory parts produced by a GmxEngine.
|
82
|
+
|
83
|
+
NOTE: This returns only the parts that exist in ascending order.
|
84
|
+
|
85
|
+
Parameters
|
86
|
+
----------
|
87
|
+
folder : str
|
88
|
+
path to a folder to search for trajectory parts
|
89
|
+
deffnm : str
|
90
|
+
deffnm (prefix of filenames) used in the simulation
|
91
|
+
traj_type : str, optional
|
92
|
+
Trajectory file ending("XTC", "TRR", "TNG", ...), by default "TRR"
|
93
|
+
|
94
|
+
Returns
|
95
|
+
-------
|
96
|
+
list[Trajectory]
|
97
|
+
Ordered list of all trajectory parts with given deffnm and type.
|
98
|
+
"""
|
99
|
+
ending = traj_type.lower()
|
100
|
+
traj_files = await get_all_file_parts(folder=folder, deffnm=deffnm,
|
101
|
+
file_ending=ending)
|
102
|
+
trajs = [Trajectory(trajectory_files=traj_file,
|
103
|
+
structure_file=os.path.join(folder, f"{deffnm}.tpr")
|
104
|
+
)
|
105
|
+
for traj_file in traj_files]
|
106
|
+
return trajs
|
107
|
+
|
108
|
+
|
109
|
+
async def get_all_file_parts(folder: str, deffnm: str, file_ending: str) -> "list[str]":
|
110
|
+
"""
|
111
|
+
Find and return all files with given ending produced by GmxEngine.
|
112
|
+
|
113
|
+
NOTE: This returns only the parts that exist in ascending order.
|
114
|
+
|
115
|
+
Parameters
|
116
|
+
----------
|
117
|
+
folder : str
|
118
|
+
Path to a folder to search for trajectory parts.
|
119
|
+
deffnm : str
|
120
|
+
deffnm (prefix of filenames) used in the simulation.
|
121
|
+
file_ending : str
|
122
|
+
File ending of the requested filetype (with or without preceeding ".").
|
123
|
+
|
124
|
+
Returns
|
125
|
+
-------
|
126
|
+
list[str]
|
127
|
+
Ordered list of filepaths for files with given ending.
|
128
|
+
"""
|
129
|
+
def partnum_suffix(num):
|
130
|
+
# construct gromacs num part suffix from simulation_part
|
131
|
+
num_suffix = ".part{:04d}".format(num)
|
132
|
+
return num_suffix
|
133
|
+
|
134
|
+
if not file_ending.startswith("."):
|
135
|
+
file_ending = "." + file_ending
|
136
|
+
content = await aiofiles.os.listdir(folder)
|
137
|
+
filtered = [f for f in content
|
138
|
+
if (f.startswith(f"{deffnm}.part")
|
139
|
+
and f.endswith(file_ending)
|
140
|
+
and (len(f) == len(deffnm) + 9 + len(file_ending))
|
141
|
+
)
|
142
|
+
]
|
143
|
+
partnums = [int(f[len(deffnm) + 5:len(deffnm) + 9]) # get the 4 number digits
|
144
|
+
for f in filtered]
|
145
|
+
partnums.sort()
|
146
|
+
parts = [os.path.join(folder, f"{deffnm}{partnum_suffix(num)}{file_ending}")
|
147
|
+
for num in partnums]
|
148
|
+
return parts
|
149
|
+
|
150
|
+
|
151
|
+
def ensure_mdp_options(mdp: MDP, genvel: str = "no", continuation: str = "yes") -> MDP:
|
152
|
+
"""
|
153
|
+
Ensure that some commonly used mdp options have the given values.
|
154
|
+
|
155
|
+
NOTE: Modifies the `MDP` inplace and returns it.
|
156
|
+
|
157
|
+
Parameters
|
158
|
+
----------
|
159
|
+
mdp : MDP
|
160
|
+
Config object for which values should be ensured.
|
161
|
+
genvel : str, optional
|
162
|
+
Value for genvel option ("yes" or "no"), by default "no".
|
163
|
+
continuation : str, optional
|
164
|
+
Value for continuation option ("yes" or "no"), by default "yes".
|
165
|
+
|
166
|
+
Returns
|
167
|
+
-------
|
168
|
+
MDP
|
169
|
+
Reference to input config object with values for options as given.
|
170
|
+
"""
|
171
|
+
try:
|
172
|
+
# make sure we do not generate velocities with gromacs
|
173
|
+
genvel_test = mdp["gen-vel"]
|
174
|
+
except KeyError:
|
175
|
+
logger.info(f"Setting 'gen-vel = {genvel}' in mdp.")
|
176
|
+
mdp["gen-vel"] = genvel
|
177
|
+
else:
|
178
|
+
if genvel_test != genvel:
|
179
|
+
logger.warning(f"Setting 'gen-vel = {genvel}' in mdp "
|
180
|
+
+ f"(was '{genvel_test}').")
|
181
|
+
mdp["gen-vel"] = genvel
|
182
|
+
try:
|
183
|
+
# TODO/FIXME: this could also be 'unconstrained-start'!
|
184
|
+
# however already the gmx v4.6.3 docs say
|
185
|
+
# "continuation: formerly know as 'unconstrained-start'"
|
186
|
+
# so I think we can ignore that for now?!
|
187
|
+
continuation_test = mdp["continuation"]
|
188
|
+
except KeyError:
|
189
|
+
logger.info(f"Setting 'continuation = {continuation}' in mdp.")
|
190
|
+
mdp["continuation"] = continuation
|
191
|
+
else:
|
192
|
+
if continuation_test != continuation:
|
193
|
+
logger.warning(f"Setting 'continuation = {continuation}' in mdp "
|
194
|
+
+ f"(was '{continuation_test}').")
|
195
|
+
mdp["continuation"] = continuation
|
196
|
+
|
197
|
+
return mdp
|
asyncmd/mdconfig.py
ADDED
@@ -0,0 +1,440 @@
|
|
1
|
+
# This file is part of asyncmd.
|
2
|
+
#
|
3
|
+
# asyncmd is free software: you can redistribute it and/or modify
|
4
|
+
# it under the terms of the GNU General Public License as published by
|
5
|
+
# the Free Software Foundation, either version 3 of the License, or
|
6
|
+
# (at your option) any later version.
|
7
|
+
#
|
8
|
+
# asyncmd is distributed in the hope that it will be useful,
|
9
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
10
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
11
|
+
# GNU General Public License for more details.
|
12
|
+
#
|
13
|
+
# You should have received a copy of the GNU General Public License
|
14
|
+
# along with asyncmd. If not, see <https://www.gnu.org/licenses/>.
|
15
|
+
import os
|
16
|
+
import abc
|
17
|
+
import typing
|
18
|
+
import shutil
|
19
|
+
import logging
|
20
|
+
import collections
|
21
|
+
|
22
|
+
|
23
|
+
logger = logging.getLogger(__name__)
|
24
|
+
|
25
|
+
|
26
|
+
class FlagChangeList(collections.abc.MutableSequence):
|
27
|
+
"""A list that knows if it has been changed after initializing."""
|
28
|
+
|
29
|
+
def __init__(self, data: list) -> None:
|
30
|
+
"""
|
31
|
+
Initialize a `FlagChangeList`.
|
32
|
+
|
33
|
+
Parameters
|
34
|
+
----------
|
35
|
+
data : list
|
36
|
+
The data this `FlagChangeList` will hold.
|
37
|
+
|
38
|
+
Raises
|
39
|
+
------
|
40
|
+
TypeError
|
41
|
+
Raised when data is not a :class:`list`.
|
42
|
+
"""
|
43
|
+
if not isinstance(data, list):
|
44
|
+
raise TypeError("FlagChangeList must be initialized with a list.")
|
45
|
+
self._data = data
|
46
|
+
self._changed = False
|
47
|
+
|
48
|
+
@property
|
49
|
+
def changed(self) -> bool:
|
50
|
+
"""
|
51
|
+
Whether this `FlagChangeList` has been modified since creation.
|
52
|
+
|
53
|
+
Returns
|
54
|
+
-------
|
55
|
+
bool
|
56
|
+
"""
|
57
|
+
return self._changed
|
58
|
+
|
59
|
+
def __repr__(self) -> str:
|
60
|
+
return self._data.__repr__()
|
61
|
+
|
62
|
+
def __getitem__(self, index: int) -> typing.Any:
|
63
|
+
return self._data.__getitem__(index)
|
64
|
+
|
65
|
+
def __len__(self) -> int:
|
66
|
+
return self._data.__len__()
|
67
|
+
|
68
|
+
def __setitem__(self, index: int, value) -> None:
|
69
|
+
self._data.__setitem__(index, value)
|
70
|
+
self._changed = True
|
71
|
+
|
72
|
+
def __delitem__(self, index: int) -> None:
|
73
|
+
self._data.__delitem__(index)
|
74
|
+
self._changed = True
|
75
|
+
|
76
|
+
def insert(self, index: int, value: typing.Any):
|
77
|
+
"""
|
78
|
+
Insert `value` at position given by `index`.
|
79
|
+
|
80
|
+
Parameters
|
81
|
+
----------
|
82
|
+
index : int
|
83
|
+
The index of the new value in the `FlagChangeList`.
|
84
|
+
value : typing.Any
|
85
|
+
The value to insert into this `FlagChangeList`.
|
86
|
+
"""
|
87
|
+
self._data.insert(index, value)
|
88
|
+
self._changed = True
|
89
|
+
|
90
|
+
|
91
|
+
class TypedFlagChangeList(FlagChangeList):
|
92
|
+
"""
|
93
|
+
A :class:`FlagChangeList` with an ensured type for individual list items.
|
94
|
+
"""
|
95
|
+
|
96
|
+
def __init__(self, data: typing.Iterable, dtype) -> None:
|
97
|
+
"""
|
98
|
+
Initialize a `TypedFlagChangeList`.
|
99
|
+
|
100
|
+
Parameters
|
101
|
+
----------
|
102
|
+
data : Iterable
|
103
|
+
(Initial) data for this `TypedFlagChangeList`.
|
104
|
+
dtype : Callable datatype
|
105
|
+
The datatype for all entries in this `TypedFlagChangeList`. Will be
|
106
|
+
called on every value seperately and is expected to convert to the
|
107
|
+
desired datatype.
|
108
|
+
"""
|
109
|
+
self._dtype = dtype # set first to use in _convert_type method
|
110
|
+
if getattr(data, '__len__', None) is None:
|
111
|
+
# convienience for singular options,
|
112
|
+
# if it has no len attribute we assume it is the only item
|
113
|
+
data = [data]
|
114
|
+
elif isinstance(data, str):
|
115
|
+
# strings have a length but we still do not want to split them into
|
116
|
+
# single letters, so just put a list around
|
117
|
+
data = [data]
|
118
|
+
typed_data = [self._convert_type(v, index=i)
|
119
|
+
for i, v in enumerate(data)]
|
120
|
+
super().__init__(data=typed_data)
|
121
|
+
|
122
|
+
def _convert_type(self, value, index=None):
|
123
|
+
# here we ignore index, but passing it should in principal make it
|
124
|
+
# possible to use different dtypes for different indices
|
125
|
+
return self._dtype(value)
|
126
|
+
|
127
|
+
def __setitem__(self, index: int, value) -> None:
|
128
|
+
typed_value = self._convert_type(value, index=index)
|
129
|
+
self._data.__setitem__(index, typed_value)
|
130
|
+
self._changed = True
|
131
|
+
|
132
|
+
def insert(self, index: int, value) -> None:
|
133
|
+
"""
|
134
|
+
Insert `value` at position given by `index`.
|
135
|
+
|
136
|
+
Parameters
|
137
|
+
----------
|
138
|
+
index : int
|
139
|
+
The index of the new value in the `TypedFlagChangeList`.
|
140
|
+
value : typing.Any
|
141
|
+
The value to insert into this `TypedFlagChangeList`.
|
142
|
+
"""
|
143
|
+
typed_value = self._convert_type(value, index=index)
|
144
|
+
self._data.insert(index, typed_value)
|
145
|
+
self._changed = True
|
146
|
+
|
147
|
+
|
148
|
+
# NOTE: only to define the interface
|
149
|
+
class MDConfig(collections.abc.MutableMapping):
|
150
|
+
@abc.abstractmethod
|
151
|
+
def parse(self):
|
152
|
+
# should read original file and populate self with key, value pairs
|
153
|
+
raise NotImplementedError
|
154
|
+
|
155
|
+
@abc.abstractmethod
|
156
|
+
def write(self, outfile):
|
157
|
+
# write out current config stored in self to outfile
|
158
|
+
raise NotImplementedError
|
159
|
+
|
160
|
+
|
161
|
+
class LineBasedMDConfig(MDConfig):
|
162
|
+
# abstract base class for line based parsing and writing,
|
163
|
+
# subclasses must implement `_parse_line()` method and should set the
|
164
|
+
# appropriate separator characters for their line format
|
165
|
+
# We assume that every line/option can be parsed and written on its own!
|
166
|
+
# We assume the order of the options in the written file is not relevant!
|
167
|
+
# We represent every line/option with a key (str), list of values pair
|
168
|
+
# values can have a specific type (e.g. int or float) or default to str.
|
169
|
+
# NOTE: Initially written for gmx, but we already had e.g. namd in mind and
|
170
|
+
# tried to make this as general as possible
|
171
|
+
|
172
|
+
# these are the gmx mdp options but should be fairly general
|
173
|
+
# (i.e. work at least for namd?)
|
174
|
+
_KEY_VALUE_SEPARATOR = " = "
|
175
|
+
_INTER_VALUE_CHAR = " "
|
176
|
+
# NOTE on typing
|
177
|
+
# use these to specify config parameters that are of type int or float
|
178
|
+
# parsed lines with dict key matching will then be converted
|
179
|
+
# any lines not matching will be left in their default str type
|
180
|
+
_FLOAT_PARAMS = [] # can have multiple values per config option
|
181
|
+
_FLOAT_SINGLETON_PARAMS = [] # must have one value per config option
|
182
|
+
_INT_PARAMS = [] # multiple int per option
|
183
|
+
_INT_SINGLETON_PARAMS = [] # one int per option
|
184
|
+
_STR_SINGLETON_PARAMS = [] # strings with only one value per option
|
185
|
+
# NOTE on SPECIAL_PARAM_DISPATCH
|
186
|
+
# can be used to set custom type convert functions on a per parameter basis
|
187
|
+
# the key must match the key in the dict for in the parsed line,
|
188
|
+
# the value must be a function taking the corresponding (parsed) line and
|
189
|
+
# which must return a FlagChangeList or subclass thereof
|
190
|
+
# this function will also be called with the new list of value(s) when the
|
191
|
+
# option is changed, i.e. it must also be able to check and cast a list of
|
192
|
+
# new values into the expected FlagChangeList format
|
193
|
+
# [note that it is probably easiest to subclass TypedFlagChangeList and
|
194
|
+
# overwrite only the '_check_type()' method]
|
195
|
+
_SPECIAL_PARAM_DISPATCH = {}
|
196
|
+
|
197
|
+
def __init__(self, original_file: str) -> None:
|
198
|
+
"""
|
199
|
+
Initialize a :class:`LineBasedMDConfig`.
|
200
|
+
|
201
|
+
Parameters
|
202
|
+
----------
|
203
|
+
original_file : str
|
204
|
+
Path to original config file (absolute or relative).
|
205
|
+
"""
|
206
|
+
self._config = {}
|
207
|
+
self._changed = False
|
208
|
+
self._type_dispatch = self._construct_type_dispatch()
|
209
|
+
# property to set/check file and parse to config dictionary all in one
|
210
|
+
self.original_file = original_file
|
211
|
+
|
212
|
+
def _construct_type_dispatch(self):
|
213
|
+
def convert_len1_list_or_singleton(val, dtype):
|
214
|
+
# helper func that accepts len1 lists
|
215
|
+
# (as expected from `_parse_line`)
|
216
|
+
# but that also accepts single values and converts them to given
|
217
|
+
# dtype (which is what we expect can/will happen when the users set
|
218
|
+
# singleton vals, i.e. "val" instead of ["val"]
|
219
|
+
if isinstance(val, str) or getattr(val, '__len__', None) is None:
|
220
|
+
return dtype(val)
|
221
|
+
else:
|
222
|
+
return dtype(val[0])
|
223
|
+
|
224
|
+
# construct type conversion dispatch
|
225
|
+
type_dispatch = collections.defaultdict(
|
226
|
+
# looks a bit strange, but the factory func
|
227
|
+
# is called to produce the default value, i.e.
|
228
|
+
# we need a func that returns our default func
|
229
|
+
lambda:
|
230
|
+
lambda l: TypedFlagChangeList(data=l,
|
231
|
+
dtype=str)
|
232
|
+
)
|
233
|
+
type_dispatch.update({param: lambda l: TypedFlagChangeList(
|
234
|
+
data=l,
|
235
|
+
dtype=float
|
236
|
+
)
|
237
|
+
for param in self._FLOAT_PARAMS})
|
238
|
+
type_dispatch.update({param: lambda v: convert_len1_list_or_singleton(
|
239
|
+
val=v,
|
240
|
+
dtype=float,
|
241
|
+
)
|
242
|
+
for param in self._FLOAT_SINGLETON_PARAMS})
|
243
|
+
type_dispatch.update({param: lambda l: TypedFlagChangeList(
|
244
|
+
data=l,
|
245
|
+
dtype=int,
|
246
|
+
)
|
247
|
+
for param in self._INT_PARAMS})
|
248
|
+
type_dispatch.update({param: lambda v: convert_len1_list_or_singleton(
|
249
|
+
val=v,
|
250
|
+
dtype=int,
|
251
|
+
)
|
252
|
+
for param in self._INT_SINGLETON_PARAMS})
|
253
|
+
type_dispatch.update({param: lambda v: convert_len1_list_or_singleton(
|
254
|
+
val=v,
|
255
|
+
dtype=str,
|
256
|
+
)
|
257
|
+
for param in self._STR_SINGLETON_PARAMS})
|
258
|
+
type_dispatch.update(self._SPECIAL_PARAM_DISPATCH)
|
259
|
+
return type_dispatch
|
260
|
+
|
261
|
+
def __getstate__(self) -> dict:
|
262
|
+
state = self.__dict__.copy()
|
263
|
+
state["_type_dispatch"] = None
|
264
|
+
return state
|
265
|
+
|
266
|
+
def __setstate__(self, state: dict) -> None:
|
267
|
+
self.__dict__.update(state)
|
268
|
+
self._type_dispatch = self._construct_type_dispatch()
|
269
|
+
|
270
|
+
@abc.abstractmethod
|
271
|
+
def _parse_line(self, line: str) -> dict:
|
272
|
+
"""
|
273
|
+
Parse a line of the configuration file and return a :class:`dict`.
|
274
|
+
|
275
|
+
Parameters
|
276
|
+
----------
|
277
|
+
line : str
|
278
|
+
A single line of the read-in configuration file
|
279
|
+
|
280
|
+
Returns
|
281
|
+
------
|
282
|
+
parsed : dict
|
283
|
+
Dictionary with a single (key, list of value(s)) pair representing
|
284
|
+
the parsed line.
|
285
|
+
"""
|
286
|
+
# NOTE: this is the only function needed to complete the class,
|
287
|
+
# the rest of this metaclass assumes the following for this func:
|
288
|
+
# it must parse a single line and return the key, list of value(s) pair
|
289
|
+
# as a dict with one item, e.g. {key: list of value(s)}
|
290
|
+
# if the line is parsed as comment the dict must be empty, e.g. {}
|
291
|
+
# if the option/key is present but without value the list must be empty
|
292
|
+
# e.g. {key: []}
|
293
|
+
raise NotImplementedError
|
294
|
+
|
295
|
+
def __getitem__(self, key):
|
296
|
+
return self._config[key]
|
297
|
+
|
298
|
+
def __setitem__(self, key, value) -> None:
|
299
|
+
typed_value = self._type_dispatch[key](value)
|
300
|
+
self._config[key] = typed_value
|
301
|
+
self._changed = True
|
302
|
+
|
303
|
+
def __delitem__(self, key) -> None:
|
304
|
+
self._config.__delitem__(key)
|
305
|
+
self._changed = True
|
306
|
+
|
307
|
+
def __iter__(self):
|
308
|
+
return self._config.__iter__()
|
309
|
+
|
310
|
+
def __len__(self) -> int:
|
311
|
+
return self._config.__len__()
|
312
|
+
|
313
|
+
def __repr__(self) -> str:
|
314
|
+
return str({"changed": self._changed,
|
315
|
+
"original_file": self.original_file,
|
316
|
+
"content": self._config.__repr__(),
|
317
|
+
}
|
318
|
+
)
|
319
|
+
|
320
|
+
def __str__(self) -> str:
|
321
|
+
repr_str = (f"{type(self)} has been changed since parsing: "
|
322
|
+
+ f"{self._changed}\n"
|
323
|
+
)
|
324
|
+
repr_str += "Current content:\n"
|
325
|
+
repr_str += "----------------\n"
|
326
|
+
for key, val in self.items():
|
327
|
+
repr_str += f"{key} : {val}\n"
|
328
|
+
return repr_str
|
329
|
+
|
330
|
+
@property
|
331
|
+
def original_file(self) -> str:
|
332
|
+
"""
|
333
|
+
Return the original config file this :class:`LineBasedMDConfig` parsed.
|
334
|
+
|
335
|
+
Returns
|
336
|
+
-------
|
337
|
+
str
|
338
|
+
Path to the original file.
|
339
|
+
"""
|
340
|
+
return self._original_file
|
341
|
+
|
342
|
+
@original_file.setter
|
343
|
+
def original_file(self, value: str) -> None:
|
344
|
+
# NOTE: (re)setting the file also replaces the current config with
|
345
|
+
# what we parse from that file
|
346
|
+
value = os.path.relpath(value)
|
347
|
+
if not os.path.isfile(value):
|
348
|
+
raise ValueError(f"Can not access the file {value}")
|
349
|
+
self._original_file = value
|
350
|
+
self.parse()
|
351
|
+
|
352
|
+
@property
|
353
|
+
def changed(self) -> bool:
|
354
|
+
"""
|
355
|
+
Indicate if the current configuration differs from original_file.
|
356
|
+
|
357
|
+
Returns
|
358
|
+
-------
|
359
|
+
bool
|
360
|
+
Whether we changed the configuration w.r.t. ``original_file``.
|
361
|
+
"""
|
362
|
+
# NOTE: we default to False, i.e. we expect that anything that
|
363
|
+
# does not have a self.changed attribute is not a container
|
364
|
+
# and we (the dictionary) would know that it changed
|
365
|
+
return self._changed or any([getattr(v, "changed", False)
|
366
|
+
for v in self._config.values()]
|
367
|
+
)
|
368
|
+
|
369
|
+
def parse(self):
|
370
|
+
"""Parse the current ``self.original_file`` to update own state."""
|
371
|
+
with open(self.original_file, "r") as f:
|
372
|
+
# NOTE: we split at newlines on all platforms by iterating over the
|
373
|
+
# file, i.e. python takes care of the differnt platforms and
|
374
|
+
# newline chars for us :)
|
375
|
+
parsed = {}
|
376
|
+
for line in f:
|
377
|
+
line_parsed = self._parse_line(line.rstrip("\n"))
|
378
|
+
# check for duplicate options, we warn but take the last one
|
379
|
+
for key in line_parsed:
|
380
|
+
try:
|
381
|
+
# check if we already have a value for that option
|
382
|
+
_ = parsed[key]
|
383
|
+
except KeyError:
|
384
|
+
# as it should be
|
385
|
+
pass
|
386
|
+
else:
|
387
|
+
# warn that we will only keep the last occurenc of key
|
388
|
+
logger.warning("Parsed duplicate configuration option "
|
389
|
+
+ f"({key}). Last values encountered "
|
390
|
+
+ "take precedence.")
|
391
|
+
parsed.update(line_parsed)
|
392
|
+
# convert the known types
|
393
|
+
self._config = {key: self._type_dispatch[key](value)
|
394
|
+
for key, value in parsed.items()}
|
395
|
+
self._changed = False
|
396
|
+
|
397
|
+
def write(self, outfile: str, overwrite: bool = False) -> None:
|
398
|
+
"""
|
399
|
+
Write current configuration to outfile.
|
400
|
+
|
401
|
+
Parameters
|
402
|
+
----------
|
403
|
+
outfile : str
|
404
|
+
Path to outfile (relative or absolute).
|
405
|
+
overwrite : bool, optional
|
406
|
+
If True overwrite existing files, by default False.
|
407
|
+
|
408
|
+
Raises
|
409
|
+
------
|
410
|
+
ValueError
|
411
|
+
Raised when `overwrite=False` but `outfile` exists.
|
412
|
+
"""
|
413
|
+
outfile = os.path.relpath(outfile)
|
414
|
+
if os.path.exists(outfile) and not overwrite:
|
415
|
+
raise ValueError(f"overwrite=False and file exists ({outfile}).")
|
416
|
+
if not self.changed:
|
417
|
+
# just copy the original
|
418
|
+
shutil.copy2(src=self.original_file, dst=outfile)
|
419
|
+
else:
|
420
|
+
# construct content for new file
|
421
|
+
lines = []
|
422
|
+
for key, value in self._config.items():
|
423
|
+
line = f"{key}{self._KEY_VALUE_SEPARATOR}"
|
424
|
+
try:
|
425
|
+
if len(value) >= 0:
|
426
|
+
if isinstance(value, str):
|
427
|
+
# it is a string singleton option
|
428
|
+
line += f"{value}"
|
429
|
+
else:
|
430
|
+
line += self._INTER_VALUE_CHAR.join(str(v)
|
431
|
+
for v in value
|
432
|
+
)
|
433
|
+
except TypeError:
|
434
|
+
# not a Sequence/Iterable or string,
|
435
|
+
# i.e. (probably) one of the float/int singleton options
|
436
|
+
line += f"{value}"
|
437
|
+
lines += [line]
|
438
|
+
# concatenate the lines and write out at once
|
439
|
+
with open(outfile, "w") as f:
|
440
|
+
f.write("\n".join(lines))
|