ialdev-core 0.2.4__tar.gz → 0.2.6__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.
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/PKG-INFO +1 -1
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/pyproject.toml +2 -1
- ialdev_core-0.2.6/src/iad/core/data/labeled.py +249 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/data/pdtools.py +239 -30
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/fs/filesproc.py +26 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/wrap.py +2 -2
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_filesproc.py +30 -1
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_pdtools.py +79 -2
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/.gitignore +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/.pixi/config.toml +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/README.md +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/__init__.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/cache.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/codetools.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/data/__init__.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/data/array.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/data/binary.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/data/label.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/data/nptools.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/data/unc_panda.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/data/units.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/datatools.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/docs/locators.ipynb +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/events.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/fnctools.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/fs/__init__.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/fs/env.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/fs/paths.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/logs.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/one_dark.puml +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/param/__init__.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/param/confargparse.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/param/paramaze.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/regexp.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/short.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/strings.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/tbox.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/array.ipynb +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/conftest.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/data/.env +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/dot_styles_examples.ipynb +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_array.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_binar.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_cachepipe.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_codetools.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_coltools.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_datatools.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_events.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_nptools.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_paths.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_regexp.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_shorts.py +0 -0
- {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_tbox.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ialdev-core"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.6"
|
|
8
8
|
description = "iad.core — algorithmic utilities: data tools, parameters, paths, caching, and more"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -63,6 +63,7 @@ name = "iad.core"
|
|
|
63
63
|
|
|
64
64
|
[tool.pytest.ini_options]
|
|
65
65
|
testpaths = ["tests"]
|
|
66
|
+
addopts = "--import-mode=importlib"
|
|
66
67
|
python_files = ["test_*.py"]
|
|
67
68
|
python_classes = ["Test*"]
|
|
68
69
|
python_functions = ["test_*"]
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .label import Labels
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .pdtools import DTable
|
|
11
|
+
|
|
12
|
+
__all__ = ['Labeled', 'LabeledType']
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Labeled(Labels):
|
|
16
|
+
"""
|
|
17
|
+
Base ABSTRACT class for Labeled data types - an extended dict containing data
|
|
18
|
+
in one field and descriptive labels in the others.
|
|
19
|
+
|
|
20
|
+
Actual Labeled Data types are created for specific collection of labels
|
|
21
|
+
(by ``LabeledType`` metaclass) using ``labeled_type()`` or ``LT`` alias
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def type_keys(cls) -> tuple:
|
|
26
|
+
return *cls._defaults, *cls._undefined
|
|
27
|
+
|
|
28
|
+
UNDEF = object()
|
|
29
|
+
|
|
30
|
+
def __eq__(self, other):
|
|
31
|
+
if not (isinstance(other, dict) and len(self) == len(other)):
|
|
32
|
+
return False
|
|
33
|
+
no_key = object()
|
|
34
|
+
try:
|
|
35
|
+
for k, vo in other.items():
|
|
36
|
+
if (v := self.get(k, no_key)) is no_key:
|
|
37
|
+
return False
|
|
38
|
+
na = isinstance(v, np.ndarray) + isinstance(vo, np.ndarray)
|
|
39
|
+
if na == 1 or na == 0 and vo != v or na == 2 and not np.array_equal(vo, v):
|
|
40
|
+
return False
|
|
41
|
+
except Exception as ex: # various not comparable types
|
|
42
|
+
print(ex)
|
|
43
|
+
return False
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
def __init__(self, data=UNDEF, **kws):
|
|
47
|
+
"""
|
|
48
|
+
Support dict-like initializations:
|
|
49
|
+
A(data=2, b=3) and A({'data':2, 'y':3})
|
|
50
|
+
and silent data field:
|
|
51
|
+
A(2, b=3) == A(data=2, b=3)
|
|
52
|
+
:param data: not defined or data or dict with labels (like kws)
|
|
53
|
+
:param kws: labels key, values
|
|
54
|
+
"""
|
|
55
|
+
cls = type(self)
|
|
56
|
+
if cls is Labeled:
|
|
57
|
+
raise RuntimeError(f"Class {cls} is a mixin class not intended for instantiation.")
|
|
58
|
+
|
|
59
|
+
# ---- STEP 1 - convert inputs into homogeneous form
|
|
60
|
+
if data is not self.UNDEF:
|
|
61
|
+
# support Labeled(dict) like initialization
|
|
62
|
+
if isinstance(data, dict) and self._all_keys.issuperset(data):
|
|
63
|
+
kws.update(data)
|
|
64
|
+
else:
|
|
65
|
+
assert 'data' in self._all_keys, "unnamed argument allowed only if 'data' field defined!"
|
|
66
|
+
kws['data'] = data
|
|
67
|
+
|
|
68
|
+
# ---- STEP 2 - check all the conditions are met
|
|
69
|
+
if missing := set(self._undefined).difference(kws):
|
|
70
|
+
raise KeyError(f"labels {missing} not initialized in {cls}!")
|
|
71
|
+
|
|
72
|
+
if violated := '\n\t'.join(
|
|
73
|
+
f"{k}={assigned} ({expected=})"
|
|
74
|
+
for k in self._frozen_defaults.intersection(kws)
|
|
75
|
+
if (expected := self._defaults[k]) != (assigned := kws[k])
|
|
76
|
+
): raise KeyError(f"Initialization of {cls} violates frozen defaults:\n {violated}")
|
|
77
|
+
|
|
78
|
+
if self._frozen_keys and not self._all_keys.issuperset(kws):
|
|
79
|
+
raise KeyError(f"Undefined keys {set(kws).difference(self._all_keys)} in frozen {cls}")
|
|
80
|
+
|
|
81
|
+
# ---- STEP 3 - initialize the items
|
|
82
|
+
super().__init__(self._defaults) # first fill with defaults
|
|
83
|
+
# then assign from the arguments
|
|
84
|
+
for k, v in kws.items():
|
|
85
|
+
if self._validate_type and \
|
|
86
|
+
(tp := self._undefined.get(k, False)) and \
|
|
87
|
+
type(tp) is type and not isinstance(v, tp):
|
|
88
|
+
v = tp(v)
|
|
89
|
+
self[k] = v
|
|
90
|
+
|
|
91
|
+
def flat(self, *, sep='_', exclude='data', data='data', fmt=None) -> tuple[str, np.ndarray]:
|
|
92
|
+
"""
|
|
93
|
+
Merge labels into a single string and produce a tuple (flatten_label, data)
|
|
94
|
+
|
|
95
|
+
:param sep: separator when joining
|
|
96
|
+
:param exclude: keys exclude from the join
|
|
97
|
+
:param data: key of the data field returned as second item in the tuple
|
|
98
|
+
:param fmt: optional str.format sting, if provided ignores arguments:
|
|
99
|
+
sep, exclude, keys
|
|
100
|
+
:return: tuple(joint_label, data_item)
|
|
101
|
+
"""
|
|
102
|
+
from iad.core import as_iter
|
|
103
|
+
data = self[data]
|
|
104
|
+
if fmt:
|
|
105
|
+
return fmt.format(**self), data
|
|
106
|
+
|
|
107
|
+
excluded = set(as_iter(exclude))
|
|
108
|
+
included = lambda _: _[0] not in excluded
|
|
109
|
+
str_val = lambda _: str(_[1])
|
|
110
|
+
return sep.join(map(str_val, filter(included, self.items()))), data
|
|
111
|
+
|
|
112
|
+
def __setattr__(self, item, val):
|
|
113
|
+
if item in self:
|
|
114
|
+
self[item] = val
|
|
115
|
+
else:
|
|
116
|
+
super().__setattr__(val)
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_table(cls, db: DTable, *, missing=KeyError, undef=()):
|
|
120
|
+
"""
|
|
121
|
+
Create instance of this Labeled type from :cls:`iad.core.data.pdtools.DataTable`
|
|
122
|
+
All the pre-defined keys are used to query the index.
|
|
123
|
+
All the undefined keys must be in the table and are initialized
|
|
124
|
+
|
|
125
|
+
If table does not contain given label, then depending on the value of ``missing``:
|
|
126
|
+
- `issubclass(missing, BaseException)` - raise `missing(f"{type(ex)}({ex.args})")`
|
|
127
|
+
- `isinstance(missing, BaseException)` - raise `missing`
|
|
128
|
+
- otherwise, return `missing`
|
|
129
|
+
|
|
130
|
+
:param db:
|
|
131
|
+
:param missing: Exception class, instance, or value to return
|
|
132
|
+
:param undef: keys listed here are forced to be considered as undefined
|
|
133
|
+
:return:
|
|
134
|
+
"""
|
|
135
|
+
if undef:
|
|
136
|
+
undef = set(undef)
|
|
137
|
+
if undef := undef.difference(cls._undefined): # undef not already undefined
|
|
138
|
+
if not_defined := undef.difference(cls._defaults): # must be defined!
|
|
139
|
+
raise KeyError(f"Unknown keys: {not_defined}")
|
|
140
|
+
# move defined undef from defined into undefined
|
|
141
|
+
defined = {k: v for k, v in cls._defaults.items() if k not in undef}
|
|
142
|
+
undefined = [*undef, cls._undefined]
|
|
143
|
+
else:
|
|
144
|
+
defined = cls._defaults
|
|
145
|
+
undefined = [*cls._undefined]
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
res = db.select(defined)
|
|
149
|
+
except (KeyError, LookupError) as ex:
|
|
150
|
+
if isinstance(missing, BaseException):
|
|
151
|
+
raise missing
|
|
152
|
+
elif isinstance(missing, type) and issubclass(missing, BaseException):
|
|
153
|
+
raise missing(f"{type(ex)}({ex.args})")
|
|
154
|
+
return missing
|
|
155
|
+
if hasattr(res, 'columns'):
|
|
156
|
+
res = res[undefined]
|
|
157
|
+
elif not (len(undefined) == 1 and res.name == undefined[0]):
|
|
158
|
+
raise KeyError(f"Table does not contain required keys {undefined}")
|
|
159
|
+
return cls(**res.reset_index().iloc[0].to_dict())
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class LabeledType(type):
|
|
163
|
+
""" Metaclass to create Labeled Data classes extending ``dict``,
|
|
164
|
+
but built around predefined keys with optional default values.
|
|
165
|
+
|
|
166
|
+
Main purpose of such classes to ensure that during instantiation all
|
|
167
|
+
those keys are initialized either by their defaults or as arguments.
|
|
168
|
+
|
|
169
|
+
Note: additional keys except those defined in the class may be added.
|
|
170
|
+
|
|
171
|
+
Classes are supposed to be created by directly calling this meta:
|
|
172
|
+
|
|
173
|
+
>>> LabeledXY = LabeledType(x=10, y=int)
|
|
174
|
+
>>> d = LabeledXY(y=2) # LT(x=2) raise exception for undefined y!
|
|
175
|
+
>>> assert d['y'] == 10
|
|
176
|
+
>>> assert type(d.__name__) == 'Labeled' # default class name
|
|
177
|
+
>>> LabeledData = LabeledType('LabeledData', x=10, y=int)
|
|
178
|
+
>>> assert LabeledData.__name__ == 'LabeledData' # custom class name
|
|
179
|
+
|
|
180
|
+
To encorage usage of 'data' key for data its positional initialization
|
|
181
|
+
is supported if data key is introduced explicitely or using `LD` function
|
|
182
|
+
>>> LabeledData = LabeledType(data=NDArray, y=int) # equivalent = LD(y=int)
|
|
183
|
+
>>> d = LabeledData([1,2,3], y=20)
|
|
184
|
+
>>> assert d['data'] == [1,2,3]
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __repr__(cls):
|
|
188
|
+
undefined = (f"{k}: {v.__name__}" for k, v in cls._undefined.items())
|
|
189
|
+
defaults = (f"{k}={v}" for k, v in cls._defaults.items())
|
|
190
|
+
return f"{cls.__qualname__}<{', '.join((*undefined, *defaults))}>"
|
|
191
|
+
|
|
192
|
+
def __new__(mcs, _name=None, *, validate_type=False,
|
|
193
|
+
frozen_defaults=False, frozen_keys=False,
|
|
194
|
+
**labels):
|
|
195
|
+
"""
|
|
196
|
+
:param _name: Name of the created class
|
|
197
|
+
:param validate_type: perform casting on keys initialization if possible
|
|
198
|
+
:param frozen_defaults: allow change default values (False)
|
|
199
|
+
use True to define templates
|
|
200
|
+
:param frozen_keys: allow not defined keys
|
|
201
|
+
:param labels: dict with {key: default_value} and {key: type} items
|
|
202
|
+
"""
|
|
203
|
+
_name = _name or f"Labeled"
|
|
204
|
+
# 'undefined' are keys assigned by types, not
|
|
205
|
+
# default values which would be called 'defaults'.
|
|
206
|
+
cls = super().__new__(mcs, _name, (Labeled,), {})
|
|
207
|
+
|
|
208
|
+
cls._undefined = {k: labels.pop(k) for k in
|
|
209
|
+
[_ for _, v in labels.items() if type(v) is type]}
|
|
210
|
+
cls._defaults = labels
|
|
211
|
+
cls._all_keys = {*cls._undefined, *cls._defaults}
|
|
212
|
+
|
|
213
|
+
frozen_defaults = cls._defaults if frozen_defaults is True else \
|
|
214
|
+
[] if frozen_defaults is False else frozen_defaults
|
|
215
|
+
cls._frozen_defaults = {*frozen_defaults}
|
|
216
|
+
cls._frozen_keys = frozen_keys
|
|
217
|
+
cls._validate_type = validate_type
|
|
218
|
+
return cls
|
|
219
|
+
|
|
220
|
+
def __init__(mcs, _name=None, *, validate_type=False,
|
|
221
|
+
frozen_defaults=False, frozen_keys=False,
|
|
222
|
+
**labels):
|
|
223
|
+
"""
|
|
224
|
+
:param _name: Name of the created class
|
|
225
|
+
:param validate_type: perform casting on keys initialization if possible
|
|
226
|
+
:param frozen_defaults: allow change default values (False)
|
|
227
|
+
use True to define templates
|
|
228
|
+
:param frozen_keys: allow not defined keys
|
|
229
|
+
:param labels: dict with {key: default_value} and {key: type} items
|
|
230
|
+
"""
|
|
231
|
+
pass # to avoid calling type.__init__ with unusual arguments
|
|
232
|
+
|
|
233
|
+
def __contains__(cls, item):
|
|
234
|
+
return item in cls._defaults or item in cls._undefined
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def undefined(cls):
|
|
238
|
+
"""keys with undefined default values"""
|
|
239
|
+
return cls._undefined.keys()
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def default(cls):
|
|
243
|
+
"""keys with defined default values"""
|
|
244
|
+
return cls._defaults.keys()
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def defined(cls):
|
|
248
|
+
"""set of ALL the keys with undefined AND default values"""
|
|
249
|
+
return cls._all_keys
|
|
@@ -4,6 +4,7 @@ import logging
|
|
|
4
4
|
import re
|
|
5
5
|
import warnings
|
|
6
6
|
from collections import namedtuple
|
|
7
|
+
from collections.abc import Mapping
|
|
7
8
|
from dataclasses import dataclass
|
|
8
9
|
from functools import cmp_to_key
|
|
9
10
|
from io import StringIO
|
|
@@ -767,7 +768,7 @@ class TablePainter:
|
|
|
767
768
|
styled.set_caption(self.cap)
|
|
768
769
|
|
|
769
770
|
if self.prec is not None:
|
|
770
|
-
styled.
|
|
771
|
+
styled.format(precision=self.prec)
|
|
771
772
|
|
|
772
773
|
if not self.disp:
|
|
773
774
|
return styled
|
|
@@ -817,8 +818,8 @@ def side_tables(*tables, caps=None, hide_index=None, painter=None, **kws):
|
|
|
817
818
|
index.names = [None] * len(index.names)
|
|
818
819
|
columns = table.columns[[0]]
|
|
819
820
|
t = pd.DataFrame(data=np.arange(len(table.index)), index=index, columns=columns).style
|
|
820
|
-
yield t.
|
|
821
|
-
yield table.
|
|
821
|
+
yield t.hide([*columns], axis="columns"), None
|
|
822
|
+
yield table.hide(axis="index"), cap
|
|
822
823
|
else:
|
|
823
824
|
yield table, cap
|
|
824
825
|
|
|
@@ -1049,6 +1050,56 @@ def _expand_vec_values(pairs: list[tuple[Scalar, Scalar | Vector]], prev_expande
|
|
|
1049
1050
|
yield prev_expanded # return as received - start aggregation
|
|
1050
1051
|
|
|
1051
1052
|
|
|
1053
|
+
def pivot_flat(df, base: str | list[str], *, pivot: str | list[str],
|
|
1054
|
+
index: bool | list[str] | str | None = None, sep=','):
|
|
1055
|
+
"""
|
|
1056
|
+
In the given dataframe creates multi-index columns by adding `pivot` levels to the `base` columns`
|
|
1057
|
+
and flattens them into str using sep.
|
|
1058
|
+
|
|
1059
|
+
All the operations are done using on both df.columns and df.index.names.
|
|
1060
|
+
|
|
1061
|
+
Sets `index` columns as multi-index levels.
|
|
1062
|
+
- if `index` is True, use all the remaining columns not mentioned in `pivot`.
|
|
1063
|
+
- if `index` is False, index is reset completely
|
|
1064
|
+
- if `index` is None, leaves original index levels (except of pivoted).
|
|
1065
|
+
"""
|
|
1066
|
+
remove = lambda x, c : [_ for _ in x if _ not in c]
|
|
1067
|
+
|
|
1068
|
+
org_index = df.index.names if isinstance(df.index, pd.MultiIndex) else []
|
|
1069
|
+
org_columns = df.columns
|
|
1070
|
+
all_labels = [*org_index, *org_columns]
|
|
1071
|
+
|
|
1072
|
+
invalid = list(filter(lambda _: not isinstance(_, (str, type(None))), org_columns))
|
|
1073
|
+
assert not invalid, f"Some of columns/ index levels are not strings: {invalid}"
|
|
1074
|
+
|
|
1075
|
+
def check(x, name):
|
|
1076
|
+
x = as_list(x)
|
|
1077
|
+
assert x, f"At least one {x} needed"
|
|
1078
|
+
assert not (_:= set(x).difference(all_labels)), f"Unknown {name}: {_}"
|
|
1079
|
+
return x
|
|
1080
|
+
|
|
1081
|
+
base, pivot = check(base, 'base'), check(pivot, 'pivot')
|
|
1082
|
+
|
|
1083
|
+
# leave only base as columns, and then pivot reuired levels
|
|
1084
|
+
df = df.set_index(remove(all_labels, base)).unstack(pivot)
|
|
1085
|
+
df.columns = [sep.join(col) for col in df.columns] # flatten columns names into str
|
|
1086
|
+
|
|
1087
|
+
if index is True:
|
|
1088
|
+
index = df.index.names
|
|
1089
|
+
elif index is False:
|
|
1090
|
+
index = []
|
|
1091
|
+
elif index is None:
|
|
1092
|
+
index = re|move(org_index, pivot)
|
|
1093
|
+
else:
|
|
1094
|
+
index = as_list(index)
|
|
1095
|
+
assert not (_:= set(index).difference(df.index.names)), f"Invaid index levels: {_}"
|
|
1096
|
+
|
|
1097
|
+
pivot_columns = list(df.columns)
|
|
1098
|
+
to_columns = remove(df.index.names, index)
|
|
1099
|
+
df = df.reset_index(to_columns)
|
|
1100
|
+
return df, pivot_columns
|
|
1101
|
+
|
|
1102
|
+
|
|
1052
1103
|
def _row_queries_gen(labels, levels, levels_map) -> Generator[list[tuple[int, Scalar]]]:
|
|
1053
1104
|
"""
|
|
1054
1105
|
Generator of mult-index row queries in form of:
|
|
@@ -1320,6 +1371,24 @@ def trans(s, max_col_width=20):
|
|
|
1320
1371
|
return ss
|
|
1321
1372
|
|
|
1322
1373
|
|
|
1374
|
+
def _format_display_cell(value, fnc):
|
|
1375
|
+
"""Apply a ``TableFormats`` cell formatter, while leaving null cells empty.
|
|
1376
|
+
|
|
1377
|
+
NA detection is guarded to scalars so array-like cells (the very ones we
|
|
1378
|
+
want to compact, e.g. ndarrays) never reach the ambiguous ``pd.isna``
|
|
1379
|
+
truth-value check.
|
|
1380
|
+
"""
|
|
1381
|
+
if value is None:
|
|
1382
|
+
return ""
|
|
1383
|
+
if np.ndim(value) == 0:
|
|
1384
|
+
try:
|
|
1385
|
+
if pd.isna(value):
|
|
1386
|
+
return ""
|
|
1387
|
+
except (TypeError, ValueError):
|
|
1388
|
+
pass
|
|
1389
|
+
return fnc(value)
|
|
1390
|
+
|
|
1391
|
+
|
|
1323
1392
|
def _invert_levels(levels, names):
|
|
1324
1393
|
if isinstance(levels, (str, int)):
|
|
1325
1394
|
levels = [levels]
|
|
@@ -1329,8 +1398,71 @@ def _invert_levels(levels, names):
|
|
|
1329
1398
|
return names.difference(levels)
|
|
1330
1399
|
|
|
1331
1400
|
|
|
1401
|
+
def _reduce(obj: PTable, op: str, *, axis=0, level=None, numeric_only=None):
|
|
1402
|
+
"""Apply a reduction (``'mean'`` | ``'sum'`` | ``'count'``) over an axis, or,
|
|
1403
|
+
if ``level`` is given, by grouping that index level.
|
|
1404
|
+
|
|
1405
|
+
This replaces the ``level=`` argument of the aggregation methods, which was
|
|
1406
|
+
removed in pandas 2.0 in favour of ``groupby(level=...)``.
|
|
1407
|
+
"""
|
|
1408
|
+
num_kws = {} if (numeric_only is None or op == 'count') else dict(numeric_only=numeric_only)
|
|
1409
|
+
if level is not None:
|
|
1410
|
+
# GroupBy methods are the stock pandas implementations, so this also
|
|
1411
|
+
# bypasses any aggregation overrides on the table subclasses.
|
|
1412
|
+
return getattr(obj.groupby(level=level), op)(**num_kws)
|
|
1413
|
+
# Call the unbound pandas method explicitly to bypass subclass overrides
|
|
1414
|
+
# (e.g. _TableMixIn.mean), matching the pre-pandas-2 behaviour.
|
|
1415
|
+
base = pd.Series if isinstance(obj, pd.Series) else pd.DataFrame
|
|
1416
|
+
if base is pd.Series:
|
|
1417
|
+
return getattr(base, op)(obj, **num_kws)
|
|
1418
|
+
return getattr(base, op)(obj, axis=axis, **num_kws)
|
|
1419
|
+
|
|
1420
|
+
|
|
1332
1421
|
class _TableMixIn:
|
|
1333
1422
|
|
|
1423
|
+
def _display_frame(self) -> pd.DataFrame:
|
|
1424
|
+
"""Build a plain ``pd.DataFrame`` copy for marimo's interactive table.
|
|
1425
|
+
|
|
1426
|
+
Object columns (which may hold large ndarrays, dicts, paths, ...) are
|
|
1427
|
+
replaced by their compact ``TableFormats`` string representation, the
|
|
1428
|
+
same one used by ``_repr_html_``. Numeric / bool / datetime / string
|
|
1429
|
+
columns are left native so marimo keeps real sorting and filtering.
|
|
1430
|
+
|
|
1431
|
+
A plain ``pd.DataFrame`` (not a ``DataTable``) is returned on purpose:
|
|
1432
|
+
it has no ``_display_`` of its own, so marimo applies its built-in
|
|
1433
|
+
opinionated formatter and renders an interactive, sortable, filterable
|
|
1434
|
+
table whose cells are already short.
|
|
1435
|
+
"""
|
|
1436
|
+
src = self if self.ndim == 2 else self._prep_repr()
|
|
1437
|
+
plain = pd.DataFrame(src)
|
|
1438
|
+
formatters = TableFormats.formatters(plain)
|
|
1439
|
+
for i, fnc in enumerate(formatters):
|
|
1440
|
+
if fnc is None or plain.dtypes.iloc[i] != object:
|
|
1441
|
+
continue
|
|
1442
|
+
col = plain.iloc[:, i]
|
|
1443
|
+
plain.isetitem(i, col.map(lambda v, _f=fnc: _format_display_cell(v, _f)))
|
|
1444
|
+
return plain
|
|
1445
|
+
|
|
1446
|
+
def _display_(self):
|
|
1447
|
+
"""marimo display protocol.
|
|
1448
|
+
|
|
1449
|
+
Has the highest precedence in marimo's formatter resolution, so it
|
|
1450
|
+
overrides marimo's built-in opinionated formatter for ``pd.DataFrame``
|
|
1451
|
+
/ ``pd.Series`` (which would otherwise dump full ndarray contents for
|
|
1452
|
+
the ``data`` column and similar).
|
|
1453
|
+
|
|
1454
|
+
We return a plain ``pd.DataFrame`` with object columns pre-rendered to
|
|
1455
|
+
compact strings, so marimo still shows its interactive table (sorting,
|
|
1456
|
+
filtering, pagination) but without dumping large arrays. On any failure
|
|
1457
|
+
we fall back to the static rich HTML used by Jupyter.
|
|
1458
|
+
"""
|
|
1459
|
+
try:
|
|
1460
|
+
return self._display_frame()
|
|
1461
|
+
except Exception:
|
|
1462
|
+
import marimo as mo
|
|
1463
|
+
html = self._repr_html_()
|
|
1464
|
+
return mo.plain(self) if html is None else mo.Html(html)
|
|
1465
|
+
|
|
1334
1466
|
def as_labels(self, index=True, data=False, squeeze=False) -> Labels | list[Labels]:
|
|
1335
1467
|
"""
|
|
1336
1468
|
Convert into list of datacast.labels.Labels objects.
|
|
@@ -1436,7 +1568,7 @@ class _TableMixIn:
|
|
|
1436
1568
|
if strict:
|
|
1437
1569
|
missing = set(levels).difference(index_names)
|
|
1438
1570
|
if missing:
|
|
1439
|
-
tbl = tbl.stack([*missing]) # will raise her if not found in columns
|
|
1571
|
+
tbl = tbl.stack([*missing], future_stack=True) # will raise her if not found in columns
|
|
1440
1572
|
index_names.update(missing)
|
|
1441
1573
|
|
|
1442
1574
|
tbl = tbl.unstack([*index_names.difference(levels)], **kws)
|
|
@@ -1469,7 +1601,13 @@ class _TableMixIn:
|
|
|
1469
1601
|
raise KeyError(f"Levels {missing} not found in index {tbl.index.names}")
|
|
1470
1602
|
col_names.update(missing)
|
|
1471
1603
|
|
|
1472
|
-
|
|
1604
|
+
# pandas 2: the new stack (future_stack=True) no longer accepts/handles
|
|
1605
|
+
# `dropna`/`sort` and never drops NaN rows, so emulate the old default.
|
|
1606
|
+
dropna = kws.pop('dropna', True)
|
|
1607
|
+
kws.pop('sort', None)
|
|
1608
|
+
tbl = tbl.stack([*col_names.difference(levels)], future_stack=True, **kws)
|
|
1609
|
+
if dropna:
|
|
1610
|
+
tbl = tbl.dropna(how='all') if tbl.ndim > 1 else tbl.dropna()
|
|
1473
1611
|
|
|
1474
1612
|
if tbl.ndim > 1 and tbl.columns.nlevels > 1: # if there are col levels
|
|
1475
1613
|
levels = [lvl for lvl in levels if lvl in col_names]
|
|
@@ -1702,18 +1840,32 @@ class _TableMixIn:
|
|
|
1702
1840
|
|
|
1703
1841
|
def qix(self: PTable, *anonymous,
|
|
1704
1842
|
drop_level: bool | Collection = False, keep: Collection = None,
|
|
1705
|
-
axis: AxisT = None, key_err=True, **named) -> PTable:
|
|
1843
|
+
axis: AxisT = None, key_err=True, reorder=False, **named) -> PTable:
|
|
1706
1844
|
"""
|
|
1707
1845
|
Filter a dataframe or series using fuzzy query of its multi-index.
|
|
1708
|
-
Arguments in different forms describe the index values to be searched for.
|
|
1709
1846
|
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1847
|
+
``qix`` is a FILTER, not a selector: each requested value may match
|
|
1848
|
+
zero, one, or many rows, and results follow the table's storage
|
|
1849
|
+
order. For structured, ordered, one-row-per-label retrieval (e.g.
|
|
1850
|
+
``img, gt = ...``) use :meth:`select`.
|
|
1851
|
+
|
|
1852
|
+
Capabilities, from simple to advanced:
|
|
1853
|
+
|
|
1854
|
+
1. One level by name: ``dt.qix(w='a')``
|
|
1855
|
+
2. Several levels (AND): ``dt.qix(w='a', x='1')``
|
|
1856
|
+
3. Several values per level: ``dt.qix(w=['b', 'a'])``
|
|
1857
|
+
(membership; rows stay in storage order)
|
|
1858
|
+
4. Both axes auto-matched: ``dt.qix(w='a', y='4')``
|
|
1859
|
+
- restrict with ``axis='index'``/``'columns'`` (or 0/1)
|
|
1860
|
+
5. Anonymous values: ``dt.qix('a', '3')``
|
|
1861
|
+
- search a value without naming its level (error if ambiguous)
|
|
1862
|
+
6. Collapse redundant levels with ``drop_level`` / ``keep``:
|
|
1863
|
+
``dt.qix(w='a', drop_level=True)`` (drops only when one value remains)
|
|
1864
|
+
7. Tolerate misses with ``key_err=False`` (returns empty table)
|
|
1865
|
+
8. Opt-in ordering: ``dt.qix(w=['b', 'a'], reorder=True)``
|
|
1866
|
+
- with ``reorder=True`` the listed values define each level's
|
|
1867
|
+
output order (first keyword = primary key; unqueried levels keep
|
|
1868
|
+
storage order), and duplicate listed values raise ``ValueError``.
|
|
1717
1869
|
|
|
1718
1870
|
:param self: The table to filter
|
|
1719
1871
|
:param anonymous: list of values from one of the index levels
|
|
@@ -1723,6 +1875,10 @@ class _TableMixIn:
|
|
|
1723
1875
|
:param keep: a collection of levels to keep - excludes using drop_level
|
|
1724
1876
|
:param axis: if specified query only this axis
|
|
1725
1877
|
:param key_err: raise KeyError if index not found or return empty
|
|
1878
|
+
:param reorder: if True, reorder results to the order values are listed
|
|
1879
|
+
per level (kwargs order = precedence) and raise ValueError
|
|
1880
|
+
on duplicate listed values. Default False (pure filter,
|
|
1881
|
+
storage order).
|
|
1726
1882
|
:param named: {level: value} - specific levels to find,
|
|
1727
1883
|
eliminates exhaustive search in all levels
|
|
1728
1884
|
:return: Filtered table
|
|
@@ -1730,8 +1886,6 @@ class _TableMixIn:
|
|
|
1730
1886
|
Tutorial:
|
|
1731
1887
|
Using this DataTable:
|
|
1732
1888
|
|
|
1733
|
-
>>> from iad.core.tests.test_pdtools import sdt_general
|
|
1734
|
-
>>> dt= sdt_general()
|
|
1735
1889
|
>>> dt # doctest: +NORMALIZE_WHITESPACE
|
|
1736
1890
|
z data1 data2
|
|
1737
1891
|
w a b c d a b c d
|
|
@@ -1776,6 +1930,17 @@ class _TableMixIn:
|
|
|
1776
1930
|
x
|
|
1777
1931
|
1 1 5
|
|
1778
1932
|
2 1 5
|
|
1933
|
+
|
|
1934
|
+
By default qix is a filter and keeps storage order. Pass
|
|
1935
|
+
``reorder=True`` to order results by the values you list.
|
|
1936
|
+
Example 4 - opt-in ordering (note y follows the listed [5, 3]):
|
|
1937
|
+
|
|
1938
|
+
>>> dt.qix(x='1', y=['5','3'], drop_level=True, reorder=True) # doctest: +NORMALIZE_WHITESPACE
|
|
1939
|
+
z data1 data2
|
|
1940
|
+
w a b c d a b c d
|
|
1941
|
+
y
|
|
1942
|
+
5 1 2 3 4 5 6 7 8
|
|
1943
|
+
3 1 2 3 4 5 6 7 8
|
|
1779
1944
|
"""
|
|
1780
1945
|
# ToDo: add option to search not only in the index
|
|
1781
1946
|
# FixMe: self.empty check twice!
|
|
@@ -1822,6 +1987,17 @@ class _TableMixIn:
|
|
|
1822
1987
|
logging.debug(f'qix:\n{err}')
|
|
1823
1988
|
return self.iloc[0:0]
|
|
1824
1989
|
|
|
1990
|
+
if reorder:
|
|
1991
|
+
for lvl, val in named.items():
|
|
1992
|
+
vals = as_list(val)
|
|
1993
|
+
if len(vals) != len(set(vals)):
|
|
1994
|
+
raise ValueError(f"Duplicate values with reorder=True: {lvl=}, {vals=}")
|
|
1995
|
+
for ax, axis_kws in enumerate(axs_kws):
|
|
1996
|
+
# follow kwargs (named) order so the first listed level is primary
|
|
1997
|
+
ordered = {lvl: named[lvl] for lvl in named if lvl in axis_kws}
|
|
1998
|
+
if ordered and (keys := order_keys(res.axes[ax], ordered)):
|
|
1999
|
+
res = res.take(np.lexsort(keys[::-1]), axis=ax)
|
|
2000
|
+
|
|
1825
2001
|
if drop_level:
|
|
1826
2002
|
to_set = lambda x: {x} if isinstance(x, (str, int)) else set(x)
|
|
1827
2003
|
for ax, (kws, axis) in enumerate(zip(axs_kws, res.axes)):
|
|
@@ -1896,15 +2072,13 @@ class _TableMixIn:
|
|
|
1896
2072
|
Include only float, int, boolean columns. If None, will attempt to use
|
|
1897
2073
|
everything, then use only numeric data. Not implemented for Series.
|
|
1898
2074
|
"""
|
|
1899
|
-
|
|
1900
|
-
kws = dict(axis=axis, level=level, numeric_only=numeric_only)
|
|
1901
|
-
mean = (pd.Series if series else pd.DataFrame).mean(self.nominal_value, **kws)
|
|
2075
|
+
mean = _reduce(self.nominal_value, 'mean', axis=axis, level=level, numeric_only=numeric_only)
|
|
1902
2076
|
if not (hasattr(mean, 'index') and hasattr(self, 'nominal_value')) or self.std_dev.sum().sum() == 0:
|
|
1903
2077
|
return mean
|
|
1904
2078
|
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
2079
|
+
num = _reduce(self.std_dev, 'count', axis=axis, level=level)
|
|
2080
|
+
sd = (_reduce(self.std_dev ** 2, 'sum', axis=axis, level=level,
|
|
2081
|
+
numeric_only=numeric_only) / num) ** 0.5
|
|
1908
2082
|
from uncertainties.unumpy import uarray
|
|
1909
2083
|
return mean.__class__(uarray(mean, sd), index=mean.index,
|
|
1910
2084
|
**({} if mean.ndim == 1 else {'columns': mean.columns}))
|
|
@@ -1930,11 +2104,14 @@ class _TableMixIn:
|
|
|
1930
2104
|
self = self.unstack_but(ws.index.names) # align indices
|
|
1931
2105
|
assert ws.shape[0] == self.shape[0], f"{ws.shape=} vs {self.shape=}"
|
|
1932
2106
|
|
|
1933
|
-
|
|
1934
|
-
|
|
2107
|
+
if level is None:
|
|
2108
|
+
ws = ws / ws.sum()
|
|
2109
|
+
else:
|
|
2110
|
+
ws = ws / ws.groupby(level=level).transform('sum')
|
|
2111
|
+
avr = _reduce(self.multiply(ws, 0), 'sum', level=level).unstack_but(level)
|
|
1935
2112
|
if unc:
|
|
1936
2113
|
from uncertainties.unumpy import uarray
|
|
1937
|
-
sd = ((self.sub(avr) ** 2).multiply(ws, 0)
|
|
2114
|
+
sd = (_reduce((self.sub(avr) ** 2).multiply(ws, 0), 'sum', level=level)) ** 0.5
|
|
1938
2115
|
if unc is True:
|
|
1939
2116
|
data = uarray(avr.values, sd.values)
|
|
1940
2117
|
return DataSeries(data, index=avr.index) if avr.ndim == 1 else \
|
|
@@ -1948,11 +2125,17 @@ class _TableMixIn:
|
|
|
1948
2125
|
index_names = self.index.names
|
|
1949
2126
|
return pd.concat([self.reset_index(), other.reset_index()]).set_index(list(index_names))
|
|
1950
2127
|
|
|
1951
|
-
def __or__(self: DTable, other
|
|
2128
|
+
def __or__(self: DTable, other):
|
|
2129
|
+
# Only the dict form adds index levels; delegate everything else
|
|
2130
|
+
# (e.g. boolean masks ``mask_a | mask_b``) to the pandas operator.
|
|
2131
|
+
if not isinstance(other, Mapping):
|
|
2132
|
+
return super().__or__(other)
|
|
1952
2133
|
(out := self.copy()).__ior__(other)
|
|
1953
2134
|
return out
|
|
1954
2135
|
|
|
1955
|
-
def __ior__(self: DTable, other
|
|
2136
|
+
def __ior__(self: DTable, other):
|
|
2137
|
+
if not isinstance(other, Mapping):
|
|
2138
|
+
return super().__ior__(other)
|
|
1956
2139
|
new_levels = list(other)
|
|
1957
2140
|
self[new_levels] = [*other.values()]
|
|
1958
2141
|
self.set_index(new_levels, append=True, inplace=True)
|
|
@@ -1980,6 +2163,30 @@ class _TableMixIn:
|
|
|
1980
2163
|
return hash_value.hexdigest()[-n:]
|
|
1981
2164
|
|
|
1982
2165
|
|
|
2166
|
+
def order_keys(axis_index: pd.Index | pd.MultiIndex, kws) -> list[np.ndarray]:
|
|
2167
|
+
""" Positional sort keys to reorder rows by the order values are listed.
|
|
2168
|
+
|
|
2169
|
+
For each level whose requested value is a list of two or more values,
|
|
2170
|
+
map every axis entry to the position of its value in that list. Levels
|
|
2171
|
+
with a scalar / single value are skipped (no reordering needed).
|
|
2172
|
+
|
|
2173
|
+
Args:
|
|
2174
|
+
axis_index: the (multi-)index of the already-filtered axis
|
|
2175
|
+
kws: {level: value | [values]} requested for this axis
|
|
2176
|
+
|
|
2177
|
+
Returns: list of int key arrays, one per listed level (kwargs order)
|
|
2178
|
+
"""
|
|
2179
|
+
keys = []
|
|
2180
|
+
for lvl, val in kws.items():
|
|
2181
|
+
vals = as_list(val)
|
|
2182
|
+
if len(vals) < 2:
|
|
2183
|
+
continue
|
|
2184
|
+
pos = {v: i for i, v in enumerate(vals)}
|
|
2185
|
+
level_vals = axis_index.get_level_values(lvl) if axis_index.nlevels > 1 else axis_index
|
|
2186
|
+
keys.append(level_vals.map(pos).to_numpy())
|
|
2187
|
+
return keys
|
|
2188
|
+
|
|
2189
|
+
|
|
1983
2190
|
def slicer(index: pd.Index | pd.MultiIndex, kws):
|
|
1984
2191
|
""" Slicing the index with kws requested
|
|
1985
2192
|
Args:
|
|
@@ -2021,7 +2228,8 @@ def path_fixer(root, fixer_name, cols='path') -> Callable[[pd.DataFrame], pd.Dat
|
|
|
2021
2228
|
|
|
2022
2229
|
def fix_path(df):
|
|
2023
2230
|
if columns := [c for c in df.columns if c in cols]:
|
|
2024
|
-
df
|
|
2231
|
+
df = df.copy()
|
|
2232
|
+
df[columns] = df[columns].map(fixer(root))
|
|
2025
2233
|
return df
|
|
2026
2234
|
|
|
2027
2235
|
return fix_path
|
|
@@ -2262,8 +2470,9 @@ def append_col(df: pd.DataFrame,
|
|
|
2262
2470
|
by cols and the added into `df`
|
|
2263
2471
|
:param levels: optionally provide names for the levels,
|
|
2264
2472
|
must be same number of them as the final levels
|
|
2265
|
-
:return:
|
|
2473
|
+
:return: a new data frame with the column(s) added (the input is not modified)
|
|
2266
2474
|
"""
|
|
2475
|
+
df = df.copy()
|
|
2267
2476
|
|
|
2268
2477
|
def add_levels(n):
|
|
2269
2478
|
df.columns = pd.MultiIndex.from_product([df.columns, *[*[['']] * n]])
|
|
@@ -2516,7 +2725,7 @@ def filter_full_groups(df, group) -> (pd.DataFrame, int):
|
|
|
2516
2725
|
group = as_list(group)
|
|
2517
2726
|
mv_idx = df.index.names.difference(group)
|
|
2518
2727
|
df = df.reset_index(mv_idx) # leave only 'scene' in the index
|
|
2519
|
-
full_groups = df.groupby(group).apply(lambda x: issubset_report(x.kind, ))
|
|
2728
|
+
full_groups = df.groupby(group).apply(lambda x: issubset_report(x.kind, ), include_groups=False)
|
|
2520
2729
|
df = df.loc[full_groups[full_groups].index]
|
|
2521
2730
|
bad_scenes = (full_groups == False).sum()
|
|
2522
2731
|
groups_num = len(full_groups) - bad_scenes
|
|
@@ -215,6 +215,32 @@ def rename_files_by_ref(files: Union[Iterable, str], ref_files: Union[Iterable,
|
|
|
215
215
|
return old_new_pairs
|
|
216
216
|
|
|
217
217
|
|
|
218
|
+
def inc_file_sfx(path, sep='_'):
|
|
219
|
+
"""Given full path (<dir>/<stem>.<ext>) find if there are any files with such name
|
|
220
|
+
or with additional suffix (<dir>/<stem><sep><num>.<ext>'),
|
|
221
|
+
and create a new name with suffix incremented over the maximal found.
|
|
222
|
+
|
|
223
|
+
- if only `path` start with '_1'
|
|
224
|
+
- if `path` is not found:
|
|
225
|
+
- found with suffixes - increment max suffix
|
|
226
|
+
- otherwise return the path
|
|
227
|
+
"""
|
|
228
|
+
path = Path(path).expanduser()
|
|
229
|
+
base = path.stem + sep
|
|
230
|
+
pat = re.compile(re.escape(base) + r'(\d+)')
|
|
231
|
+
last = max((
|
|
232
|
+
int(m.group(1))
|
|
233
|
+
for p in path.parent.iterdir()
|
|
234
|
+
if p.is_file() and (m := pat.fullmatch(p.stem))
|
|
235
|
+
), default=None)
|
|
236
|
+
|
|
237
|
+
if last is None:
|
|
238
|
+
stem = base + '1' if path.exists() else path.stem
|
|
239
|
+
else:
|
|
240
|
+
stem = base + str(last+1)
|
|
241
|
+
return path.with_stem(stem)
|
|
242
|
+
|
|
243
|
+
|
|
218
244
|
def normalize(path: PathT, out: Type[Path] | Type[str] | None = Path):
|
|
219
245
|
"""
|
|
220
246
|
Translate path into canonical form expanding user but not resolving links.
|
|
@@ -150,7 +150,6 @@ class NamedTupleI:
|
|
|
150
150
|
::
|
|
151
151
|
isinstance(type(obj), NamedTupleMeta)
|
|
152
152
|
"""
|
|
153
|
-
from .data.nptools import array_info_str
|
|
154
153
|
|
|
155
154
|
_fields: list[str]
|
|
156
155
|
__iter__: Callable
|
|
@@ -158,7 +157,8 @@ class NamedTupleI:
|
|
|
158
157
|
def __init__(self, *args, **kws): ...
|
|
159
158
|
|
|
160
159
|
def __repr__(self):
|
|
161
|
-
|
|
160
|
+
from .data.nptools import array_info_str
|
|
161
|
+
val_str = lambda v: (array_info_str(v, stats=False)
|
|
162
162
|
if hasattr(v, 'shape') and hasattr(v, 'ndim') else str(v))
|
|
163
163
|
fields_str = (f'{k}: {val_str(v)}' for k, v in zip(self._fields, self))
|
|
164
164
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
|
|
3
|
-
from iad.core.fs.filesproc import Locator, Path
|
|
3
|
+
from iad.core.fs.filesproc import Locator, Path, inc_file_sfx
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
def test_locator(tmp_path):
|
|
@@ -95,3 +95,32 @@ def test_locator_not_responsive(tmp_path):
|
|
|
95
95
|
loc.validate(alarm=True) # removes the "invalid" folder
|
|
96
96
|
assert not any(loc.defined()), "Must have failed to validate in 0 time"
|
|
97
97
|
|
|
98
|
+
|
|
99
|
+
def test_inc_file_sfx_returns_unused_path(tmp_path):
|
|
100
|
+
path = tmp_path / 'result.txt'
|
|
101
|
+
|
|
102
|
+
assert inc_file_sfx(path) == path
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_inc_file_sfx_starts_suffix_when_file_exists(tmp_path):
|
|
106
|
+
path = tmp_path / 'result.txt'
|
|
107
|
+
path.touch()
|
|
108
|
+
|
|
109
|
+
assert inc_file_sfx(path) == tmp_path / 'result_1.txt'
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_inc_file_sfx_increments_max_suffix(tmp_path):
|
|
113
|
+
path = tmp_path / 'result.txt'
|
|
114
|
+
(tmp_path / 'result_1.txt').touch()
|
|
115
|
+
(tmp_path / 'result_3.txt').touch()
|
|
116
|
+
(tmp_path / 'result_other.txt').touch()
|
|
117
|
+
|
|
118
|
+
assert inc_file_sfx(path) == tmp_path / 'result_4.txt'
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_inc_file_sfx_supports_custom_separator(tmp_path):
|
|
122
|
+
path = tmp_path / 'result.txt'
|
|
123
|
+
(tmp_path / 'result-2.txt').touch()
|
|
124
|
+
|
|
125
|
+
assert inc_file_sfx(path, sep='-') == tmp_path / 'result-3.txt'
|
|
126
|
+
|
|
@@ -326,6 +326,34 @@ def test_qix_anonymous(sdt):
|
|
|
326
326
|
sdt.qix('Im not there')
|
|
327
327
|
|
|
328
328
|
|
|
329
|
+
def test_qix_reorder(sdt):
|
|
330
|
+
# default is a pure filter: storage order, not requested order
|
|
331
|
+
qixed = sdt.qix(x='1', y=['5', '3'])
|
|
332
|
+
assert list(qixed.index.get_level_values('y')) == ['3', '5']
|
|
333
|
+
# opt-in reorder follows the listed order, single index level
|
|
334
|
+
qixed = sdt.qix(x='1', y=['5', '3'], reorder=True)
|
|
335
|
+
assert list(qixed.index.get_level_values('y')) == ['5', '3']
|
|
336
|
+
# reorder on the columns axis
|
|
337
|
+
qixed = sdt.qix(z='data1', w=['c', 'a'], reorder=True)
|
|
338
|
+
assert list(qixed.columns.get_level_values('w')) == ['c', 'a']
|
|
339
|
+
# multi-level: first kwarg ('y') is the primary key
|
|
340
|
+
qixed = sdt.qix(y=['5', '3'], x=['2', '1'], reorder=True)
|
|
341
|
+
assert list(qixed.index.get_level_values('y'))[:2] == ['5', '5']
|
|
342
|
+
assert list(qixed.index.get_level_values('x'))[:2] == ['2', '1']
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_qix_reorder_duplicates_raise(sdt):
|
|
346
|
+
# duplicates are rejected only under reorder=True
|
|
347
|
+
with pytest.raises(ValueError):
|
|
348
|
+
sdt.qix(y=['3', '3'], reorder=True)
|
|
349
|
+
with pytest.raises(ValueError):
|
|
350
|
+
sdt.qix(w=['a', 'a'], reorder=True)
|
|
351
|
+
with pytest.raises(ValueError):
|
|
352
|
+
sdt.qix('a', w='a', reorder=True)
|
|
353
|
+
# without reorder the same duplicates are tolerated (no raise)
|
|
354
|
+
assert sdt.qix(y=['3', '3']) is not None
|
|
355
|
+
|
|
356
|
+
|
|
329
357
|
def test_freeze(multi_label_data_table):
|
|
330
358
|
dt = multi_label_data_table
|
|
331
359
|
initial_id = id(dt)
|
|
@@ -373,11 +401,58 @@ def test_append_col():
|
|
|
373
401
|
[4, 5, 6],
|
|
374
402
|
[7, 8, 9]], index=pd.MultiIndex.from_tuples([(1, 1), (1, 2), (1, 3)]))
|
|
375
403
|
dt.columns = pd.MultiIndex.from_tuples([(1, 1), (1, 2), (1, 3)])
|
|
376
|
-
append_col(dt, col='4', values=[10, 11, 12])
|
|
377
|
-
append_col(dt, col='4', values=[3, 1, 11])
|
|
404
|
+
dt = append_col(dt, col='4', values=[10, 11, 12])
|
|
405
|
+
dt = append_col(dt, col='4', values=[3, 1, 11])
|
|
378
406
|
assert int(dt.loc[(1, 3), '4']) == 11
|
|
379
407
|
|
|
380
408
|
|
|
409
|
+
def test_append_col_does_not_mutate_input():
|
|
410
|
+
"""append_col must return a new frame and leave the input untouched (pandas 2 / CoW)."""
|
|
411
|
+
base = DT(data=[[1], [2]], columns=['a'])
|
|
412
|
+
out = pdt.append_col(base, col='b', values=[10, 20])
|
|
413
|
+
assert 'b' not in base.columns # input untouched
|
|
414
|
+
assert out['b'].tolist() == [10, 20] # result carries the new column
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def test_reduce_level_aggregation():
|
|
418
|
+
"""`_reduce` replaces the removed pandas-1 `level=` aggregation argument."""
|
|
419
|
+
idx = pd.MultiIndex.from_product([['a', 'b'], [1, 2]], names=['g', 'i'])
|
|
420
|
+
s = DS([1.0, 3.0, 10.0, 20.0], index=idx)
|
|
421
|
+
df = DT({'v': [1.0, 3.0, 10.0, 20.0], 'w': [2.0, 4.0, 6.0, 8.0]}, index=idx)
|
|
422
|
+
|
|
423
|
+
# by level -> grouped result indexed by that level
|
|
424
|
+
assert pdt._reduce(s, 'mean', level='g').to_dict() == {'a': 2.0, 'b': 15.0}
|
|
425
|
+
assert pdt._reduce(s, 'sum', level='g').to_dict() == {'a': 4.0, 'b': 30.0}
|
|
426
|
+
assert pdt._reduce(s, 'count', level='g').to_dict() == {'a': 2, 'b': 2}
|
|
427
|
+
assert pdt._reduce(df, 'sum', level='g').loc['a'].to_dict() == {'v': 4.0, 'w': 6.0}
|
|
428
|
+
|
|
429
|
+
# no level -> plain reduction
|
|
430
|
+
assert pdt._reduce(s, 'sum') == 34.0
|
|
431
|
+
assert pdt._reduce(df, 'mean')['v'] == 8.5
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def test_bitwise_or_is_elementwise_for_masks():
|
|
435
|
+
"""The custom __or__ must not hijack boolean-mask combination (`mask | mask`)."""
|
|
436
|
+
idx = pd.MultiIndex.from_product([['a', 'b'], [1, 2]], names=['g', 'i'])
|
|
437
|
+
s = DS([1.0, np.nan, np.inf, -np.inf], index=idx)
|
|
438
|
+
assert (s.isnull() | np.isinf(s)).tolist() == [False, True, True, True]
|
|
439
|
+
|
|
440
|
+
df = DT({'x': [1.0, np.nan], 'y': [np.inf, 2.0]})
|
|
441
|
+
assert (df.isnull() | ~np.isfinite(df)).values.tolist() == [[False, True], [True, False]]
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def test_or_dict_still_adds_index_level():
|
|
445
|
+
"""The dict form of `|` keeps its index-level-appending behaviour."""
|
|
446
|
+
dt = DT({'v': [1, 2]})
|
|
447
|
+
out = dt | dict(mode='X', fid='001')
|
|
448
|
+
assert 'mode' in out.index.names and 'fid' in out.index.names
|
|
449
|
+
assert out.index.get_level_values('mode').tolist() == ['X', 'X']
|
|
450
|
+
|
|
451
|
+
dt2 = DT({'v': [1, 2]})
|
|
452
|
+
dt2 |= dict(mode='Y')
|
|
453
|
+
assert dt2.index.get_level_values('mode').tolist() == ['Y', 'Y']
|
|
454
|
+
|
|
455
|
+
|
|
381
456
|
def test_sample(sdt):
|
|
382
457
|
sample = pdt.sample
|
|
383
458
|
assert len(sample(sdt, 2)) == 2
|
|
@@ -571,6 +646,8 @@ if __name__ == '__main__':
|
|
|
571
646
|
test_qix_basic(sdt)
|
|
572
647
|
test_qix_specified(sdt)
|
|
573
648
|
test_qix_anonymous(sdt)
|
|
649
|
+
test_qix_reorder(sdt)
|
|
650
|
+
test_qix_reorder_duplicates_raise(sdt)
|
|
574
651
|
test_freeze()
|
|
575
652
|
test_kron()
|
|
576
653
|
test_outer()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|