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.
- {flpit-0.3.0 → flpit-0.3.1.dev6}/PKG-INFO +43 -4
- {flpit-0.3.0 → flpit-0.3.1.dev6}/README.md +42 -3
- {flpit-0.3.0 → flpit-0.3.1.dev6}/pyproject.toml +1 -1
- {flpit-0.3.0 → flpit-0.3.1.dev6}/src/flpit/__init__.py +14 -1
- flpit-0.3.1.dev6/src/flpit/core/_join.py +7 -0
- flpit-0.3.1.dev6/src/flpit/core/_zip.py +13 -0
- {flpit-0.3.0 → flpit-0.3.1.dev6}/src/flpit/core/linq.py +582 -67
- {flpit-0.3.0 → flpit-0.3.1.dev6}/src/flpit/csv.py +0 -145
- {flpit-0.3.0 → flpit-0.3.1.dev6}/.gitignore +0 -0
- {flpit-0.3.0 → flpit-0.3.1.dev6}/LICENSE +0 -0
- {flpit-0.3.0 → flpit-0.3.1.dev6}/src/flp/__init__.py +0 -0
- {flpit-0.3.0 → flpit-0.3.1.dev6}/src/flpit/core/__init__.py +0 -0
- {flpit-0.3.0 → flpit-0.3.1.dev6}/src/flpit/core/_grouping.py +0 -0
- {flpit-0.3.0 → flpit-0.3.1.dev6}/src/flpit/flp.py +0 -0
- {flpit-0.3.0 → flpit-0.3.1.dev6}/src/flpit/json.py +0 -0
- {flpit-0.3.0 → flpit-0.3.1.dev6}/src/flpit/py.typed +0 -0
- {flpit-0.3.0 → flpit-0.3.1.dev6}/src/flpit/task.py +0 -0
- {flpit-0.3.0 → flpit-0.3.1.dev6}/src/flpit/threading.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: flpit
|
|
3
|
-
Version: 0.3.
|
|
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
|
-
| │ ├──
|
|
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
|
-
| │
|
|
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 |
|
|
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
|
-
| │ ├──
|
|
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
|
-
| │
|
|
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 |
|
|
487
|
+
| GRAND TOTALS | 4733 |
|
|
449
488
|
================================================================================
|
|
450
489
|
```
|
|
@@ -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
|
|
|
@@ -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
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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[
|
|
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
|
|
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
|
-
|
|
558
|
-
|
|
946
|
+
@overload
|
|
947
|
+
def min(self) -> TItem: ...
|
|
559
948
|
|
|
560
|
-
|
|
561
|
-
|
|
949
|
+
@overload
|
|
950
|
+
def min[TResult](self, selector: Callable[[TItem], TResult]) -> TResult: ...
|
|
562
951
|
|
|
563
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
return self._extreme(builtins.min, key=key_selector)
|
|
963
|
+
@overload
|
|
964
|
+
def max(self) -> TItem: ...
|
|
572
965
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
|
579
|
-
return self
|
|
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[
|
|
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
|
-
@
|
|
1212
|
-
def min(self) -> TItem:
|
|
1213
|
-
|
|
1710
|
+
@overload
|
|
1711
|
+
def min(self) -> TItem: ...
|
|
1712
|
+
|
|
1713
|
+
@overload
|
|
1714
|
+
def min[TResult](self, selector: Callable[[TItem], TResult]) -> TResult: ...
|
|
1214
1715
|
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
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
|
-
@
|
|
1220
|
-
def max(self) ->
|
|
1221
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|