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 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
@@ -0,0 +1,4 @@
1
+ from __future__ import annotations
2
+
3
+ version: str
4
+ version_tuple: tuple[int, int, int] | tuple[int, int, int, str, str]
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
+ [![PyPI](https://img.shields.io/pypi/v/dbetto?logo=pypi)](https://pypi.org/project/dbetto/)
48
+ ![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/gipert/dbetto?logo=git)
49
+ [![GitHub Workflow Status](https://img.shields.io/github/checks-status/gipert/dbetto/main?label=main%20branch&logo=github)](https://github.com/gipert/dbetto/actions)
50
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit)
51
+ [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
52
+ [![Codecov](https://img.shields.io/codecov/c/github/gipert/dbetto?logo=codecov)](https://app.codecov.io/gh/gipert/dbetto)
53
+ ![GitHub issues](https://img.shields.io/github/issues/gipert/dbetto?logo=github)
54
+ ![GitHub pull requests](https://img.shields.io/github/issues-pr/gipert/dbetto?logo=github)
55
+ ![License](https://img.shields.io/github/license/gipert/dbetto)
56
+ [![Read the Docs](https://img.shields.io/readthedocs/dbetto?logo=readthedocs)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.