dbzero-modelkit 0.1.1.1.0__tar.gz

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,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,22 @@
1
+ # dbzero-modelkit
2
+
3
+ Reusable dbzero-backed model utilities copied from Selltime workspace projects.
4
+
5
+ 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.
6
+
7
+ ## Included Models
8
+
9
+ - `ActiveBase` and `ActiveIndex` for active-window objects.
10
+ - `Calendar`, `MonthCalendar`, `get_month_index`, and `get_date_from_month_index` for sparse date calendars.
11
+ - `LanguageCode` and `ML_String` for multilingual strings.
12
+ - `FiFoQueue` and `FQ_Item` for dbzero-backed FIFO queues.
13
+ - `MonthStore` for sparse month-indexed object storage.
14
+ - `ObjectLock` for tag-based object locking.
15
+
16
+ ## Testing
17
+
18
+ Run tests from this directory:
19
+
20
+ ```bash
21
+ python3 -m pytest -q
22
+ ```
@@ -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])