ialdev-core 0.2.5__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.
Files changed (53) hide show
  1. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/PKG-INFO +1 -1
  2. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/pyproject.toml +1 -1
  3. ialdev_core-0.2.6/src/iad/core/data/labeled.py +249 -0
  4. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/data/pdtools.py +123 -9
  5. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/fs/filesproc.py +26 -0
  6. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/wrap.py +2 -2
  7. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/tests/test_filesproc.py +30 -1
  8. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/tests/test_pdtools.py +30 -0
  9. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/.gitignore +0 -0
  10. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/.pixi/config.toml +0 -0
  11. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/README.md +0 -0
  12. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/__init__.py +0 -0
  13. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/cache.py +0 -0
  14. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/codetools.py +0 -0
  15. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/data/__init__.py +0 -0
  16. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/data/array.py +0 -0
  17. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/data/binary.py +0 -0
  18. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/data/label.py +0 -0
  19. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/data/nptools.py +0 -0
  20. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/data/unc_panda.py +0 -0
  21. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/data/units.py +0 -0
  22. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/datatools.py +0 -0
  23. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/docs/locators.ipynb +0 -0
  24. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/events.py +0 -0
  25. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/fnctools.py +0 -0
  26. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/fs/__init__.py +0 -0
  27. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/fs/env.py +0 -0
  28. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/fs/paths.py +0 -0
  29. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/logs.py +0 -0
  30. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/one_dark.puml +0 -0
  31. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/param/__init__.py +0 -0
  32. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/param/confargparse.py +0 -0
  33. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/param/paramaze.py +0 -0
  34. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/regexp.py +0 -0
  35. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/short.py +0 -0
  36. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/strings.py +0 -0
  37. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/src/iad/core/tbox.py +0 -0
  38. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/tests/array.ipynb +0 -0
  39. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/tests/conftest.py +0 -0
  40. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/tests/data/.env +0 -0
  41. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/tests/dot_styles_examples.ipynb +0 -0
  42. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/tests/test_array.py +0 -0
  43. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/tests/test_binar.py +0 -0
  44. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/tests/test_cachepipe.py +0 -0
  45. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/tests/test_codetools.py +0 -0
  46. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/tests/test_coltools.py +0 -0
  47. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/tests/test_datatools.py +0 -0
  48. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/tests/test_events.py +0 -0
  49. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/tests/test_nptools.py +0 -0
  50. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/tests/test_paths.py +0 -0
  51. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/tests/test_regexp.py +0 -0
  52. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/tests/test_shorts.py +0 -0
  53. {ialdev_core-0.2.5 → ialdev_core-0.2.6}/tests/test_tbox.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ialdev-core
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: iad.core — algorithmic utilities: data tools, parameters, paths, caching, and more
5
5
  Author: ipcoder
6
6
  Requires-Python: >=3.10
@@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi"
4
4
 
5
5
  [project]
6
6
  name = "ialdev-core"
7
- version = "0.2.5"
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"
@@ -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
@@ -1050,6 +1050,56 @@ def _expand_vec_values(pairs: list[tuple[Scalar, Scalar | Vector]], prev_expande
1050
1050
  yield prev_expanded # return as received - start aggregation
1051
1051
 
1052
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
+
1053
1103
  def _row_queries_gen(labels, levels, levels_map) -> Generator[list[tuple[int, Scalar]]]:
1054
1104
  """
1055
1105
  Generator of mult-index row queries in form of:
@@ -1790,18 +1840,32 @@ class _TableMixIn:
1790
1840
 
1791
1841
  def qix(self: PTable, *anonymous,
1792
1842
  drop_level: bool | Collection = False, keep: Collection = None,
1793
- axis: AxisT = None, key_err=True, **named) -> PTable:
1843
+ axis: AxisT = None, key_err=True, reorder=False, **named) -> PTable:
1794
1844
  """
1795
1845
  Filter a dataframe or series using fuzzy query of its multi-index.
1796
- Arguments in different forms describe the index values to be searched for.
1797
-
1798
- Some index levels in the output can be dropped by specifying either
1799
- ``drop_level`` or ``keep`` (mutually exclusive) arguments.
1800
1846
 
1801
- Use ``drop_level=True`` to drop all the redundant levels used in the query, or
1802
- just provide one (or list) of specific levels to drop.
1803
- Would not drop if the level is important for indexing the data (there is no redundancy),
1804
- even if specified in the drop_level list
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``.
1805
1869
 
1806
1870
  :param self: The table to filter
1807
1871
  :param anonymous: list of values from one of the index levels
@@ -1811,6 +1875,10 @@ class _TableMixIn:
1811
1875
  :param keep: a collection of levels to keep - excludes using drop_level
1812
1876
  :param axis: if specified query only this axis
1813
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).
1814
1882
  :param named: {level: value} - specific levels to find,
1815
1883
  eliminates exhaustive search in all levels
1816
1884
  :return: Filtered table
@@ -1862,6 +1930,17 @@ class _TableMixIn:
1862
1930
  x
1863
1931
  1 1 5
1864
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
1865
1944
  """
1866
1945
  # ToDo: add option to search not only in the index
1867
1946
  # FixMe: self.empty check twice!
@@ -1908,6 +1987,17 @@ class _TableMixIn:
1908
1987
  logging.debug(f'qix:\n{err}')
1909
1988
  return self.iloc[0:0]
1910
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
+
1911
2001
  if drop_level:
1912
2002
  to_set = lambda x: {x} if isinstance(x, (str, int)) else set(x)
1913
2003
  for ax, (kws, axis) in enumerate(zip(axs_kws, res.axes)):
@@ -2073,6 +2163,30 @@ class _TableMixIn:
2073
2163
  return hash_value.hexdigest()[-n:]
2074
2164
 
2075
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
+
2076
2190
  def slicer(index: pd.Index | pd.MultiIndex, kws):
2077
2191
  """ Slicing the index with kws requested
2078
2192
  Args:
@@ -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
- val_str = lambda v: (NamedTupleI.array_info_str(v, stats=False)
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)
@@ -618,6 +646,8 @@ if __name__ == '__main__':
618
646
  test_qix_basic(sdt)
619
647
  test_qix_specified(sdt)
620
648
  test_qix_anonymous(sdt)
649
+ test_qix_reorder(sdt)
650
+ test_qix_reorder_duplicates_raise(sdt)
621
651
  test_freeze()
622
652
  test_kron()
623
653
  test_outer()
File without changes
File without changes
File without changes