flpit 0.1.1.dev1__tar.gz → 0.1.1.dev2__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.1.1.dev1 → flpit-0.1.1.dev2}/PKG-INFO +5 -5
- {flpit-0.1.1.dev1 → flpit-0.1.1.dev2}/README.md +4 -4
- {flpit-0.1.1.dev1 → flpit-0.1.1.dev2}/pyproject.toml +1 -1
- {flpit-0.1.1.dev1 → flpit-0.1.1.dev2}/pyproject.toml.orig +1 -1
- {flpit-0.1.1.dev1 → flpit-0.1.1.dev2}/src/flp/core/linq.py +212 -80
- {flpit-0.1.1.dev1 → flpit-0.1.1.dev2}/src/flp/__init__.py +0 -0
- {flpit-0.1.1.dev1 → flpit-0.1.1.dev2}/src/flp/core/__init__.py +0 -0
- {flpit-0.1.1.dev1 → flpit-0.1.1.dev2}/src/flp/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: flpit
|
|
3
|
-
Version: 0.1.1.
|
|
3
|
+
Version: 0.1.1.dev2
|
|
4
4
|
Summary: Fluent LINQ for Python — flip your iterables
|
|
5
5
|
License: MIT
|
|
6
6
|
Classifier: Development Status :: 3 - Alpha
|
|
@@ -18,14 +18,14 @@ Description-Content-Type: text/markdown
|
|
|
18
18
|
|
|
19
19
|
> **Fluent LINQ for Python – flip your iterables**
|
|
20
20
|
|
|
21
|
-
`flp` brings
|
|
21
|
+
`flp` brings a LINQ-inspired fluent API to standard Python iterables with full static typing.
|
|
22
22
|
|
|
23
23
|
## ⚡ Key Features
|
|
24
24
|
|
|
25
|
-
* **⚡ Lazy Evaluation (`FlpIt`):** Deferred
|
|
26
|
-
* **📦
|
|
25
|
+
* **⚡ Lazy Evaluation (`FlpIt`):** Deferred execution using Python iterators and generators.
|
|
26
|
+
* **📦 Materialized Container (`FlpList`):** Eager, mutable container backed by `collections.UserList`.
|
|
27
27
|
* **🔹 Static-First Typing:** Built for full `mypy` and `pyright` inference without plugins.
|
|
28
|
-
*
|
|
28
|
+
* **🔄 No Implicit Caching:** Query results are not cached unless explicitly documented, such as `order_by`.
|
|
29
29
|
|
|
30
30
|
## ⚙️ Installation
|
|
31
31
|
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
> **Fluent LINQ for Python – flip your iterables**
|
|
4
4
|
|
|
5
|
-
`flp` brings
|
|
5
|
+
`flp` brings a LINQ-inspired fluent API to standard Python iterables with full static typing.
|
|
6
6
|
|
|
7
7
|
## ⚡ Key Features
|
|
8
8
|
|
|
9
|
-
* **⚡ Lazy Evaluation (`FlpIt`):** Deferred
|
|
10
|
-
* **📦
|
|
9
|
+
* **⚡ Lazy Evaluation (`FlpIt`):** Deferred execution using Python iterators and generators.
|
|
10
|
+
* **📦 Materialized Container (`FlpList`):** Eager, mutable container backed by `collections.UserList`.
|
|
11
11
|
* **🔹 Static-First Typing:** Built for full `mypy` and `pyright` inference without plugins.
|
|
12
|
-
*
|
|
12
|
+
* **🔄 No Implicit Caching:** Query results are not cached unless explicitly documented, such as `order_by`.
|
|
13
13
|
|
|
14
14
|
## ⚙️ Installation
|
|
15
15
|
|
|
@@ -66,8 +66,12 @@ class _FactoryIterable(Iterable[TItem], Generic[TItem]):
|
|
|
66
66
|
class FlpIt(Iterable[TItem], Generic[TItem]):
|
|
67
67
|
"""
|
|
68
68
|
| Fluent Iterable
|
|
69
|
-
Lazy evaluation wrapper around an iterable
|
|
70
|
-
|
|
69
|
+
Lazy evaluation wrapper around an iterable, inspired by .NET LINQ's
|
|
70
|
+
IEnumerable<T> semantics. Operations are deferred and do not cache results
|
|
71
|
+
unless explicitly documented otherwise.
|
|
72
|
+
|
|
73
|
+
Multiple enumeration is supported when the underlying source is re-iterable;
|
|
74
|
+
one-shot iterators and generators remain one-shot.
|
|
71
75
|
"""
|
|
72
76
|
__slots__ = ("_iterable",)
|
|
73
77
|
|
|
@@ -92,6 +96,13 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
|
|
|
92
96
|
|
|
93
97
|
return FlpIt(_FactoryIterable(_generator))
|
|
94
98
|
|
|
99
|
+
def concat(self, second: Iterable[TItem]) -> "FlpIt[TItem]":
|
|
100
|
+
def _generator() -> Iterator[TItem]:
|
|
101
|
+
yield from self
|
|
102
|
+
yield from second
|
|
103
|
+
|
|
104
|
+
return FlpIt(_FactoryIterable(_generator))
|
|
105
|
+
|
|
95
106
|
def prepend(self, element: TItem) -> FlpIt[TItem]:
|
|
96
107
|
"""Prepends an element to the beginning of the sequence (deferred)."""
|
|
97
108
|
def _generator() -> Iterator[TItem]:
|
|
@@ -128,20 +139,25 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
|
|
|
128
139
|
return FlpIt(_FactoryIterable(_generator))
|
|
129
140
|
|
|
130
141
|
def take(self, count: int) -> "FlpIt[TItem]":
|
|
131
|
-
"""
|
|
142
|
+
"""
|
|
143
|
+
|Returns a specified number of contiguous elements from the start.
|
|
144
|
+
|
|
145
|
+
Execution is deferred until the returned sequence is enumerated.
|
|
146
|
+
The operator consumes no more than `count` elements from upstream.
|
|
147
|
+
|
|
148
|
+
`take()` does not own the upstream iterator and therefore does not
|
|
149
|
+
close it when the limit is reached. Upstream resource lifetime is the
|
|
150
|
+
responsibility of the code that owns/acquires the source; use an
|
|
151
|
+
explicit context manager or close the source explicitly when required.
|
|
152
|
+
|
|
153
|
+
This is intentional: reaching the `take()` limit is normal completion,
|
|
154
|
+
not cancellation or disposal of the upstream sequence.
|
|
155
|
+
"""
|
|
132
156
|
if count <= 0:
|
|
133
157
|
return FlpIt(())
|
|
134
158
|
|
|
135
159
|
def _generator() -> Iterator[TItem]:
|
|
136
|
-
|
|
137
|
-
upstream_iterator = iter(self)
|
|
138
|
-
try:
|
|
139
|
-
yield from islice(upstream_iterator, count)
|
|
140
|
-
finally:
|
|
141
|
-
# 2. once islice finishes or gets aborted,
|
|
142
|
-
# we FORCE the upstream chain to collapse and trigger its cleanup!
|
|
143
|
-
if hasattr(upstream_iterator, "close"):
|
|
144
|
-
upstream_iterator.close()
|
|
160
|
+
yield from islice(iter(self), count)
|
|
145
161
|
|
|
146
162
|
return FlpIt(_FactoryIterable(_generator))
|
|
147
163
|
|
|
@@ -259,27 +275,27 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
|
|
|
259
275
|
|
|
260
276
|
return FlpIt(_FactoryIterable(_generator))
|
|
261
277
|
|
|
262
|
-
def join(
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
) -> FlpIt[TResult]:
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
278
|
+
# def join(
|
|
279
|
+
# self,
|
|
280
|
+
# inner: Iterable[TOther],
|
|
281
|
+
# outer_key_selector: Callable[[TItem], TKey],
|
|
282
|
+
# inner_key_selector: Callable[[TOther], TKey],
|
|
283
|
+
# result_selector: Callable[[TItem, TOther], TResult],
|
|
284
|
+
# ) -> FlpIt[TResult]:
|
|
285
|
+
# """Correlates elements of two sequences based on matching keys (Hash Join)."""
|
|
286
|
+
# def _generator() -> Iterator[TResult]:
|
|
287
|
+
# lookup: dict[TKey, List[TOther]] = {}
|
|
288
|
+
# for inner_item in inner:
|
|
289
|
+
# key = inner_key_selector(inner_item)
|
|
290
|
+
# lookup.setdefault(key, []).append(inner_item)
|
|
291
|
+
#
|
|
292
|
+
# for outer_item in self:
|
|
293
|
+
# key = outer_key_selector(outer_item)
|
|
294
|
+
# if key in lookup:
|
|
295
|
+
# for inner_item in lookup[key]:
|
|
296
|
+
# yield result_selector(outer_item, inner_item)
|
|
297
|
+
#
|
|
298
|
+
# return FlpIt(_FactoryIterable(_generator))
|
|
283
299
|
|
|
284
300
|
# --- Immediate Execution (Materialization & Aggregation) ---
|
|
285
301
|
|
|
@@ -427,42 +443,136 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
|
|
|
427
443
|
return FlpList(self)
|
|
428
444
|
|
|
429
445
|
|
|
446
|
+
from threading import Lock
|
|
447
|
+
|
|
430
448
|
class OrderedIt(FlpIt[TItem]):
|
|
431
449
|
"""
|
|
432
|
-
|
|
433
|
-
|
|
450
|
+
Ordered Iterable.
|
|
451
|
+
|
|
452
|
+
Sorts the source lazily on first iteration and caches the resulting order.
|
|
453
|
+
The source is consumed at most once; subsequent iterations reuse the cached
|
|
454
|
+
result.
|
|
434
455
|
"""
|
|
435
|
-
|
|
456
|
+
|
|
457
|
+
__slots__ = (
|
|
458
|
+
"_source",
|
|
459
|
+
"_key_selector",
|
|
460
|
+
"_descending",
|
|
461
|
+
"_parent",
|
|
462
|
+
"_cached_result",
|
|
463
|
+
"_lock",
|
|
464
|
+
)
|
|
436
465
|
|
|
437
466
|
def __init__(
|
|
438
467
|
self,
|
|
439
468
|
source: Iterable[TItem],
|
|
440
469
|
key_selector: Callable[[TItem], Any],
|
|
441
470
|
descending: bool = False,
|
|
471
|
+
parent: Optional["OrderedIt[TItem]"] = None,
|
|
442
472
|
) -> None:
|
|
443
473
|
super().__init__(source)
|
|
444
|
-
|
|
445
|
-
self.
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
474
|
+
|
|
475
|
+
self._source = source
|
|
476
|
+
self._key_selector = key_selector
|
|
477
|
+
self._descending = descending
|
|
478
|
+
self._parent = parent
|
|
479
|
+
|
|
480
|
+
self._cached_result: Optional[list[TItem]] = None
|
|
481
|
+
self._lock = Lock()
|
|
482
|
+
|
|
483
|
+
def then_by(
|
|
484
|
+
self,
|
|
485
|
+
key_selector: Callable[[TItem], Any],
|
|
486
|
+
) -> "OrderedIt[TItem]":
|
|
487
|
+
return OrderedIt(
|
|
488
|
+
self._source,
|
|
489
|
+
key_selector,
|
|
490
|
+
descending=False,
|
|
491
|
+
parent=self,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
def then_by_descending(
|
|
495
|
+
self,
|
|
496
|
+
key_selector: Callable[[TItem], Any],
|
|
497
|
+
) -> "OrderedIt[TItem]":
|
|
498
|
+
return OrderedIt(
|
|
499
|
+
self._source,
|
|
500
|
+
key_selector,
|
|
501
|
+
descending=True,
|
|
502
|
+
parent=self,
|
|
503
|
+
)
|
|
460
504
|
|
|
461
505
|
def __iter__(self) -> Iterator[TItem]:
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
506
|
+
cached = self._cached_result
|
|
507
|
+
|
|
508
|
+
if cached is None:
|
|
509
|
+
with self._lock:
|
|
510
|
+
cached = self._cached_result
|
|
511
|
+
|
|
512
|
+
if cached is None:
|
|
513
|
+
# Collect the complete ordering chain.
|
|
514
|
+
comparers: list[
|
|
515
|
+
tuple[Callable[[TItem], Any], bool]
|
|
516
|
+
] = []
|
|
517
|
+
|
|
518
|
+
node: Optional["OrderedIt[TItem]"] = self
|
|
519
|
+
|
|
520
|
+
while node is not None:
|
|
521
|
+
comparers.append(
|
|
522
|
+
(node._key_selector, node._descending)
|
|
523
|
+
)
|
|
524
|
+
node = node._parent
|
|
525
|
+
|
|
526
|
+
comparers.reverse()
|
|
527
|
+
|
|
528
|
+
# Consume the source exactly once.
|
|
529
|
+
items = list(self._source)
|
|
530
|
+
|
|
531
|
+
class SortWrapper:
|
|
532
|
+
__slots__ = ("obj", "keys")
|
|
533
|
+
|
|
534
|
+
def __init__(self, obj: Any) -> None:
|
|
535
|
+
self.obj = obj
|
|
536
|
+
self.keys = [
|
|
537
|
+
selector(obj)
|
|
538
|
+
for selector, _ in comparers
|
|
539
|
+
]
|
|
540
|
+
|
|
541
|
+
def __lt__(self, other: "SortWrapper") -> bool:
|
|
542
|
+
for index, (_, descending) in enumerate(comparers):
|
|
543
|
+
left = self.keys[index]
|
|
544
|
+
right = other.keys[index]
|
|
545
|
+
|
|
546
|
+
if left == right:
|
|
547
|
+
continue
|
|
548
|
+
|
|
549
|
+
return right < left if descending else left < right
|
|
550
|
+
|
|
551
|
+
return False
|
|
552
|
+
|
|
553
|
+
wrapped_items = [
|
|
554
|
+
SortWrapper(item)
|
|
555
|
+
for item in items
|
|
556
|
+
]
|
|
557
|
+
|
|
558
|
+
wrapped_items.sort()
|
|
559
|
+
|
|
560
|
+
# Store ONLY the actual result objects.
|
|
561
|
+
cached = [
|
|
562
|
+
wrapper.obj
|
|
563
|
+
for wrapper in wrapped_items
|
|
564
|
+
]
|
|
565
|
+
|
|
566
|
+
self._cached_result = cached
|
|
567
|
+
|
|
568
|
+
# Important: __iter__ is a generator function.
|
|
569
|
+
# Release potentially large temporary structures
|
|
570
|
+
# before yielding anything.
|
|
571
|
+
del wrapped_items
|
|
572
|
+
del items
|
|
573
|
+
del comparers
|
|
574
|
+
|
|
575
|
+
yield from cached
|
|
466
576
|
|
|
467
577
|
|
|
468
578
|
class Grouping(FlpIt[TItem], Generic[TKey, TItem]):
|
|
@@ -480,6 +590,14 @@ class Grouping(FlpIt[TItem], Generic[TKey, TItem]):
|
|
|
480
590
|
def __repr__(self) -> str:
|
|
481
591
|
return f"Grouping(key={self.key!r}, elements={self.to_list()!r})"
|
|
482
592
|
|
|
593
|
+
def __eq__(self, other: Any) -> bool:
|
|
594
|
+
# Check if the other object is a Grouping (or subclass)
|
|
595
|
+
if not isinstance(other, Grouping):
|
|
596
|
+
return False
|
|
597
|
+
|
|
598
|
+
# Compare the keys, then compare the elements inside FlpIt
|
|
599
|
+
return self.key == other.key and self.to_list() == other.to_list()
|
|
600
|
+
|
|
483
601
|
|
|
484
602
|
class FlpList(UserList[TItem], Sequence[TItem], Generic[TItem]):
|
|
485
603
|
"""
|
|
@@ -488,31 +606,44 @@ class FlpList(UserList[TItem], Sequence[TItem], Generic[TItem]):
|
|
|
488
606
|
"""
|
|
489
607
|
|
|
490
608
|
def add(self, item: TItem) -> None:
|
|
491
|
-
"""
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
609
|
+
"""
|
|
610
|
+
|Adds an item and performs O(1) type consistency check against the first element.
|
|
611
|
+
|
|
612
|
+
|Type safety via
|
|
613
|
+
- type checks
|
|
614
|
+
- manual of_type(...) filter if you don't trust your checks
|
|
615
|
+
"""
|
|
497
616
|
self.data.append(item)
|
|
498
617
|
|
|
499
618
|
def add_range(self, items: Iterable[TItem]) -> None:
|
|
500
|
-
"""
|
|
619
|
+
"""
|
|
620
|
+
| Adds an Iterable sequence or stream.
|
|
621
|
+
Optimizes paths based on input type without destroying volatile generators.
|
|
622
|
+
|
|
623
|
+
|Type safety via
|
|
624
|
+
- type checks
|
|
625
|
+
- manual of_type(...) filter if you don't trust your checks
|
|
626
|
+
"""
|
|
627
|
+
# 1. Optimized Path: Fast memory extensions for pre-materialized sequences
|
|
628
|
+
if isinstance(items, (Sequence, list, tuple, UserList)):
|
|
629
|
+
self.data.extend(items)
|
|
630
|
+
return
|
|
631
|
+
|
|
632
|
+
# 2. Stream Path: Volatile one-shot generator handling
|
|
501
633
|
it = iter(items)
|
|
502
634
|
try:
|
|
503
635
|
first_item = next(it)
|
|
504
636
|
except StopIteration:
|
|
505
637
|
return
|
|
506
638
|
|
|
507
|
-
|
|
508
|
-
raise TypeError(
|
|
509
|
-
f"Element of type '{type(first_item).__name__}' does not match "
|
|
510
|
-
f"list item type '{type(self.data[0]).__name__}'."
|
|
511
|
-
)
|
|
512
|
-
|
|
639
|
+
# Append the tracked peek-element and stream the remainder safely
|
|
513
640
|
self.data.append(first_item)
|
|
514
641
|
self.data.extend(it)
|
|
515
642
|
|
|
643
|
+
def to_list(self) -> "FlpList[TItem]":
|
|
644
|
+
"""Explicitly returns a new shallow copy instance to isolate mutations matching .NET."""
|
|
645
|
+
return FlpList(self.data.copy())
|
|
646
|
+
|
|
516
647
|
def append_linq(self, element: TItem) -> FlpIt[TItem]:
|
|
517
648
|
"""Appends an element to the sequence lazily, returning a FlpIt without mutating this list."""
|
|
518
649
|
return FlpIt(self.data).append(element)
|
|
@@ -588,16 +719,16 @@ class FlpList(UserList[TItem], Sequence[TItem], Generic[TItem]):
|
|
|
588
719
|
) -> FlpIt[Grouping[TKey, TItem]]:
|
|
589
720
|
return FlpIt(self.data).group_by(key_selector)
|
|
590
721
|
|
|
591
|
-
def join(
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
) -> FlpIt[TResult]:
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
722
|
+
# def join(
|
|
723
|
+
# self,
|
|
724
|
+
# inner: Iterable[TOther],
|
|
725
|
+
# outer_key_selector: Callable[[TItem], TKey],
|
|
726
|
+
# inner_key_selector: Callable[[TOther], TKey],
|
|
727
|
+
# result_selector: Callable[[TItem, TOther], TResult],
|
|
728
|
+
# ) -> FlpIt[TResult]:
|
|
729
|
+
# return FlpIt(self.data).join(
|
|
730
|
+
# inner, outer_key_selector, inner_key_selector, result_selector
|
|
731
|
+
# )
|
|
601
732
|
|
|
602
733
|
@overload
|
|
603
734
|
def aggregate(self, func: Callable[[TItem, TItem], TItem]) -> TItem: ...
|
|
@@ -688,4 +819,5 @@ class FlpList(UserList[TItem], Sequence[TItem], Generic[TItem]):
|
|
|
688
819
|
return FlpIt(self.data).single(predicate)
|
|
689
820
|
|
|
690
821
|
def to_list(self) -> FlpList[TItem]:
|
|
691
|
-
|
|
822
|
+
"""Explicitly returns a shallow copy instance to isolate mutations."""
|
|
823
|
+
return FlpList(self.data.copy())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|