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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ dbzero_modelkit