dbetto 1.0.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.
- dbetto/__init__.py +28 -0
- dbetto/_version.py +16 -0
- dbetto/_version.pyi +4 -0
- dbetto/attrsdict.py +242 -0
- dbetto/catalog.py +238 -0
- dbetto/py.typed +0 -0
- dbetto/textdb.py +353 -0
- dbetto/time.py +35 -0
- dbetto/utils.py +76 -0
- dbetto-1.0.2.dist-info/METADATA +120 -0
- dbetto-1.0.2.dist-info/RECORD +13 -0
- dbetto-1.0.2.dist-info/WHEEL +4 -0
- dbetto-1.0.2.dist-info/licenses/LICENSE +19 -0
dbetto/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Copyright (C) 2022 Luigi Pertoldi <gipert@pm.me>
|
|
2
|
+
#
|
|
3
|
+
# This program 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
|
+
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from ._version import version as __version__
|
|
19
|
+
from .attrsdict import AttrsDict
|
|
20
|
+
from .textdb import TextDB
|
|
21
|
+
from .time import str_to_datetime
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"__version__",
|
|
25
|
+
"TextDB",
|
|
26
|
+
"AttrsDict",
|
|
27
|
+
"str_to_datetime",
|
|
28
|
+
]
|
dbetto/_version.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# file generated by setuptools_scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
TYPE_CHECKING = False
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from typing import Tuple, Union
|
|
6
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
7
|
+
else:
|
|
8
|
+
VERSION_TUPLE = object
|
|
9
|
+
|
|
10
|
+
version: str
|
|
11
|
+
__version__: str
|
|
12
|
+
__version_tuple__: VERSION_TUPLE
|
|
13
|
+
version_tuple: VERSION_TUPLE
|
|
14
|
+
|
|
15
|
+
__version__ = version = '1.0.2'
|
|
16
|
+
__version_tuple__ = version_tuple = (1, 0, 2)
|
dbetto/_version.pyi
ADDED
dbetto/attrsdict.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# Copyright (C) 2022 Luigi Pertoldi <gipert@pm.me>
|
|
2
|
+
#
|
|
3
|
+
# This program 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
|
+
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AttrsDict(dict):
|
|
25
|
+
"""Access dictionary items as attributes.
|
|
26
|
+
|
|
27
|
+
Examples
|
|
28
|
+
--------
|
|
29
|
+
>>> d = AttrsDict({"key1": {"key2": 1}})
|
|
30
|
+
>>> d.key1.key2
|
|
31
|
+
1
|
|
32
|
+
>>> d1 = AttrsDict()
|
|
33
|
+
>>> d1["a"] = 1
|
|
34
|
+
>>> d1.a
|
|
35
|
+
1
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, value: dict | None = None) -> None:
|
|
39
|
+
"""Construct an :class:`.AttrsDict` object.
|
|
40
|
+
|
|
41
|
+
Note
|
|
42
|
+
----
|
|
43
|
+
The input dictionary is copied.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
value
|
|
48
|
+
a :class:`dict` object to initialize the instance with.
|
|
49
|
+
"""
|
|
50
|
+
if value is None:
|
|
51
|
+
super().__init__()
|
|
52
|
+
# can only be initialized with a dict
|
|
53
|
+
elif isinstance(value, dict):
|
|
54
|
+
for key in value:
|
|
55
|
+
self.__setitem__(key, value[key])
|
|
56
|
+
else:
|
|
57
|
+
msg = "expected dict"
|
|
58
|
+
raise TypeError(msg)
|
|
59
|
+
|
|
60
|
+
# attribute that holds cached remappings -- see map()
|
|
61
|
+
super().__setattr__("__cached_remaps__", {})
|
|
62
|
+
|
|
63
|
+
def __setitem__(self, key: str | int | float, value: Any) -> Any:
|
|
64
|
+
# convert dicts to AttrsDicts
|
|
65
|
+
if not isinstance(value, AttrsDict):
|
|
66
|
+
if isinstance(value, dict):
|
|
67
|
+
value = AttrsDict(value) # this should make it recursive
|
|
68
|
+
# recurse lists
|
|
69
|
+
elif isinstance(value, list):
|
|
70
|
+
for i, el in enumerate(value):
|
|
71
|
+
if isinstance(el, dict):
|
|
72
|
+
value[i] = AttrsDict(el) # this should make it recursive
|
|
73
|
+
|
|
74
|
+
super().__setitem__(key, value)
|
|
75
|
+
|
|
76
|
+
# if the key is a valid attribute name, create a new attribute
|
|
77
|
+
if isinstance(key, str) and key.isidentifier():
|
|
78
|
+
super().__setattr__(key, value)
|
|
79
|
+
|
|
80
|
+
# reset special __cached_remaps__ private attribute -- see map()
|
|
81
|
+
super().__setattr__("__cached_remaps__", {})
|
|
82
|
+
|
|
83
|
+
__setattr__ = __setitem__
|
|
84
|
+
|
|
85
|
+
def __getattr__(self, name: str) -> Any:
|
|
86
|
+
try:
|
|
87
|
+
super().__getattr__(name)
|
|
88
|
+
except AttributeError as exc:
|
|
89
|
+
msg = f"dictionary does not contain a '{name}' key"
|
|
90
|
+
raise AttributeError(msg) from exc
|
|
91
|
+
|
|
92
|
+
def map(self, label: str, unique: bool = True) -> AttrsDict:
|
|
93
|
+
"""Remap dictionary according to an alternative unique label.
|
|
94
|
+
|
|
95
|
+
Loop over keys in the first level and search for key named `label` in
|
|
96
|
+
their values. If `label` is found and its value `newid` is unique,
|
|
97
|
+
create a mapping between `newid` and the first-level dictionary `obj`.
|
|
98
|
+
If `label` is of the form ``key.label``, ``label`` will be searched in
|
|
99
|
+
a dictionary keyed by ``key``. If the label is unique a dictionary of
|
|
100
|
+
dictionaries will be returned, if not unique and `unique` is false, a
|
|
101
|
+
dictionary will be returned where each entry is a dictionary of
|
|
102
|
+
dictionaries keyed by an arbitrary integer.
|
|
103
|
+
|
|
104
|
+
Parameters
|
|
105
|
+
----------
|
|
106
|
+
label
|
|
107
|
+
game (key) at which the new label can be found. If nested in
|
|
108
|
+
dictionaries, use ``.`` to separate levels, e.g.
|
|
109
|
+
``level1.level2.label``.
|
|
110
|
+
unique
|
|
111
|
+
bool specifying whether only unique keys are allowed. If true
|
|
112
|
+
will raise an error if the specified key is not unique.
|
|
113
|
+
|
|
114
|
+
Examples
|
|
115
|
+
--------
|
|
116
|
+
>>> d = AttrsDict({
|
|
117
|
+
... "a": {
|
|
118
|
+
... "id": 1,
|
|
119
|
+
... "group": {
|
|
120
|
+
... "id": 3,
|
|
121
|
+
... },
|
|
122
|
+
... "data": "x"
|
|
123
|
+
... },
|
|
124
|
+
... "b": {
|
|
125
|
+
... "id": 2,
|
|
126
|
+
... "group": {
|
|
127
|
+
... "id": 4,
|
|
128
|
+
... },
|
|
129
|
+
... "data": "y"
|
|
130
|
+
... },
|
|
131
|
+
... })
|
|
132
|
+
>>> d.map("id")[1].data == "x"
|
|
133
|
+
True
|
|
134
|
+
>>> d.map("group.id")[4].data == "y"
|
|
135
|
+
True
|
|
136
|
+
|
|
137
|
+
Note
|
|
138
|
+
----
|
|
139
|
+
No copy is performed, the returned dictionary is made of references to
|
|
140
|
+
the original objects.
|
|
141
|
+
|
|
142
|
+
Warning
|
|
143
|
+
-------
|
|
144
|
+
The result is cached internally for fast access after the first call.
|
|
145
|
+
If the dictionary is modified, the cache gets cleared.
|
|
146
|
+
"""
|
|
147
|
+
# if this is a second call, return the cached result
|
|
148
|
+
if label in self.__cached_remaps__:
|
|
149
|
+
return self.__cached_remaps__[label]
|
|
150
|
+
|
|
151
|
+
splitk = label.split(".")
|
|
152
|
+
newmap = AttrsDict()
|
|
153
|
+
unique_tracker = True
|
|
154
|
+
# loop over values in the first level
|
|
155
|
+
for v in self.values():
|
|
156
|
+
# find the (nested) label value
|
|
157
|
+
newid = v
|
|
158
|
+
try:
|
|
159
|
+
for k in splitk:
|
|
160
|
+
newid = newid[k]
|
|
161
|
+
# just skip if the label is not there
|
|
162
|
+
except (KeyError, TypeError, FileNotFoundError):
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
if not isinstance(newid, (int, float, str)):
|
|
166
|
+
msg = f"'{label}' values are not all numbers or strings"
|
|
167
|
+
raise RuntimeError(msg)
|
|
168
|
+
|
|
169
|
+
if newid in newmap:
|
|
170
|
+
newkey = sorted(newmap[newid].keys())[-1] + 1
|
|
171
|
+
newmap[newid].update({newkey: v})
|
|
172
|
+
unique_tracker = False
|
|
173
|
+
else:
|
|
174
|
+
# add an item to the new dict with key equal to the value of the label
|
|
175
|
+
newmap[newid] = {0: v}
|
|
176
|
+
|
|
177
|
+
if unique is True and unique_tracker is False:
|
|
178
|
+
# complain if a label with the same value was already found
|
|
179
|
+
msg = f"'{label}' values are not unique"
|
|
180
|
+
raise RuntimeError(msg)
|
|
181
|
+
|
|
182
|
+
if unique_tracker is True:
|
|
183
|
+
newmap = AttrsDict({entry: newmap[entry][0] for entry in newmap})
|
|
184
|
+
|
|
185
|
+
if not newmap:
|
|
186
|
+
msg = f"could not find '{label}' anywhere in the dictionary"
|
|
187
|
+
raise ValueError(msg)
|
|
188
|
+
|
|
189
|
+
# cache it
|
|
190
|
+
self.__cached_remaps__[label] = newmap
|
|
191
|
+
return newmap
|
|
192
|
+
|
|
193
|
+
def group(self, label: str) -> AttrsDict:
|
|
194
|
+
"""Group dictionary according to a `label`.
|
|
195
|
+
|
|
196
|
+
This is equivalent to :meth:`.map` with `unique` set to ``False``.
|
|
197
|
+
|
|
198
|
+
Parameters
|
|
199
|
+
----------
|
|
200
|
+
label
|
|
201
|
+
name (key) at which the new label can be found. If nested in
|
|
202
|
+
dictionaries, use ``.`` to separate levels, e.g.
|
|
203
|
+
``level1.level2.label``.
|
|
204
|
+
|
|
205
|
+
Examples
|
|
206
|
+
--------
|
|
207
|
+
>>> d = AttrsDict({
|
|
208
|
+
... "a": {
|
|
209
|
+
... "type": "A",
|
|
210
|
+
... "data": 1
|
|
211
|
+
... },
|
|
212
|
+
... "b": {
|
|
213
|
+
... "type": "A",
|
|
214
|
+
... "data": 2
|
|
215
|
+
... },
|
|
216
|
+
... "c": {
|
|
217
|
+
... "type": "B",
|
|
218
|
+
... "data": 3
|
|
219
|
+
... },
|
|
220
|
+
... })
|
|
221
|
+
>>> d.group("type").keys()
|
|
222
|
+
dict_keys(['A', 'B'])
|
|
223
|
+
>>> d.group("type").A.values()
|
|
224
|
+
dict_values([{'type': 'A', 'data': 1}, {'type': 'A', 'data': 2}])
|
|
225
|
+
>>> d.group("type").B.values()
|
|
226
|
+
dict_values([{'type': 'B', 'data': 3}])
|
|
227
|
+
>>> d.group("type").A.map("data")[1]
|
|
228
|
+
{'type': 'A', 'data': 1}
|
|
229
|
+
|
|
230
|
+
See Also
|
|
231
|
+
--------
|
|
232
|
+
map
|
|
233
|
+
"""
|
|
234
|
+
return self.map(label, unique=False)
|
|
235
|
+
|
|
236
|
+
# d |= other_d should still produce a valid AttrsDict
|
|
237
|
+
def __ior__(self, other: dict | AttrsDict) -> AttrsDict:
|
|
238
|
+
return AttrsDict(super().__ior__(other))
|
|
239
|
+
|
|
240
|
+
# d1 | d2 should still produce a valid AttrsDict
|
|
241
|
+
def __or__(self, other: dict | AttrsDict) -> AttrsDict:
|
|
242
|
+
return AttrsDict(super().__or__(other))
|
dbetto/catalog.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# Copyright (C) 2015 Oliver Schulz <oschulz@mpp.mpg.de>
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import bisect
|
|
18
|
+
import collections
|
|
19
|
+
import copy
|
|
20
|
+
import types
|
|
21
|
+
from collections import namedtuple
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from string import Template
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
from . import time, utils
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PropsStream:
|
|
31
|
+
"""Simple class to control loading of validity.yaml files"""
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def get(value):
|
|
35
|
+
if isinstance(value, str):
|
|
36
|
+
return PropsStream.read_from(value)
|
|
37
|
+
|
|
38
|
+
if isinstance(value, (collections.abc.Sequence, types.GeneratorType)):
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
msg = f"Can't get PropsStream from value of type {type(value)}"
|
|
42
|
+
raise ValueError(msg)
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def read_from(file_name):
|
|
46
|
+
with Path(file_name).open(encoding="utf-8") as r:
|
|
47
|
+
file = yaml.safe_load(r)
|
|
48
|
+
file = sorted(file, key=lambda item: time.unix_time(item["valid_from"]))
|
|
49
|
+
yield from file
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Catalog(namedtuple("Catalog", ["entries"])):
|
|
53
|
+
"""Implementation of the `YAML metadata validity specification <https://legend-exp.github.io/legend-data-format-specs/dev/metadata/#Specifying-metadata-validity-in-time-(and-system)>`_."""
|
|
54
|
+
|
|
55
|
+
__slots__ = ()
|
|
56
|
+
|
|
57
|
+
class Entry(namedtuple("Entry", ["valid_from", "file"])):
|
|
58
|
+
"""An entry in the validity file."""
|
|
59
|
+
|
|
60
|
+
__slots__ = ()
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def get(value):
|
|
64
|
+
if isinstance(value, Catalog):
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
if isinstance(value, str):
|
|
68
|
+
return Catalog.read_from(value)
|
|
69
|
+
|
|
70
|
+
msg = f"Can't get Catalog from value of type {type(value)}"
|
|
71
|
+
raise ValueError(msg)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def read_from(file_name):
|
|
75
|
+
"""Read from a valdiity YAML file and build a Catalog object"""
|
|
76
|
+
entries = {}
|
|
77
|
+
for props in PropsStream.get(file_name):
|
|
78
|
+
timestamp = props["valid_from"]
|
|
79
|
+
system = "all" if props.get("category") is None else props["category"]
|
|
80
|
+
file_key = props["apply"]
|
|
81
|
+
if system not in entries:
|
|
82
|
+
entries[system] = []
|
|
83
|
+
mode = "append" if props.get("mode") is None else props["mode"]
|
|
84
|
+
mode = "reset" if len(entries[system]) == 0 else mode
|
|
85
|
+
if mode == "reset":
|
|
86
|
+
new = file_key
|
|
87
|
+
elif mode == "append":
|
|
88
|
+
new = entries[system][-1].file.copy() + file_key
|
|
89
|
+
elif mode == "remove":
|
|
90
|
+
new = entries[system][-1].file.copy()
|
|
91
|
+
for file in file_key:
|
|
92
|
+
new.remove(file)
|
|
93
|
+
elif mode == "replace":
|
|
94
|
+
new = entries[system][-1].file.copy()
|
|
95
|
+
if len(file_key) != 2:
|
|
96
|
+
msg = f"Invalid number of elements in replace mode: {len(file_key)}"
|
|
97
|
+
raise ValueError(msg)
|
|
98
|
+
new.remove(file_key[0])
|
|
99
|
+
new += [file_key[1]]
|
|
100
|
+
|
|
101
|
+
else:
|
|
102
|
+
msg = f"Unknown mode for {timestamp}"
|
|
103
|
+
raise ValueError(msg)
|
|
104
|
+
|
|
105
|
+
if timestamp in [entry.valid_from for entry in entries[system]]:
|
|
106
|
+
msg = f"Duplicate timestamp: {timestamp}, use reset mode instead with a single entry"
|
|
107
|
+
raise ValueError(msg)
|
|
108
|
+
entries[system].append(Catalog.Entry(time.unix_time(timestamp), new))
|
|
109
|
+
|
|
110
|
+
for system in entries:
|
|
111
|
+
entries[system] = sorted(
|
|
112
|
+
entries[system], key=lambda entry: entry.valid_from
|
|
113
|
+
)
|
|
114
|
+
return Catalog(entries)
|
|
115
|
+
|
|
116
|
+
def valid_for(self, timestamp, system="all", allow_none=False):
|
|
117
|
+
"""Get the valid entries for a given timestamp and system"""
|
|
118
|
+
if system in self.entries:
|
|
119
|
+
valid_from = [entry.valid_from for entry in self.entries[system]]
|
|
120
|
+
pos = bisect.bisect_right(valid_from, time.unix_time(timestamp))
|
|
121
|
+
if pos > 0:
|
|
122
|
+
return self.entries[system][pos - 1].file
|
|
123
|
+
|
|
124
|
+
if system != "all":
|
|
125
|
+
return self.valid_for(timestamp, system="all", allow_none=allow_none)
|
|
126
|
+
|
|
127
|
+
if allow_none:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
msg = f"No valid entries found for timestamp: {timestamp}, system: {system}"
|
|
131
|
+
raise RuntimeError(msg)
|
|
132
|
+
|
|
133
|
+
if system != "all":
|
|
134
|
+
return self.valid_for(timestamp, system="all", allow_none=allow_none)
|
|
135
|
+
|
|
136
|
+
if allow_none:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
msg = f"No entries found for system: {system}"
|
|
140
|
+
raise RuntimeError(msg)
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def get_files(catalog_file, timestamp, category="all"):
|
|
144
|
+
"""Helper function to get the files for a given timestamp and category"""
|
|
145
|
+
catalog = Catalog.read_from(catalog_file)
|
|
146
|
+
return Catalog.valid_for(catalog, timestamp, category)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Props:
|
|
150
|
+
"""Class to handle overwriting of dictionaries in cascade order"""
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def read_from(sources, subst_pathvar=False, trim_null=False):
|
|
154
|
+
def read_impl(sources):
|
|
155
|
+
if isinstance(sources, str):
|
|
156
|
+
file_name = sources
|
|
157
|
+
result = utils.load_dict(file_name)
|
|
158
|
+
if subst_pathvar:
|
|
159
|
+
Props.subst_vars(
|
|
160
|
+
result,
|
|
161
|
+
var_values={"_": Path(file_name).parent},
|
|
162
|
+
ignore_missing=True,
|
|
163
|
+
)
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
if isinstance(sources, list):
|
|
167
|
+
result = {}
|
|
168
|
+
for p in map(read_impl, sources):
|
|
169
|
+
Props.add_to(result, p)
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
msg = f"Can't run Props.read_from on sources-value of type {type(sources)}"
|
|
173
|
+
raise ValueError(msg)
|
|
174
|
+
|
|
175
|
+
result = read_impl(sources)
|
|
176
|
+
if trim_null:
|
|
177
|
+
Props.trim_null(result)
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def write_to(file_name, obj, ftype: str | None = None):
|
|
182
|
+
utils.write_dict(file_name, obj, ftype)
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def add_to(props_a, props_b):
|
|
186
|
+
a = props_a
|
|
187
|
+
b = props_b
|
|
188
|
+
|
|
189
|
+
for key in b:
|
|
190
|
+
if key in a:
|
|
191
|
+
if isinstance(a[key], dict) and isinstance(b[key], dict):
|
|
192
|
+
Props.add_to(a[key], b[key])
|
|
193
|
+
elif a[key] != b[key]:
|
|
194
|
+
a[key] = copy.deepcopy(b[key])
|
|
195
|
+
else:
|
|
196
|
+
a[key] = copy.deepcopy(b[key])
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def trim_null(props_a):
|
|
200
|
+
a = props_a
|
|
201
|
+
|
|
202
|
+
for key in list(a.keys()):
|
|
203
|
+
if isinstance(a[key], dict):
|
|
204
|
+
Props.trim_null(a[key])
|
|
205
|
+
elif a[key] is None:
|
|
206
|
+
del a[key]
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def subst_vars(props, var_values=None, ignore_missing=False):
|
|
210
|
+
if not var_values:
|
|
211
|
+
var_values = {}
|
|
212
|
+
|
|
213
|
+
for key in props:
|
|
214
|
+
value = props[key]
|
|
215
|
+
if isinstance(value, str) and "$" in value:
|
|
216
|
+
new_value = None
|
|
217
|
+
if ignore_missing:
|
|
218
|
+
new_value = Template(value).safe_substitute(var_values)
|
|
219
|
+
else:
|
|
220
|
+
new_value = Template(value).substitute(var_values)
|
|
221
|
+
|
|
222
|
+
if new_value != value:
|
|
223
|
+
props[key] = new_value
|
|
224
|
+
elif isinstance(value, list):
|
|
225
|
+
new_values = []
|
|
226
|
+
for val in value:
|
|
227
|
+
if isinstance(val, str) and "$" in val:
|
|
228
|
+
if ignore_missing:
|
|
229
|
+
new_value = Template(val).safe_substitute(var_values)
|
|
230
|
+
else:
|
|
231
|
+
new_value = Template(val).substitute(var_values)
|
|
232
|
+
else:
|
|
233
|
+
new_value = val
|
|
234
|
+
new_values.append(new_value)
|
|
235
|
+
if new_values != value:
|
|
236
|
+
props[key] = new_values
|
|
237
|
+
elif isinstance(value, dict):
|
|
238
|
+
Props.subst_vars(value, var_values, ignore_missing)
|
dbetto/py.typed
ADDED
|
File without changes
|
dbetto/textdb.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# Copyright (C) 2022 Luigi Pertoldi <gipert@pm.me>
|
|
2
|
+
#
|
|
3
|
+
# This program 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
|
+
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import yaml
|
|
27
|
+
|
|
28
|
+
from . import utils
|
|
29
|
+
from .attrsdict import AttrsDict
|
|
30
|
+
from .catalog import Catalog, Props
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TextDB:
|
|
36
|
+
"""A simple text file database.
|
|
37
|
+
|
|
38
|
+
The database is represented on disk by a collection of text files
|
|
39
|
+
arbitrarily scattered in a filesystem. Subdirectories are also
|
|
40
|
+
:class:`.TextDB` objects. In memory, the database is represented as an
|
|
41
|
+
:class:`~.attrsdict.AttrsDict`.
|
|
42
|
+
|
|
43
|
+
Currently supported file formats are JSON and YAML.
|
|
44
|
+
|
|
45
|
+
Tip
|
|
46
|
+
---
|
|
47
|
+
For large databases, a basic "lazy" mode is available. In this case, no
|
|
48
|
+
global scan of the filesystem is performed at initialization time. Once a
|
|
49
|
+
file is queried, it is also cached in the internal store for faster access.
|
|
50
|
+
Caution, this option is for advanced use (see warning message below).
|
|
51
|
+
|
|
52
|
+
Warning
|
|
53
|
+
-------
|
|
54
|
+
A manual call to :meth:`scan` is needed before most class methods (e.g.
|
|
55
|
+
iterating on the database files) can be properly used.
|
|
56
|
+
|
|
57
|
+
Examples
|
|
58
|
+
--------
|
|
59
|
+
>>> from dbetto import TextDB
|
|
60
|
+
>>> jdb = TextDB("path/to/dir")
|
|
61
|
+
>>> jdb["file1.json"] # is a dict
|
|
62
|
+
>>> jdb["file1.yaml"] # is a dict
|
|
63
|
+
>>> jdb["file1"] # also works
|
|
64
|
+
>>> jdb["dir1"] # TextDB instance
|
|
65
|
+
>>> jdb["dir1"]["file1"] # nested file
|
|
66
|
+
>>> jdb["dir1/file1"] # also works
|
|
67
|
+
>>> jdb.dir1.file # keys can be accessed as attributes
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self, path: str | Path, lazy: str | bool = False, hidden: bool = False
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Construct a :class:`.TextDB` object.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
path
|
|
78
|
+
path to the directory containing the database.
|
|
79
|
+
lazy
|
|
80
|
+
whether a database scan should be performed at initialization time.
|
|
81
|
+
if ``auto``, be non-lazy only if working in a python interactive
|
|
82
|
+
session.
|
|
83
|
+
hidden
|
|
84
|
+
ignore hidden (i.e. starting with ".") files of directories.
|
|
85
|
+
"""
|
|
86
|
+
if isinstance(lazy, bool):
|
|
87
|
+
self.__lazy__ = lazy
|
|
88
|
+
elif lazy == "auto":
|
|
89
|
+
self.__lazy__ = not hasattr(sys, "ps1")
|
|
90
|
+
else:
|
|
91
|
+
msg = f"unrecognized value {lazy=}"
|
|
92
|
+
raise ValueError(msg)
|
|
93
|
+
|
|
94
|
+
self.__hidden__ = hidden
|
|
95
|
+
self.__path__ = Path(path).expanduser().resolve()
|
|
96
|
+
|
|
97
|
+
if not self.__path__.is_dir():
|
|
98
|
+
msg = "input path is not a valid directory"
|
|
99
|
+
raise ValueError(msg)
|
|
100
|
+
|
|
101
|
+
self.__store__ = AttrsDict()
|
|
102
|
+
self.__ftypes__ = {"json", "yaml"}
|
|
103
|
+
|
|
104
|
+
if not self.__lazy__:
|
|
105
|
+
self.scan()
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def __extensions__(self) -> set:
|
|
109
|
+
# determine list of supported file extensions
|
|
110
|
+
return set().union(
|
|
111
|
+
*[
|
|
112
|
+
exts
|
|
113
|
+
for ft, exts in utils.__file_extensions__.items()
|
|
114
|
+
if ft in self.__ftypes__
|
|
115
|
+
]
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def scan(self, recursive: bool = True, subdir: str = ".") -> None:
|
|
119
|
+
"""Populate the database by walking the filesystem.
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
recursive
|
|
124
|
+
if ``True``, recurse subdirectories.
|
|
125
|
+
subdir
|
|
126
|
+
restrict scan to path relative to the database location.
|
|
127
|
+
"""
|
|
128
|
+
# recursive search or not?
|
|
129
|
+
_fcn = self.__path__.rglob if recursive else self.__path__.glob
|
|
130
|
+
# build file list
|
|
131
|
+
flist = (
|
|
132
|
+
p for p in _fcn(str(Path(subdir) / "*")) if p.suffix in self.__extensions__
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
for j in flist:
|
|
136
|
+
try:
|
|
137
|
+
self[j.with_suffix("")]
|
|
138
|
+
except (json.JSONDecodeError, yaml.YAMLError, ValueError) as e:
|
|
139
|
+
msg = f"could not scan file {j}, reason: {e!r}"
|
|
140
|
+
log.warning(msg)
|
|
141
|
+
|
|
142
|
+
def keys(self) -> list[str]:
|
|
143
|
+
return self.__store__.keys()
|
|
144
|
+
|
|
145
|
+
def items(self) -> Iterator[(str, TextDB | AttrsDict | list)]:
|
|
146
|
+
return self.__store__.items()
|
|
147
|
+
|
|
148
|
+
def on(
|
|
149
|
+
self, timestamp: str | datetime, pattern: str | None = None, system: str = "all"
|
|
150
|
+
) -> AttrsDict | list:
|
|
151
|
+
"""Query database in `time[, file pattern, system]`.
|
|
152
|
+
|
|
153
|
+
A (only one) valid ``validity.yaml`` file must exist in the directory
|
|
154
|
+
to specify a validity mapping. This functionality relies on the
|
|
155
|
+
:class:`.catalog.Catalog` class.
|
|
156
|
+
|
|
157
|
+
The YAML specification is documented at `this link
|
|
158
|
+
<https://legend-exp.github.io/legend-data-format-specs/dev/metadata/#Specifying-metadata-validity-in-time-(and-system)>`_.
|
|
159
|
+
|
|
160
|
+
The special ``$_`` string is expanded to the directory containing the
|
|
161
|
+
text files.
|
|
162
|
+
|
|
163
|
+
Parameters
|
|
164
|
+
----------
|
|
165
|
+
timestamp
|
|
166
|
+
a :class:`~datetime.datetime` object or a string matching the
|
|
167
|
+
pattern ``YYYYmmddTHHMMSSZ``.
|
|
168
|
+
pattern
|
|
169
|
+
query by filename pattern.
|
|
170
|
+
system
|
|
171
|
+
query only a data taking "system" (e.g. 'all', 'phy', 'cal', 'lar', ...)
|
|
172
|
+
"""
|
|
173
|
+
for ext in utils.__file_extensions__["yaml"]:
|
|
174
|
+
yml = self.__path__ / f"validity{ext}"
|
|
175
|
+
if yml.is_file():
|
|
176
|
+
break
|
|
177
|
+
if not yml.is_file():
|
|
178
|
+
msg = f"no validity.yaml / validity.yml file found in {self.__path__!s}"
|
|
179
|
+
raise RuntimeError(msg)
|
|
180
|
+
|
|
181
|
+
file_list = Catalog.get_files(str(yml), timestamp, system)
|
|
182
|
+
# select only files matching pattern if specified
|
|
183
|
+
if pattern is not None:
|
|
184
|
+
c = re.compile(pattern)
|
|
185
|
+
out_files = []
|
|
186
|
+
for file in file_list:
|
|
187
|
+
if c.match(file):
|
|
188
|
+
out_files.append(file)
|
|
189
|
+
files = out_files
|
|
190
|
+
else:
|
|
191
|
+
files = file_list
|
|
192
|
+
|
|
193
|
+
# read files in and combine as necessary
|
|
194
|
+
db_ptr = self
|
|
195
|
+
if isinstance(files, list):
|
|
196
|
+
result = AttrsDict()
|
|
197
|
+
for file in files:
|
|
198
|
+
fp = self.__path__.rglob(file)
|
|
199
|
+
# TODO: what does this do exactly?
|
|
200
|
+
Props.add_to(result, db_ptr[next(iter(fp))])
|
|
201
|
+
db_ptr = result
|
|
202
|
+
else:
|
|
203
|
+
fp = self.__path__.rglob(files)
|
|
204
|
+
db_ptr = db_ptr[next(iter(fp))]
|
|
205
|
+
Props.subst_vars(db_ptr, var_values={"_": self.__path__})
|
|
206
|
+
return db_ptr
|
|
207
|
+
|
|
208
|
+
def map(self, label: str, unique: bool = True) -> AttrsDict:
|
|
209
|
+
"""Remap dictionary according to a second unique `label`.
|
|
210
|
+
|
|
211
|
+
See Also
|
|
212
|
+
--------
|
|
213
|
+
.attrsdict.AttrsDict.map
|
|
214
|
+
|
|
215
|
+
Warning
|
|
216
|
+
-------
|
|
217
|
+
If the database is lazy, you must call :meth:`.scan` in advance to
|
|
218
|
+
populate it, otherwise mappings cannot be created.
|
|
219
|
+
"""
|
|
220
|
+
return self.__store__.map(label, unique=unique)
|
|
221
|
+
|
|
222
|
+
def group(self, label: str) -> AttrsDict:
|
|
223
|
+
"""Group dictionary according to a second unique `label`.
|
|
224
|
+
|
|
225
|
+
See Also
|
|
226
|
+
--------
|
|
227
|
+
.attrsdict.AttrsDict.group
|
|
228
|
+
|
|
229
|
+
Warning
|
|
230
|
+
-------
|
|
231
|
+
If the database is lazy, you must call :meth:`.scan` in advance to
|
|
232
|
+
populate it, otherwise groupings cannot be created.
|
|
233
|
+
"""
|
|
234
|
+
return self.__store__.group(label)
|
|
235
|
+
|
|
236
|
+
def __getitem__(self, item: str | Path) -> TextDB | AttrsDict | list | None:
|
|
237
|
+
"""Access files or directories in the database."""
|
|
238
|
+
# resolve relative paths / links, but keep it relative to self.__path__
|
|
239
|
+
item = Path(item)
|
|
240
|
+
|
|
241
|
+
if item.is_absolute() and item.is_relative_to(self.__path__):
|
|
242
|
+
item = item.expanduser().resolve().relative_to(self.__path__)
|
|
243
|
+
elif not item.is_absolute():
|
|
244
|
+
item = (
|
|
245
|
+
(self.__path__ / item).expanduser().resolve().relative_to(self.__path__)
|
|
246
|
+
)
|
|
247
|
+
else:
|
|
248
|
+
msg = f"{item} lies outside the database root path {self.__path__!s}"
|
|
249
|
+
raise ValueError(msg)
|
|
250
|
+
|
|
251
|
+
ext_list = "[" + "|".join(self.__extensions__) + "]"
|
|
252
|
+
msg = f"parsing directory or file{ext_list}: {item}"
|
|
253
|
+
log.debug(msg)
|
|
254
|
+
|
|
255
|
+
# now call this very function recursively to walk the directories to the file
|
|
256
|
+
db_ptr = self
|
|
257
|
+
for d in item.parts[:-1]:
|
|
258
|
+
db_ptr = db_ptr[d]
|
|
259
|
+
# check if we encountered hidden directory (must skip)
|
|
260
|
+
if not self.__hidden__ and db_ptr is None:
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
# item_id should not contain any / at this point
|
|
264
|
+
# store file names without extension
|
|
265
|
+
item_id = item.stem
|
|
266
|
+
# skip if object is already in the store
|
|
267
|
+
if item_id not in db_ptr.__store__:
|
|
268
|
+
obj = db_ptr.__path__ / item.name
|
|
269
|
+
|
|
270
|
+
# do not consider hidden files
|
|
271
|
+
if not self.__hidden__ and obj.name.startswith("."):
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
# if directory, construct another TextDB object
|
|
275
|
+
if obj.is_dir():
|
|
276
|
+
db_ptr.__store__[item_id] = TextDB(obj, lazy=self.__lazy__)
|
|
277
|
+
|
|
278
|
+
else:
|
|
279
|
+
# try to attach an extension if file cannot be found
|
|
280
|
+
# but check if there are multiple files that only differ in extension (unsupported)
|
|
281
|
+
found = True
|
|
282
|
+
if not obj.is_file():
|
|
283
|
+
found = False
|
|
284
|
+
for ext in self.__extensions__:
|
|
285
|
+
if obj.with_suffix(ext).is_file():
|
|
286
|
+
if found:
|
|
287
|
+
msg = "the database cannot contain files that differ only in the extension"
|
|
288
|
+
raise RuntimeError(msg)
|
|
289
|
+
|
|
290
|
+
obj = obj.with_suffix(ext)
|
|
291
|
+
found = True
|
|
292
|
+
|
|
293
|
+
if not found:
|
|
294
|
+
msg = f"{obj.with_stem(ext_list)} is not a valid file or directory"
|
|
295
|
+
raise FileNotFoundError(msg)
|
|
296
|
+
|
|
297
|
+
# if it's a valid file, construct an AttrsDict object
|
|
298
|
+
loaded = utils.load_dict(obj)
|
|
299
|
+
if isinstance(loaded, dict):
|
|
300
|
+
loaded = AttrsDict(loaded)
|
|
301
|
+
Props.subst_vars(loaded, var_values={"_": self.__path__})
|
|
302
|
+
else: # must be a list, check if there are dicts inside to convert
|
|
303
|
+
for i, el in enumerate(loaded):
|
|
304
|
+
if isinstance(el, dict):
|
|
305
|
+
loaded[i] = AttrsDict(el)
|
|
306
|
+
Props.subst_vars(loaded[i], var_values={"_": self.__path__})
|
|
307
|
+
|
|
308
|
+
db_ptr.__store__[item_id] = loaded
|
|
309
|
+
|
|
310
|
+
# set also an attribute, if possible
|
|
311
|
+
if item_id.isidentifier():
|
|
312
|
+
db_ptr.__setattr__(item_id, db_ptr.__store__[item_id])
|
|
313
|
+
|
|
314
|
+
return db_ptr.__store__[item_id]
|
|
315
|
+
|
|
316
|
+
def __getattr__(self, name: str) -> TextDB | AttrsDict | list:
|
|
317
|
+
try:
|
|
318
|
+
return object.__getattribute__(self, name)
|
|
319
|
+
except AttributeError:
|
|
320
|
+
try:
|
|
321
|
+
return self.__getitem__(name)
|
|
322
|
+
except AttributeError as exc:
|
|
323
|
+
msg = f"file database does not contain '{name}'"
|
|
324
|
+
raise AttributeError(msg) from exc
|
|
325
|
+
|
|
326
|
+
# NOTE: self cannot stay a TextDB, since the class is characterized by a
|
|
327
|
+
# (unique) root directory. What would be the root directory of the merged
|
|
328
|
+
# TextDB?
|
|
329
|
+
def __ior__(self, other: TextDB) -> AttrsDict:
|
|
330
|
+
msg = "cannot merge TextDB in-place"
|
|
331
|
+
raise TypeError(msg)
|
|
332
|
+
|
|
333
|
+
# NOTE: returning a TextDB does not make much sense, see above
|
|
334
|
+
def __or__(self, other: TextDB) -> AttrsDict:
|
|
335
|
+
if isinstance(other, TextDB):
|
|
336
|
+
return self.__store__ | other.__store__
|
|
337
|
+
|
|
338
|
+
return self.__store__ | other
|
|
339
|
+
|
|
340
|
+
def __contains__(self, value: str) -> bool:
|
|
341
|
+
return self.__store__.__contains__(value)
|
|
342
|
+
|
|
343
|
+
def __len__(self) -> int:
|
|
344
|
+
return len(self.__store__)
|
|
345
|
+
|
|
346
|
+
def __iter__(self) -> Iterator:
|
|
347
|
+
return iter(self.__store__)
|
|
348
|
+
|
|
349
|
+
def __str__(self) -> str:
|
|
350
|
+
return str(self.__store__)
|
|
351
|
+
|
|
352
|
+
def __repr__(self) -> str:
|
|
353
|
+
return f"{self.__class__.__name__}('{self.__path__!s}')"
|
dbetto/time.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Copyright (C) 2022 Luigi Pertoldi <gipert@pm.me>
|
|
2
|
+
#
|
|
3
|
+
# This program 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
|
+
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def str_to_datetime(value):
|
|
22
|
+
"""Convert a string in the format %Y%m%dT%H%M%SZ to :class:`datetime.datetime`."""
|
|
23
|
+
return datetime.strptime(value, "%Y%m%dT%H%M%SZ")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def unix_time(value):
|
|
27
|
+
"""Convert a string in the format %Y%m%dT%H%M%SZ or datetime object to Unix time value"""
|
|
28
|
+
if isinstance(value, str):
|
|
29
|
+
return datetime.timestamp(str_to_datetime(value))
|
|
30
|
+
|
|
31
|
+
if isinstance(value, datetime):
|
|
32
|
+
return datetime.timestamp(value)
|
|
33
|
+
|
|
34
|
+
msg = f"Can't convert type {type(value)} to unix time"
|
|
35
|
+
raise ValueError(msg)
|
dbetto/utils.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Copyright (C) 2022 Luigi Pertoldi <gipert@pm.me>
|
|
2
|
+
#
|
|
3
|
+
# This program 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
|
+
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
__file_extensions__ = {"json": [".json"], "yaml": [".yaml", ".yml"]}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_dict(fname: str, ftype: str | None = None) -> dict:
|
|
30
|
+
"""Load a text file as a Python dict."""
|
|
31
|
+
fname = Path(fname)
|
|
32
|
+
|
|
33
|
+
# determine file type from extension
|
|
34
|
+
if ftype is None:
|
|
35
|
+
for _ftype, exts in __file_extensions__.items():
|
|
36
|
+
if fname.suffix in exts:
|
|
37
|
+
ftype = _ftype
|
|
38
|
+
|
|
39
|
+
msg = f"writing {ftype} dict to: {fname}"
|
|
40
|
+
log.debug(msg)
|
|
41
|
+
|
|
42
|
+
with fname.open(encoding="utf-8") as f:
|
|
43
|
+
if ftype == "json":
|
|
44
|
+
return json.load(f)
|
|
45
|
+
if ftype == "yaml":
|
|
46
|
+
return yaml.safe_load(f)
|
|
47
|
+
|
|
48
|
+
msg = f"unsupported file format {ftype}"
|
|
49
|
+
raise NotImplementedError(msg)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def write_dict(fname: str, obj: dict, ftype: str | None = None) -> dict:
|
|
53
|
+
"""Load a text file as a Python dict."""
|
|
54
|
+
fname = Path(fname)
|
|
55
|
+
|
|
56
|
+
# determine file type from extension
|
|
57
|
+
if ftype is None:
|
|
58
|
+
for _ftype, exts in __file_extensions__.items():
|
|
59
|
+
if fname.suffix in exts:
|
|
60
|
+
ftype = _ftype
|
|
61
|
+
|
|
62
|
+
msg = f"writing {ftype} dict to: {fname}"
|
|
63
|
+
log.debug(msg)
|
|
64
|
+
|
|
65
|
+
with fname.open("w", encoding="utf-8") as f:
|
|
66
|
+
if ftype == "json":
|
|
67
|
+
separators = (",", ":")
|
|
68
|
+
indent = 2
|
|
69
|
+
json.dump(obj, f, indent=indent, separators=separators)
|
|
70
|
+
f.write("\n")
|
|
71
|
+
elif ftype == "yaml":
|
|
72
|
+
yaml.dump(obj, f, sort_keys=False)
|
|
73
|
+
|
|
74
|
+
else:
|
|
75
|
+
msg = f"unsupported file format {ftype}"
|
|
76
|
+
raise NotImplementedError(msg)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dbetto
|
|
3
|
+
Version: 1.0.2
|
|
4
|
+
Summary: A tiny text file based database
|
|
5
|
+
Project-URL: Homepage, https://github.com/gipert/dbetto
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/gipert/dbetto/issues
|
|
7
|
+
Project-URL: Discussions, https://github.com/gipert/dbetto/discussions
|
|
8
|
+
Project-URL: Changelog, https://github.com/gipert/dbetto/releases
|
|
9
|
+
Author-email: Luigi Pertoldi <gipert@pm.me>
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 1 - Planning
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Requires-Dist: pyyaml
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest-cov>=3; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=6; extra == 'dev'
|
|
31
|
+
Provides-Extra: docs
|
|
32
|
+
Requires-Dist: furo>=2023.08.17; extra == 'docs'
|
|
33
|
+
Requires-Dist: myst-parser>=0.13; extra == 'docs'
|
|
34
|
+
Requires-Dist: sphinx-autodoc-typehints; extra == 'docs'
|
|
35
|
+
Requires-Dist: sphinx-copybutton; extra == 'docs'
|
|
36
|
+
Requires-Dist: sphinx-inline-tabs; extra == 'docs'
|
|
37
|
+
Requires-Dist: sphinx>=7.0; extra == 'docs'
|
|
38
|
+
Provides-Extra: test
|
|
39
|
+
Requires-Dist: pytest-cov>=3; extra == 'test'
|
|
40
|
+
Requires-Dist: pytest>=6; extra == 'test'
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# 📂 dbetto
|
|
44
|
+
|
|
45
|
+
A tiny text-based database.
|
|
46
|
+
|
|
47
|
+
[](https://pypi.org/project/dbetto/)
|
|
48
|
+

|
|
49
|
+
[](https://github.com/gipert/dbetto/actions)
|
|
50
|
+
[](https://github.com/pre-commit/pre-commit)
|
|
51
|
+
[](https://github.com/psf/black)
|
|
52
|
+
[](https://app.codecov.io/gh/gipert/dbetto)
|
|
53
|
+

|
|
54
|
+

|
|
55
|
+

|
|
56
|
+
[](https://dbetto.readthedocs.io)
|
|
57
|
+
|
|
58
|
+
_dbetto_ is a lightweight Python library that enables seamless access and
|
|
59
|
+
management of **text-based databases** (JSON/YAML) scattered across your
|
|
60
|
+
filesystem. Whether you're handling nested data structures or querying data by
|
|
61
|
+
validity periods, _dbetto_ simplifies it all with an intuitive interface.
|
|
62
|
+
|
|
63
|
+
The project was originally developed to efficiently manage metadata for the
|
|
64
|
+
LEGEND experiment.
|
|
65
|
+
|
|
66
|
+
### Key Features
|
|
67
|
+
|
|
68
|
+
- Access JSON/YAML files like a Python dictionary
|
|
69
|
+
- Attribute-style access with tab-completion for rapid query
|
|
70
|
+
- Time-sensitive data querying with validity rules
|
|
71
|
+
- Data remapping and grouping functionalities
|
|
72
|
+
|
|
73
|
+
## Showcase
|
|
74
|
+
|
|
75
|
+
Install from PyPI:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install dbetto
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Access data arbitrarily structured as JSON or YAML files:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
>>> from dbetto import TextDB
|
|
85
|
+
>>> db = TextDB("/path/to/data/folder")
|
|
86
|
+
>>> db["dir1"]["file1.json"]["value"]
|
|
87
|
+
1
|
|
88
|
+
>>> db.dir1.file1.value
|
|
89
|
+
1
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Query data valid on a given time period:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
>>> from datetime import datetime
|
|
96
|
+
>>> db.on(datetime(2023, 1, 10, 9, 53, 0)).value
|
|
97
|
+
3
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Map data according to alternative keys:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
>>> chmap = db["detectors.yaml"]
|
|
104
|
+
>>> chmap.V05266A
|
|
105
|
+
{'name': 'V05266A',
|
|
106
|
+
'location': {'string': 1, 'position': 4},
|
|
107
|
+
'daq': {'crate': 0,
|
|
108
|
+
'rawid': 1104003,
|
|
109
|
+
...
|
|
110
|
+
>>> chmap.map("daq.rawid")[1104003]
|
|
111
|
+
{'name': 'V05266A',
|
|
112
|
+
'location': {'string': 1, 'position': 4},
|
|
113
|
+
'daq': {'crate': 0,
|
|
114
|
+
'rawid': 1104003}}
|
|
115
|
+
>>> grouped = chmap.group("electronics.cc4.id")["C3"]
|
|
116
|
+
>>> grouped.map("name").keys()
|
|
117
|
+
dict_keys(['V02160A', 'V02160B', 'V05261B', 'V05266A', 'V05266B', 'V05268B', 'V05612A'])
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Have a look at the [docs](https://dbetto.readthedocs.io) for more!
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
dbetto/__init__.py,sha256=0-QwaMgvWS9HS45JNjTfoBKi6CwL365R8DMtcDbkWK8,952
|
|
2
|
+
dbetto/_version.py,sha256=BGgbj7AHZwtVw29XgDIjcG5woR-f3jQy2X-ArcRT22E,411
|
|
3
|
+
dbetto/_version.pyi,sha256=j5kbzfm6lOn8BzASXWjGIA1yT0OlHTWqlbyZ8Si_o0E,118
|
|
4
|
+
dbetto/attrsdict.py,sha256=f3jcFwEA5ginIDQM0F8iRFNPx1RKi3wcY7VT0IbS0kQ,8044
|
|
5
|
+
dbetto/catalog.py,sha256=rebVBYyCFAsJf7gDHZsoBfKGbLJ12iStDgPHJrCNX0I,8337
|
|
6
|
+
dbetto/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
dbetto/textdb.py,sha256=ILAez98Z5XTcXPEZxB7HFDBJ6fQi_56lKTe1PoJihN4,12638
|
|
8
|
+
dbetto/time.py,sha256=nwSy1bSggZVFp1q-kjW4NIlrHBwlD9xRaSqgMiPdrZs,1297
|
|
9
|
+
dbetto/utils.py,sha256=HG5Pe0AhVpsMTV-WMDgYNHLgnX_gfpHZqJ2-x4P7y4s,2359
|
|
10
|
+
dbetto-1.0.2.dist-info/METADATA,sha256=dIKN9kKyaKtALTrslynvRxt5e92-9LQj1Hqc_qlGq5A,4517
|
|
11
|
+
dbetto-1.0.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
12
|
+
dbetto-1.0.2.dist-info/licenses/LICENSE,sha256=yO1J9FccKANTlBrPhCtWEiFW9nIJFT_AEqR86iinBf4,1054
|
|
13
|
+
dbetto-1.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright 2024 Luigi Pertoldi
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
4
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
5
|
+
the Software without restriction, including without limitation the rights to
|
|
6
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
|
7
|
+
of the Software, and to permit persons to whom the Software is furnished to do
|
|
8
|
+
so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|