flpit 0.3.0__tar.gz → 0.3.1.dev6__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: flpit
3
- Version: 0.3.0
3
+ Version: 0.3.1.dev6
4
4
  Summary: Fluent LINQ for Python — flip your iterables
5
5
  Project-URL: Homepage, https://github.com/riemar/flpit
6
6
  Project-URL: Repository, https://github.com/riemar/flpit
@@ -311,6 +311,30 @@ It does not modify `original`, and it is not affected by the separately created
311
311
 
312
312
  This matches the independent-query behavior of .NET LINQ.
313
313
 
314
+ ### Joins
315
+
316
+ ```python
317
+ customers = [("Prakash", 1), ("Tim", 2), ("Robert", 3)]
318
+ orders = [(10, 1), (11, 1), (12, 4)] # (order_id, customer_id)
319
+
320
+ flp.it(customers).join(orders, lambda c: c[1], lambda o: o[1], lambda c, o: (c[0], o[0]))
321
+ # [("Prakash", 10), ("Prakash", 11)] inner join (.NET Join)
322
+
323
+ flp.it(customers).left_join(orders, lambda c: c[1], lambda o: o[1], lambda c, o: (c[0], o and o[0]))
324
+ # [("Prakash", 10), ("Prakash", 11), ("Tim", None), ("Robert", None)] (.NET 10 LeftJoin)
325
+
326
+ flp.it(customers).full_join(orders, lambda c: c[1], lambda o: o[1], lambda c, o: (c and c[0], o and o[0]))
327
+ # [("Prakash", 10), ("Prakash", 11), ("Tim", None), ("Robert", None), (None, 12)]
328
+ ```
329
+
330
+ Without a result selector all three yield `JoinTuple(outer, inner)`.
331
+
332
+ * Elements whose key is `None` never match. `left_join()` / `full_join()` still emit them as unmatched.
333
+ * An unmatched side is passed to the result selector as `None` (the Python equivalent of .NET `default`).
334
+ * `full_join()` has no .NET LINQ equivalent. It yields the `left_join()` rows in outer order, followed by every inner element that was never matched (in inner order).
335
+ * Argument validation is eager (`InnerNoneError`, `OuterKeySelectorNoneError`, `InnerKeySelectorNoneError`, `ResultSelectorNoneError`); everything else is deferred.
336
+ * Custom equality comparers are not supported yet (keys are compared with `==` / `hash`, like `group_by()`).
337
+
314
338
  ## Execution Characteristics
315
339
 
316
340
  Lazy execution does not mean that every operator is streaming. Some operators must consume and buffer their input before they can produce their result.
@@ -321,9 +345,14 @@ Lazy execution does not mean that every operator is streaming. Some operators mu
321
345
  | `where()` / `select()` | Yes | Yes | No |
322
346
  | `order_by()` | Yes | No | Yes |
323
347
  | `group_by()` | Yes | No | Yes |
348
+ | `join()` | Yes | Yes (outer) | Yes (inner) |
349
+ | `left_join()` | Yes | Yes (outer) | Yes (inner) |
350
+ | `full_join()` | Yes | Yes (outer) | Yes (inner) |
324
351
 
325
352
  `order_by()` and `group_by()` therefore remain deferred at query construction time, but enumeration of either operator requires buffering its upstream input.
326
353
 
354
+ `join()`, `left_join()` and `full_join()` build a hash lookup of the `inner` sequence when enumeration starts and stream the outer sequence (O(N + M)).
355
+
327
356
  `group_by()` produces a query of groupings rather than terminating the pipeline:
328
357
 
329
358
  ```python
@@ -441,17 +470,26 @@ Distributed under the MIT License. See `LICENSE` for more information.
441
470
  | │ ├── test_all.py | 80 |
442
471
  | │ ├── test_any.py | 90 |
443
472
  | │ ├── test_average.py | 572 |
473
+ | │ ├── test_chunk.py | 22 |
444
474
  | │ ├── test_concat.py | 64 |
475
+ | │ ├── test_contains.py | 108 |
445
476
  | │ ├── test_count.py | 37 |
446
477
  | │ ├── test_distinct.py | 87 |
447
478
  | │ ├── test_element_at.py | 43 |
448
479
  | │ ├── test_first.py | 29 |
449
480
  | │ ├── test_first_or_default.py | 50 |
481
+ | │ ├── test_full_join.py | 37 |
450
482
  | │ ├── test_generic_list.py | 95 |
451
483
  | │ ├── test_group_by.py | 23 |
484
+ | │ ├── test_join.py | 47 |
452
485
  | │ ├── test_last.py | 29 |
453
- | │ ├── test_order_by.py | 98 |
486
+ | │ ├── test_last_or_default.py | 45 |
487
+ | │ ├── test_left_join.py | 47 |
488
+ | │ ├── test_max.py | 1100 |
489
+ | │ ├── test_min.py | 968 |
490
+ | │ ├── test_order_by.py | 100 |
454
491
  | │ ├── test_order_descending_by.py | 45 |
492
+ | │ ├── test_reverse.py | 35 |
455
493
  | │ ├── test_select.py | 140 |
456
494
  | │ ├── test_select_many.py | 85 |
457
495
  | │ ├── test_single.py | 34 |
@@ -461,7 +499,8 @@ Distributed under the MIT License. See `LICENSE` for more information.
461
499
  | │ ├── test_take.py | 39 |
462
500
  | │ ├── test_then_by.py | 40 |
463
501
  | │ ├── test_then_descending_by.py | 34 |
464
- | │ └── test_where.py | 76 |
502
+ | │ ├── test_where.py | 76 |
503
+ | │ └── test_zip.py | 101 |
465
504
  | └── task/ | |
466
505
  | ├── test_cancellation.py | 69 |
467
506
  | ├── test_task_completion_source.py | 26 |
@@ -469,6 +508,6 @@ Distributed under the MIT License. See `LICENSE` for more information.
469
508
  | ├── test_task_rt.py | 12 |
470
509
  | └── test_task_rt_core.py | 22 |
471
510
  --------------------------------------------------------------------------------
472
- | GRAND TOTALS | 2221 |
511
+ | GRAND TOTALS | 4733 |
473
512
  ================================================================================
474
513
  ```
@@ -287,6 +287,30 @@ It does not modify `original`, and it is not affected by the separately created
287
287
 
288
288
  This matches the independent-query behavior of .NET LINQ.
289
289
 
290
+ ### Joins
291
+
292
+ ```python
293
+ customers = [("Prakash", 1), ("Tim", 2), ("Robert", 3)]
294
+ orders = [(10, 1), (11, 1), (12, 4)] # (order_id, customer_id)
295
+
296
+ flp.it(customers).join(orders, lambda c: c[1], lambda o: o[1], lambda c, o: (c[0], o[0]))
297
+ # [("Prakash", 10), ("Prakash", 11)] inner join (.NET Join)
298
+
299
+ flp.it(customers).left_join(orders, lambda c: c[1], lambda o: o[1], lambda c, o: (c[0], o and o[0]))
300
+ # [("Prakash", 10), ("Prakash", 11), ("Tim", None), ("Robert", None)] (.NET 10 LeftJoin)
301
+
302
+ flp.it(customers).full_join(orders, lambda c: c[1], lambda o: o[1], lambda c, o: (c and c[0], o and o[0]))
303
+ # [("Prakash", 10), ("Prakash", 11), ("Tim", None), ("Robert", None), (None, 12)]
304
+ ```
305
+
306
+ Without a result selector all three yield `JoinTuple(outer, inner)`.
307
+
308
+ * Elements whose key is `None` never match. `left_join()` / `full_join()` still emit them as unmatched.
309
+ * An unmatched side is passed to the result selector as `None` (the Python equivalent of .NET `default`).
310
+ * `full_join()` has no .NET LINQ equivalent. It yields the `left_join()` rows in outer order, followed by every inner element that was never matched (in inner order).
311
+ * Argument validation is eager (`InnerNoneError`, `OuterKeySelectorNoneError`, `InnerKeySelectorNoneError`, `ResultSelectorNoneError`); everything else is deferred.
312
+ * Custom equality comparers are not supported yet (keys are compared with `==` / `hash`, like `group_by()`).
313
+
290
314
  ## Execution Characteristics
291
315
 
292
316
  Lazy execution does not mean that every operator is streaming. Some operators must consume and buffer their input before they can produce their result.
@@ -297,9 +321,14 @@ Lazy execution does not mean that every operator is streaming. Some operators mu
297
321
  | `where()` / `select()` | Yes | Yes | No |
298
322
  | `order_by()` | Yes | No | Yes |
299
323
  | `group_by()` | Yes | No | Yes |
324
+ | `join()` | Yes | Yes (outer) | Yes (inner) |
325
+ | `left_join()` | Yes | Yes (outer) | Yes (inner) |
326
+ | `full_join()` | Yes | Yes (outer) | Yes (inner) |
300
327
 
301
328
  `order_by()` and `group_by()` therefore remain deferred at query construction time, but enumeration of either operator requires buffering its upstream input.
302
329
 
330
+ `join()`, `left_join()` and `full_join()` build a hash lookup of the `inner` sequence when enumeration starts and stream the outer sequence (O(N + M)).
331
+
303
332
  `group_by()` produces a query of groupings rather than terminating the pipeline:
304
333
 
305
334
  ```python
@@ -417,17 +446,26 @@ Distributed under the MIT License. See `LICENSE` for more information.
417
446
  | │ ├── test_all.py | 80 |
418
447
  | │ ├── test_any.py | 90 |
419
448
  | │ ├── test_average.py | 572 |
449
+ | │ ├── test_chunk.py | 22 |
420
450
  | │ ├── test_concat.py | 64 |
451
+ | │ ├── test_contains.py | 108 |
421
452
  | │ ├── test_count.py | 37 |
422
453
  | │ ├── test_distinct.py | 87 |
423
454
  | │ ├── test_element_at.py | 43 |
424
455
  | │ ├── test_first.py | 29 |
425
456
  | │ ├── test_first_or_default.py | 50 |
457
+ | │ ├── test_full_join.py | 37 |
426
458
  | │ ├── test_generic_list.py | 95 |
427
459
  | │ ├── test_group_by.py | 23 |
460
+ | │ ├── test_join.py | 47 |
428
461
  | │ ├── test_last.py | 29 |
429
- | │ ├── test_order_by.py | 98 |
462
+ | │ ├── test_last_or_default.py | 45 |
463
+ | │ ├── test_left_join.py | 47 |
464
+ | │ ├── test_max.py | 1100 |
465
+ | │ ├── test_min.py | 968 |
466
+ | │ ├── test_order_by.py | 100 |
430
467
  | │ ├── test_order_descending_by.py | 45 |
468
+ | │ ├── test_reverse.py | 35 |
431
469
  | │ ├── test_select.py | 140 |
432
470
  | │ ├── test_select_many.py | 85 |
433
471
  | │ ├── test_single.py | 34 |
@@ -437,7 +475,8 @@ Distributed under the MIT License. See `LICENSE` for more information.
437
475
  | │ ├── test_take.py | 39 |
438
476
  | │ ├── test_then_by.py | 40 |
439
477
  | │ ├── test_then_descending_by.py | 34 |
440
- | │ └── test_where.py | 76 |
478
+ | │ ├── test_where.py | 76 |
479
+ | │ └── test_zip.py | 101 |
441
480
  | └── task/ | |
442
481
  | ├── test_cancellation.py | 69 |
443
482
  | ├── test_task_completion_source.py | 26 |
@@ -445,6 +484,6 @@ Distributed under the MIT License. See `LICENSE` for more information.
445
484
  | ├── test_task_rt.py | 12 |
446
485
  | └── test_task_rt_core.py | 22 |
447
486
  --------------------------------------------------------------------------------
448
- | GRAND TOTALS | 2221 |
487
+ | GRAND TOTALS | 4733 |
449
488
  ================================================================================
450
489
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flpit"
3
- version = "0.3.0"
3
+ version = "0.3.1.dev6"
4
4
  description = "Fluent LINQ for Python — flip your iterables"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -4,6 +4,10 @@ Fluent LINQ for Python — flip your iterables.
4
4
  from importlib.metadata import version
5
5
  __version__ = version("flpit")
6
6
 
7
+ # noinspection protected-member
8
+ from flpit.core._zip import ZipTuple
9
+ from flpit.core._join import JoinTuple
10
+
7
11
  from flpit import flp
8
12
  from flpit.core.linq import (
9
13
  FlpIt,
@@ -32,9 +36,12 @@ from flpit.core.linq import (
32
36
  SecondNoneError,
33
37
  FuncNoneError,
34
38
  ResultSelectorNoneError,
39
+ InnerNoneError,
40
+ OuterKeySelectorNoneError,
41
+ InnerKeySelectorNoneError,
35
42
  ArgumentOutOfRangeError,
36
43
  ArgumentNonNegError,
37
- ArgumentOutOfBoundsError
44
+ ArgumentOutOfBoundsError,
38
45
  )
39
46
 
40
47
  from flpit.core._grouping import Grouping
@@ -61,8 +68,14 @@ __all__ = [
61
68
  "SecondNoneError",
62
69
  "FuncNoneError",
63
70
  "ResultSelectorNoneError",
71
+ "InnerNoneError",
72
+ "OuterKeySelectorNoneError",
73
+ "InnerKeySelectorNoneError",
64
74
  "ArgumentOutOfRangeError",
65
75
  "ArgumentNonNegError",
66
76
  "ArgumentOutOfBoundsError",
77
+
78
+ "ZipTuple",
79
+ "JoinTuple",
67
80
  ]
68
81
 
@@ -0,0 +1,7 @@
1
+ from typing import NamedTuple
2
+
3
+
4
+ class JoinTuple[TOuter, TInner](NamedTuple):
5
+ """Default result of join / left_join / full_join when no result selector is given."""
6
+ outer: TOuter
7
+ inner: TInner
@@ -0,0 +1,13 @@
1
+ from typing import NamedTuple
2
+
3
+ class ZipTuple[TFirst, TSecond](NamedTuple):
4
+ first: TFirst
5
+ second: TSecond
6
+
7
+ @property
8
+ def item1(self) -> TFirst:
9
+ return self.first
10
+
11
+ @property
12
+ def item2(self) -> TSecond:
13
+ return self.second
@@ -8,8 +8,10 @@ from contextlib import AbstractContextManager
8
8
 
9
9
  import builtins
10
10
  from collections import UserList
11
- from functools import wraps
12
- from itertools import islice
11
+ from functools import wraps, partial as _partial
12
+ from operator import is_not as _is_not, ne as _ne, eq as _eq
13
+ from itertools import islice, chain, compress
14
+ from numbers import Real as _Real
13
15
 
14
16
  from typing import (
15
17
  Any,
@@ -33,6 +35,8 @@ from typing import (
33
35
  )
34
36
 
35
37
  from flpit.json import JsonConvert, JsonSerializerOptions
38
+ from flpit import ZipTuple
39
+ from flpit.core._join import JoinTuple
36
40
 
37
41
  TKey = TypeVar("TKey")
38
42
  TOther = TypeVar("TOther")
@@ -105,9 +109,21 @@ class FuncNoneError(ArgumentNullError):
105
109
  super().__init__("func")
106
110
 
107
111
  class ResultSelectorNoneError(ArgumentNullError):
108
- def __init__(self):
112
+ def __init__(self) -> None:
109
113
  super().__init__("resultSelector")
110
114
 
115
+ class InnerNoneError(ArgumentNullError):
116
+ def __init__(self) -> None:
117
+ super().__init__("inner")
118
+
119
+ class OuterKeySelectorNoneError(ArgumentNullError):
120
+ def __init__(self) -> None:
121
+ super().__init__("outerKeySelector")
122
+
123
+ class InnerKeySelectorNoneError(ArgumentNullError):
124
+ def __init__(self) -> None:
125
+ super().__init__("innerKeySelector")
126
+
111
127
  class ArgumentOutOfRangeError(ArgumentError):
112
128
  def __init__(self, argument: str, pattern:str="Index was out of range. Must be non-negative and less than the size of the collection."):
113
129
  super().__init__(f"{pattern} (Parameter '{argument}')")
@@ -121,25 +137,147 @@ class ArgumentOutOfBoundsError(ArgumentError):
121
137
  super().__init__("Offset and length were out of bounds for the array or count is greater than the number of elements from index to the end of the source collection.")
122
138
 
123
139
 
124
- def _guard_empty[SelfT: Sized, R, **P](
125
- func: Callable[Concatenate[SelfT, P], R],
126
- ) -> Callable[Concatenate[SelfT, P], R]:
127
- """Translate empty-sequence ValueError into EmptySequenceError."""
140
+ _NOT_NONE = _partial(_is_not, None)
141
+ _EXTREME_CHUNK = 1024
128
142
 
129
- @wraps(func)
130
- def wrapper(
131
- self: SelfT,
132
- *args: P.args,
133
- **kwargs: P.kwargs,
134
- ) -> R:
135
- try:
136
- return func(self, *args, **kwargs)
137
- except ValueError as e:
138
- if len(self) == 0:
139
- raise EmptySequenceError() from e
140
- raise
141
143
 
142
- return wrapper
144
+ def _project(items: Iterable[Any], selector: Any) -> Iterable[Any]:
145
+ """Applies an optional selector lazily (C-level map); None selector is an error."""
146
+ if selector is _SENTINEL:
147
+ return items
148
+ if selector is None:
149
+ raise SelectorNoneError()
150
+ return map(selector, items)
151
+
152
+
153
+ def _extreme_values(items: Iterable[Any], is_min: bool) -> Any:
154
+ """
155
+ Single-pass min/max of values with .NET Enumerable.Min/Max semantics.
156
+
157
+ flpit has no runtime generics, so every sequence is "None-able":
158
+
159
+ - empty input or only None values -> None
160
+ - None values are skipped (nullable Min/Max)
161
+ - numeric NaN uses .NET's total order, where NaN is smaller than every
162
+ other value, including -inf: min -> NaN if any is present,
163
+ max -> NaN only if every value is NaN
164
+ (builtins.min/max depend on where the NaN sits, since every comparison
165
+ with NaN is False)
166
+
167
+ Only the leading run of None values is scanned in Python; the remainder
168
+ is filtered at C speed. Numeric input is reduced in bounded chunks so NaN
169
+ can be detected at C speed (x != x is True only for NaN) in O(chunk) memory.
170
+ """
171
+ pick = builtins.min if is_min else builtins.max
172
+ it = iter(items)
173
+
174
+ for first in it:
175
+ if first is None:
176
+ continue
177
+
178
+ rest = filter(_NOT_NONE, it)
179
+
180
+ if not isinstance(first, (_Real, Decimal)):
181
+ # Non-numeric (str, tuple, date, ...): no NaN semantics apply.
182
+ return pick(chain((first,), rest)) # pyrefly: ignore [bad-specialization]
183
+
184
+ if is_min:
185
+ if first != first:
186
+ return first
187
+
188
+ best = first
189
+ while chunk := list(islice(rest, _EXTREME_CHUNK)):
190
+ if any(map(_ne, chunk, chunk)):
191
+ return next(x for x in chunk if x != x)
192
+ best = pick(best, pick(chunk)) # pyrefly: ignore [bad-specialization]
193
+ return best
194
+
195
+ nan: Any = first if first != first else _MISSING
196
+ best = _MISSING if nan is not _MISSING else first
197
+ while chunk := list(islice(rest, _EXTREME_CHUNK)):
198
+ if any(map(_ne, chunk, chunk)):
199
+ if nan is _MISSING:
200
+ nan = next(x for x in chunk if x != x)
201
+ chunk = list(compress(chunk, map(_eq, chunk, chunk)))
202
+ if not chunk:
203
+ continue
204
+ candidate = builtins.max(chunk) # pyrefly: ignore [bad-specialization]
205
+ best = candidate if best is _MISSING else builtins.max(best, candidate) # pyrefly: ignore [bad-specialization]
206
+ return nan if best is _MISSING else best
207
+
208
+ return None
209
+
210
+
211
+ def _extreme_by(items: Iterable[Any], key_selector: Any, is_min: bool) -> Any:
212
+ """
213
+ Single-pass MinBy/MaxBy returning the *element* with the extreme key.
214
+
215
+ Follows .NET Enumerable.MinBy/MaxBy:
216
+ - empty input -> None (no runtime generics: everything is None-able)
217
+ - key_selector is None -> SelectorNoneError as soon as there is an element
218
+ to select a key for (empty input still gives None)
219
+ - None keys are skipped; if every key is None the first element is returned
220
+ - ties keep the first element
221
+ - NaN keys order below every other number (Comparer<double>.Default):
222
+ min_by returns the first NaN-keyed element, max_by ignores NaN keys
223
+ unless all keys are NaN.
224
+
225
+ The selector runs exactly once per element, in order. Work is done in
226
+ bounded chunks so None/NaN filtering and the reduction run at C speed.
227
+ """
228
+ it = iter(items)
229
+
230
+ if key_selector is None:
231
+ for _ in it:
232
+ raise SelectorNoneError()
233
+ return None
234
+
235
+ pick = builtins.min if is_min else builtins.max
236
+ first_item: Any = _MISSING
237
+ nan_item: Any = _MISSING
238
+ best_item: Any = _MISSING
239
+ best_key: Any = _MISSING
240
+
241
+ while chunk := list(islice(it, _EXTREME_CHUNK)):
242
+ if first_item is _MISSING:
243
+ first_item = chunk[0]
244
+
245
+ keys = list(map(key_selector, chunk))
246
+
247
+ if not all(map(_NOT_NONE, keys)):
248
+ mask = list(map(_NOT_NONE, keys))
249
+ keys = list(compress(keys, mask))
250
+ chunk = list(compress(chunk, mask))
251
+ if not keys:
252
+ continue
253
+
254
+ if any(map(_ne, keys, keys)):
255
+ nan_index = next(i for i, k in enumerate(keys) if k != k)
256
+ if is_min:
257
+ return chunk[nan_index]
258
+ if nan_item is _MISSING:
259
+ nan_item = chunk[nan_index]
260
+ mask = list(map(_eq, keys, keys))
261
+ keys = list(compress(keys, mask))
262
+ chunk = list(compress(chunk, mask))
263
+ if not keys:
264
+ continue
265
+
266
+ extreme = pick(keys)
267
+ if best_key is _MISSING or (extreme < best_key if is_min else extreme > best_key):
268
+ best_key = extreme
269
+ best_item = chunk[keys.index(extreme)]
270
+
271
+ if best_item is not _MISSING:
272
+ return best_item
273
+ if nan_item is not _MISSING:
274
+ return nan_item
275
+ if first_item is not _MISSING:
276
+ return first_item
277
+
278
+ # raise EmptySequenceError()
279
+ # no proper generic runtime type, so everything is assumed to be nullable
280
+ return None
143
281
 
144
282
 
145
283
  def dotnet_context() -> AbstractContextManager[decimal.Context]:
@@ -167,6 +305,73 @@ class _FactoryIterable[TItem](Iterable[TItem]):
167
305
  return self._factory()
168
306
 
169
307
 
308
+ # Exact builtin types whose iter() has no side effects and no failure mode, so
309
+ # calling it eagerly is indistinguishable from calling it on the first next().
310
+ _PURE_ITER_TYPES = (list, tuple, range)
311
+
312
+
313
+ def _is_pure(source: object) -> bool:
314
+ return type(source) in _PURE_ITER_TYPES or getattr(source, "_pure", False) is True
315
+
316
+
317
+ class _WhereIterable[TItem](Iterable[TItem]):
318
+ """Deferred filter node with independent iteration state."""
319
+ __slots__ = ("_source", "_predicate", "_pure")
320
+
321
+ def __init__(self, source: Iterable[TItem], predicate: Callable[[TItem], bool]) -> None:
322
+ self._source = source
323
+ self._predicate = predicate
324
+ self._pure = _is_pure(source)
325
+
326
+ def __iter__(self) -> Iterator[TItem]:
327
+ if self._pure: # whole chain bottoms out in a list/tuple/range: no wrapper needed
328
+ return filter(self._predicate, iter(self._source)) # type: ignore[arg-type]
329
+ # chain.from_iterable defers iter(source) to the first next(), like a generator would.
330
+ return filter(self._predicate, chain.from_iterable((self._source,))) # type: ignore[arg-type]
331
+
332
+
333
+ class _SelectIterable[TItem, TResult](Iterable[TResult]):
334
+ """Deferred projection node with independent iteration state."""
335
+ __slots__ = ("_source", "_selector", "_pure")
336
+
337
+ def __init__(self, source: Iterable[TItem], selector: Callable[[TItem], TResult]) -> None:
338
+ self._source = source
339
+ self._selector = selector
340
+ self._pure = _is_pure(source)
341
+
342
+ def __iter__(self) -> Iterator[TResult]:
343
+ if self._pure:
344
+ return map(self._selector, iter(self._source)) # type: ignore[arg-type]
345
+ return map(self._selector, chain.from_iterable((self._source,))) # type: ignore[arg-type]
346
+
347
+
348
+ def _validate_join_args(
349
+ inner: Any,
350
+ outer_key_selector: Any,
351
+ inner_key_selector: Any,
352
+ result_selector: Any,
353
+ ) -> None:
354
+ """Eager argument validation shared by join / left_join / full_join (mirrors .NET)."""
355
+ if inner is None:
356
+ raise InnerNoneError()
357
+ if outer_key_selector is None:
358
+ raise OuterKeySelectorNoneError()
359
+ if inner_key_selector is None:
360
+ raise InnerKeySelectorNoneError()
361
+ if result_selector is None:
362
+ raise ResultSelectorNoneError()
363
+
364
+
365
+ def _build_lookup(inner: Iterable[Any], inner_key_selector: Callable[[Any], Any]) -> dict[Any, list[Any]]:
366
+ """Hash lookup key -> inner elements (insertion order kept). Elements with a None key are dropped."""
367
+ lookup: dict[Any, list[Any]] = {}
368
+ for inner_item in inner:
369
+ key = inner_key_selector(inner_item)
370
+ if key is not None:
371
+ lookup.setdefault(key, []).append(inner_item)
372
+ return lookup
373
+
374
+
170
375
  class FlpIt[TItem](Iterable[TItem]):
171
376
  """
172
377
  | Fluent Iterable
@@ -263,28 +468,33 @@ class FlpIt[TItem](Iterable[TItem]):
263
468
 
264
469
  return FlpIt(_FactoryIterable(_generator))
265
470
 
471
+ def contains(self, value: TItem) -> bool:
472
+ source = self._iterable
473
+
474
+ if isinstance(source, Collection):
475
+ return value in source
476
+
477
+ for item in source:
478
+ if item == value:
479
+ return True
480
+
481
+ return False
482
+
483
+ def _node_source(self) -> Iterable[TItem]:
484
+ """Return the upstream iterable, preserving subclasses that customize iteration."""
485
+ return self._iterable if type(self) is FlpIt else self
486
+
266
487
  def where(self, predicate: Callable[[TItem], bool]) -> FlpIt[TItem]:
267
- """Filters elements based on a predicate."""
488
+ """Filters elements based on a predicate (deferred)."""
268
489
  if predicate is None:
269
490
  raise PredicateNoneError()
270
-
271
- def _generator() -> Iterator[TItem]:
272
- for item in self:
273
- if predicate(item):
274
- yield item
275
- # return typing_cast(FlpIt[TItem], FlpIt[TItem](_FactoryIterable(_generator)))
276
- return FlpIt[TItem](_FactoryIterable(_generator))
491
+ return FlpIt[TItem](_WhereIterable(self._node_source(), predicate))
277
492
 
278
493
  def select[TResult](self, selector: Callable[[TItem], TResult]) -> FlpIt[TResult]:
279
- """Projects each element into a new form."""
494
+ """Projects each element into a new form (deferred)."""
280
495
  if selector is None:
281
496
  raise SelectorNoneError()
282
-
283
- def _generator() -> Iterator[TResult]:
284
- for item in self:
285
- yield selector(item)
286
-
287
- return FlpIt(_FactoryIterable(_generator))
497
+ return FlpIt(_SelectIterable(self._node_source(), selector))
288
498
 
289
499
  def select_many[TResult](
290
500
  self, selector: Callable[[TItem], Iterable[TResult]]
@@ -384,7 +594,7 @@ class FlpIt[TItem](Iterable[TItem]):
384
594
  return FlpIt(_FactoryIterable(_generator))
385
595
 
386
596
  @overload
387
- def zip(self, second: Iterable[TOther]) -> FlpIt[tuple[TItem, TOther]]: ...
597
+ def zip(self, second: Iterable[TOther]) -> FlpIt[ZipTuple[TItem, TOther]]: ...
388
598
 
389
599
  @overload
390
600
  def zip[TResult, TOther](
@@ -399,19 +609,198 @@ class FlpIt[TItem](Iterable[TItem]):
399
609
  result_selector: Callable[[TItem, TOther], Any] | _Sentinel = _SENTINEL,
400
610
  ) -> FlpIt[Any]:
401
611
  """Applies a specified function to corresponding elements of two sequences."""
612
+ if second is None:
613
+ raise SecondNoneError()
614
+
615
+ if result_selector is None:
616
+ raise ResultSelectorNoneError()
617
+
402
618
  def _generator() -> Iterator[Any]:
403
619
  for first_item, second_item in zip(self, second):
404
620
  if result_selector is not _SENTINEL:
405
621
  yield result_selector(first_item, second_item)
406
622
  else:
407
- yield first_item, second_item
623
+ yield ZipTuple(first_item, second_item)
624
+
625
+ return FlpIt(_FactoryIterable(_generator))
626
+
627
+ @overload
628
+ def join[TInner](
629
+ self,
630
+ inner: Iterable[TInner],
631
+ outer_key_selector: Callable[[TItem], Any],
632
+ inner_key_selector: Callable[[TInner], Any],
633
+ ) -> FlpIt[JoinTuple[TItem, TInner]]: ...
634
+
635
+ @overload
636
+ def join[TResult, TInner](
637
+ self,
638
+ inner: Iterable[TInner],
639
+ outer_key_selector: Callable[[TItem], Any],
640
+ inner_key_selector: Callable[[TInner], Any],
641
+ result_selector: Callable[[TItem, TInner], TResult],
642
+ ) -> FlpIt[TResult]: ...
643
+
644
+ def join[TInner](
645
+ self,
646
+ inner: Iterable[TInner],
647
+ outer_key_selector: Callable[[TItem], Any],
648
+ inner_key_selector: Callable[[TInner], Any],
649
+ result_selector: Callable[[TItem, TInner], Any] | _Sentinel = _SENTINEL,
650
+ ) -> FlpIt[Any]:
651
+ """
652
+ Inner join: correlates the elements of two sequences based on matching keys.
653
+
654
+ Yields one result per matching (outer, inner) pair, in outer order and, per outer element,
655
+ in inner order. Elements whose key is ``None`` never match. Arguments are validated eagerly;
656
+ ``inner`` is buffered into a hash lookup when enumeration starts (O(N + M)).
657
+ """
658
+ _validate_join_args(inner, outer_key_selector, inner_key_selector, result_selector)
659
+
660
+ def _generator() -> Iterator[Any]:
661
+ lookup = _build_lookup(inner, inner_key_selector)
662
+ if not lookup: # like .NET: nothing can match, so the outer sequence is not enumerated
663
+ return
664
+ for outer_item in self:
665
+ key = outer_key_selector(outer_item)
666
+ if key is None:
667
+ continue
668
+ matches = lookup.get(key)
669
+ if matches is None:
670
+ continue
671
+ for inner_item in matches:
672
+ if result_selector is _SENTINEL:
673
+ yield JoinTuple(outer_item, inner_item)
674
+ else:
675
+ yield result_selector(outer_item, inner_item)
676
+
677
+ return FlpIt(_FactoryIterable(_generator))
678
+
679
+ @overload
680
+ def left_join[TInner](
681
+ self,
682
+ inner: Iterable[TInner],
683
+ outer_key_selector: Callable[[TItem], Any],
684
+ inner_key_selector: Callable[[TInner], Any],
685
+ ) -> FlpIt[JoinTuple[TItem, TInner | None]]: ...
686
+
687
+ @overload
688
+ def left_join[TResult, TInner](
689
+ self,
690
+ inner: Iterable[TInner],
691
+ outer_key_selector: Callable[[TItem], Any],
692
+ inner_key_selector: Callable[[TInner], Any],
693
+ result_selector: Callable[[TItem, TInner | None], TResult],
694
+ ) -> FlpIt[TResult]: ...
695
+
696
+ def left_join[TInner](
697
+ self,
698
+ inner: Iterable[TInner],
699
+ outer_key_selector: Callable[[TItem], Any],
700
+ inner_key_selector: Callable[[TInner], Any],
701
+ result_selector: Callable[[TItem, TInner | None], Any] | _Sentinel = _SENTINEL,
702
+ ) -> FlpIt[Any]:
703
+ """
704
+ Left outer join (.NET ``LeftJoin``): every outer element is produced at least once.
705
+
706
+ Outer elements without a match (including those with a ``None`` key) are paired with
707
+ ``None`` as the inner element, i.e. ``result_selector(outer, None)``.
708
+ """
709
+ _validate_join_args(inner, outer_key_selector, inner_key_selector, result_selector)
710
+
711
+ def _generator() -> Iterator[Any]:
712
+ lookup = _build_lookup(inner, inner_key_selector)
713
+ for outer_item in self:
714
+ key = outer_key_selector(outer_item)
715
+ matches = None if key is None else lookup.get(key)
716
+ if matches is None:
717
+ if result_selector is _SENTINEL:
718
+ yield JoinTuple(outer_item, None)
719
+ else:
720
+ yield result_selector(outer_item, None)
721
+ continue
722
+ for inner_item in matches:
723
+ if result_selector is _SENTINEL:
724
+ yield JoinTuple(outer_item, inner_item)
725
+ else:
726
+ yield result_selector(outer_item, inner_item)
727
+
728
+ return FlpIt(_FactoryIterable(_generator))
729
+
730
+ @overload
731
+ def full_join[TInner](
732
+ self,
733
+ inner: Iterable[TInner],
734
+ outer_key_selector: Callable[[TItem], Any],
735
+ inner_key_selector: Callable[[TInner], Any],
736
+ ) -> FlpIt[JoinTuple[TItem | None, TInner | None]]: ...
737
+
738
+ @overload
739
+ def full_join[TResult, TInner](
740
+ self,
741
+ inner: Iterable[TInner],
742
+ outer_key_selector: Callable[[TItem], Any],
743
+ inner_key_selector: Callable[[TInner], Any],
744
+ result_selector: Callable[[TItem | None, TInner | None], TResult],
745
+ ) -> FlpIt[TResult]: ...
746
+
747
+ def full_join[TInner](
748
+ self,
749
+ inner: Iterable[TInner],
750
+ outer_key_selector: Callable[[TItem], Any],
751
+ inner_key_selector: Callable[[TInner], Any],
752
+ result_selector: Callable[[TItem | None, TInner | None], Any] | _Sentinel = _SENTINEL,
753
+ ) -> FlpIt[Any]:
754
+ """
755
+ Full outer join (no .NET LINQ equivalent).
756
+
757
+ Produces everything ``left_join`` does (unmatched outer elements paired with ``None``),
758
+ followed by every inner element that was never matched by any outer element, paired with
759
+ ``None`` as the outer element (``result_selector(None, inner)``), in inner order.
760
+ Elements with a ``None`` key never match and therefore always appear unmatched.
761
+ The outer sequence is streamed; the inner sequence is buffered.
762
+ """
763
+ _validate_join_args(inner, outer_key_selector, inner_key_selector, result_selector)
764
+
765
+ def _generator() -> Iterator[Any]:
766
+ inner_items: list[tuple[Any, TInner]] = []
767
+ lookup: dict[Any, list[TInner]] = {}
768
+ for inner_item in inner:
769
+ inner_key = inner_key_selector(inner_item)
770
+ inner_items.append((inner_key, inner_item))
771
+ if inner_key is not None:
772
+ lookup.setdefault(inner_key, []).append(inner_item)
773
+
774
+ matched_keys: set[Any] = set()
775
+ for outer_item in self:
776
+ key = outer_key_selector(outer_item)
777
+ matches = None if key is None else lookup.get(key)
778
+ if matches is None:
779
+ if result_selector is _SENTINEL:
780
+ yield JoinTuple(outer_item, None)
781
+ else:
782
+ yield result_selector(outer_item, None)
783
+ continue
784
+ matched_keys.add(key)
785
+ for inner_item in matches:
786
+ if result_selector is _SENTINEL:
787
+ yield JoinTuple(outer_item, inner_item)
788
+ else:
789
+ yield result_selector(outer_item, inner_item)
790
+
791
+ for inner_key, inner_item in inner_items:
792
+ if inner_key is None or inner_key not in matched_keys:
793
+ if result_selector is _SENTINEL:
794
+ yield JoinTuple(None, inner_item)
795
+ else:
796
+ yield result_selector(None, inner_item)
408
797
 
409
798
  return FlpIt(_FactoryIterable(_generator))
410
799
 
411
800
  def chunk(self, size: int) -> FlpIt[FlpList[TItem]]:
412
801
  """Splits the elements of a sequence into chunks of size at most size."""
413
802
  if size <= 0:
414
- raise ValueError("Chunk size must be greater than 0.")
803
+ raise ArgumentOutOfRangeError("Specified argument was out of the range of valid values. (Parameter 'size')")
415
804
 
416
805
  def _generator() -> Iterator[FlpList[TItem]]:
417
806
  current_chunk: List[TItem] = []
@@ -554,29 +943,44 @@ class FlpIt[TItem](Iterable[TItem]):
554
943
 
555
944
  return typing_cast(TItem, last_item)
556
945
 
557
- def _extreme(self, func: Any, **kwargs: Any) -> TItem:
558
- result = func(self, default=_MISSING, **kwargs)
946
+ @overload
947
+ def min(self) -> TItem: ...
559
948
 
560
- if result is _MISSING:
561
- raise EmptySequenceError()
949
+ @overload
950
+ def min[TResult](self, selector: Callable[[TItem], TResult]) -> TResult: ...
562
951
 
563
- return typing_cast(TItem, result)
952
+ def min(self, selector: Callable[[TItem], Any] | _Sentinel = _SENTINEL) -> Any:
953
+ """
954
+ Returns the minimum value in a sequence, optionally projecting each
955
+ element through a selector first (Enumerable.Min / Min(selector)).
564
956
 
565
- def min(self) -> TItem:
566
- """Returns the minimum value in a sequence."""
567
- return self._extreme(builtins.min)
957
+ With a selector the minimum *projected value* is returned, not the
958
+ element (use min_by for that). None values are ignored; all-None
959
+ returns None; empty raises EmptySequenceError; NaN wins if present.
960
+ """
961
+ return _extreme_values(_project(self, selector), True)
568
962
 
569
- def min_by(self, key_selector: Callable[[TItem], Any]) -> TItem:
570
- """Returns the value in a sequence that has the minimum key value."""
571
- return self._extreme(builtins.min, key=key_selector)
963
+ @overload
964
+ def max(self) -> TItem: ...
572
965
 
573
- def max(self) -> TItem:
574
- """Returns the maximum value in a sequence."""
575
- return self._extreme(builtins.max)
966
+ @overload
967
+ def max[TResult](self, selector: Callable[[TItem], TResult]) -> TResult: ...
968
+
969
+ def max(self, selector: Callable[[TItem], Any] | _Sentinel = _SENTINEL) -> Any:
970
+ """
971
+ Returns the maximum value in a sequence, optionally projecting each
972
+ element through a selector first (Enumerable.Max / Max(selector)).
973
+ Same None / NaN / empty rules as min.
974
+ """
975
+ return _extreme_values(_project(self, selector), False)
976
+
977
+ def min_by(self, key_selector: Callable[[TItem], Any]) -> TItem:
978
+ """Returns the element with the minimum key (Enumerable.MinBy)."""
979
+ return _extreme_by(self, key_selector, True)
576
980
 
577
981
  def max_by(self, key_selector: Callable[[TItem], Any]) -> TItem:
578
- """Returns the value in a sequence that has the maximum key value."""
579
- return self._extreme(builtins.max, key=key_selector)
982
+ """Returns the element with the maximum key (Enumerable.MaxBy)."""
983
+ return _extreme_by(self, key_selector, False)
580
984
 
581
985
  def average(
582
986
  self, selector: Callable[[TItem], int | float | Decimal] | _Sentinel = _SENTINEL
@@ -672,6 +1076,23 @@ class FlpIt[TItem](Iterable[TItem]):
672
1076
  except (EmptySequenceError, NoMatchError):
673
1077
  return default
674
1078
 
1079
+ def last_or_default[TResult](
1080
+ self, default: TResult, predicate: Callable[[TItem], bool] | _Sentinel = _SENTINEL
1081
+ ) -> TItem | TResult:
1082
+ """Returns the last element matching a predicate, or a default value."""
1083
+ try:
1084
+ return self.last(predicate)
1085
+ except (EmptySequenceError, NoMatchError):
1086
+ return default
1087
+
1088
+ def reverse(self) -> FlpIt[TItem]:
1089
+ """Returns the elements of the sequence in reverse order."""
1090
+ def _generator() -> Iterator[TItem]:
1091
+ items = list(self)
1092
+ yield from reversed(items)
1093
+
1094
+ return FlpIt(_FactoryIterable(_generator))
1095
+
675
1096
  def single(self, predicate: Callable[[TItem], bool] | _Sentinel = _SENTINEL) -> TItem:
676
1097
  """Returns the single, unique element matching a predicate."""
677
1098
  has_predicate = predicate is not _SENTINEL
@@ -1154,7 +1575,7 @@ class FlpList[TItem](Collection[TItem]):
1154
1575
  return FlpIt(self.__list.data).distinct_by(key_selector)
1155
1576
 
1156
1577
  @overload
1157
- def zip(self, second: Iterable[TOther]) -> FlpIt[tuple[TItem, TOther]]: ...
1578
+ def zip(self, second: Iterable[TOther]) -> FlpIt[ZipTuple[TItem, TOther]]: ...
1158
1579
 
1159
1580
  @overload
1160
1581
  def zip[TResult, TOther](
@@ -1170,6 +1591,84 @@ class FlpList[TItem](Collection[TItem]):
1170
1591
  ) -> FlpIt[Any]:
1171
1592
  return FlpIt(self.__list.data).zip(second, result_selector)
1172
1593
 
1594
+ @overload
1595
+ def join[TInner](
1596
+ self,
1597
+ inner: Iterable[TInner],
1598
+ outer_key_selector: Callable[[TItem], Any],
1599
+ inner_key_selector: Callable[[TInner], Any],
1600
+ ) -> FlpIt[JoinTuple[TItem, TInner]]: ...
1601
+
1602
+ @overload
1603
+ def join[TResult, TInner](
1604
+ self,
1605
+ inner: Iterable[TInner],
1606
+ outer_key_selector: Callable[[TItem], Any],
1607
+ inner_key_selector: Callable[[TInner], Any],
1608
+ result_selector: Callable[[TItem, TInner], TResult],
1609
+ ) -> FlpIt[TResult]: ...
1610
+
1611
+ def join[TInner](
1612
+ self,
1613
+ inner: Iterable[TInner],
1614
+ outer_key_selector: Callable[[TItem], Any],
1615
+ inner_key_selector: Callable[[TInner], Any],
1616
+ result_selector: Callable[[TItem, TInner], Any] | _Sentinel = _SENTINEL,
1617
+ ) -> FlpIt[Any]:
1618
+ return FlpIt(self.__list.data).join(inner, outer_key_selector, inner_key_selector, result_selector)
1619
+
1620
+ @overload
1621
+ def left_join[TInner](
1622
+ self,
1623
+ inner: Iterable[TInner],
1624
+ outer_key_selector: Callable[[TItem], Any],
1625
+ inner_key_selector: Callable[[TInner], Any],
1626
+ ) -> FlpIt[JoinTuple[TItem, TInner | None]]: ...
1627
+
1628
+ @overload
1629
+ def left_join[TResult, TInner](
1630
+ self,
1631
+ inner: Iterable[TInner],
1632
+ outer_key_selector: Callable[[TItem], Any],
1633
+ inner_key_selector: Callable[[TInner], Any],
1634
+ result_selector: Callable[[TItem, TInner | None], TResult],
1635
+ ) -> FlpIt[TResult]: ...
1636
+
1637
+ def left_join[TInner](
1638
+ self,
1639
+ inner: Iterable[TInner],
1640
+ outer_key_selector: Callable[[TItem], Any],
1641
+ inner_key_selector: Callable[[TInner], Any],
1642
+ result_selector: Callable[[TItem, TInner | None], Any] | _Sentinel = _SENTINEL,
1643
+ ) -> FlpIt[Any]:
1644
+ return FlpIt(self.__list.data).left_join(inner, outer_key_selector, inner_key_selector, result_selector)
1645
+
1646
+ @overload
1647
+ def full_join[TInner](
1648
+ self,
1649
+ inner: Iterable[TInner],
1650
+ outer_key_selector: Callable[[TItem], Any],
1651
+ inner_key_selector: Callable[[TInner], Any],
1652
+ ) -> FlpIt[JoinTuple[TItem | None, TInner | None]]: ...
1653
+
1654
+ @overload
1655
+ def full_join[TResult, TInner](
1656
+ self,
1657
+ inner: Iterable[TInner],
1658
+ outer_key_selector: Callable[[TItem], Any],
1659
+ inner_key_selector: Callable[[TInner], Any],
1660
+ result_selector: Callable[[TItem | None, TInner | None], TResult],
1661
+ ) -> FlpIt[TResult]: ...
1662
+
1663
+ def full_join[TInner](
1664
+ self,
1665
+ inner: Iterable[TInner],
1666
+ outer_key_selector: Callable[[TItem], Any],
1667
+ inner_key_selector: Callable[[TInner], Any],
1668
+ result_selector: Callable[[TItem | None, TInner | None], Any] | _Sentinel = _SENTINEL,
1669
+ ) -> FlpIt[Any]:
1670
+ return FlpIt(self.__list.data).full_join(inner, outer_key_selector, inner_key_selector, result_selector)
1671
+
1173
1672
  def chunk(self, size: int) -> FlpIt[FlpList[TItem]]:
1174
1673
  return FlpIt(self.__list.data).chunk(size)
1175
1674
 
@@ -1208,21 +1707,29 @@ class FlpList[TItem](Collection[TItem]):
1208
1707
  def aggregate(self, *args: Any) -> Any:
1209
1708
  return FlpIt(self.__list.data).aggregate(*args)
1210
1709
 
1211
- @_guard_empty
1212
- def min(self) -> TItem:
1213
- return builtins.min(self.__list.data) # pyrefly: ignore [bad-specialization]
1710
+ @overload
1711
+ def min(self) -> TItem: ...
1712
+
1713
+ @overload
1714
+ def min[TResult](self, selector: Callable[[TItem], TResult]) -> TResult: ...
1214
1715
 
1215
- @_guard_empty
1216
- def min_by(self, key_selector: Callable[[TItem], Any]) -> TItem:
1217
- return builtins.min(self.__list.data, key=key_selector)
1716
+ def min(self, selector: Callable[[TItem], Any] | _Sentinel = _SENTINEL) -> Any:
1717
+ return _extreme_values(_project(self.__list.data, selector), True)
1718
+
1719
+ @overload
1720
+ def max(self) -> TItem: ...
1218
1721
 
1219
- @_guard_empty
1220
- def max(self) -> TItem:
1221
- return builtins.max(self.__list.data) # pyrefly: ignore [bad-specialization]
1722
+ @overload
1723
+ def max[TResult](self, selector: Callable[[TItem], TResult]) -> TResult: ...
1724
+
1725
+ def max(self, selector: Callable[[TItem], Any] | _Sentinel = _SENTINEL) -> Any:
1726
+ return _extreme_values(_project(self.__list.data, selector), False)
1727
+
1728
+ def min_by(self, key_selector: Callable[[TItem], Any]) -> TItem:
1729
+ return _extreme_by(self.__list.data, key_selector, True)
1222
1730
 
1223
- @_guard_empty
1224
1731
  def max_by(self, key_selector: Callable[[TItem], Any]) -> TItem:
1225
- return builtins.max(self.__list.data, key=key_selector)
1732
+ return _extreme_by(self.__list.data, key_selector, False)
1226
1733
 
1227
1734
  def sum(
1228
1735
  self, selector: Callable[[TItem], int | float | Decimal] | _Sentinel = _SENTINEL
@@ -1300,6 +1807,14 @@ class FlpList[TItem](Collection[TItem]):
1300
1807
  ) -> TItem | TResult:
1301
1808
  return FlpIt(self.__list.data).first_or_default(default, predicate)
1302
1809
 
1810
+ def last_or_default[TResult](
1811
+ self, default: TResult, predicate: Callable[[TItem], bool] | _Sentinel = _SENTINEL
1812
+ ) -> TItem | TResult:
1813
+ try:
1814
+ return self.last(predicate)
1815
+ except (EmptySequenceError, NoMatchError):
1816
+ return default
1817
+
1303
1818
  def single(self, predicate: Callable[[TItem], bool] | _Sentinel = _SENTINEL) -> TItem:
1304
1819
  return FlpIt(self.__list.data).single(predicate)
1305
1820
 
@@ -1360,4 +1875,4 @@ class FlpList[TItem](Collection[TItem]):
1360
1875
  JsonConvert.serialize_to(self, item_type, output, jso)
1361
1876
 
1362
1877
  # I hate it, but Python being Python
1363
- from ._grouping import Grouping
1878
+ from ._grouping import Grouping
@@ -186,148 +186,3 @@ class CsvLoader:
186
186
  from_attributes=True,
187
187
  strict=False,
188
188
  )
189
-
190
- # import csv
191
- # import io
192
- # from dataclasses import dataclass
193
- # from pathlib import Path
194
- # from typing import TextIO
195
- #
196
- # import msgspec
197
- #
198
- # from flpit.core.linq import FlpList
199
- # from flpit.json import (
200
- # JProp,
201
- # JsonNamingPolicy,
202
- # JsonSerializerOptions,
203
- # wire_type_for,
204
- # )
205
- #
206
- #
207
- # class CsvCol(JProp):
208
- # """Explicit CSV column name using the existing property metadata."""
209
- #
210
- #
211
- # @dataclass(frozen=True, slots=True)
212
- # class CsvOptions:
213
- # """CSV parsing and field-mapping options.
214
- #
215
- # The naming policy defaults to none, so CSV headers match Python field
216
- # names unless an explicit CsvCol/JProp name is supplied.
217
- # """
218
- #
219
- # delimiter: str = ","
220
- # quotechar: str = '"'
221
- # escapechar: str | None = None
222
- # doublequote: bool = True
223
- # skipinitialspace: bool = False
224
- # strict: bool = False
225
- # encoding: str = "utf-8-sig"
226
- # naming_policy: JsonNamingPolicy = JsonNamingPolicy.none
227
- #
228
- # def __post_init__(self) -> None:
229
- # if len(self.delimiter) != 1:
230
- # raise ValueError("delimiter must be exactly one character")
231
- # if len(self.quotechar) != 1:
232
- # raise ValueError("quotechar must be exactly one character")
233
- # if self.escapechar is not None and len(self.escapechar) != 1:
234
- # raise ValueError("escapechar must be exactly one character")
235
- #
236
- # def to_json_options(self) -> JsonSerializerOptions:
237
- # """Map CSV field naming to the shared wire-schema builder."""
238
- # return JsonSerializerOptions(naming_policy=self.naming_policy)
239
- #
240
- #
241
- # class CsvLoader:
242
- # @staticmethod
243
- # def load[T](
244
- # data: str | Path,
245
- # item_type: type[T],
246
- # options: CsvOptions | None = None,
247
- # ) -> FlpList[T]:
248
- # """Load CSV content or a CSV file into a FlpList.
249
- #
250
- # CSV headers are matched using the configured naming policy and
251
- # explicit property metadata. The naming policy is passed to the
252
- # existing JSON wire-schema builder so annotation handling stays
253
- # consistent with JsonConvert.
254
- #
255
- # CSV values are converted in lax mode because CSV supplies text
256
- # values, including numeric values represented as strings.
257
- #
258
- # Args:
259
- # data: CSV content as a string, or a Path to a CSV file.
260
- # item_type: Model type to construct for each row.
261
- # options: CSV dialect, encoding and naming options.
262
- #
263
- # Returns:
264
- # A FlpList containing instances of item_type.
265
- # """
266
- # csv_options = options or CsvOptions()
267
- # json_options = csv_options.to_json_options()
268
- # wire_type = wire_type_for(item_type, json_options)
269
- #
270
- # if isinstance(data, Path):
271
- # with data.open(
272
- # "r",
273
- # newline="",
274
- # encoding=csv_options.encoding,
275
- # ) as stream:
276
- # return CsvLoader._load_stream(
277
- # stream, item_type, wire_type, csv_options
278
- # )
279
- #
280
- # with io.StringIO(data, newline="") as stream:
281
- # return CsvLoader._load_stream(
282
- # stream, item_type, wire_type, csv_options
283
- # )
284
- #
285
- # @staticmethod
286
- # def _load_stream[T](
287
- # stream: TextIO,
288
- # item_type: type[T],
289
- # wire_type: type,
290
- # options: CsvOptions,
291
- # ) -> FlpList[T]:
292
- # reader = csv.DictReader(
293
- # stream,
294
- # delimiter=options.delimiter,
295
- # quotechar=options.quotechar,
296
- # escapechar=options.escapechar,
297
- # doublequote=options.doublequote,
298
- # skipinitialspace=options.skipinitialspace,
299
- # strict=options.strict,
300
- # )
301
- #
302
- # if reader.fieldnames is None:
303
- # raise ValueError("CSV input must contain a header row")
304
- #
305
- # if len(reader.fieldnames) != len(set(reader.fieldnames)):
306
- # raise ValueError("CSV column names must be unique")
307
- #
308
- # def convert_rows():
309
- # for row in reader:
310
- # if None in row:
311
- # raise ValueError(
312
- # f"CSV row ending at line {reader.line_num} "
313
- # "contains more values than the header"
314
- # )
315
- #
316
- # wire_obj = msgspec.convert(
317
- # row,
318
- # wire_type,
319
- # from_attributes=True,
320
- # strict=False,
321
- # )
322
- #
323
- # # Reconstruct the declared model rather than leaking the
324
- # # dynamically generated wire type to the caller.
325
- # yield msgspec.convert(
326
- # wire_obj,
327
- # item_type,
328
- # from_attributes=True,
329
- # strict=False,
330
- # )
331
- #
332
- # return FlpList[T](convert_rows())
333
- #
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes