mspu 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.
mspu/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+
3
+ try:
4
+ __version__ = version('mspu')
5
+ except PackageNotFoundError:
6
+ __version__ = '0.0.0' # Fallback for development
mspu/data/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .rand_df import gen_rand_df
2
+
3
+ __all__ = [
4
+ 'gen_rand_df',
5
+ ]
mspu/data/rand_df.py ADDED
@@ -0,0 +1,346 @@
1
+ import string
2
+ import numpy as np
3
+ import pandas as pd
4
+ from collections.abc import Iterable
5
+
6
+
7
+ def gen_rand_strs(
8
+ rng: np.random.Generator,
9
+ str_cnt: int,
10
+ str_len: tuple[int, int],
11
+ str_chars: list[str],
12
+ ) -> list[str]:
13
+ str_lens = rng.integers(
14
+ low=str_len[0], high=str_len[1], size=str_cnt, endpoint=True
15
+ )
16
+ rand_strs = [''.join(rng.choice(str_chars, size=str_len)) for str_len in str_lens]
17
+ return rand_strs
18
+
19
+
20
+ def gen_str_vals(
21
+ size: int,
22
+ rng: np.random.Generator,
23
+ str_cnt: int = None,
24
+ str_len: int | tuple[int, int] = None,
25
+ str_chars: list[str] = None,
26
+ col_strs: list[str] = None,
27
+ ) -> np.ndarray:
28
+ if str_cnt is None:
29
+ str_cnt = 10
30
+ if str_len is None:
31
+ str_len = (5, 5)
32
+ elif isinstance(str_len, int):
33
+ str_len = (str_len, str_len)
34
+ if col_strs is None:
35
+ if str_chars is None:
36
+ str_chars = [c for c in string.ascii_letters + string.digits]
37
+ col_strs = gen_rand_strs(rng, str_cnt, str_len, str_chars)
38
+ val = rng.choice(col_strs, size=size)
39
+ return val
40
+
41
+
42
+ def gen_ts_vals(
43
+ size: int,
44
+ rng: np.random.Generator,
45
+ start_date: str = None,
46
+ end_date: str = None,
47
+ freq: str = None,
48
+ random: bool = False,
49
+ ) -> np.ndarray:
50
+ if start_date is None:
51
+ start_date = '2024-01-01'
52
+ if end_date is None:
53
+ end_date = '2025-01-01'
54
+ if freq is None:
55
+ freq = 'D'
56
+ if random is None:
57
+ random = False
58
+ val = pd.date_range(start_date, end_date, freq=freq, inclusive='left')[:size]
59
+ if random:
60
+ val = rng.choice(val, size=size)
61
+ return val
62
+
63
+
64
+ def gen_num_vals(
65
+ size: int,
66
+ rng: np.random.Generator,
67
+ low: int | float = None,
68
+ high: int | float = None,
69
+ dtype: str = None,
70
+ ) -> np.ndarray:
71
+ if low is None:
72
+ low = 0
73
+ if high is None:
74
+ high = 2
75
+ func = rng.integers if dtype[0] == 'i' else rng.uniform
76
+ vals = func(low=low, high=high, size=size)
77
+ return vals
78
+
79
+
80
+ def gen_missing_vals(
81
+ vals: np.ndarray,
82
+ rng: np.random._generator.Generator,
83
+ dtype: str,
84
+ missing_pct: float = None,
85
+ ) -> np.ndarray:
86
+ if missing_pct is None or missing_pct <= 0 or missing_pct >= 1:
87
+ return vals
88
+ if dtype == 's':
89
+ missing_val = None
90
+ elif dtype == 't':
91
+ missing_val = np.datetime64('NaT')
92
+ else:
93
+ missing_val = np.nan
94
+ if dtype == 'i':
95
+ vals = vals.astype(np.float64)
96
+ mask = rng.uniform(size=len(vals)) <= missing_pct
97
+ vals[mask] = missing_val
98
+ return vals
99
+
100
+
101
+ def sanitize_parameters(
102
+ name_prefix: str,
103
+ params: dict,
104
+ par_names: list[str],
105
+ ):
106
+ if isinstance(params, int):
107
+ cnt = params
108
+ par = {}
109
+ else:
110
+ cnt = params['count']
111
+ par = {
112
+ k: (
113
+ list(v) + [None] * (cnt - len(v))
114
+ if isinstance(v, Iterable) and not isinstance(v, str)
115
+ else [v] * cnt
116
+ )
117
+ for k, v in params.items()
118
+ }
119
+ if name_prefix in ('i', 'f'):
120
+ par_names += ['dtype']
121
+ par['dtype'] = [name_prefix] * cnt
122
+ default_val = [None] * cnt
123
+ parameters = [
124
+ {key: par.get(key, default_val)[i] for key in par_names} for i in range(cnt)
125
+ ]
126
+ col_names = par.get('name')
127
+ if col_names is None or col_names.count(None) > 1 or col_names.count('') > 1:
128
+ col_names = [f'{name_prefix}{i}' for i in range(1, cnt + 1)]
129
+ col_missing_pcts = par.get('missing_pct', default_val)
130
+ return col_names, parameters, col_missing_pcts
131
+
132
+
133
+ def gen_rand_df(
134
+ nrow: int,
135
+ str_cols: dict = None,
136
+ ts_cols: dict = None,
137
+ int_cols: dict = None,
138
+ float_cols: dict = None,
139
+ rand_seed: int = 11,
140
+ ) -> pd.DataFrame:
141
+ """
142
+ Generate a random DataFrame with specified column types and parameters.
143
+
144
+ Parameters
145
+ ----------
146
+ nrow : int
147
+ Number of rows in the generated DataFrame.
148
+ str_cols : dict or int, optional
149
+ Parameters for string columns. If an integer is provided, it specifies the
150
+ number of string columns to generate with default parameters. If a dictionary
151
+ is provided, it should include the following keys:
152
+
153
+ - count: number of columns
154
+ - name: list of column names
155
+ - str_len: int or tuple of (min, max) string length
156
+ - str_chars: list of characters to use in strings
157
+ - col_strs: list of lists of strings to sample from for each column (if
158
+ provided, overrides str_len and str_chars)
159
+ ts_cols : dict or int, optional
160
+ Parameters for timestamp columns. If an integer is provided, it specifies the
161
+ number of timestamp columns to generate with default parameters. If a dictionary
162
+ is provided, it should include the following keys:
163
+
164
+ - count: number of columns
165
+ - name: list of column names
166
+ - start_date: start date for timestamp generation
167
+ - end_date: end date for timestamp generation
168
+ - freq: frequency for timestamp generation (e.g. 'D' for daily)
169
+ - random: whether to sample timestamps randomly from the generated range
170
+ int_cols : dict or int, optional
171
+ Parameters for integer columns. If an integer is provided, it specifies the
172
+ number of integer columns to generate with default parameters. If a dictionary
173
+ is provided, it should include the following keys:
174
+
175
+ - count: number of columns
176
+ - name: list of column names
177
+ - low: lower bound for random number generation
178
+ - high: upper bound for random number generation
179
+ - missing_pct: percentage of values to set as missing (NaN or None)
180
+ float_cols : dict or int, optional
181
+ Parameters for float columns. If an integer is provided, it specifies the
182
+ number of float columns to generate with default parameters. If a dictionary
183
+ is provided, it should include the following keys:
184
+
185
+ - count: number of columns
186
+ - name: list of column names
187
+ - low: lower bound for random number generation
188
+ - high: upper bound for random number generation
189
+ - missing_pct: percentage of values to set as missing (NaN or None)
190
+ rand_seed : int, optional
191
+ Random seed for reproducibility (default is 11).
192
+
193
+ Returns
194
+ -------
195
+ pd.DataFrame
196
+ A DataFrame with the specified random data.
197
+
198
+ Examples
199
+ --------
200
+
201
+ Simply specify the number of columns for each type.
202
+
203
+ >>> import pandas as pd
204
+ >>> from mspu.data import gen_rand_df
205
+ >>> from mspu.pandas import pd_ht
206
+ >>> pd.DataFrame.ht = pd_ht
207
+ >>> df = gen_rand_df(
208
+ ... nrow=365 * 24 * 60,
209
+ ... str_cols=1,
210
+ ... ts_cols=2,
211
+ ... int_cols=1,
212
+ ... float_cols=2,
213
+ ... )
214
+ >>> df.ht()
215
+ shape: (525600, 6)
216
+ s1 t1 t2 i1 f1 f2
217
+ 0 IZD8v 2024-01-01 2024-01-01 0 1.593760 1.648840
218
+ 1 P9r1i 2024-01-02 2024-01-02 0 1.622772 1.943180
219
+ 525598 iU68M NaT NaT 1 1.277124 0.093818
220
+ 525599 P9r1i NaT NaT 1 1.248407 0.601518
221
+
222
+ Provide detailed parameters for each column type.
223
+
224
+ >>> import pandas as pd
225
+ >>> from mspu.data import gen_rand_df
226
+ >>> from mspu.pandas import pd_ht
227
+ >>> pd.DataFrame.ht = pd_ht
228
+ >>> d2 = gen_rand_df(
229
+ ... nrow=10,
230
+ ... str_cols={
231
+ ... 'count': 2,
232
+ ... 'name': ['country', 'color'],
233
+ ... 'str_len': [3, (3, 9)],
234
+ ... 'str_cnt': [2, 5],
235
+ ... 'col_strs': [['UK', 'US', 'AU'], ['blue', 'black', 'red']],
236
+ ... },
237
+ ... ts_cols={
238
+ ... 'count': 2,
239
+ ... 'name': ['start_date', 'end_date'],
240
+ ... 'start_date': ['2020-01-01', '2024-01-01'],
241
+ ... 'end_date': ['2021-01-01', '2025-01-01'],
242
+ ... 'freq': 'QS',
243
+ ... 'random': False,
244
+ ... },
245
+ ... int_cols={
246
+ ... 'count': 1,
247
+ ... 'name': ['quantity'],
248
+ ... 'low': [0],
249
+ ... 'high': [100],
250
+ ... 'missing_pct': [0.3],
251
+ ... },
252
+ ... float_cols={
253
+ ... 'count': 2,
254
+ ... 'name': ['price', 'charge'],
255
+ ... 'low': [1, 0.1],
256
+ ... 'high': [100, 0.9],
257
+ ... 'missing_pct': [0.3, 0.2],
258
+ ... },
259
+ ... )
260
+ >>> d2.ht(1)
261
+ shape: (10, 7)
262
+ country color start_date end_date quantity price charge
263
+ 0 UK black 2020-01-01 2024-01-01 86.0 NaN 0.657889
264
+ 9 UK black NaT NaT 13.0 43.564921 0.776712
265
+ """
266
+ col_types = ['s', 't', 'i', 'f']
267
+ col_params = [str_cols, ts_cols, int_cols, float_cols]
268
+ funcs = [gen_str_vals, gen_ts_vals, gen_num_vals, gen_num_vals]
269
+ par_names = [
270
+ ['str_cnt', 'str_len', 'str_chars', 'col_strs'],
271
+ ['start_date', 'end_date', 'freq', 'random'],
272
+ ['low', 'high'],
273
+ ['low', 'high'],
274
+ ]
275
+ dfs = []
276
+ rand_rng = np.random.default_rng(seed=rand_seed)
277
+ for i, params in enumerate(col_params):
278
+ if params is None:
279
+ continue
280
+ col_names, col_params, col_missing_pcts = sanitize_parameters(
281
+ col_types[i], params, par_names[i]
282
+ )
283
+ df = pd.DataFrame(
284
+ {
285
+ col: gen_missing_vals(
286
+ funcs[i](nrow, rand_rng, **col_params[j]),
287
+ rand_rng,
288
+ col_types[i],
289
+ col_missing_pcts[j],
290
+ )
291
+ for j, col in enumerate(col_names)
292
+ }
293
+ )
294
+ dfs.append(df)
295
+ if len(dfs) == 0:
296
+ df = pd.DataFrame()
297
+ else:
298
+ df = pd.concat(dfs, axis=1)
299
+ return df
300
+
301
+
302
+ if __name__ == '__main__':
303
+ # example 1
304
+ df = gen_rand_df(
305
+ nrow=365 * 24 * 60,
306
+ str_cols=1,
307
+ ts_cols=2,
308
+ int_cols=1,
309
+ float_cols=2,
310
+ )
311
+ print(df[:2])
312
+
313
+ # example 2
314
+ d2 = gen_rand_df(
315
+ nrow=10,
316
+ str_cols={
317
+ 'count': 2,
318
+ 'name': ['country', 'color'],
319
+ 'str_len': [3, (3, 9)],
320
+ 'str_cnt': [2, 5],
321
+ 'col_strs': [['UK', 'US', 'AU'], ['blue', 'black', 'red']],
322
+ },
323
+ ts_cols={
324
+ 'count': 2,
325
+ 'name': ['start_date', 'end_date'],
326
+ 'start_date': ['2020-01-01', '2024-01-01'],
327
+ 'end_date': ['2021-01-01', '2025-01-01'],
328
+ 'freq': 'QS',
329
+ 'random': False,
330
+ },
331
+ int_cols={
332
+ 'count': 1,
333
+ 'name': ['quantity'],
334
+ 'low': [0],
335
+ 'high': [100],
336
+ 'missing_pct': [0.3],
337
+ },
338
+ float_cols={
339
+ 'count': 2,
340
+ 'name': ['price', 'charge'],
341
+ 'low': [1, 0.1],
342
+ 'high': [100, 0.9],
343
+ 'missing_pct': [0.3, 0.2],
344
+ },
345
+ )
346
+ print(d2[:3])
File without changes
mspu/datetime/utils.py ADDED
File without changes
mspu/io/__init__.py ADDED
File without changes
mspu/io/file.py ADDED
File without changes
@@ -0,0 +1,10 @@
1
+ from .datetime import explode_date_range
2
+ from .parquet import pa_mod
3
+ from .utils import pd_ht, df_diffs
4
+
5
+ __all__ = [
6
+ 'pd_ht',
7
+ 'df_diffs',
8
+ 'explode_date_range',
9
+ 'pa_mod',
10
+ ]
@@ -0,0 +1,262 @@
1
+ import pandas as pd
2
+ from typing import Literal
3
+
4
+
5
+ def explode_date_range(
6
+ df: pd.DataFrame,
7
+ start_date_col: str,
8
+ end_date_col: str,
9
+ date_col: str = 'ts',
10
+ freq: str = '30min',
11
+ start_date_offset: pd.DateOffset = None,
12
+ end_date_offset: pd.DateOffset = None,
13
+ start_date_roll: Literal['backward', 'forward'] | None = None,
14
+ end_date_roll: Literal['backward', 'forward'] | None = None,
15
+ min_date: str | pd.Timestamp = None,
16
+ max_date: str | pd.Timestamp = None,
17
+ inclusive: Literal['both', 'left', 'right', 'neither'] = 'both',
18
+ drop_index: bool = True,
19
+ drop_date_cols: bool = True,
20
+ ) -> pd.DataFrame:
21
+ """
22
+ Explode DataFrame start/end date columns to date column.
23
+
24
+ Parameters
25
+ ----------
26
+ df:
27
+ The input DataFrame with start/end date columns/index levels.
28
+ start_date_col:
29
+ The column name in the DataFrame for the start date.
30
+ end_date_col:
31
+ The column name in the DataFrame for the end date.
32
+ date_col:
33
+ The column name in the DataFrame for the new date.
34
+ freq:
35
+ The frequency of the new date column.
36
+ start_date_offset:
37
+ The date offset for the start date column.
38
+ end_date_offset:
39
+ The date offset for the end date column.
40
+ start_date_roll:
41
+ Roll the start_date to the start of the current/next period.
42
+ end_date_roll:
43
+ Roll the end date to the start of the current/next period.
44
+ min_date:
45
+ The min value of the start date after offset.
46
+ max_date:
47
+ The max value of the end date after offset.
48
+ inclusive:
49
+ Include boundaries; Whether to set each bound as closed or open.
50
+ drop_index:
51
+ This flag should be False when the input DataFrame has meaningful index.
52
+ drop_date_cols:
53
+ Whether to drop the start_date_col and end_date_col or not.
54
+
55
+ Returns
56
+ -------
57
+ pl.DataFrame
58
+ The DataFrame same as the input but with
59
+ start/end date columns replaced by the new date column
60
+
61
+ Examples
62
+ --------
63
+ Simple usage.
64
+
65
+ >>> df = pd.DataFrame({
66
+ ... 'start_date': ['2023-01-01', '2023-01-02'],
67
+ ... 'end_date': ['2023-01-01 01:00', '2023-01-02 02:00'],
68
+ ... })
69
+ >>> df_exploded = explode_date_range(df, 'start_date', 'end_date', freq='1h')
70
+ >>> print(df_exploded)
71
+ ts
72
+ 0 2023-01-01 00:00:00
73
+ 1 2023-01-01 01:00:00
74
+ 2 2023-01-02 00:00:00
75
+ 3 2023-01-02 01:00:00
76
+ 4 2023-01-02 02:00:00
77
+
78
+ With offset for start and end date.
79
+
80
+ >>> df = pd.DataFrame({
81
+ ... 'start_date': ['2023-01-01', '2023-01-02'],
82
+ ... 'end_date': ['2023-01-01 01:00:00', '2023-01-02 02:00:00'],
83
+ ... })
84
+ >>> df_exploded = explode_date_range(
85
+ ... df=df,
86
+ ... start_date_col='start_date',
87
+ ... end_date_col='end_date',
88
+ ... freq='1h',
89
+ ... start_date_offset=pd.DateOffset(hours=1),
90
+ ... end_date_offset=pd.DateOffset(hours=-1),
91
+ ... )
92
+ >>> print(df_exploded)
93
+ ts
94
+ 0 2023-01-02 01:00:00
95
+
96
+ With rolling start and end date to the nearest frequency (hour).
97
+
98
+ >>> df = pd.DataFrame({
99
+ ... 'start_date': ['2023-01-01 00:30', '2023-01-02 00:30'],
100
+ ... 'end_date': ['2023-01-01 01:30', '2023-01-02 02:30'],
101
+ ... })
102
+ >>> df_exploded = explode_date_range(
103
+ ... df=df,
104
+ ... start_date_col='start_date',
105
+ ... end_date_col='end_date',
106
+ ... freq='1h',
107
+ ... start_date_roll='forward',
108
+ ... end_date_roll='backward',
109
+ ... )
110
+ >>> print(df_exploded)
111
+ ts
112
+ 0 2023-01-01 01:00:00
113
+ 1 2023-01-02 01:00:00
114
+ 2 2023-01-02 02:00:00
115
+ """
116
+ if not drop_index:
117
+ levels_old = list(df.index.names)
118
+ index_names = [
119
+ f'_idx{i}' if name is None else name for i, name in enumerate(levels_old)
120
+ ]
121
+ df = df.rename_axis(index_names, axis=0)
122
+ if start_date_col in df.columns and end_date_col in df.columns:
123
+ levels = index_names
124
+ else:
125
+ if drop_date_cols:
126
+ levels = [
127
+ level
128
+ for level in index_names
129
+ if level not in (start_date_col, end_date_col)
130
+ ]
131
+ levels_old = [
132
+ level
133
+ for level in levels_old
134
+ if level not in (start_date_col, end_date_col)
135
+ ]
136
+ else:
137
+ levels = index_names
138
+ # move start/end_date_col to index if one exists
139
+ if start_date_col in df.columns:
140
+ levels += [start_date_col]
141
+ levels_old += [start_date_col]
142
+ elif end_date_col in df.columns:
143
+ levels += [end_date_col]
144
+ levels_old += [end_date_col]
145
+ levels += [date_col]
146
+ levels_old += [date_col]
147
+
148
+ df = df.reset_index(drop=drop_index).astype(
149
+ {
150
+ start_date_col: 'datetime64[ns]',
151
+ end_date_col: 'datetime64[ns]',
152
+ }
153
+ )
154
+
155
+ # offset start date
156
+ if start_date_offset is not None:
157
+ df[start_date_col] += start_date_offset
158
+
159
+ # roll start date
160
+ if start_date_roll is not None:
161
+ roll_freq = freq if freq[-1] != 'S' else freq[:-1]
162
+ extra_period = 0 if start_date_roll == 'backward' else 1
163
+ df[start_date_col] = (
164
+ df[start_date_col].dt.to_period(roll_freq) + extra_period
165
+ ).dt.start_time
166
+
167
+ # limit start_date and replace null with min_date
168
+ if min_date is not None:
169
+ min_date = pd.to_datetime(min_date, dayfirst=True)
170
+ df[start_date_col] = (
171
+ df[start_date_col]
172
+ .fillna(min_date)
173
+ .where(df[start_date_col] > min_date, min_date)
174
+ )
175
+
176
+ # offset end date
177
+ if end_date_offset is not None:
178
+ df[end_date_col] += end_date_offset
179
+
180
+ # roll end date
181
+ if end_date_roll is not None:
182
+ roll_freq = freq if freq[-1] != 'S' else freq[:-1]
183
+ extra_period = 0 if end_date_roll == 'backward' else 1
184
+ df[end_date_col] = (
185
+ df[end_date_col].dt.to_period(roll_freq) + extra_period
186
+ ).dt.start_time
187
+
188
+ # limit end_date and replace null with max_date
189
+ if max_date is not None:
190
+ max_date = pd.to_datetime(max_date, dayfirst=True)
191
+ df[end_date_col] = (
192
+ df[end_date_col]
193
+ .fillna(max_date)
194
+ .where(df[end_date_col] < max_date, max_date)
195
+ )
196
+
197
+ # FIXME: special regarding pandas version
198
+ pd_version = tuple(int(n) for n in pd.__version__.split('.')[:3])
199
+ if pd_version >= (1, 4, 0):
200
+ inclusive_par = {'inclusive': inclusive}
201
+ else:
202
+ if inclusive == 'neither':
203
+ inclusive = 'right'
204
+ df[end_date_col] -= pd.DateOffset(microseconds=1)
205
+ inclusive_par = {'closed': None if inclusive == 'both' else inclusive}
206
+
207
+ # if inclusive = 'left' we expect date_end is exclusive
208
+ # so records with start_date == end_date should be excluded
209
+ # also reset index to ensure index start from 0 and is consecutive
210
+ if inclusive == 'left':
211
+ df = df.query(f'{start_date_col} < {end_date_col}').reset_index(drop=True)
212
+ else:
213
+ df = df.query(f'{start_date_col} <= {end_date_col}').reset_index(drop=True)
214
+
215
+ # get exploded timestamp column
216
+ if df.empty:
217
+ dt = (
218
+ pd.DataFrame(
219
+ {
220
+ 'i': pd.Series(dtype='int'),
221
+ date_col: pd.Series(dtype='datetime64[ns]'),
222
+ }
223
+ )
224
+ .set_index('i')
225
+ .rename_axis(None, axis=0)
226
+ )
227
+ else:
228
+ dt = (
229
+ pd.concat(
230
+ [
231
+ pd.DataFrame(
232
+ {
233
+ 'i': i,
234
+ date_col: pd.date_range(
235
+ start=s, end=e, freq=freq, **inclusive_par
236
+ ),
237
+ }
238
+ )
239
+ for i, (s, e) in enumerate(
240
+ zip(df[start_date_col], df[end_date_col])
241
+ )
242
+ ]
243
+ )
244
+ .set_index('i')
245
+ .rename_axis(None, axis=0)
246
+ )
247
+
248
+ # drop start_date_col and end_date_col
249
+ if drop_date_cols:
250
+ df = df.drop(columns=[start_date_col, end_date_col])
251
+
252
+ # sample df based on new timestamp column
253
+ df = df.reindex(dt.index)
254
+ df[date_col] = dt[date_col]
255
+
256
+ # set index
257
+ if drop_index:
258
+ df = df.reset_index(drop=True)
259
+ else:
260
+ df = df.set_index(levels).rename_axis(levels_old, axis=0)
261
+
262
+ return df
mspu/pandas/parquet.py ADDED
@@ -0,0 +1,36 @@
1
+ import pandas as pd
2
+
3
+
4
+ def pa_mod(
5
+ ds: int | pd.Series,
6
+ divisor: int,
7
+ ) -> int | pd.Series:
8
+ """
9
+ Calculates remainder after division by a positive divisor.
10
+
11
+ Parameters:
12
+ -----------
13
+ ds: int | pd.Series
14
+ An integer value or a pd.Series with int64[pyarrow] dtype
15
+ divisor: int
16
+ The positive divisor for the modulo operation
17
+
18
+ Returns:
19
+ -----------
20
+ int | pd.Series:
21
+ An integer or pd.series containing the modulo results
22
+ """
23
+
24
+ if divisor <= 0:
25
+ raise ValueError('Divisor must be a positive integer')
26
+
27
+ # Check if divisor is a power of 2
28
+ if divisor & (divisor - 1) == 0:
29
+ # Efficient bitwise AND for power-of-2 divisors
30
+ remainder = ds & (divisor - 1)
31
+ else:
32
+ # Slower integer division for non-power-of-2 divisors
33
+ quotient = ds // divisor
34
+ remainder = ds - (quotient * divisor)
35
+
36
+ return remainder
mspu/pandas/utils.py ADDED
@@ -0,0 +1,196 @@
1
+ import pandas as pd
2
+
3
+
4
+ def pd_ht(self, n: int = 2, c: int = None, w: int = None, r: int = None) -> None:
5
+ """
6
+ Pandas head and tail in one command, with optional rounding.
7
+
8
+ Parameters
9
+ ----------
10
+ n : int
11
+ Number of rows to show from the head and tail. If n < 0 or n is greater than
12
+ half the number of rows, the entire DataFrame will be shown.
13
+ c : int
14
+ Number of columns to show. If None or greater than the number of columns,
15
+ all columns will be shown.
16
+ w : int
17
+ Display width in characters. If None, pandas default is used.
18
+ r : int
19
+ Number of decimal places to round float columns. If None or negative,
20
+ no rounding will be applied.
21
+
22
+ Returns
23
+ -------
24
+ None
25
+
26
+ Examples
27
+ --------
28
+ >>> import pandas as pd
29
+ >>> from mspu.pandas.utils import pd_ht
30
+ >>> pd.DataFrame.ht = pd_ht
31
+ >>> df = pd.DataFrame({
32
+ ... 'foo': [1.12345, 2.98765, 3.14159],
33
+ ... 'bar': [7, 8, 9],
34
+ ... 'ham': ['x', 'y', 'z'],
35
+ ... })
36
+ >>> df.ht(n=1, c=2, r=2)
37
+ shape: (3, 3)
38
+ foo ... ham
39
+ 0 1.12 ... x
40
+ 2 3.14 ... z
41
+ """
42
+ # Columns to show
43
+ if isinstance(c, int) and c <= 0:
44
+ c = None
45
+
46
+ # Display width
47
+ if isinstance(w, int) and w <= 0:
48
+ w = 1000
49
+
50
+ # Slice head + tail (or full df)
51
+ if n < 0 or self.shape[0] <= 2 * n:
52
+ df = self
53
+ else:
54
+ df = pd.concat([self.iloc[:n], self.iloc[-n:]])
55
+
56
+ # Round float columns
57
+ if r is not None and r >= 0:
58
+ float_cols = df.select_dtypes(include='float').columns
59
+ df[float_cols] = df[float_cols].round(r)
60
+
61
+ with pd.option_context(
62
+ 'display.show_dimensions',
63
+ False,
64
+ 'display.width',
65
+ w,
66
+ 'display.max_rows',
67
+ df.shape[0],
68
+ 'display.max_columns',
69
+ c,
70
+ ):
71
+ print(f'shape: {self.shape}')
72
+ print(df)
73
+
74
+
75
+ def remove_cols_utc(df: pd.DataFrame) -> pd.DataFrame:
76
+ """
77
+ Converts datetime columns in UTC to timezone-naive (tz=None).
78
+ """
79
+ for col in df.select_dtypes(include=['datetimetz']).columns:
80
+ tz = df[col].dt.tz
81
+ if tz is not None and 'utc' in str(tz).lower():
82
+ df[col] = df[col].dt.tz_localize(None)
83
+ return df
84
+
85
+
86
+ def df_diffs(
87
+ df1: pd.DataFrame,
88
+ df2: pd.DataFrame,
89
+ left_suffix: str = '_df1',
90
+ right_suffix: str = '_df2',
91
+ ) -> pd.DataFrame:
92
+ """
93
+ Get rows in two dfs that are different to each other.
94
+
95
+ Comparison is based on df MultiIndex.
96
+
97
+ Parameters
98
+ ----------
99
+ df1 : pd.DataFrame
100
+ First DataFrame to compare.
101
+ df2 : pd.DataFrame
102
+ Second DataFrame to compare.
103
+ left_suffix : str
104
+ Suffix to append to column names from df1 in the output DataFrame.
105
+ right_suffix : str
106
+ Suffix to append to column names from df2 in the output DataFrame.
107
+
108
+ Returns
109
+ -------
110
+ pd.DataFrame
111
+ DataFrame containing rows that are different between df1 and df2, with suffixes
112
+ indicating the source of each column.
113
+
114
+ Examples
115
+ --------
116
+ >>> import pandas as pd
117
+ >>> from mspu.pandas import df_diffs
118
+ >>> df1 = pd.DataFrame({
119
+ ... 'fruit': ['apple', 'banana', 'apple'],
120
+ ... 'id': [1, 2, 4],
121
+ ... 'store': ['us', 'uk', 'cn'],
122
+ ... 'price': [4, 3.1, 2.5],
123
+ ... }).set_index(['fruit', 'id'])
124
+ >>> print(df1)
125
+ store price
126
+ fruit id
127
+ apple 1 us 4.0
128
+ banana 2 uk 3.1
129
+ apple 4 cn 2.5
130
+ >>> df2 = pd.DataFrame({
131
+ ... 'fruit': ['apple', 'banana', 'apple'],
132
+ ... 'id': [1, 3, 4],
133
+ ... 'store': ['us', 'uk', 'cn'],
134
+ ... 'price': [4, 3.2, 2.5],
135
+ ... }).set_index(['fruit', 'id'])
136
+ >>> print(df2)
137
+ store price
138
+ fruit id
139
+ apple 1 us 4.0
140
+ banana 3 uk 3.2
141
+ apple 4 cn 2.5
142
+ >>> diff = df_diffs(df1, df2)
143
+ >>> print(diff)
144
+ store_df1 price_df1 store_df2 price_df2
145
+ fruit id
146
+ banana 2 uk 3.1 NaN NaN
147
+ 3 NaN NaN uk 3.2
148
+ """
149
+ # Perform an outer join
150
+ df_joined = pd.merge(
151
+ df1,
152
+ df2,
153
+ how='outer',
154
+ left_index=True,
155
+ right_index=True,
156
+ suffixes=(left_suffix, right_suffix),
157
+ )
158
+
159
+ # Compare the columns to find differences
160
+ d1_joined = df_joined.filter(like=left_suffix).rename(
161
+ columns=lambda x: x.rsplit('_', 1)[0]
162
+ )
163
+ d2_joined = df_joined.filter(like=right_suffix).rename(
164
+ columns=lambda x: x.rsplit('_', 1)[0]
165
+ )
166
+
167
+ # Compare two dfs with `ne`, col names must be the same
168
+ diffs = df_joined[d1_joined.ne(d2_joined).any(axis=1)]
169
+
170
+ return diffs
171
+
172
+
173
+ if __name__ == '__main__':
174
+ # Create two example DataFrames with string columns
175
+ df1 = pd.DataFrame(
176
+ {
177
+ 'fruit': ['apple', 'banana', 'apple'],
178
+ 'id': [1, 2, 4],
179
+ 'store': ['us', 'uk', 'cn'],
180
+ 'price': [4, 3.1, 2.5],
181
+ }
182
+ ).set_index(['fruit', 'id'])
183
+
184
+ df2 = pd.DataFrame(
185
+ {
186
+ 'fruit': ['apple', 'banana', 'apple'],
187
+ 'id': [1, 3, 4],
188
+ 'store': ['us', 'uk', 'cn'],
189
+ 'price': [4, 3.2, 2.5],
190
+ }
191
+ ).set_index(['fruit', 'id'])
192
+
193
+ df = df_diffs(df1, df2)
194
+
195
+ # Show the resulting differences
196
+ print(df)
@@ -0,0 +1,17 @@
1
+ from .utils import (
2
+ pl_ht,
3
+ inf_count,
4
+ nan_count,
5
+ nul_count,
6
+ lowercase_polars_df,
7
+ to_float32_polars_df,
8
+ )
9
+
10
+ __all__ = [
11
+ 'pl_ht',
12
+ 'inf_count',
13
+ 'nan_count',
14
+ 'nul_count',
15
+ 'lowercase_polars_df',
16
+ 'to_float32_polars_df',
17
+ ]
mspu/polars/utils.py ADDED
@@ -0,0 +1,142 @@
1
+ import polars as pl
2
+ import polars.selectors as cs
3
+ from typing import Literal
4
+
5
+
6
+ def pl_ht(self, n: int = 2, c: int = None, w: int = None, r: int = None) -> None:
7
+ """
8
+ Polars head and tail in one command, with optional rounding.
9
+
10
+ Parameters
11
+ ----------
12
+ n : int
13
+ Number of rows to show from the head and tail. If n < 0 or n is greater than
14
+ half the number of rows, the entire DataFrame will be shown.
15
+ c : int
16
+ Number of columns to show. If None or greater than the number of columns,
17
+ all columns will be shown.
18
+ w : int
19
+ Width of the output in characters. If None, the width will be determined.
20
+ r : int
21
+ Number of decimal places to round float and decimal columns. If None or negative,
22
+ no rounding will be applied.
23
+
24
+ Returns
25
+ -------
26
+ None
27
+
28
+ Examples
29
+ --------
30
+ >>> import polars as pl
31
+ >>> from mspu.polars.utils import pl_ht
32
+ >>> pl.DataFrame.ht = pl_ht
33
+ >>> df = pl.DataFrame({
34
+ ... 'foo': [1.12345, 2.98765, 3.14159],
35
+ ... 'bar': [7, 8, 9],
36
+ ... 'ham': ['x', 'y', 'z'],
37
+ ... })
38
+ >>> df.ht(n=1, c=2, r=2)
39
+ shape: (3, 3)
40
+ ┌──────┬───┬─────┐
41
+ │ foo ┆ … ┆ ham │
42
+ │ --- ┆ ┆ --- │
43
+ │ f64 ┆ ┆ str │
44
+ ╞══════╪═══╪═════╡
45
+ │ 1.12 ┆ … ┆ x │
46
+ │ 3.14 ┆ … ┆ z │
47
+ └──────┴───┴─────┘
48
+ """
49
+ if n < 0 or self.shape[0] < 2 * n:
50
+ df = self
51
+ else:
52
+ df = pl.concat([self[:n], self[-n:]])
53
+ if r is not None and r >= 0:
54
+ df = df.with_columns((cs.float() | cs.decimal()).round(r))
55
+ with pl.Config(
56
+ tbl_hide_dataframe_shape=True,
57
+ tbl_width_chars=w,
58
+ tbl_rows=df.shape[0],
59
+ tbl_cols=c,
60
+ ):
61
+ print(f'shape: {self.shape}')
62
+ print(df)
63
+
64
+
65
+ def parquet_to_csv(filepath: str):
66
+ """
67
+ Read parquet and save as csv.
68
+ """
69
+ filepath_csv = f'{filepath[:-7]}csv'
70
+ pl.read_parquet(filepath).write_csv(filepath_csv)
71
+
72
+
73
+ def lowercase_polars_df(
74
+ df: pl.DataFrame,
75
+ lowercase: Literal['header', 'columns', 'both'] = 'both',
76
+ ) -> pl.DataFrame:
77
+ """
78
+ Converts all column names and string columns to lowercase.
79
+ """
80
+ # Lowercase column headers
81
+ if lowercase in ('header', 'both'):
82
+ df = df.rename({col: col.lower() for col in df.columns})
83
+ # Lowercase string columns
84
+ if lowercase in ('columns', 'both'):
85
+ df = df.with_columns([cs.string().str.to_lowercase()])
86
+ return df
87
+
88
+
89
+ def to_float32_polars_df(df: pl.DataFrame) -> pl.DataFrame:
90
+ """
91
+ Convert all numerical columns type to float32.
92
+ """
93
+ df = df.with_columns((cs.float() | cs.decimal()).cast(pl.Float32))
94
+ return df
95
+
96
+
97
+ def inf_count(df: pl.DataFrame) -> pl.DataFrame:
98
+ """
99
+ Counts the number of infinite values in each column of a Polars DataFrame.
100
+
101
+ Returns a new DataFrame with the column names and their corresponding counts,
102
+ sorted in descending order.
103
+ """
104
+ df_inf = (
105
+ df.select([(pl.col(col).is_infinite().sum().alias(col)) for col in df.columns])
106
+ .unpivot(variable_name='col', value_name='inf_cnt')
107
+ .filter(pl.col('inf_cnt') > 0)
108
+ .sort('inf_cnt', descending=True)
109
+ )
110
+ return df_inf
111
+
112
+
113
+ def nan_count(df: pl.DataFrame) -> pl.DataFrame:
114
+ """
115
+ Counts the number of NaN values in each column of a Polars DataFrame.
116
+
117
+ Returns a new DataFrame with the column names and their corresponding counts,
118
+ sorted in descending order.
119
+ """
120
+ df_nan = (
121
+ df.select([(pl.col(col).is_nan().sum().alias(col)) for col in df.columns])
122
+ .unpivot(variable_name='col', value_name='nan_cnt')
123
+ .filter(pl.col('nan_cnt') > 0)
124
+ .sort('nan_cnt', descending=True)
125
+ )
126
+ return df_nan
127
+
128
+
129
+ def nul_count(df: pl.DataFrame) -> pl.DataFrame:
130
+ """
131
+ Counts the number of null values in each column of a Polars DataFrame.
132
+
133
+ Returns a new DataFrame with the column names and their corresponding counts,
134
+ sorted in descending order.
135
+ """
136
+ df_nul = (
137
+ df.select([(pl.col(col).is_null().sum().alias(col)) for col in df.columns])
138
+ .unpivot(variable_name='col', value_name='nul_cnt')
139
+ .filter(pl.col('nul_cnt') > 0)
140
+ .sort('nul_cnt', descending=True)
141
+ )
142
+ return df_nul
mspu/security/crypt.py ADDED
@@ -0,0 +1,80 @@
1
+ import json
2
+ import base64
3
+ import hashlib
4
+ from cryptography.fernet import Fernet
5
+
6
+
7
+ def create_key(keypath: str):
8
+ # Generate a key
9
+ key = Fernet.generate_key()
10
+
11
+ # Save the key securely
12
+ with open(keypath, 'wb') as f:
13
+ f.write(key)
14
+
15
+
16
+ def load_key(keypath: str) -> bytes:
17
+ # Load key from file
18
+ with open(keypath, 'rb') as f:
19
+ key = f.read()
20
+ return key
21
+
22
+
23
+ def encrypt_txt(key: bytes, txt: str) -> bytes:
24
+ # Serialize and encrypt the txt
25
+ cipher = Fernet(key)
26
+ encrypted_credentials = cipher.encrypt(txt)
27
+ return encrypted_credentials
28
+
29
+
30
+ def decrypt_txt(key: bytes, txt: bytes) -> bytes:
31
+ # Decrypt the credentials
32
+ cipher = Fernet(key)
33
+ decrypted_credentials = cipher.decrypt(txt)
34
+ return decrypted_credentials
35
+
36
+
37
+ def encrypt_file(key: bytes, filepath: str):
38
+ # Load the credentials
39
+ with open(filepath, 'rb') as f:
40
+ txt = f.read()
41
+ # Serialize and encrypt the txt
42
+ encrypted_credentials = encrypt_txt(key, txt)
43
+ # Save the encrypted file
44
+ file, ext = filepath.rsplit('.', 1)
45
+ filepath = f'{file}_.{ext}'
46
+ with open(filepath, 'wb') as f:
47
+ f.write(encrypted_credentials)
48
+
49
+
50
+ def decrypt_file(key: bytes, filepath: str, to_json: bool = True):
51
+ # Load the credentials
52
+ with open(filepath, 'rb') as f:
53
+ txt = f.read()
54
+ # Decrypt the credentials
55
+ decrypted_credentials = decrypt_txt(key, txt).decode()
56
+ # Load the credentials into a dictionary
57
+ if to_json:
58
+ decrypted_credentials = json.loads(decrypted_credentials)
59
+ return decrypted_credentials
60
+
61
+
62
+ def ha64(txt: str):
63
+ txt = hashlib.sha256(txt.encode()).digest()
64
+ return base64.urlsafe_b64encode(txt)
65
+
66
+ if __name__ == '__main__':
67
+ fp = 'c:/files'
68
+ # create_key(f'{fp}/my-key.json')
69
+
70
+ key = load_key(f'{fp}/my-key.json')
71
+
72
+ key = hashlib.sha256('my-key'.encode()).digest()
73
+ key = base64.urlsafe_b64encode(key)
74
+ fl = 'my-key.json'
75
+ # encrypt_file(key, f'{fp}/{fl}')
76
+
77
+ key = decrypt_file(key, f'{fp}/my-key_.json', False)
78
+ db_cred = decrypt_file(key, f'{fp}/credentials_.json')
79
+
80
+ print(db_cred)
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: mspu
3
+ Version: 0.0.1
4
+ Summary: Ma's python utils package.
5
+ Author-email: Sean Ma <sean.sl.ma@gmail.com>
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: numpy
10
+ Requires-Dist: pandas
11
+ Requires-Dist: polars
12
+ Description-Content-Type: text/markdown
13
+
14
+ # mspu
15
+
16
+ My python utils package. It includes some commonly used functions, like `gen_rand_df` and `explode_date_range`.
17
+
18
+ ## How to install `mspu`
19
+
20
+ Using `pip`:
21
+ ```sh
22
+ pip install mspu
23
+ ```
24
+
25
+ Using `uv`:
26
+ ```sh
27
+ uv pip install mspu
28
+ ```
29
+
30
+ ## The document for `mspu`
31
+
32
+ Visit the [document](https://seanslma.github.io/mspu) for more details.
@@ -0,0 +1,18 @@
1
+ mspu/__init__.py,sha256=uWOTnD7uFq1Qca0T90qOTjvIq5on-r64Ug4hJWPYQ64,184
2
+ mspu/data/__init__.py,sha256=THICntQKY0THohm-I1gPwi9I0yJHHg5Jv0IVCgZ8uAA,67
3
+ mspu/data/rand_df.py,sha256=I1Hkc8NfrLamff2fUG6il0KW67epoVrvq9TrKJSVS6w,10841
4
+ mspu/datetime/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ mspu/datetime/utils.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ mspu/io/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ mspu/io/file.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ mspu/pandas/__init__.py,sha256=gommfn2jXrmUBnXG2qMtQzqRMg1oEqyLPMGmRzGSgmA,188
9
+ mspu/pandas/datetime.py,sha256=_iAZ9xqFJ-tYnT3x5hT09wt0Ihlc21PPaBOfFyL8boc,8656
10
+ mspu/pandas/parquet.py,sha256=XjjdpgNG7Qsm3KbpbuR1qfGKFnq1FdPaoJzmA19A7I8,915
11
+ mspu/pandas/utils.py,sha256=3JzKQDssYzxJosgtARc90EFQCb24yHqyNmGJlWx6dDk,5542
12
+ mspu/polars/__init__.py,sha256=dh4ekg-o_Gv4-nnnYI3flRliNn9BF16fJJE97_vCBzI,264
13
+ mspu/polars/utils.py,sha256=VMZrljTV7mW7Uy0SpMRS2aV6whhTIgDMYrRNbHf7bIA,4511
14
+ mspu/security/crypt.py,sha256=iYnkg9YdPJjEgYrw7iAnpK0qiH0illpp1-bIkZpIxF0,2097
15
+ mspu-0.0.1.dist-info/METADATA,sha256=ZHGLKpsG-e2WwuBTpCxzVpOT9hmBDHKFgRqJQC9BKd8,625
16
+ mspu-0.0.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
17
+ mspu-0.0.1.dist-info/licenses/LICENSE,sha256=Rc482qeBCHvWChKavd4nCgfkKshLEHDcmTlXNOIQ6DY,1064
18
+ mspu-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Sean Ma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.