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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: flpit
3
- Version: 0.1.1.dev1
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 .NET's LINQ fluent API to standard Python iterables with full static typing.
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 evaluation via pure Python generator expressions.
26
- * **📦 Eager Container (`FlpList`):** Persisted state backed by `collections.UserList`.
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
- * **🟢 O(1) Boundary Validation:** Boundary checks rely on O(1) sampling instead of O(N) scans.
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 .NET's LINQ fluent API to standard Python iterables with full static typing.
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 evaluation via pure Python generator expressions.
10
- * **📦 Eager Container (`FlpList`):** Persisted state backed by `collections.UserList`.
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
- * **🟢 O(1) Boundary Validation:** Boundary checks rely on O(1) sampling instead of O(N) scans.
12
+ * **🔄 No Implicit Caching:** Query results are not cached unless explicitly documented, such as `order_by`.
13
13
 
14
14
  ## ⚙️ Installation
15
15
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flpit"
3
- version = "0.1.1.dev1"
3
+ version = "0.1.1.dev2"
4
4
  description = "Fluent LINQ for Python — flip your iterables"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flpit"
3
- version = "0.1.1.dev1"
3
+ version = "0.1.1.dev2"
4
4
  description = "Fluent LINQ for Python — flip your iterables"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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 (matching .NET IEnumerable<T>). No internal caching.
70
- Supports multiple passes over queries if the underlying collection is re-iterable.
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
- """Returns a specified number of contiguous elements from the start."""
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
- # 1. We must actively grab the live iterator instance from self
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
- self,
264
- inner: Iterable[TOther],
265
- outer_key_selector: Callable[[TItem], TKey],
266
- inner_key_selector: Callable[[TOther], TKey],
267
- result_selector: Callable[[TItem, TOther], TResult],
268
- ) -> FlpIt[TResult]:
269
- """Correlates elements of two sequences based on matching keys (Hash Join)."""
270
- def _generator() -> Iterator[TResult]:
271
- lookup: dict[TKey, List[TOther]] = {}
272
- for inner_item in inner:
273
- key = inner_key_selector(inner_item)
274
- lookup.setdefault(key, []).append(inner_item)
275
-
276
- for outer_item in self:
277
- key = outer_key_selector(outer_item)
278
- if key in lookup:
279
- for inner_item in lookup[key]:
280
- yield result_selector(outer_item, inner_item)
281
-
282
- return FlpIt(_FactoryIterable(_generator))
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
- | Ordered Iterable
433
- Represents a sorted sequence (matching .NET IOrderedEnumerable<T>). Supports then_by chaining.
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
- __slots__ = ("_source", "_comparers")
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
- self._source: Iterable[TItem] = source
445
- self._comparers: list[tuple[Callable[[TItem], Any], bool]] = [
446
- (key_selector, descending)
447
- ]
448
-
449
- def then_by(self, key_selector: Callable[[TItem], Any]) -> OrderedIt[TItem]:
450
- """Performs a subsequent ordering in ascending order."""
451
- new_ordered = OrderedIt(self._source, key_selector, descending=False)
452
- new_ordered._comparers = self._comparers + [(key_selector, False)]
453
- return new_ordered
454
-
455
- def then_by_descending(self, key_selector: Callable[[TItem], Any]) -> OrderedIt[TItem]:
456
- """Performs a subsequent ordering in descending order."""
457
- new_ordered = OrderedIt(self._source, key_selector, descending=True)
458
- new_ordered._comparers = self._comparers + [(key_selector, True)]
459
- return new_ordered
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
- items = list(self._source)
463
- for key_selector, descending in reversed(self._comparers):
464
- items.sort(key=key_selector, reverse=descending)
465
- return iter(items)
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
- """Adds an item and performs O(1) type consistency check against the first element."""
492
- if self.data and not isinstance(item, type(self.data[0])):
493
- raise TypeError(
494
- f"Element of type '{type(item).__name__}' does not match "
495
- f"list item type '{type(self.data[0]).__name__}'."
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
- """Adds a sequence of items and performs O(1) type checking on the first incoming element."""
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
- if self.data and not isinstance(first_item, type(self.data[0])):
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
- self,
593
- inner: Iterable[TOther],
594
- outer_key_selector: Callable[[TItem], TKey],
595
- inner_key_selector: Callable[[TOther], TKey],
596
- result_selector: Callable[[TItem, TOther], TResult],
597
- ) -> FlpIt[TResult]:
598
- return FlpIt(self.data).join(
599
- inner, outer_key_selector, inner_key_selector, result_selector
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
- return self
822
+ """Explicitly returns a shallow copy instance to isolate mutations."""
823
+ return FlpList(self.data.copy())
File without changes