hcs-core 0.1.250__py3-none-any.whl → 0.1.316__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.
Files changed (61) hide show
  1. hcs_core/__init__.py +1 -0
  2. hcs_core/ctxp/__init__.py +12 -4
  3. hcs_core/ctxp/_init.py +94 -22
  4. hcs_core/ctxp/built_in_cmds/_ut.py +4 -3
  5. hcs_core/ctxp/built_in_cmds/context.py +16 -1
  6. hcs_core/ctxp/built_in_cmds/profile.py +30 -11
  7. hcs_core/ctxp/cli_options.py +34 -13
  8. hcs_core/ctxp/cli_processor.py +33 -20
  9. hcs_core/ctxp/cmd_util.py +87 -0
  10. hcs_core/ctxp/config.py +1 -1
  11. hcs_core/ctxp/context.py +82 -3
  12. hcs_core/ctxp/data_util.py +56 -20
  13. hcs_core/ctxp/dispatcher.py +82 -0
  14. hcs_core/ctxp/duration.py +65 -0
  15. hcs_core/ctxp/extension.py +7 -6
  16. hcs_core/ctxp/fn_util.py +57 -0
  17. hcs_core/ctxp/fstore.py +39 -22
  18. hcs_core/ctxp/jsondot.py +259 -78
  19. hcs_core/ctxp/logger.py +7 -6
  20. hcs_core/ctxp/profile.py +53 -21
  21. hcs_core/ctxp/profile_store.py +1 -0
  22. hcs_core/ctxp/recent.py +3 -3
  23. hcs_core/ctxp/state.py +4 -3
  24. hcs_core/ctxp/task_schd.py +168 -0
  25. hcs_core/ctxp/telemetry.py +145 -0
  26. hcs_core/ctxp/template_util.py +21 -0
  27. hcs_core/ctxp/timeutil.py +11 -0
  28. hcs_core/ctxp/util.py +194 -33
  29. hcs_core/ctxp/var_template.py +3 -4
  30. hcs_core/plan/__init__.py +11 -5
  31. hcs_core/plan/base_provider.py +1 -0
  32. hcs_core/plan/core.py +29 -26
  33. hcs_core/plan/dag.py +15 -12
  34. hcs_core/plan/helper.py +4 -2
  35. hcs_core/plan/kop.py +21 -8
  36. hcs_core/plan/provider/dev/dummy.py +3 -3
  37. hcs_core/sglib/auth.py +137 -95
  38. hcs_core/sglib/cli_options.py +20 -5
  39. hcs_core/sglib/client_util.py +230 -62
  40. hcs_core/sglib/csp.py +73 -6
  41. hcs_core/sglib/ez_client.py +139 -41
  42. hcs_core/sglib/hcs_client.py +3 -9
  43. hcs_core/sglib/init.py +17 -0
  44. hcs_core/sglib/login_support.py +22 -83
  45. hcs_core/sglib/payload_util.py +3 -1
  46. hcs_core/sglib/requtil.py +38 -0
  47. hcs_core/sglib/utils.py +107 -0
  48. hcs_core/util/check_license.py +0 -2
  49. hcs_core/util/duration.py +6 -3
  50. hcs_core/util/job_view.py +35 -15
  51. hcs_core/util/pki_util.py +48 -1
  52. hcs_core/util/query_util.py +54 -8
  53. hcs_core/util/scheduler.py +3 -3
  54. hcs_core/util/ssl_util.py +1 -1
  55. hcs_core/util/versions.py +15 -12
  56. hcs_core-0.1.316.dist-info/METADATA +54 -0
  57. hcs_core-0.1.316.dist-info/RECORD +69 -0
  58. {hcs_core-0.1.250.dist-info → hcs_core-0.1.316.dist-info}/WHEEL +1 -2
  59. hcs_core-0.1.250.dist-info/METADATA +0 -36
  60. hcs_core-0.1.250.dist-info/RECORD +0 -59
  61. hcs_core-0.1.250.dist-info/top_level.txt +0 -1
hcs_core/ctxp/context.py CHANGED
@@ -13,8 +13,12 @@ See the License for the specific language governing permissions and
13
13
  limitations under the License.
14
14
  """
15
15
 
16
- from .jsondot import dotdict
17
- from .profile_store import profile_store, fstore
16
+ import json
17
+ import re
18
+ from typing import Any
19
+
20
+ from . import jsondot
21
+ from .profile_store import fstore, profile_store
18
22
 
19
23
 
20
24
  def _store() -> fstore:
@@ -25,11 +29,13 @@ def list() -> list:
25
29
  return _store().keys()
26
30
 
27
31
 
28
- def get(name: str, reload: bool = False, default=None) -> dotdict:
32
+ def get(name: str, reload: bool = False, default=None) -> jsondot.dotdict:
29
33
  return _store().get(key=name, reload=reload, default=default)
30
34
 
31
35
 
32
36
  def set(name: str, data: dict):
37
+ if data is None or len(data) == 0:
38
+ return _store().delete(name)
33
39
  return _store().save(name, data)
34
40
 
35
41
 
@@ -43,3 +49,76 @@ def file(name: str):
43
49
 
44
50
  def clear():
45
51
  return _store().clear()
52
+
53
+
54
+ class Context:
55
+ def __init__(self, name):
56
+ self.name = name
57
+ self._store = _store()
58
+ self._data = None
59
+ self._changed = False
60
+
61
+ def get(self, key: str, default=None):
62
+ return self._data.get(key, default)
63
+
64
+ def set(self, key: str, value: Any):
65
+ existing = self._data.get(key)
66
+ if existing != value:
67
+ self._changed = True
68
+ self._data[key] = value
69
+ return self
70
+
71
+ def remove(self, key: str):
72
+ if key in self._data:
73
+ self._changed = True
74
+ del self._data[key]
75
+
76
+ def __enter__(self):
77
+ self._data = self._store.get(key=self.name, reload=False, default={})
78
+
79
+ def __exit__(self, exc_type, exc_value, traceback):
80
+ if self._changed:
81
+ self._store.save(key=self.name, data=self.data)
82
+ return False
83
+
84
+ def apply_variables(self, object: any, additional_vars: dict = None):
85
+
86
+ mapping = self._data.copy()
87
+ if additional_vars:
88
+ mapping.update(additional_vars)
89
+
90
+ # The Python default Template.substitute has too many limitations...
91
+ def substitute(text: str):
92
+ pattern = re.compile(r".*\${(.+?)}.*")
93
+ while True:
94
+ m = pattern.match(text)
95
+ if not m:
96
+ break
97
+ name = m.group(1)
98
+ text = text.replace("${" + name + "}", self.get(name))
99
+ return text
100
+
101
+ t = type(object)
102
+ try:
103
+ if t is str:
104
+ return substitute(object)
105
+
106
+ if t is dict:
107
+ text = json.dumps(object)
108
+ text = substitute(text)
109
+ return json.loads(text)
110
+
111
+ if t is jsondot.dotdict:
112
+ text = json.dumps(object)
113
+ text = substitute(text)
114
+ return jsondot.parse(text)
115
+ except KeyError as e:
116
+ raise Exception("Variable not defined in context") from e
117
+
118
+ def load_template(self, name: str):
119
+ # with open(name) as f:
120
+ # text = f.read()
121
+ # return apply_variables(text)
122
+
123
+ tmpl = jsondot.load(name)
124
+ return self.apply_variables(tmpl)
@@ -1,9 +1,10 @@
1
- from io import TextIOWrapper
2
- from os import path, chmod
3
- from typing import Tuple, Any
4
1
  import json
5
- import yaml
2
+ import os
6
3
  import re
4
+ from io import TextIOWrapper
5
+ from os import chmod, path
6
+ from typing import Any, Tuple
7
+
7
8
  from .util import CtxpException
8
9
 
9
10
 
@@ -23,9 +24,13 @@ def load_data_file(file, default=None, format="auto"):
23
24
  if format == "json":
24
25
  data = json.loads(text)
25
26
  elif format == "yaml" or format == "yml":
27
+ import yaml
28
+
26
29
  data = yaml.safe_load(text)
27
30
  else:
28
31
  try:
32
+ import yaml
33
+
29
34
  data = yaml.safe_load(text)
30
35
  except Exception:
31
36
  data = json.loads(text)
@@ -45,6 +50,8 @@ def load_data_file(file, default=None, format="auto"):
45
50
  if ext == ".yaml" or ext == ".yml" or format == "yml" or format == "yaml":
46
51
  if format != "auto" and format != "yaml" and format != "yml":
47
52
  raise CtxpException(f"File extension does not match specified format. File={file}, format={format}")
53
+ import yaml
54
+
48
55
  data = yaml.safe_load(text)
49
56
  return default if data is None else data # handle empty file
50
57
  return text
@@ -53,6 +60,8 @@ def load_data_file(file, default=None, format="auto"):
53
60
  def save_data_file(data, file_name: str, format: str = "yaml", file_mod: int = 0):
54
61
  with open(file_name, "w") as file:
55
62
  if format == "yaml":
63
+ import yaml
64
+
56
65
  # TODO
57
66
  # yaml.safe_dump(data, file, sort_keys=False)
58
67
  yaml.safe_dump(json.loads(json.dumps(data, default=vars)), file, sort_keys=False)
@@ -91,9 +100,7 @@ def strict_dict_to_class(data: dict, class_type):
91
100
  value = strict_dict_to_class(value, field_type)
92
101
  setattr(inst, field_name, value)
93
102
  continue
94
- raise ValueError(
95
- f"Field '{class_type.__name__}.{field_name}' has an incorrect type. Declared: {field_type}, actual: {type(value)}"
96
- )
103
+ raise ValueError(f"Field '{class_type.__name__}.{field_name}' has an incorrect type. Declared: {field_type}, actual: {type(value)}")
97
104
  return inst
98
105
 
99
106
 
@@ -152,7 +159,15 @@ def deep_set_attr(obj: dict, path: str, value, raise_on_not_found: bool = True):
152
159
  try:
153
160
  for i in range(len(parts)):
154
161
  k = parts[i]
155
- obj = _get_obj_attr(obj, k)
162
+ try:
163
+ sub_obj = _get_obj_attr(obj, k)
164
+ except KeyError:
165
+ if raise_on_not_found:
166
+ raise
167
+ sub_obj = {}
168
+ obj[k] = sub_obj
169
+
170
+ obj = sub_obj
156
171
 
157
172
  if i == len(parts) - 2:
158
173
  # found the one before the leaf.
@@ -244,22 +259,26 @@ def process_variables(obj: dict, fn_get_var=None, use_env: bool = True):
244
259
  fn_get_var = _fn_get_var
245
260
 
246
261
  if use_env:
247
- import os
248
262
 
249
263
  prev_fn_get_var = fn_get_var
250
264
 
251
265
  def _fn_get_var_from_env(name: str):
252
266
  if not name.startswith("env."):
253
267
  return prev_fn_get_var(name)
254
- v = None
255
268
  actual_name = name[4:]
256
- v = os.environ.get(actual_name, None)
257
- if v:
258
- return v, True
259
- v, found = prev_fn_get_var(name)
260
- if found:
261
- return v
262
- raise CtxpException(f"Environment variable '{actual_name}' is used in template, but not found. ")
269
+ if actual_name.endswith("?"):
270
+ # optional
271
+ actual_name = actual_name[:-1]
272
+ required = False
273
+ else:
274
+ # required
275
+ required = True
276
+
277
+ if actual_name not in os.environ:
278
+ if required:
279
+ raise CtxpException(f"Environment variable '{actual_name}' is used in template, but not found. ")
280
+ return None, True
281
+ return os.environ[actual_name], True
263
282
 
264
283
  fn_get_var = _fn_get_var_from_env
265
284
 
@@ -320,9 +339,7 @@ def resolve_expression(expr, fn_get_value, referencing_attr_path) -> Tuple[Any,
320
339
  f"Invalid variable value for expression. Expect list, actual {type(target_value).__name__}. attr_path={referencing_attr_path}, src_var_name={src_var_name}"
321
340
  )
322
341
  if not mapped_value.startswith(tmp_var_name + "."):
323
- raise CtxpException(
324
- f"Unsupported expression. attr_path={referencing_attr_path}, src_var_name={src_var_name}"
325
- )
342
+ raise CtxpException(f"Unsupported expression. attr_path={referencing_attr_path}, src_var_name={src_var_name}")
326
343
  new_attr_path = mapped_value[len(tmp_var_name) + 1 :]
327
344
  ret = []
328
345
  for i in target_value:
@@ -375,6 +392,25 @@ def get_common_items(iter1, iter2):
375
392
  return common_items
376
393
 
377
394
 
395
+ def get_delta(dict_base, dict_update):
396
+ delta = {}
397
+ for k, v2 in dict_update.items():
398
+ v1 = dict_base.get(k)
399
+ if not deep_equals(v1, v2):
400
+ delta[k] = v2
401
+ return delta
402
+
403
+
404
+ def deep_equals(v1, v2):
405
+ if v1 is None and v2 is None:
406
+ return True
407
+ if v1 is None or v2 is None:
408
+ return False
409
+ if v1 == v2:
410
+ return True
411
+ return json.dumps(v1) == json.dumps(v2)
412
+
413
+
378
414
  def _evaluate(value, smart_search):
379
415
  # If we found an exact match, return 2 immediately
380
416
  if value == smart_search:
@@ -0,0 +1,82 @@
1
+ import logging
2
+ from weakref import WeakSet
3
+
4
+ log = logging.getLogger(__name__)
5
+
6
+
7
+ class Dispatcher:
8
+ _registry: dict = {}
9
+
10
+ def register(self, key: str, fn: callable):
11
+ listeners = self._registry.get(key)
12
+ if not listeners:
13
+ listeners = WeakSet()
14
+ self._registry[key] = listeners
15
+ listeners.add(fn)
16
+ return self
17
+
18
+ def register_map(self, map: dict):
19
+ for k, v in map.items():
20
+ self.register(k, v)
21
+ return self
22
+
23
+ def unregister(self, key: str, fn: callable):
24
+ listeners = self._registry.get(key)
25
+ if listeners:
26
+ listeners.remove(fn)
27
+ return self
28
+
29
+ def unregister_map(self, map: dict):
30
+ for k, v in map.items():
31
+ self.unregister(k, v)
32
+ return self
33
+
34
+ def emit(self, key: str, *args, **kwargs):
35
+ listeners = self._registry.get(key)
36
+ if listeners:
37
+ copy = listeners.copy()
38
+ for fn in copy:
39
+ try:
40
+ fn(*args, **kwargs)
41
+ except Exception as e:
42
+ log.error(f"Error handling event: {key}", e)
43
+ log.exception(e)
44
+ return self
45
+
46
+ def scope(self, events):
47
+ return StrictDispatcher(self, events)
48
+
49
+
50
+ class StrictDispatcher:
51
+ def __init__(self, impl: Dispatcher, events: set):
52
+ self._impl = impl
53
+ self._events = events
54
+
55
+ def register(self, key: str, fn: callable):
56
+ self._validate(key)
57
+ self._impl.register(key, fn)
58
+ return self
59
+
60
+ def register_map(self, map: dict):
61
+ for k, v in map.items():
62
+ self.register(k, v)
63
+ return self
64
+
65
+ def unregister(self, key: str, fn: callable):
66
+ self._validate(key)
67
+ self._impl.unregister(key, fn)
68
+ return self
69
+
70
+ def unregister_map(self, map: dict):
71
+ for k, v in map.items():
72
+ self.unregister(k, v)
73
+ return self
74
+
75
+ def emit(self, key: str, *args, **kwargs):
76
+ self._validate(key)
77
+ self._impl.emit(key, *args, **kwargs)
78
+ return self
79
+
80
+ def _validate(self, key: str):
81
+ if key not in self._events:
82
+ raise Exception("Event is not in dispatcher scope: %s. The scoped events are: %s " % (key, self._events))
@@ -0,0 +1,65 @@
1
+ # Utility for Java Duration interoperability
2
+
3
+ import re
4
+
5
+ PATTERN = "([-+]?)P(?:([-+]?[0-9]+)D)?(T(?:([-+]?[0-9]+)H)?(?:([-+]?[0-9]+)M)?(?:([-+]?[0-9]+)(?:[.,]([0-9]{0,9}))?S)?)?"
6
+
7
+ # Examples
8
+
9
+ # PT0.555S
10
+ # PT9M15S
11
+ # PT9H15M
12
+ # PT555H
13
+ # PT13320H
14
+
15
+
16
+ def to_seconds(duration_string: str):
17
+ duration_string = duration_string.upper()
18
+
19
+ p = re.compile(PATTERN)
20
+ matcher = p.fullmatch(duration_string)
21
+ if matcher is None:
22
+ raise Exception("Unsupported duration format: %s" % duration_string)
23
+
24
+ if matcher.start(3) >= 0 and matcher.end(3) == matcher.start(3) + 1 and duration_string[matcher.start(3)] == "T":
25
+ raise Exception("Unsupported duration format: %s" % duration_string)
26
+
27
+ day_start = matcher.start(2)
28
+ day_end = matcher.end(2)
29
+ hour_start = matcher.start(4)
30
+ hour_end = matcher.end(4)
31
+ minute_start = matcher.start(5)
32
+ minute_end = matcher.end(5)
33
+ second_start = matcher.start(6)
34
+ second_end = matcher.end(6)
35
+
36
+ total_seconds = 0
37
+ if day_start >= 0:
38
+ days = int(duration_string[day_start:day_end])
39
+ total_seconds += days * 24 * 3600
40
+ if hour_start >= 0:
41
+ hours = int(duration_string[hour_start:hour_end])
42
+ total_seconds += hours * 3600
43
+ if minute_start >= 0:
44
+ minutes = int(duration_string[minute_start:minute_end])
45
+ total_seconds += minutes * 60
46
+ if second_start >= 0:
47
+ seconds = int(duration_string[second_start:second_end])
48
+ total_seconds += seconds
49
+
50
+ return total_seconds
51
+
52
+
53
+ if __name__ == "__main__":
54
+ assert to_seconds("PT5S") == 5
55
+ assert to_seconds("PT5M") == 5 * 60
56
+ assert to_seconds("PT5H") == 5 * 3600
57
+ assert to_seconds("PT2M3S") == 2 * 60 + 3
58
+ assert to_seconds("PT2H3M") == 2 * 3600 + 3 * 60
59
+ assert to_seconds("PT2H3S") == 2 * 3600 + 3
60
+ assert to_seconds("PT2H3M4S") == 2 * 3600 + 3 * 60 + 4
61
+ assert to_seconds("P1D") == 1 * 24 * 60 * 60
62
+ assert to_seconds("P1DT1M") == 1 * 24 * 60 * 60 + 1 * 60
63
+ assert to_seconds("P1DT1S") == 1 * 24 * 60 * 60 + 1
64
+ assert to_seconds("P1DT1H1S") == 1 * 24 * 60 * 60 + 1 * 3600 + 1
65
+ print("SUCCESS")
@@ -13,16 +13,18 @@ See the License for the specific language governing permissions and
13
13
  limitations under the License.
14
14
  """
15
15
 
16
- import click
17
- import sys
18
16
  import subprocess
19
- from importlib import reload, import_module
20
- import pkg_resources
17
+ import sys
18
+ from importlib import import_module, reload
19
+ from importlib.metadata import distributions
20
+
21
+ import click
22
+
21
23
  from .util import CtxpException
22
24
 
23
25
 
24
26
  def ensure_extension(required: str, parent_module_name: str = None):
25
- installed = {pkg.key for pkg in pkg_resources.working_set} # pylint: disable=not-an-iterable
27
+ installed = {dist.metadata["Name"].lower() for dist in distributions()}
26
28
  if required in installed:
27
29
  return
28
30
 
@@ -43,4 +45,3 @@ def ensure_extension(required: str, parent_module_name: str = None):
43
45
  reload(m)
44
46
  else:
45
47
  import_module(required.replace("-", "_"))
46
- reload(pkg_resources)
@@ -0,0 +1,57 @@
1
+ import inspect
2
+ import os
3
+ import threading
4
+
5
+
6
+ def debounce(graceful_seconds):
7
+ """
8
+ Delay a function call by graceful_seconds. If an additional call happens during this time,
9
+ the delay will be reset.
10
+ """
11
+
12
+ def decorator(function):
13
+ def wrapper(*args, **kwargs):
14
+ def call_impl():
15
+ wrapper._timer = None
16
+ return function(*args, **kwargs)
17
+
18
+ # Cancel existing timer, if any
19
+ if wrapper._timer is not None:
20
+ wrapper._timer.cancel()
21
+
22
+ # Schedule debounced run
23
+ wrapper._timer = threading.Timer(graceful_seconds, call_impl)
24
+ wrapper._timer.start()
25
+
26
+ wrapper._timer = None
27
+ return wrapper
28
+
29
+ return decorator
30
+
31
+
32
+ def once(fn):
33
+ """
34
+ Raise exception if the function called more than once.
35
+ """
36
+
37
+ def wrapper(*args, **kwargs):
38
+ if wrapper._invoked:
39
+ raise Exception("Duplicated invocation: %s, %s" % (_get_caller_file(), fn.__name__))
40
+ wrapper._invoked = True
41
+ return fn(*args, **kwargs)
42
+
43
+ wrapper._invoked = False
44
+ return wrapper
45
+
46
+
47
+ def _get_caller_file():
48
+ # first get the full filename (including path and file extension)
49
+ caller_frame = inspect.stack()[2]
50
+ caller_filename_full = caller_frame.filename
51
+
52
+ # now get rid of the directory (via basename)
53
+ # then split filename and extension (via splitext)
54
+ caller_filename_only = os.path.splitext(os.path.basename(caller_filename_full))[0]
55
+
56
+ # return both filename versions as tuple
57
+ return caller_filename_only
hcs_core/ctxp/fstore.py CHANGED
@@ -13,16 +13,15 @@ See the License for the specific language governing permissions and
13
13
  limitations under the License.
14
14
  """
15
15
 
16
- import os
17
16
  import logging
18
- from os import path, listdir
19
- from typing import Any
17
+ import os
18
+ import shutil
20
19
  from collections.abc import Generator
20
+ from os import listdir, path
21
+ from typing import Any
22
+
21
23
  from . import jsondot
22
24
  from .jsondot import dotdict
23
- import yaml
24
- import shutil
25
-
26
25
 
27
26
  log = logging.getLogger(__name__)
28
27
 
@@ -30,12 +29,15 @@ log = logging.getLogger(__name__)
30
29
  def _validate_key(key: str):
31
30
  # if key.find(os.pathsep) >= 0:
32
31
  if key.find("/") >= 0 or key.find("\\") >= 0:
33
- raise Exception("Invalid profile name: " + key)
32
+ raise Exception("Invalid key: " + key)
34
33
 
35
34
 
36
35
  def _load_yaml(file_name: str):
37
36
  if not os.path.exists(file_name):
38
37
  return
38
+
39
+ import yaml
40
+
39
41
  with open(file_name, "r") as file:
40
42
  ret = yaml.safe_load(file)
41
43
  return jsondot.dotify(ret)
@@ -120,21 +122,12 @@ class fstore:
120
122
 
121
123
  Args:
122
124
  store_path (str): The path to store state files. If None, state will not be stored.
123
- create (bool, optional): If store_path is specified, try creating it if not exist. Defaults to True.
124
-
125
- Raises:
126
- Exception: [description]
125
+ create (bool, optional): If store_path is specified, try creating it if not exist. Defaults to True.
127
126
  """
128
127
  if store_path:
129
- store_path = path.realpath(store_path)
130
- if not path.exists(store_path):
131
- if create:
132
- os.makedirs(store_path)
133
- else:
134
- raise Exception(f"Store path does not exist: {store_path}")
135
- elif not os.path.isdir(store_path):
136
- raise Exception(f"Store path is not a directory: {store_path}")
137
- self._path = store_path
128
+ self._path = path.realpath(store_path)
129
+ self._create = create
130
+ self._path_checked = False
138
131
  else:
139
132
  self._path = None
140
133
  self._cache = {}
@@ -165,6 +158,23 @@ class fstore:
165
158
  return None
166
159
  return path.join(self._path, key)
167
160
 
161
+ def _ensure_dir(self):
162
+ if not self._path or self._path_checked:
163
+ return
164
+ if path.exists(self._path):
165
+ if not os.path.isdir(self._path):
166
+ raise Exception(f"Store path is not a directory: {self._path}")
167
+ else:
168
+ # Good.
169
+ self._path_checked = True
170
+ return
171
+ else:
172
+ if self._create:
173
+ os.makedirs(self._path, exist_ok=True)
174
+ else:
175
+ raise Exception(f"Store path does not exist: {self._path}")
176
+ self._path_checked = True
177
+
168
178
  def save(self, key: str, data: Any, format: str = "auto") -> Any:
169
179
  _validate_key(key)
170
180
 
@@ -178,6 +188,7 @@ class fstore:
178
188
 
179
189
  self._cache[key] = data
180
190
  if self._path:
191
+ self._ensure_dir()
181
192
  file_path = self._get_path(key)
182
193
  log.debug(f"Write {file_path}")
183
194
  if format == "json":
@@ -187,8 +198,10 @@ class fstore:
187
198
  # TODO lock
188
199
  outfile.write(str(data))
189
200
  elif format == "json-compact":
190
- jsondot.save(data, file_path, format=False, lock=True)
191
- elif format == "yaml":
201
+ jsondot.save(data=data, file=file_path, pretty=False, lock=True)
202
+ elif format == "yaml" or format == "yml":
203
+ import yaml
204
+
192
205
  with open(file_path, "w", encoding="utf-8") as outfile:
193
206
  # TODO lock
194
207
  yaml.safe_dump(data, outfile)
@@ -208,12 +221,16 @@ class fstore:
208
221
 
209
222
  def keys(self) -> list[str]:
210
223
  if self._path:
224
+ if not os.path.exists(self._path):
225
+ return []
211
226
  return [f for f in listdir(self._path) if path.isfile(path.join(self._path, f))]
212
227
  return list(self._cache.keys())
213
228
 
214
229
  def children(self, depth: int = 0) -> list[str]:
215
230
  """List child stores"""
216
231
  if self._path:
232
+ if not os.path.exists(self._path):
233
+ return []
217
234
  if depth == 0:
218
235
  return [f for f in listdir(self._path) if path.isdir(path.join(self._path, f))]
219
236
  else: