dbzero-modelkit 0.1.1.1.0__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.
- dbzero_modelkit/__init__.py +28 -0
- dbzero_modelkit/active.py +235 -0
- dbzero_modelkit/calendars.py +183 -0
- dbzero_modelkit/language.py +61 -0
- dbzero_modelkit/month_store.py +139 -0
- dbzero_modelkit/object_lock.py +41 -0
- dbzero_modelkit/queues.py +95 -0
- dbzero_modelkit/rpc_integration.py +41 -0
- dbzero_modelkit/time_utils.py +24 -0
- dbzero_modelkit-0.1.1.1.0.dist-info/METADATA +43 -0
- dbzero_modelkit-0.1.1.1.0.dist-info/RECORD +13 -0
- dbzero_modelkit-0.1.1.1.0.dist-info/WHEEL +5 -0
- dbzero_modelkit-0.1.1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Reusable dbzero-backed data model utilities."""
|
|
2
|
+
|
|
3
|
+
from dbzero_modelkit.active import ActiveBase, ActiveIndex
|
|
4
|
+
from dbzero_modelkit.calendars import (
|
|
5
|
+
Calendar,
|
|
6
|
+
MonthCalendar,
|
|
7
|
+
get_date_from_month_index,
|
|
8
|
+
get_month_index,
|
|
9
|
+
)
|
|
10
|
+
from dbzero_modelkit.language import LanguageCode, ML_String
|
|
11
|
+
from dbzero_modelkit.month_store import MonthStore
|
|
12
|
+
from dbzero_modelkit.object_lock import ObjectLock
|
|
13
|
+
from dbzero_modelkit.queues import FQ_Item, FiFoQueue
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ActiveBase",
|
|
17
|
+
"ActiveIndex",
|
|
18
|
+
"Calendar",
|
|
19
|
+
"FQ_Item",
|
|
20
|
+
"FiFoQueue",
|
|
21
|
+
"LanguageCode",
|
|
22
|
+
"ML_String",
|
|
23
|
+
"MonthCalendar",
|
|
24
|
+
"MonthStore",
|
|
25
|
+
"ObjectLock",
|
|
26
|
+
"get_date_from_month_index",
|
|
27
|
+
"get_month_index",
|
|
28
|
+
]
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Active-window model primitives backed by dbzero indexes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime, timedelta
|
|
6
|
+
from typing import Iterable, Optional
|
|
7
|
+
|
|
8
|
+
import dbzero as db0
|
|
9
|
+
|
|
10
|
+
from dbzero_modelkit.rpc_integration import rpc as db0_rpc
|
|
11
|
+
from dbzero_modelkit.time_utils import normalize_end, normalize_start
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@db0.memo(no_default_tags=True)
|
|
15
|
+
class ActiveBase:
|
|
16
|
+
"""Base object for models that are active only within a period of time."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
active_from: datetime | date | None = None,
|
|
21
|
+
expires_on: datetime | date | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.active_from = active_from
|
|
24
|
+
self.expires_on = expires_on
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def active(self) -> bool:
|
|
28
|
+
"""Return whether this object is active at the current date and time."""
|
|
29
|
+
return self.is_active()
|
|
30
|
+
|
|
31
|
+
@db0.immutable
|
|
32
|
+
def is_active(self, as_of: datetime | date | None = None) -> bool:
|
|
33
|
+
"""Return whether as_of is within the active period."""
|
|
34
|
+
if not as_of:
|
|
35
|
+
as_of = datetime.now()
|
|
36
|
+
|
|
37
|
+
if isinstance(as_of, date) and not isinstance(as_of, datetime):
|
|
38
|
+
as_of = datetime.combine(as_of, datetime.min.time())
|
|
39
|
+
|
|
40
|
+
active_from_dt = normalize_start(self.active_from) if self.active_from is not None else None
|
|
41
|
+
expires_on_dt = normalize_end(self.expires_on) if self.expires_on is not None else None
|
|
42
|
+
|
|
43
|
+
if active_from_dt is None and expires_on_dt is None:
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
if active_from_dt and as_of < active_from_dt:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
if expires_on_dt and as_of > expires_on_dt:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
@db0.immutable
|
|
55
|
+
def expired(self, date_and_time: datetime) -> bool:
|
|
56
|
+
"""Return whether the object is expired at date_and_time."""
|
|
57
|
+
return not self.is_active(date_and_time)
|
|
58
|
+
|
|
59
|
+
@db0.immutable
|
|
60
|
+
def overlaps(self, other: "ActiveBase") -> bool:
|
|
61
|
+
"""Return whether this active period overlaps another one."""
|
|
62
|
+
start_self = normalize_start(self.active_from)
|
|
63
|
+
end_self = normalize_end(self.expires_on)
|
|
64
|
+
start_other = normalize_start(other.active_from)
|
|
65
|
+
end_other = normalize_end(other.expires_on)
|
|
66
|
+
return start_self <= end_other and start_other <= end_self
|
|
67
|
+
|
|
68
|
+
@db0.immutable
|
|
69
|
+
def is_adjacent(self, other: "ActiveBase", max_gap: timedelta = timedelta(0)) -> bool:
|
|
70
|
+
"""Return whether this period is adjacent to another one within max_gap."""
|
|
71
|
+
if self.overlaps(other):
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
start_self = normalize_start(self.active_from)
|
|
75
|
+
end_self = normalize_end(self.expires_on)
|
|
76
|
+
start_other = normalize_start(other.active_from)
|
|
77
|
+
end_other = normalize_end(other.expires_on)
|
|
78
|
+
|
|
79
|
+
if end_self < start_other:
|
|
80
|
+
return start_other - end_self <= max_gap
|
|
81
|
+
|
|
82
|
+
if end_other < start_self:
|
|
83
|
+
return start_self - end_other <= max_gap
|
|
84
|
+
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
@db0.immutable
|
|
88
|
+
def can_merge(self, other: "ActiveBase", max_gap: timedelta = timedelta(0)) -> bool:
|
|
89
|
+
"""Return True when periods overlap or are adjacent within max_gap."""
|
|
90
|
+
return self.overlaps(other) or self.is_adjacent(other, max_gap=max_gap)
|
|
91
|
+
|
|
92
|
+
@db0.immutable
|
|
93
|
+
def merge(
|
|
94
|
+
self,
|
|
95
|
+
other: "ActiveBase",
|
|
96
|
+
max_gap: timedelta = timedelta(0),
|
|
97
|
+
) -> Optional[tuple[datetime | date | None, datetime | date | None]]:
|
|
98
|
+
"""Return merged active bounds or None when periods cannot be merged."""
|
|
99
|
+
if not self.can_merge(other, max_gap=max_gap):
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
if self.active_from is None or other.active_from is None:
|
|
103
|
+
merged_active_from = None
|
|
104
|
+
elif normalize_start(self.active_from) <= normalize_start(other.active_from):
|
|
105
|
+
merged_active_from = self.active_from
|
|
106
|
+
else:
|
|
107
|
+
merged_active_from = other.active_from
|
|
108
|
+
|
|
109
|
+
if self.expires_on is None or other.expires_on is None:
|
|
110
|
+
merged_expires_on = None
|
|
111
|
+
elif normalize_end(self.expires_on) >= normalize_end(other.expires_on):
|
|
112
|
+
merged_expires_on = self.expires_on
|
|
113
|
+
else:
|
|
114
|
+
merged_expires_on = other.expires_on
|
|
115
|
+
|
|
116
|
+
return merged_active_from, merged_expires_on
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@db0.memo(no_default_tags=True)
|
|
120
|
+
class ActiveIndex:
|
|
121
|
+
"""Container indexing ActiveBase-compatible objects by active period."""
|
|
122
|
+
|
|
123
|
+
def __init__(self) -> None:
|
|
124
|
+
self.__ix_active_from = db0.index()
|
|
125
|
+
self.__ix_expires_on = db0.index()
|
|
126
|
+
|
|
127
|
+
@db0_rpc.remote
|
|
128
|
+
def add(self, obj: ActiveBase) -> None:
|
|
129
|
+
"""Add an ActiveBase-compatible object to both active-window indexes."""
|
|
130
|
+
self.__ix_active_from.add(obj.active_from, obj)
|
|
131
|
+
self.__ix_expires_on.add(obj.expires_on, obj)
|
|
132
|
+
|
|
133
|
+
@db0_rpc.remote
|
|
134
|
+
def remove(self, obj: ActiveBase) -> None:
|
|
135
|
+
"""Remove an ActiveBase-compatible object from both active-window indexes."""
|
|
136
|
+
self.__ix_active_from.remove(obj.active_from, obj)
|
|
137
|
+
self.__ix_expires_on.remove(obj.expires_on, obj)
|
|
138
|
+
|
|
139
|
+
def _update_db0_index_attr(
|
|
140
|
+
self,
|
|
141
|
+
db0_index: db0.index,
|
|
142
|
+
obj: ActiveBase,
|
|
143
|
+
property_name: str,
|
|
144
|
+
value: datetime | date | None,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Update one indexed active-window attribute and reindex the object."""
|
|
147
|
+
if not db0_index.sort(db0.find(obj), desc=True, null_first=True):
|
|
148
|
+
raise RuntimeError("Given ActiveBase object doesn't exist in any index.")
|
|
149
|
+
self.remove(obj)
|
|
150
|
+
setattr(obj, property_name, value)
|
|
151
|
+
self.add(obj)
|
|
152
|
+
|
|
153
|
+
@db0_rpc.remote
|
|
154
|
+
def update(self, obj: ActiveBase, **kwargs: datetime | date | None) -> None:
|
|
155
|
+
"""Change active-window attributes and update the corresponding indexes."""
|
|
156
|
+
if "active_from" in kwargs:
|
|
157
|
+
self._update_db0_index_attr(
|
|
158
|
+
db0_index=self.__ix_active_from,
|
|
159
|
+
obj=obj,
|
|
160
|
+
property_name="active_from",
|
|
161
|
+
value=kwargs["active_from"],
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if "expires_on" in kwargs:
|
|
165
|
+
self._update_db0_index_attr(
|
|
166
|
+
db0_index=self.__ix_expires_on,
|
|
167
|
+
obj=obj,
|
|
168
|
+
property_name="expires_on",
|
|
169
|
+
value=kwargs["expires_on"],
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
@db0.immutable
|
|
173
|
+
def find(self, **kwargs: bool) -> Iterable:
|
|
174
|
+
"""Return all indexed objects sorted by a selected active-window index."""
|
|
175
|
+
index_to_find, desc = self.__get_index_and_desc_from_kwargs(**kwargs)
|
|
176
|
+
return index_to_find.sort(index_to_find.select(), desc=desc)
|
|
177
|
+
|
|
178
|
+
@db0.immutable
|
|
179
|
+
def find_active(
|
|
180
|
+
self,
|
|
181
|
+
as_of: datetime | date | None = None,
|
|
182
|
+
sort: bool = True,
|
|
183
|
+
**kwargs: bool,
|
|
184
|
+
) -> Iterable:
|
|
185
|
+
"""Find objects active at as_of."""
|
|
186
|
+
if not as_of:
|
|
187
|
+
as_of = datetime.now()
|
|
188
|
+
index_to_find, desc = self.__get_index_and_desc_from_kwargs(**kwargs)
|
|
189
|
+
query = (
|
|
190
|
+
self.active_from_index.select(low=None, high=as_of, null_first=True),
|
|
191
|
+
self.expires_on_index.select(low=as_of, high=None, null_first=False),
|
|
192
|
+
)
|
|
193
|
+
if sort:
|
|
194
|
+
return index_to_find.sort(db0.find(query), desc=desc)
|
|
195
|
+
|
|
196
|
+
return db0.find(query)
|
|
197
|
+
|
|
198
|
+
@db0.immutable
|
|
199
|
+
def find_active_between(
|
|
200
|
+
self,
|
|
201
|
+
from_date: datetime | date,
|
|
202
|
+
to_date: Optional[datetime | date] = None,
|
|
203
|
+
sort: bool = True,
|
|
204
|
+
**kwargs: bool,
|
|
205
|
+
) -> Iterable:
|
|
206
|
+
"""Find objects active within the given date range."""
|
|
207
|
+
index_to_find, desc = self.__get_index_and_desc_from_kwargs(**kwargs)
|
|
208
|
+
query = (
|
|
209
|
+
self.active_from_index.select(low=None, high=to_date, null_first=True),
|
|
210
|
+
self.expires_on_index.select(low=from_date, high=None, null_first=False),
|
|
211
|
+
)
|
|
212
|
+
if sort:
|
|
213
|
+
return index_to_find.sort(db0.find(query), desc=desc)
|
|
214
|
+
return db0.find(query)
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def active_from_index(self):
|
|
218
|
+
"""Return the active_from dbzero index."""
|
|
219
|
+
return self.__ix_active_from
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def expires_on_index(self):
|
|
223
|
+
"""Return the expires_on dbzero index."""
|
|
224
|
+
return self.__ix_expires_on
|
|
225
|
+
|
|
226
|
+
def __get_index_and_desc_from_kwargs(self, **kwargs: bool):
|
|
227
|
+
"""Return selected index and sort direction from keyword arguments."""
|
|
228
|
+
index_to_find_dict = {
|
|
229
|
+
"active_from": self.active_from_index,
|
|
230
|
+
"expires_on": self.expires_on_index,
|
|
231
|
+
}
|
|
232
|
+
for key, db0_index in index_to_find_dict.items():
|
|
233
|
+
if key in kwargs:
|
|
234
|
+
return db0_index, kwargs[key]
|
|
235
|
+
return self.expires_on_index, True
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Sparse calendar model primitives backed by dbzero."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from calendar import monthrange
|
|
6
|
+
from datetime import date as Date, timedelta
|
|
7
|
+
from typing import Any, Iterable, Iterator
|
|
8
|
+
|
|
9
|
+
import dbzero as db0
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_month_index(base_year: int, date: Date) -> int:
|
|
13
|
+
"""Return the zero-based month index relative to base_year."""
|
|
14
|
+
return (date.year * 12 + date.month - base_year * 12) - 1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_date_from_month_index(base_year: int, month_index: int) -> Date:
|
|
18
|
+
"""Return the first day of the month for a zero-based month index."""
|
|
19
|
+
return Date(base_year + month_index // 12, month_index % 12 + 1, 1)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@db0.memo(no_default_tags=True)
|
|
23
|
+
class MonthCalendar:
|
|
24
|
+
"""Single sparse month view for Calendar."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, calendar: "Calendar", month_index: int, prefix: str | None = None) -> None:
|
|
27
|
+
db0.set_prefix(self, prefix)
|
|
28
|
+
self.__calendar = calendar
|
|
29
|
+
self.__index = month_index
|
|
30
|
+
self.__days = []
|
|
31
|
+
self.__max_days = monthrange(
|
|
32
|
+
calendar.base_year + month_index // 12,
|
|
33
|
+
month_index % 12 + 1,
|
|
34
|
+
)[1]
|
|
35
|
+
|
|
36
|
+
def __validate_date(self, date: Date) -> None:
|
|
37
|
+
month_index = get_month_index(self.__calendar.base_year, date)
|
|
38
|
+
if month_index != self.__index:
|
|
39
|
+
raise ValueError("Date is not in the month")
|
|
40
|
+
if self.__max_days < date.day:
|
|
41
|
+
raise ValueError("Date is out of range")
|
|
42
|
+
|
|
43
|
+
def set(self, date: Date, value: Any) -> None:
|
|
44
|
+
"""Set a value for a date in this month."""
|
|
45
|
+
self.__validate_date(date)
|
|
46
|
+
if len(self.__days) <= date.day:
|
|
47
|
+
self.__days.extend([None] * (date.day - len(self.__days)))
|
|
48
|
+
self.__days[date.day - 1] = value
|
|
49
|
+
|
|
50
|
+
def get(self, date: Date) -> Any | None:
|
|
51
|
+
"""Return the stored value for date, or None when unset."""
|
|
52
|
+
self.__validate_date(date)
|
|
53
|
+
if len(self.__days) >= date.day:
|
|
54
|
+
return self.__days[date.day - 1]
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def range(self, from_date: Date | None, to_date: Date | None = None) -> Iterator[Any | None]:
|
|
58
|
+
"""Yield values from from_date through to_date within this month."""
|
|
59
|
+
start_day = 0
|
|
60
|
+
if from_date is not None:
|
|
61
|
+
self.__validate_date(from_date)
|
|
62
|
+
start_day = from_date.day - 1
|
|
63
|
+
end_day = self.__max_days
|
|
64
|
+
if to_date is not None:
|
|
65
|
+
self.__validate_date(to_date)
|
|
66
|
+
end_day = to_date.day
|
|
67
|
+
if start_day > end_day:
|
|
68
|
+
raise ValueError("Invalid range. 'from_date' must be before 'to_date'")
|
|
69
|
+
while start_day <= end_day - 1:
|
|
70
|
+
if start_day < len(self.__days):
|
|
71
|
+
yield self.__days[start_day]
|
|
72
|
+
else:
|
|
73
|
+
yield None
|
|
74
|
+
start_day += 1
|
|
75
|
+
|
|
76
|
+
def __iter__(self):
|
|
77
|
+
"""Yield `(date, value)` pairs for set non-None days."""
|
|
78
|
+
actual_day = get_date_from_month_index(self.__calendar.base_year, self.__index)
|
|
79
|
+
for day in self.__days:
|
|
80
|
+
if day is not None:
|
|
81
|
+
yield actual_day, day
|
|
82
|
+
actual_day += timedelta(days=1)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@db0.memo(no_default_tags=True)
|
|
86
|
+
class Calendar:
|
|
87
|
+
"""Sparse date calendar with lazy month creation."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, base_year: int = 2025, prefix: str | None = None) -> None:
|
|
90
|
+
db0.set_prefix(self, prefix)
|
|
91
|
+
self.__months = []
|
|
92
|
+
self.__base_year = base_year
|
|
93
|
+
|
|
94
|
+
def get_month(self, date: Date, create: bool = False) -> MonthCalendar | None:
|
|
95
|
+
"""Retrieve or create the MonthCalendar associated with date."""
|
|
96
|
+
self.__validate_date(date)
|
|
97
|
+
month_index = get_month_index(self.base_year, date)
|
|
98
|
+
if len(self.__months) <= month_index:
|
|
99
|
+
if create:
|
|
100
|
+
self.__months.extend([None] * (month_index - len(self.__months) + 1))
|
|
101
|
+
else:
|
|
102
|
+
return None
|
|
103
|
+
if self.__months[month_index] is None and create is True:
|
|
104
|
+
self.__months[month_index] = MonthCalendar(self, month_index)
|
|
105
|
+
return self.__months[month_index]
|
|
106
|
+
|
|
107
|
+
def date_range(
|
|
108
|
+
self,
|
|
109
|
+
from_date: Date | None = None,
|
|
110
|
+
to_date: Date | None = None,
|
|
111
|
+
reverse: bool = False,
|
|
112
|
+
) -> Iterable[tuple[Date, Any | None]]:
|
|
113
|
+
"""Yield `(date, value)` pairs for consecutive dates."""
|
|
114
|
+
if from_date is None:
|
|
115
|
+
from_date = Date(self.base_year, 1, 1)
|
|
116
|
+
else:
|
|
117
|
+
self.__validate_date(from_date)
|
|
118
|
+
if to_date is not None:
|
|
119
|
+
self.__validate_date(to_date)
|
|
120
|
+
if from_date > to_date:
|
|
121
|
+
raise ValueError("from_date should be less than to_date")
|
|
122
|
+
|
|
123
|
+
if reverse:
|
|
124
|
+
if to_date is None:
|
|
125
|
+
raise ValueError("to_date is required when iterating in reverse")
|
|
126
|
+
actual_date = to_date
|
|
127
|
+
end_date = from_date
|
|
128
|
+
delta = timedelta(days=-1)
|
|
129
|
+
else:
|
|
130
|
+
actual_date = from_date
|
|
131
|
+
end_date = to_date
|
|
132
|
+
delta = timedelta(days=1)
|
|
133
|
+
|
|
134
|
+
while True:
|
|
135
|
+
yield actual_date, self.get(actual_date)
|
|
136
|
+
if actual_date == end_date:
|
|
137
|
+
break
|
|
138
|
+
actual_date += delta
|
|
139
|
+
|
|
140
|
+
def range(
|
|
141
|
+
self,
|
|
142
|
+
from_date: Date | None = None,
|
|
143
|
+
to_date: Date | None = None,
|
|
144
|
+
reverse: bool = False,
|
|
145
|
+
) -> Iterable[Any | None]:
|
|
146
|
+
"""Yield only values from date_range."""
|
|
147
|
+
return (value for _date, value in self.date_range(from_date, to_date, reverse))
|
|
148
|
+
|
|
149
|
+
def get(self, date: Date) -> Any | None:
|
|
150
|
+
"""Return the stored value for date, or None when unset."""
|
|
151
|
+
month = self.get_month(date)
|
|
152
|
+
return month.get(date) if month is not None else None
|
|
153
|
+
|
|
154
|
+
def set(self, date: Date, value: Any) -> None:
|
|
155
|
+
"""Set the value for date, creating its month when needed."""
|
|
156
|
+
self.get_month(date, create=True).set(date, value)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def base_year(self) -> int:
|
|
160
|
+
"""Return the base year for this calendar."""
|
|
161
|
+
return self.__base_year
|
|
162
|
+
|
|
163
|
+
def __validate_date(self, date: Date) -> None:
|
|
164
|
+
if date.year < self.base_year:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
f"Date is out of range. Date must be after year {self.base_year}. Got: {date}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def __iter__(self):
|
|
170
|
+
"""Yield `(date, value)` pairs for all set non-None days."""
|
|
171
|
+
for month in self.__months:
|
|
172
|
+
if month is not None:
|
|
173
|
+
yield from month
|
|
174
|
+
|
|
175
|
+
def is_set(self, date: Date) -> bool:
|
|
176
|
+
"""Return True when the calendar value at date is truthy."""
|
|
177
|
+
return bool(self.get(date))
|
|
178
|
+
|
|
179
|
+
def find_not_set(self, dates: Iterable[Date]) -> Iterable[Date]:
|
|
180
|
+
"""Yield dates that do not have a truthy calendar value."""
|
|
181
|
+
for date in dates:
|
|
182
|
+
if not self.is_set(date):
|
|
183
|
+
yield date
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Multi-language string support backed by dbzero."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import dbzero as db0
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@db0.enum(values=["LEN", "LPL", "LGER", "LFR", "LESP"])
|
|
11
|
+
class LanguageCode:
|
|
12
|
+
"""Common language codes used by multi-language strings."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@db0.memo(no_default_tags=True)
|
|
16
|
+
class ML_String:
|
|
17
|
+
"""Store textual information in a primary language and optional translations."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
value: str,
|
|
22
|
+
lang_code: LanguageCode,
|
|
23
|
+
ml_versions: Optional[dict[LanguageCode, str]] = None,
|
|
24
|
+
prefix: Optional[str] = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
db0.set_prefix(self, prefix)
|
|
27
|
+
self.value = value
|
|
28
|
+
self.__lang_code = lang_code
|
|
29
|
+
self.__ml_versions = ml_versions
|
|
30
|
+
|
|
31
|
+
def __str__(self) -> str:
|
|
32
|
+
"""Return the primary stored value."""
|
|
33
|
+
return self.value
|
|
34
|
+
|
|
35
|
+
def get(
|
|
36
|
+
self,
|
|
37
|
+
lang_code: Optional[LanguageCode] = None,
|
|
38
|
+
fallback_codes: Optional[list[LanguageCode]] = None,
|
|
39
|
+
default: Optional[str] = None,
|
|
40
|
+
) -> Optional[str]:
|
|
41
|
+
"""Return a requested translation, fallback translation, or default value."""
|
|
42
|
+
if lang_code is None or lang_code == self.__lang_code:
|
|
43
|
+
return self.value
|
|
44
|
+
|
|
45
|
+
if self.__ml_versions is not None:
|
|
46
|
+
if lang_code in self.__ml_versions:
|
|
47
|
+
return self.__ml_versions[lang_code]
|
|
48
|
+
|
|
49
|
+
if fallback_codes is not None:
|
|
50
|
+
for code in fallback_codes:
|
|
51
|
+
if code in self.__ml_versions:
|
|
52
|
+
return self.__ml_versions[code]
|
|
53
|
+
|
|
54
|
+
if fallback_codes is not None and self.__lang_code in fallback_codes:
|
|
55
|
+
return self.value
|
|
56
|
+
|
|
57
|
+
return default
|
|
58
|
+
|
|
59
|
+
def __load__(self, lang: Optional[LanguageCode] = None) -> Optional[str]:
|
|
60
|
+
"""Load the requested language value, defaulting to Polish fallback."""
|
|
61
|
+
return self.get(lang, fallback_codes=[LanguageCode.LPL])
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Sparse month-indexed object store backed by dbzero."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import dbzero as db0
|
|
9
|
+
|
|
10
|
+
from dbzero_modelkit.rpc_integration import rpc as db0_rpc
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@db0.memo(no_default_tags=True)
|
|
14
|
+
class MonthStore:
|
|
15
|
+
"""General-purpose container for storing one object per month."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, item_type: type, base_year: int = 2025) -> None:
|
|
18
|
+
self.__item_type = item_type
|
|
19
|
+
self.__base_year = base_year
|
|
20
|
+
self.__months = []
|
|
21
|
+
|
|
22
|
+
def __str__(self) -> str:
|
|
23
|
+
"""Return a YYYY-MM span for stored months, or an empty string."""
|
|
24
|
+
span = self.get_span()
|
|
25
|
+
if not span:
|
|
26
|
+
return ""
|
|
27
|
+
start, end = span
|
|
28
|
+
return f"{start.year:04d}-{start.month:02d} - {end.year:04d}-{end.month:02d}"
|
|
29
|
+
|
|
30
|
+
def __get_index(self, month: date) -> int:
|
|
31
|
+
"""Return the month index relative to the base year."""
|
|
32
|
+
return (month.year * 12 + month.month - self.__base_year * 12) - 1
|
|
33
|
+
|
|
34
|
+
@db0_rpc.remote
|
|
35
|
+
def get_month(self, month: date, create: bool = False) -> Any:
|
|
36
|
+
"""Return the stored month object, optionally creating it when missing."""
|
|
37
|
+
index = self.__get_index(month)
|
|
38
|
+
|
|
39
|
+
if index < 0:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"Month {month.year}-{month.month:02d} is before base year "
|
|
42
|
+
f"{self.__base_year}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if index < len(self.__months):
|
|
46
|
+
existing_item = self.__months[index]
|
|
47
|
+
if existing_item is not None:
|
|
48
|
+
return existing_item
|
|
49
|
+
|
|
50
|
+
if not create:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"No item found for month {month.year}-{month.month:02d} and create=False"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
while len(self.__months) <= index:
|
|
56
|
+
self.__months.append(None)
|
|
57
|
+
|
|
58
|
+
new_item = self.__item_type()
|
|
59
|
+
self.__months[index] = new_item
|
|
60
|
+
return new_item
|
|
61
|
+
|
|
62
|
+
@db0.immutable
|
|
63
|
+
def try_get_existing_month(self, month: date) -> Optional[Any]:
|
|
64
|
+
"""Return an existing month object, or None when absent."""
|
|
65
|
+
index = self.__get_index(month)
|
|
66
|
+
|
|
67
|
+
if index < 0:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
if index < len(self.__months):
|
|
71
|
+
existing_item = self.__months[index]
|
|
72
|
+
if existing_item is not None:
|
|
73
|
+
return existing_item
|
|
74
|
+
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
@db0.immutable
|
|
78
|
+
def get_existing_month(self, month: date) -> Any:
|
|
79
|
+
"""Return an existing month object, raising if absent."""
|
|
80
|
+
return self.get_month(month, create=False)
|
|
81
|
+
|
|
82
|
+
@db0_rpc.remote
|
|
83
|
+
def set_month(self, month: date, value: Any) -> None:
|
|
84
|
+
"""Set or clear the object at a specific month."""
|
|
85
|
+
index = self.__get_index(month)
|
|
86
|
+
|
|
87
|
+
if index < 0:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"Month {month.year}-{month.month:02d} is before base year "
|
|
90
|
+
f"{self.__base_year}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
while len(self.__months) <= index:
|
|
94
|
+
self.__months.append(None)
|
|
95
|
+
|
|
96
|
+
if value is not None and not isinstance(value, self.__item_type):
|
|
97
|
+
raise TypeError(
|
|
98
|
+
"Invalid value type for MonthStore: expected "
|
|
99
|
+
f"{self.__item_type.__name__} or None, got {type(value).__name__}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
self.__months[index] = value
|
|
103
|
+
|
|
104
|
+
def get_span(self) -> tuple[date, date] | None:
|
|
105
|
+
"""Return the first and last stored month dates, or None when empty."""
|
|
106
|
+
if not self.__months:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
first_index = next((i for i, item in enumerate(self.__months) if item is not None), None)
|
|
110
|
+
if first_index is None:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
last_index = next(i for i, item in enumerate(reversed(self.__months)) if item is not None)
|
|
114
|
+
last_index = len(self.__months) - 1 - last_index
|
|
115
|
+
|
|
116
|
+
def index_to_date(idx: int) -> date:
|
|
117
|
+
year = self.__base_year + (idx // 12)
|
|
118
|
+
month = (idx % 12) + 1
|
|
119
|
+
return date(year, month, 1)
|
|
120
|
+
|
|
121
|
+
start_date = index_to_date(first_index)
|
|
122
|
+
end_date = index_to_date(last_index)
|
|
123
|
+
return start_date, end_date
|
|
124
|
+
|
|
125
|
+
def get_recent(self, count: int = 12) -> list[Any]:
|
|
126
|
+
"""Return up to count stored items in reverse chronological order."""
|
|
127
|
+
recent_items = []
|
|
128
|
+
for i in range(len(self.__months) - 1, -1, -1):
|
|
129
|
+
if len(recent_items) >= count:
|
|
130
|
+
break
|
|
131
|
+
item = self.__months[i]
|
|
132
|
+
if item is not None:
|
|
133
|
+
recent_items.append(item)
|
|
134
|
+
return recent_items
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def recent(self) -> list[Any]:
|
|
138
|
+
"""Return up to 12 most recent stored items."""
|
|
139
|
+
return self.get_recent(12)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Tag-based object locking utility backed by dbzero."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Any, Iterable, Type
|
|
7
|
+
|
|
8
|
+
import dbzero as db0
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@db0.memo
|
|
12
|
+
class ObjectLock:
|
|
13
|
+
"""General-purpose lock that holds references to locked objects."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, locked_objects: Any | list[Any], duration: int = 300) -> None:
|
|
16
|
+
if not isinstance(locked_objects, list):
|
|
17
|
+
locked_objects = [locked_objects]
|
|
18
|
+
self.locked_objects = locked_objects
|
|
19
|
+
self.expires_at = datetime.now(timezone.utc) + timedelta(seconds=duration)
|
|
20
|
+
db0.tags(*locked_objects).add("LOCKED")
|
|
21
|
+
|
|
22
|
+
def unlock(self) -> None:
|
|
23
|
+
"""Remove the LOCKED tag from all locked objects."""
|
|
24
|
+
db0.tags(*self.locked_objects).remove("LOCKED")
|
|
25
|
+
|
|
26
|
+
def unlock_with_error(self, error_objects: Any | list[Any] | None = None) -> None:
|
|
27
|
+
"""Unlock objects and mark selected or all objects with ERROR."""
|
|
28
|
+
if error_objects is None:
|
|
29
|
+
error_targets = self.locked_objects
|
|
30
|
+
elif isinstance(error_objects, list):
|
|
31
|
+
error_targets = error_objects
|
|
32
|
+
else:
|
|
33
|
+
error_targets = [error_objects]
|
|
34
|
+
|
|
35
|
+
self.unlock()
|
|
36
|
+
if error_targets:
|
|
37
|
+
db0.tags(*error_targets).add("ERROR")
|
|
38
|
+
|
|
39
|
+
def select(self, obj_type: Type) -> Iterable:
|
|
40
|
+
"""Return locked objects that are instances of obj_type."""
|
|
41
|
+
return filter(lambda obj: isinstance(obj, obj_type), self.locked_objects)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""dbzero-backed queue model utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
import dbzero as db0
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@db0.memo
|
|
11
|
+
class FQ_Item: # pylint: disable=invalid-name
|
|
12
|
+
"""Single FIFO queue entry storing keyword arguments and its integer key."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, key: int, prefix: str | None = None, **kwargs) -> None:
|
|
15
|
+
db0.set_prefix(self, prefix)
|
|
16
|
+
self.__key = key
|
|
17
|
+
for k, v in kwargs.items():
|
|
18
|
+
setattr(self, k, v)
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def queue_key(self) -> int:
|
|
22
|
+
"""Return the index key for this queue item."""
|
|
23
|
+
return self.__key
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> Dict:
|
|
26
|
+
"""Return stored item keyword arguments."""
|
|
27
|
+
return {
|
|
28
|
+
key: value
|
|
29
|
+
for key, value in vars(self).items()
|
|
30
|
+
if key != "_FQ_Item__key"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@db0.memo
|
|
35
|
+
class FiFoQueue:
|
|
36
|
+
"""FIFO queue container backed by a dbzero index."""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self.__items = db0.index()
|
|
40
|
+
self.__next_key = 0
|
|
41
|
+
|
|
42
|
+
def is_empty(self) -> bool:
|
|
43
|
+
"""Return True when the queue has no items."""
|
|
44
|
+
return len(self.__items) == 0
|
|
45
|
+
|
|
46
|
+
def push_back(self, **kwargs) -> None:
|
|
47
|
+
"""Append a single element to the back of the queue."""
|
|
48
|
+
prefix = db0.get_prefix_of(self).name
|
|
49
|
+
self.__items.add(self.__next_key, FQ_Item(self.__next_key, prefix=prefix, **kwargs))
|
|
50
|
+
self.__next_key += 1
|
|
51
|
+
|
|
52
|
+
def has_item(
|
|
53
|
+
self,
|
|
54
|
+
filter: Callable, # pylint: disable=redefined-builtin
|
|
55
|
+
max_scan: int = 100,
|
|
56
|
+
) -> Optional[bool]:
|
|
57
|
+
"""Return whether any queued item matches filter within max_scan items."""
|
|
58
|
+
scanned = 0
|
|
59
|
+
for item in self.__items.select():
|
|
60
|
+
if scanned >= max_scan:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
scanned += 1
|
|
64
|
+
if filter(**item.to_dict()):
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def pop_front(
|
|
70
|
+
self,
|
|
71
|
+
count: int,
|
|
72
|
+
filter: Callable | None = None, # pylint: disable=redefined-builtin
|
|
73
|
+
) -> List[Dict]:
|
|
74
|
+
"""Retrieve and remove up to count first matching elements from the queue."""
|
|
75
|
+
if filter is not None:
|
|
76
|
+
def _item_filter(item):
|
|
77
|
+
return isinstance(item, FQ_Item) and filter(**item.to_dict())
|
|
78
|
+
|
|
79
|
+
query = db0.filter(_item_filter, self.__items.select())
|
|
80
|
+
else:
|
|
81
|
+
query = self.__items.select()
|
|
82
|
+
|
|
83
|
+
results = []
|
|
84
|
+
items_to_remove = []
|
|
85
|
+
|
|
86
|
+
for item in self.__items.sort(query):
|
|
87
|
+
if len(results) >= count:
|
|
88
|
+
break
|
|
89
|
+
items_to_remove.append(item)
|
|
90
|
+
results.append(item.to_dict())
|
|
91
|
+
|
|
92
|
+
for item in items_to_remove:
|
|
93
|
+
self.__items.remove(item.queue_key, item)
|
|
94
|
+
|
|
95
|
+
return results
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Optional dbzero RPC integration for shared model classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _NoRpcAdapter:
|
|
10
|
+
"""No-op adapter used when db0_rpc is not installed."""
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def init(*_args: Any, **_kwargs: Any) -> None:
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def remote(func: Any = None, **_kwargs: Any) -> Any:
|
|
18
|
+
if func is None:
|
|
19
|
+
return lambda wrapped: wrapped
|
|
20
|
+
return func
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_NO_RPC_ADAPTER = _NoRpcAdapter()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@functools.lru_cache(None)
|
|
27
|
+
def _load_rpc() -> Any:
|
|
28
|
+
try:
|
|
29
|
+
import db0_rpc # pylint: disable=import-outside-toplevel
|
|
30
|
+
|
|
31
|
+
return db0_rpc
|
|
32
|
+
except ModuleNotFoundError:
|
|
33
|
+
return _NO_RPC_ADAPTER
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
rpc = _load_rpc()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def has_rpc() -> bool:
|
|
40
|
+
"""Return True when db0_rpc is available."""
|
|
41
|
+
return rpc is not _NO_RPC_ADAPTER
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Reusable date and datetime helpers for dbzero model utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def normalize_start(value: Optional[datetime | date]) -> datetime:
|
|
10
|
+
"""Normalize a start value to datetime, treating None as datetime.min."""
|
|
11
|
+
if value is None:
|
|
12
|
+
return datetime.min
|
|
13
|
+
if isinstance(value, date) and not isinstance(value, datetime):
|
|
14
|
+
return datetime.combine(value, datetime.min.time())
|
|
15
|
+
return value
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_end(value: Optional[datetime | date]) -> datetime:
|
|
19
|
+
"""Normalize an end value to datetime, treating None as datetime.max."""
|
|
20
|
+
if value is None:
|
|
21
|
+
return datetime.max
|
|
22
|
+
if isinstance(value, date) and not isinstance(value, datetime):
|
|
23
|
+
return datetime.combine(value, datetime.max.time())
|
|
24
|
+
return value
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dbzero-modelkit
|
|
3
|
+
Version: 0.1.1.1.0
|
|
4
|
+
Summary: Reusable dbzero-backed data model utilities
|
|
5
|
+
Author-email: Selltime <dev@selltime.ai>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/selltime/dbzero-modelkit
|
|
8
|
+
Project-URL: Repository, https://github.com/selltime/dbzero-modelkit
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: dbzero<0.4,>=0.3.0
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: build>=1.0.0; extra == "dev"
|
|
19
|
+
Requires-Dist: pylint>=3.0; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
21
|
+
|
|
22
|
+
# dbzero-modelkit
|
|
23
|
+
|
|
24
|
+
Reusable dbzero-backed model utilities copied from Selltime workspace projects.
|
|
25
|
+
|
|
26
|
+
This package currently contains standalone copies of common data-model helpers only. It does not yet replace implementations in `selltime`, `statek`, or `kangal`; integration into those projects is planned as separate follow-up work.
|
|
27
|
+
|
|
28
|
+
## Included Models
|
|
29
|
+
|
|
30
|
+
- `ActiveBase` and `ActiveIndex` for active-window objects.
|
|
31
|
+
- `Calendar`, `MonthCalendar`, `get_month_index`, and `get_date_from_month_index` for sparse date calendars.
|
|
32
|
+
- `LanguageCode` and `ML_String` for multilingual strings.
|
|
33
|
+
- `FiFoQueue` and `FQ_Item` for dbzero-backed FIFO queues.
|
|
34
|
+
- `MonthStore` for sparse month-indexed object storage.
|
|
35
|
+
- `ObjectLock` for tag-based object locking.
|
|
36
|
+
|
|
37
|
+
## Testing
|
|
38
|
+
|
|
39
|
+
Run tests from this directory:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
python3 -m pytest -q
|
|
43
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
dbzero_modelkit/__init__.py,sha256=gRGv3UB9gTcTNSnu5vWwEU0iC5mJA7VlOHHogu_yoxc,705
|
|
2
|
+
dbzero_modelkit/active.py,sha256=CSjyCuAidbSN-P14t5s8j1taECIqItYzDo99bPhs4h8,8571
|
|
3
|
+
dbzero_modelkit/calendars.py,sha256=9jFy_ubYjizFIVu0cnXRmpWCj97sluekPSLHrnjDOQg,6745
|
|
4
|
+
dbzero_modelkit/language.py,sha256=dh5V_N71JH4DXaw4lgA2G6ndrPLwhFiVckiyrWZWTro,1945
|
|
5
|
+
dbzero_modelkit/month_store.py,sha256=rf2mW7im9kPqJ7iU5j34Iq5S8IpSCpcQk1OovhKpgOU,4647
|
|
6
|
+
dbzero_modelkit/object_lock.py,sha256=Z7qxSLmsrGR1OwYxiaqcP_0NTPYIhz5PmbKFP7SvkVY,1504
|
|
7
|
+
dbzero_modelkit/queues.py,sha256=JMxFhMRsP-Jtzsz8z9A_FtdD66HrCh6APJeqXgwavPg,2779
|
|
8
|
+
dbzero_modelkit/rpc_integration.py,sha256=PJRfK7_iuV2VvW0d9SXPzbH7m7TjhxhZo1rYSrqV7J0,861
|
|
9
|
+
dbzero_modelkit/time_utils.py,sha256=jFAL5U8CwgzT863PmvMUnOCTDXzEpmT1lUNGZanN1CY,851
|
|
10
|
+
dbzero_modelkit-0.1.1.1.0.dist-info/METADATA,sha256=e3K08P9skp5cJWmR5Rd3AZgmzr1L6xH1tf8kImrmGb4,1625
|
|
11
|
+
dbzero_modelkit-0.1.1.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
dbzero_modelkit-0.1.1.1.0.dist-info/top_level.txt,sha256=kHjhnDkuK5LBnO9XSQ7A4PyY9cdvPRYoVjdWS9UlWXE,16
|
|
13
|
+
dbzero_modelkit-0.1.1.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dbzero_modelkit
|