lythonic 0.0.1__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.
lythonic/__init__.py ADDED
@@ -0,0 +1,198 @@
1
+ import logging
2
+ import sys
3
+ from inspect import isclass, iscoroutinefunction, isfunction, ismodule
4
+ from types import ModuleType
5
+ from typing import Annotated, Any, Generic, TypeVar, final
6
+
7
+ from pydantic import (
8
+ AfterValidator,
9
+ GetCoreSchemaHandler,
10
+ PlainSerializer,
11
+ WithJsonSchema,
12
+ )
13
+ from pydantic_core import CoreSchema, core_schema
14
+ from typing_extensions import override
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+
19
+ class GlobalRef:
20
+ """
21
+ >>> ref = GlobalRef('lythonic:GlobalRef')
22
+ >>> ref
23
+ GlobalRef('lythonic:GlobalRef')
24
+ >>> ref.get_instance().__name__
25
+ 'GlobalRef'
26
+ >>> ref.is_module()
27
+ False
28
+ >>> ref.get_module().__name__
29
+ 'lythonic'
30
+ >>> grgr = GlobalRef(GlobalRef)
31
+ >>> grgr
32
+ GlobalRef('lythonic:GlobalRef')
33
+ >>> grgr.get_instance()
34
+ <class 'lythonic.GlobalRef'>
35
+ >>> grgr.is_class()
36
+ True
37
+ >>> grgr.is_function()
38
+ False
39
+ >>> grgr.is_module()
40
+ False
41
+ >>> uref = GlobalRef('lythonic:')
42
+ >>> uref.is_module()
43
+ True
44
+ >>> uref.get_module().__name__
45
+ 'lythonic'
46
+ >>> uref = GlobalRef('lythonic')
47
+ >>> uref.is_module()
48
+ True
49
+ >>> uref = GlobalRef(uref)
50
+ >>> uref.is_module()
51
+ True
52
+ >>> uref.get_module().__name__
53
+ 'lythonic'
54
+ >>> uref = GlobalRef(uref.get_module())
55
+ >>> uref.is_module()
56
+ True
57
+ >>> uref.get_module().__name__
58
+ 'lythonic'
59
+ """
60
+
61
+ module: str
62
+ name: str
63
+
64
+ def __init__(self, s: Any) -> None:
65
+ if isinstance(s, GlobalRef):
66
+ self.module, self.name = s.module, s.name
67
+ elif ismodule(s):
68
+ self.module, self.name = s.__name__, ""
69
+ elif isclass(s) or isfunction(s):
70
+ self.module, self.name = s.__module__, s.__name__
71
+ else:
72
+ split = s.split(":")
73
+ if len(split) == 1:
74
+ assert bool(split[0]), f"is {repr(s)} empty?"
75
+ split.append("")
76
+ else:
77
+ assert len(split) == 2, f"too many ':' in: {repr(s)}"
78
+ self.module, self.name = split
79
+
80
+ @override
81
+ def __str__(self):
82
+ return f"{self.module}:{self.name}"
83
+
84
+ @override
85
+ def __repr__(self):
86
+ return f"{self.__class__.__name__}({repr(str(self))})"
87
+
88
+ def get_module(self) -> ModuleType:
89
+ return __import__(self.module, fromlist=[""])
90
+
91
+ def is_module(self) -> bool:
92
+ return not (self.name)
93
+
94
+ def is_class(self) -> bool:
95
+ return not (self.is_module()) and isclass(self.get_instance())
96
+
97
+ def is_function(self) -> bool:
98
+ return not (self.is_module()) and isfunction(self.get_instance())
99
+
100
+ def is_async(self) -> bool:
101
+ if self.is_module():
102
+ return False
103
+ if self.is_class():
104
+ return iscoroutinefunction(self.get_instance().__call__)
105
+ return iscoroutinefunction(self.get_instance())
106
+
107
+ def get_instance(self) -> Any:
108
+ assert not self.is_module(), f"{repr(self)}.get_module() only"
109
+ attr = getattr(self.get_module(), self.name)
110
+ return attr
111
+
112
+ @classmethod
113
+ def __get_pydantic_core_schema__(
114
+ cls, source_type: Any, handler: GetCoreSchemaHandler
115
+ ) -> CoreSchema:
116
+ return core_schema.no_info_after_validator_function(cls, handler(str))
117
+
118
+
119
+ GRef = Annotated[
120
+ GlobalRef,
121
+ PlainSerializer(str, return_type=str),
122
+ AfterValidator(GlobalRef),
123
+ WithJsonSchema({"type": "string"}, mode="serialization"),
124
+ ]
125
+
126
+
127
+ def get_module(name: str) -> ModuleType:
128
+ """
129
+ >>> type(get_module('lythonic'))
130
+ <class 'module'>
131
+ >>> get_module('lythonic.c99')
132
+ Traceback (most recent call last):
133
+ ...
134
+ ModuleNotFoundError: No module named 'lythonic.c99'
135
+ """
136
+ if name in sys.modules:
137
+ return sys.modules[name]
138
+ return __import__(name, fromlist=[""])
139
+
140
+
141
+ TOk = TypeVar("TOk")
142
+ TErr = TypeVar("TErr")
143
+
144
+
145
+ @final
146
+ class Result(Generic[TOk, TErr]):
147
+ """
148
+ A generic Result type inspired by Rust, representing either success (Ok) or failure (Err).
149
+ """
150
+
151
+ _ok: TOk | None
152
+ _err: TErr | None
153
+
154
+ __slots__ = ("_ok", "_err")
155
+
156
+ def __init__(self, ok: TOk | None = None, err: TErr | None = None) -> None:
157
+ assert (ok is None and err is not None) or (ok is not None and err is None), (
158
+ "Result can only have one of ok or err set."
159
+ )
160
+ self._ok = ok
161
+ self._err = err
162
+
163
+ @classmethod
164
+ def Ok(cls, value: TOk) -> "Result[TOk, TErr]":
165
+ return cls(ok=value)
166
+
167
+ @classmethod
168
+ def Err(cls, error: TErr) -> "Result[TOk, TErr]":
169
+ return cls(err=error)
170
+
171
+ def is_ok(self) -> bool:
172
+ return self._ok is not None
173
+
174
+ def is_err(self) -> bool:
175
+ return self._err is not None
176
+
177
+ def ok(self) -> TOk | None:
178
+ return self._ok
179
+
180
+ def err(self) -> TErr | None:
181
+ return self._err
182
+
183
+ def unwrap(self) -> TOk:
184
+ if self._ok is not None:
185
+ return self._ok
186
+ raise ValueError(f"Called unwrap on Err: {self._err}")
187
+
188
+ def unwrap_err(self) -> TErr:
189
+ if self._err is not None:
190
+ return self._err
191
+ raise ValueError(f"Called unwrap_err on Ok: {self._ok}")
192
+
193
+ @override
194
+ def __repr__(self):
195
+ if self.is_ok():
196
+ return f"Ok({self._ok!r})"
197
+ else:
198
+ return f"Err({self._err!r})"
lythonic/logic.py ADDED
@@ -0,0 +1,23 @@
1
+ # @final
2
+ # class Logic:
3
+ # def __init__(self, config: dict[str, Any], default_ref: str | GlobalRef | None = None) -> None:
4
+ # config = dict(config)
5
+ # try:
6
+ # if default_ref is not None:
7
+ # ref = GlobalRef(config.pop("ref$", default_ref))
8
+ # else:
9
+ # ref = GlobalRef(config.pop("ref$"))
10
+ # self.async_call = ref.is_async()
11
+ # if ref.is_function():
12
+ # self.instance = None
13
+ # self.call = ref.get_instance()
14
+ # assert config == {}, f"Unexpected entries {config}"
15
+ # elif ref.is_class():
16
+ # cls = ref.get_instance()
17
+ # self.call = self.instance = cls(config)
18
+ # else:
19
+ # raise AssertionError(f"Invalid logic {ref} in config {config}") # pragma: no cover
20
+ # except BaseException:
21
+ # log.error(f"Error in {config}")
22
+ # raise
23
+
lythonic/misc.py ADDED
@@ -0,0 +1,21 @@
1
+ import shutil
2
+ from pathlib import Path
3
+
4
+
5
+ def ensure_dir(dir: Path | str) -> Path:
6
+ dir = Path(dir)
7
+ dir.mkdir(parents=True, exist_ok=True)
8
+ return dir
9
+
10
+
11
+ def tabula_rasa_path(p: Path | str) -> Path:
12
+ """Make sure that path does not exists, but parent dir does"""
13
+ p = Path(p)
14
+ if p.exists():
15
+ if p.is_dir():
16
+ shutil.rmtree(p)
17
+ else:
18
+ p.unlink()
19
+ if not p.parent.is_dir():
20
+ ensure_dir(p.parent)
21
+ return p
lythonic/periodic.py ADDED
@@ -0,0 +1,380 @@
1
+ import asyncio
2
+ import inspect
3
+ import logging
4
+ import sys
5
+ import time as tt
6
+ from collections.abc import Callable
7
+ from typing import Annotated, Any, final
8
+
9
+ from pydantic import BeforeValidator, PlainSerializer, WithJsonSchema
10
+ from typing_extensions import override
11
+
12
+ from lythonic.types import str_or_none
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ import re
17
+ from datetime import UTC, date, datetime, timedelta
18
+ from enum import Enum
19
+ from pathlib import Path
20
+
21
+ YEAR_IN_DAYS = 365.256
22
+ SECONDS_IN_DAY = 24 * 60 * 60
23
+
24
+
25
+ EPOCH_ZERO = datetime(1970, 1, 1, tzinfo=UTC)
26
+
27
+
28
+ def total_microseconds(d: timedelta) -> int:
29
+ return (d.days * SECONDS_IN_DAY + d.seconds) * 1_000_000 + d.microseconds
30
+
31
+
32
+ def stamp_time() -> datetime:
33
+ """return the current time in UTC
34
+ >>> stamp_time().tzinfo
35
+ datetime.timezone.utc
36
+ """
37
+ return datetime.now(UTC)
38
+
39
+
40
+ def dt_to_bytes(dt: datetime) -> bytes:
41
+ """Convert datetime to bytes
42
+ >>> dt_to_bytes(datetime( 1900,1,1,0,0,0))
43
+ b'\\xff\\xf8&\\xef\\xb7C`\\x00'
44
+ >>> dt_to_bytes(datetime( 2000,1,1,0,0,0))
45
+ b'\\x00\\x03]\\x01;7\\xe0\\x00'
46
+ """
47
+ if dt.tzinfo is None:
48
+ dt = dt.replace(tzinfo=UTC)
49
+ mics = total_microseconds(dt - EPOCH_ZERO)
50
+ return mics.to_bytes(8, "big", signed=True)
51
+
52
+
53
+ def dt_from_bytes(b: bytes) -> datetime:
54
+ """Convert bytes to datetime
55
+ >>> dt_from_bytes(b'\\xff\\xf8&\\xef\\xb7C`\\x00')
56
+ datetime.datetime(1900, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
57
+ >>> dt_from_bytes(b'\\x00\\x03]\\x01;7\\xe0\\x00')
58
+ datetime.datetime(2000, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
59
+ """
60
+ mics = int.from_bytes(b, "big", signed=True)
61
+ return EPOCH_ZERO + timedelta(microseconds=mics)
62
+
63
+
64
+ DT_BYTES_LENGTH = len(dt_to_bytes(stamp_time()))
65
+
66
+
67
+ class SimulatedTime:
68
+ """
69
+ >>> st = SimulatedTime()
70
+ >>> st.get_datetime().tzinfo
71
+ datetime.timezone.utc
72
+ >>> cmp = lambda ss: abs((st.get_datetime()-stamp_time()).total_seconds()-ss) < 1e-3
73
+ >>> cmp(0)
74
+ True
75
+ >>> st.set_offset(timedelta(days=1))
76
+ >>> cmp(86400)
77
+ True
78
+ >>> st.set_offset(timedelta(days=1).total_seconds())
79
+ >>> cmp(86400)
80
+ True
81
+ >>> st.set_now(stamp_time() + timedelta(days=1))
82
+ >>> cmp(86400)
83
+ True
84
+ >>> st.set_now( (stamp_time() - timedelta(days=1)).timestamp() )
85
+ >>> cmp(-86400)
86
+ True
87
+ >>> st.is_real_time()
88
+ False
89
+ >>> st.reset()
90
+ >>> st.is_real_time()
91
+ True
92
+ """
93
+
94
+ offset: float
95
+
96
+ def __init__(self, offset: float = 0.0) -> None:
97
+ self.offset = offset
98
+
99
+ def time(self):
100
+ return tt.time() + self.offset
101
+
102
+ def set_offset(self, offset: timedelta | float):
103
+ if isinstance(offset, timedelta):
104
+ self.offset = offset.total_seconds()
105
+ else:
106
+ self.offset = offset
107
+
108
+ def set_now(self, dt: datetime | float):
109
+ if isinstance(dt, datetime):
110
+ epoch = dt.timestamp()
111
+ else:
112
+ epoch = dt
113
+ self.offset = epoch - tt.time()
114
+
115
+ def reset(self):
116
+ self.offset = 0.0
117
+
118
+ def is_real_time(self):
119
+ return self.offset == 0.0
120
+
121
+ def get_datetime(self) -> datetime:
122
+ return datetime.fromtimestamp(self.time(), tz=UTC)
123
+
124
+
125
+ stime: SimulatedTime = SimulatedTime()
126
+
127
+
128
+ class IntervalUnit(Enum):
129
+ """
130
+ >>> IntervalUnit.D
131
+ IntervalUnit.D
132
+ """
133
+
134
+ D = 1
135
+ W = 7
136
+ M = YEAR_IN_DAYS / 12
137
+ Q = YEAR_IN_DAYS / 4
138
+ Y = YEAR_IN_DAYS
139
+
140
+ @classmethod
141
+ def from_string(cls, n: str) -> "IntervalUnit":
142
+ return cls[n.upper()]
143
+
144
+ def timedelta(self) -> timedelta:
145
+ return timedelta(days=self.value)
146
+
147
+ @override
148
+ def __str__(self) -> str:
149
+ return self.name
150
+
151
+ @override
152
+ def __repr__(self) -> str:
153
+ return f"IntervalUnit.{str(self)}"
154
+
155
+
156
+ @final
157
+ class Interval:
158
+ _P = "".join(p.name for p in IntervalUnit)
159
+ FREQ_RE = re.compile(r"(\d+)([" + _P + _P.lower() + "])")
160
+
161
+ multiplier: int
162
+ period: IntervalUnit
163
+
164
+ def __init__(self, multiplier: int, period: IntervalUnit) -> None:
165
+ self.multiplier = multiplier
166
+ self.period = period
167
+
168
+ @classmethod
169
+ def from_string_safe(cls, s: "Interval|str|None") -> "Interval | None":
170
+ if s is None:
171
+ return None
172
+ if isinstance(s, Interval):
173
+ return s
174
+ return cls.from_string(s)
175
+
176
+ @classmethod
177
+ def from_string(cls, s: str) -> "Interval":
178
+ m = cls.matcher(s)
179
+ if m:
180
+ n, p = m.groups()
181
+ return cls(int(n), IntervalUnit.from_string(p))
182
+ else:
183
+ raise ValueError("Invalid frequency string", s)
184
+
185
+ @classmethod
186
+ def matcher(cls, s: str) -> re.Match[str] | None:
187
+ return re.match(cls.FREQ_RE, s)
188
+
189
+ def timedelta(self) -> timedelta:
190
+ return self.multiplier * self.period.timedelta()
191
+
192
+ def match(self, d: date, as_of: date) -> bool:
193
+ return d <= as_of and d + self.timedelta() > as_of
194
+
195
+ def find_file(
196
+ self,
197
+ path: Path,
198
+ as_of: date | datetime,
199
+ suffix: str = ".csv",
200
+ ) -> Path | None:
201
+ ff = list(
202
+ reversed(
203
+ sorted(
204
+ (date_from_name(f.name), f)
205
+ for f in path.glob(f"*{suffix}")
206
+ if re.match(r"^\d{8}", f.name[:8])
207
+ )
208
+ )
209
+ )
210
+ for d, f in ff:
211
+ if d <= as_of:
212
+ if self.match(d, as_of):
213
+ return f
214
+ else:
215
+ break
216
+ return None
217
+
218
+ @override
219
+ def __str__(self) -> str:
220
+ return f"{self.multiplier}{self.period}"
221
+
222
+ @override
223
+ def __repr__(self) -> str:
224
+ return f"Interval({self.multiplier}, {self.period!r})"
225
+
226
+
227
+ IntervalSafe = Annotated[
228
+ Interval | str | None,
229
+ BeforeValidator(Interval.from_string_safe),
230
+ PlainSerializer(str_or_none, return_type=str),
231
+ WithJsonSchema({"anyOf": [{"type": "string"}, {"type": "null"}]}),
232
+ ]
233
+
234
+
235
+ def date_from_name(s: str) -> date:
236
+ return date(int(s[:4]), int(s[4:6]), int(s[6:8]))
237
+
238
+
239
+ class Moment:
240
+ """
241
+ >>> m = Moment.start()
242
+ >>> m = m.capture("instant")
243
+ >>> tt.sleep(1)
244
+ >>> m = m.capture("a second")
245
+ >>> s = m.chain()
246
+ >>> s.startswith('[start] 0.0'), 's-> [instant] 1.' in s , s.endswith('s-> [a second]')
247
+ (True, True, True)
248
+ """
249
+
250
+ time: float
251
+ name: str
252
+ prev: "Moment | None"
253
+
254
+ def __init__(self, name: str, prev: "Moment | None" = None) -> None:
255
+ self.time = tt.time()
256
+ self.name = name
257
+ self.prev = prev
258
+
259
+ @staticmethod
260
+ def start():
261
+ """capture the starting moment"""
262
+ return Moment("start")
263
+
264
+ def capture(self, name: str):
265
+ """capture the named moment relative to this one"""
266
+ return Moment(name, self)
267
+
268
+ def elapsed(self):
269
+ """return time in seconds since previous moment"""
270
+ if self.prev is None:
271
+ return 0
272
+ return self.time - self.prev.time
273
+
274
+ @override
275
+ def __str__(self):
276
+ return (
277
+ f" {self.elapsed():.3f}s-> [{self.name}]" if self.prev is not None else f"[{self.name}]"
278
+ )
279
+
280
+ def chain(self) -> str:
281
+ return str(self) if self.prev is None else self.prev.chain() + str(self)
282
+
283
+
284
+ class PeriodicTask:
285
+ freq: int
286
+ logic: Callable[[], Any]
287
+ last_run: float | None = None
288
+
289
+ def __init__(self, freq: int, logic: Callable[[], Any]) -> None:
290
+ self.freq = freq
291
+ self.logic = logic
292
+
293
+ def is_due(self):
294
+ return self.last_run is None or stime.time() - self.last_run > self.freq
295
+
296
+
297
+ def gcd_pair(a: int, b: int) -> int:
298
+ """
299
+ >>> gcd_pair(4, 6)
300
+ 2
301
+ >>> gcd_pair(6*15, 6*7)
302
+ 6
303
+ >>> gcd_pair(6,35)
304
+ 1
305
+ """
306
+ return abs(a) if b == 0 else gcd_pair(b, a % b)
307
+
308
+
309
+ def gcd(*nn: int) -> int:
310
+ """
311
+ >>> gcd(4)
312
+ 4
313
+ >>> gcd(4, 6)
314
+ 2
315
+ >>> gcd(6*15, 6*7)
316
+ 6
317
+ >>> gcd(6,35)
318
+ 1
319
+ >>> gcd(6*15, 6*7, 6*5)
320
+ 6
321
+ >>> gcd(6*15, 6*7, 10)
322
+ 2
323
+ >>> gcd(6*15, 6*7, 35)
324
+ 1
325
+ >>> gcd()
326
+ Traceback (most recent call last):
327
+ ...
328
+ IndexError: tuple index out of range
329
+ """
330
+ r = nn[0]
331
+ for i in range(1, len(nn)):
332
+ r = gcd_pair(r, nn[i])
333
+ return r
334
+
335
+
336
+ def _collect_nothing(n: str, x: Any): # pyright: ignore [reportUnusedParameter]
337
+ pass # pragma: no cover
338
+
339
+
340
+ async def run_all(
341
+ *tasks: PeriodicTask,
342
+ shutdown_event: asyncio.Event | None = None,
343
+ collect_results: Callable[[str, Any], None] = _collect_nothing,
344
+ ):
345
+ if shutdown_event is None:
346
+ shutdown_event = asyncio.Event()
347
+ if len(tasks) == 0:
348
+ log.warning("No tasks to run")
349
+ return
350
+ tick = gcd(*[t.freq for t in tasks])
351
+ loop = asyncio.get_running_loop()
352
+
353
+ while True:
354
+ start = stime.time()
355
+ for t in tasks:
356
+ if t.is_due():
357
+ t.last_run = start
358
+
359
+ try:
360
+ if inspect.iscoroutinefunction(t.logic):
361
+ r = await t.logic()
362
+ else:
363
+ r = await loop.run_in_executor(None, t.logic)
364
+ except (Exception, asyncio.CancelledError) as _:
365
+ r = sys.exc_info()
366
+ collect_results(t.logic.__name__, r)
367
+ if shutdown_event.is_set():
368
+ return
369
+ elapsed = stime.time() - start
370
+ await asyncio.sleep(tick - elapsed if elapsed < tick else 0)
371
+
372
+
373
+ def adjust_as_of_date(as_of_date: date | None) -> date:
374
+ """
375
+ >>> adjust_as_of_date(None) == date.today()
376
+ True
377
+ >>> adjust_as_of_date(date(2021, 1, 1)) == date(2021, 1, 1)
378
+ True
379
+ """
380
+ return date.today() if as_of_date is None else as_of_date
lythonic/py.typed ADDED
File without changes