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.
Files changed (53) hide show
  1. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/PKG-INFO +1 -1
  2. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/pyproject.toml +2 -1
  3. ialdev_core-0.2.6/src/iad/core/data/labeled.py +249 -0
  4. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/data/pdtools.py +239 -30
  5. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/fs/filesproc.py +26 -0
  6. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/wrap.py +2 -2
  7. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_filesproc.py +30 -1
  8. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_pdtools.py +79 -2
  9. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/.gitignore +0 -0
  10. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/.pixi/config.toml +0 -0
  11. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/README.md +0 -0
  12. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/__init__.py +0 -0
  13. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/cache.py +0 -0
  14. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/codetools.py +0 -0
  15. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/data/__init__.py +0 -0
  16. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/data/array.py +0 -0
  17. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/data/binary.py +0 -0
  18. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/data/label.py +0 -0
  19. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/data/nptools.py +0 -0
  20. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/data/unc_panda.py +0 -0
  21. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/data/units.py +0 -0
  22. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/datatools.py +0 -0
  23. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/docs/locators.ipynb +0 -0
  24. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/events.py +0 -0
  25. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/fnctools.py +0 -0
  26. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/fs/__init__.py +0 -0
  27. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/fs/env.py +0 -0
  28. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/fs/paths.py +0 -0
  29. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/logs.py +0 -0
  30. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/one_dark.puml +0 -0
  31. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/param/__init__.py +0 -0
  32. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/param/confargparse.py +0 -0
  33. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/param/paramaze.py +0 -0
  34. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/regexp.py +0 -0
  35. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/short.py +0 -0
  36. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/strings.py +0 -0
  37. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/src/iad/core/tbox.py +0 -0
  38. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/array.ipynb +0 -0
  39. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/conftest.py +0 -0
  40. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/data/.env +0 -0
  41. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/dot_styles_examples.ipynb +0 -0
  42. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_array.py +0 -0
  43. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_binar.py +0 -0
  44. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_cachepipe.py +0 -0
  45. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_codetools.py +0 -0
  46. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_coltools.py +0 -0
  47. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_datatools.py +0 -0
  48. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_events.py +0 -0
  49. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_nptools.py +0 -0
  50. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_paths.py +0 -0
  51. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_regexp.py +0 -0
  52. {ialdev_core-0.2.4 → ialdev_core-0.2.6}/tests/test_shorts.py +0 -0
  53. {ialdev_core-0.2.4 → 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.4
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.4"
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.set_precision(self.prec)
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.hide_columns([*columns]), None
821
- yield table.hide_index(), cap
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
- tbl = tbl.stack([*col_names.difference(levels)], **kws) # Need remove NULL rows?
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
- Some index levels in the output can be dropped by specifying either
1711
- ``drop_level`` or ``keep`` (mutually exclusive) arguments.
1712
-
1713
- Use ``drop_level=True`` to drop all the redundant levels used in the query, or
1714
- just provide one (or list) of specific levels to drop.
1715
- Would not drop if the level is important for indexing the data (there is no redundancy),
1716
- 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``.
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
- series = isinstance(self, pd.Series)
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
- kws.update(level=level)
1906
- num = self.std_dev.count(**(dict(level=level) if series else kws))
1907
- sd = ((self.std_dev ** 2).sum(**kws) / num) ** 0.5
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
- ws = ws / ws.sum(level=level)
1934
- avr = (self.multiply(ws, 0)).sum(level=level).unstack_but(level)
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).sum(level=level)) ** 0.5
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: dict):
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: dict):
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[columns] = df[columns].applymap(fixer(root))
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: return the input df (the operations are inplace)
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
- 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)
@@ -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