flpit 0.1.1.dev3__tar.gz → 0.1.3__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.dev3
3
+ Version: 0.1.3
4
4
  Summary: Fluent LINQ for Python — flip your iterables
5
5
  License: MIT
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flpit"
3
- version = "0.1.1.dev3"
3
+ version = "0.1.3"
4
4
  description = "Fluent LINQ for Python — flip your iterables"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -25,6 +25,7 @@ dev = [
25
25
  "hypothesis>=6.168.0",
26
26
  "pyrefly>=1.3.1",
27
27
  "pyright>=1.1.414",
28
+ "pytest-benchmark>=5.3.0",
28
29
  ]
29
30
 
30
31
  [build-system]
@@ -42,3 +43,7 @@ exclude = [
42
43
  "tests",
43
44
  ]
44
45
  typeCheckingMode = "basic"
46
+
47
+ [tool.pytest.ini_options]
48
+ addopts = "-m 'not benchmark'"
49
+ markers = ["benchmark: performance benchmarks"]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flpit"
3
- version = "0.1.1.dev3"
3
+ version = "0.1.3"
4
4
  description = "Fluent LINQ for Python — flip your iterables"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -23,6 +23,7 @@ dev = [
23
23
  "hypothesis>=6.168.0",
24
24
  "pyrefly>=1.3.1",
25
25
  "pyright>=1.1.414",
26
+ "pytest-benchmark>=5.3.0",
26
27
  ]
27
28
 
28
29
  [build-system]
@@ -37,3 +38,12 @@ module-root = "src"
37
38
  include = ["src"]
38
39
  exclude = ["**/__pycache__", "tests"]
39
40
  typeCheckingMode = "basic"
41
+
42
+ [tool.pytest.ini_options]
43
+ addopts = "-m 'not benchmark'"
44
+ markers = [
45
+ "benchmark: performance benchmarks",
46
+ ]
47
+ #[tool.pyrefly]
48
+ #extra-paths = ["src", "tests"]
49
+ #python-interpreter-path = ".venv/bin/python" # or .venv/Scripts/python.exe on Windows
@@ -1,4 +1,7 @@
1
1
  from __future__ import annotations
2
+ from threading import Lock
3
+
4
+ from typing import cast
2
5
 
3
6
  import builtins
4
7
  from collections import UserList
@@ -28,12 +31,25 @@ TOther = TypeVar("TOther")
28
31
  TAccumulate = TypeVar("TAccumulate")
29
32
 
30
33
  _SENTINEL = object()
34
+ _MISSING = object()
31
35
 
32
36
 
33
37
  class EmptySequenceError(ValueError):
34
38
  def __init__(self, message="Sequence contains no elements"):
35
39
  super().__init__(message)
36
40
 
41
+ class NoMatchError(ValueError):
42
+ def __init__(self, message="Sequence contains no matching elements"):
43
+ super().__init__(message)
44
+
45
+ class MultipleMatchesError(ValueError):
46
+ def __init__(self, message="Sequence contains more than one matching element"):
47
+ super().__init__(message)
48
+
49
+ class MultipleElementsError(ValueError):
50
+ def __init__(self, message="Sequence contains more than one element"):
51
+ super().__init__(message)
52
+
37
53
 
38
54
  def _guard_empty(func: Callable[..., Any]) -> Callable[..., Any]:
39
55
  """Catches Python's native empty-sequence ValueError and re-raises LINQ-compliant error."""
@@ -96,6 +112,49 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
96
112
 
97
113
  return FlpIt(_FactoryIterable(_generator))
98
114
 
115
+ @overload
116
+ def any(self) -> bool: ...
117
+
118
+ @overload
119
+ def any(self, predicate: Callable[[TItem], bool]) -> bool: ...
120
+
121
+ def any(
122
+ self,
123
+ predicate: Optional[Callable[[TItem], bool]] = None,
124
+ ) -> bool:
125
+ """
126
+ Determines whether the sequence contains any elements,
127
+ or whether any element satisfies the predicate.
128
+
129
+ Mirrors Enumerable.Any:
130
+ Any()
131
+ Any(predicate)
132
+
133
+ Evaluation stops as soon as the result is known.
134
+ """
135
+ if predicate is not None:
136
+ return builtins.any(map(predicate, self))
137
+
138
+ iterator = iter(self)
139
+ try:
140
+ next(iterator)
141
+ except StopIteration:
142
+ return False
143
+
144
+ return True
145
+
146
+
147
+ def all(self, predicate: Callable[[TItem], bool]) -> bool:
148
+ """
149
+ Determines whether all elements satisfy the predicate.
150
+
151
+ Mirrors Enumerable.All.
152
+
153
+ Returns True for an empty sequence.
154
+ Evaluation stops at the first element that fails the predicate.
155
+ """
156
+ return builtins.all(map(predicate, self))
157
+
99
158
  def concat(self, second: Iterable[TItem]) -> "FlpIt[TItem]":
100
159
  def _generator() -> Iterator[TItem]:
101
160
  yield from self
@@ -305,33 +364,61 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
305
364
  accumulator = func(accumulator, item)
306
365
  return accumulator
307
366
 
367
+ @overload
368
+ def last(self) -> TItem: ...
369
+
370
+ @overload
371
+ def last(self, predicate: Callable[[TItem], bool]) -> TItem: ...
372
+
373
+ def last(
374
+ self,
375
+ predicate: Optional[Callable[[TItem], bool]] = None,
376
+ ) -> TItem:
377
+ """
378
+ Returns the last element of the sequence, or the last element
379
+ satisfying the predicate.
380
+
381
+ Mirrors Enumerable.Last:
382
+ Last()
383
+ Last(predicate)
384
+
385
+ Raises EmptySequenceError if the sequence is empty or if no
386
+ element satisfies the predicate.
387
+ """
388
+ last_item: TItem | object = _SENTINEL
389
+
390
+ for item in self:
391
+ if predicate is None or predicate(item):
392
+ last_item = item
393
+
394
+ if last_item is _SENTINEL:
395
+ raise EmptySequenceError() if predicate is None else NoMatchError()
396
+
397
+ return cast(TItem, last_item)
398
+
399
+ def _extreme(self, func, **kwargs) -> TItem:
400
+ result = func(self, default=_MISSING, **kwargs)
401
+
402
+ if result is _MISSING:
403
+ raise EmptySequenceError()
404
+
405
+ return cast(TItem, result)
406
+
308
407
  def min(self) -> TItem:
309
408
  """Returns the minimum value in a sequence."""
310
- try:
311
- return builtins.min(self) # type: ignore[type-var]
312
- except ValueError:
313
- raise EmptySequenceError()
409
+ return self._extreme(builtins.min)
314
410
 
315
411
  def min_by(self, key_selector: Callable[[TItem], Any]) -> TItem:
316
412
  """Returns the value in a sequence that has the minimum key value."""
317
- try:
318
- return builtins.min(self, key=key_selector)
319
- except ValueError:
320
- raise EmptySequenceError()
413
+ return self._extreme(builtins.min, key=key_selector)
321
414
 
322
415
  def max(self) -> TItem:
323
416
  """Returns the maximum value in a sequence."""
324
- try:
325
- return builtins.max(self) # type: ignore[type-var]
326
- except ValueError:
327
- raise EmptySequenceError()
417
+ return self._extreme(builtins.max)
328
418
 
329
419
  def max_by(self, key_selector: Callable[[TItem], Any]) -> TItem:
330
420
  """Returns the value in a sequence that has the maximum key value."""
331
- try:
332
- return builtins.max(self, key=key_selector)
333
- except ValueError:
334
- raise EmptySequenceError()
421
+ return self._extreme(builtins.max, key=key_selector)
335
422
 
336
423
  def average(
337
424
  self, selector: Optional[Callable[[TItem], Union[int, float]]] = None
@@ -372,7 +459,7 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
372
459
 
373
460
  def count(self, predicate: Optional[Callable[[TItem], bool]] = None) -> int:
374
461
  """Counts elements in the sequence matching an optional predicate."""
375
- query = self.where(predicate) if predicate else self
462
+ query = self.where(predicate) if predicate is not None else self
376
463
  return builtins.sum(1 for _ in query)
377
464
 
378
465
  def element_at(self, index: int) -> TItem:
@@ -386,10 +473,10 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
386
473
 
387
474
  def first(self, predicate: Optional[Callable[[TItem], bool]] = None) -> TItem:
388
475
  """Returns the first element matching a predicate, or raises ValueError."""
389
- query = self.where(predicate) if predicate else self
476
+ query = self.where(predicate) if predicate is not None else self
390
477
  for item in query:
391
478
  return item
392
- raise ValueError("Sequence contains no matching elements")
479
+ raise EmptySequenceError() if predicate is None else NoMatchError()
393
480
 
394
481
  def first_or_default(
395
482
  self, default: TResult, predicate: Optional[Callable[[TItem], bool]] = None
@@ -397,48 +484,91 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
397
484
  """Returns the first element matching a predicate, or a default value."""
398
485
  try:
399
486
  return self.first(predicate)
400
- except ValueError:
487
+ except (EmptySequenceError, NoMatchError):
401
488
  return default
402
489
 
403
- def single(self, predicate: Optional[Callable[[TItem], bool]] = None) -> TItem:
490
+ def single(self, predicate: Optional[Callable[[TItem], bool]] = _SENTINEL) -> TItem:
404
491
  """Returns the single, unique element matching a predicate."""
405
- query = self.where(predicate) if predicate else self
492
+ if predicate is None:
493
+ raise TypeError("Predicate cannot be None")
494
+
495
+ has_predicate = predicate is not _SENTINEL
496
+ query = self.where(predicate) if has_predicate else self
406
497
  it = iter(query)
498
+
407
499
  try:
408
500
  first_val = next(it)
409
501
  except StopIteration:
410
- raise ValueError("Sequence contains no matching elements")
502
+ raise NoMatchError() if has_predicate else EmptySequenceError()
411
503
 
412
504
  try:
413
505
  next(it)
414
506
  except StopIteration:
415
507
  return first_val
416
508
 
417
- raise ValueError("Sequence contains more than one matching element")
509
+ raise MultipleMatchesError() if has_predicate else MultipleElementsError()
510
+
418
511
 
419
512
  def to_list(self) -> FlpList[TItem]:
420
513
  """Explicitly materializes the query into a FlpList."""
421
514
  return FlpList(self)
422
515
 
423
516
 
424
- from threading import Lock
517
+ _OrderCriterion = tuple[Callable[[TItem], Any], bool]
518
+
519
+
520
+ class _OrderState(Generic[TItem]):
521
+ """
522
+ Shared lazy snapshot for an OrderBy/ThenBy chain.
523
+
524
+ The upstream source is consumed at most once. After materialization,
525
+ the original source reference is released.
526
+ """
527
+
528
+ __slots__ = ("_source", "_items", "_lock")
529
+
530
+ def __init__(self, source: Iterable[TItem]) -> None:
531
+ self._source: Optional[Iterable[TItem]] = source
532
+ self._items: Optional[list[TItem]] = None
533
+ self._lock = Lock()
534
+
535
+ def materialize(self) -> list[TItem]:
536
+ items = self._items
537
+
538
+ if items is not None:
539
+ return items
540
+
541
+ with self._lock:
542
+ items = self._items
543
+
544
+ if items is None:
545
+ source = self._source
546
+
547
+ if source is None:
548
+ raise RuntimeError("OrderState source is unavailable")
549
+
550
+ items = list(source)
551
+
552
+ self._source = None
553
+ self._items = items
554
+
555
+ return items
556
+
425
557
 
426
558
  class OrderedIt(FlpIt[TItem]):
427
559
  """
428
- Ordered Iterable.
560
+ Lazy, memoized ordered iterable.
429
561
 
430
- Sorts the source lazily on first iteration and caches the resulting order.
431
- The source is consumed at most once; subsequent iterations reuse the cached
432
- result.
562
+ The source is materialized once per OrderBy chain.
563
+ ThenBy nodes share that source snapshot but maintain independent
564
+ ordered-result caches.
433
565
  """
434
566
 
435
567
  __slots__ = (
436
- "_source",
437
- "_key_selector",
438
- "_descending",
439
- "_parent",
568
+ "_state",
569
+ "_criteria",
440
570
  "_cached_result",
441
- "_lock",
571
+ "_result_lock",
442
572
  )
443
573
 
444
574
  def __init__(
@@ -446,116 +576,94 @@ class OrderedIt(FlpIt[TItem]):
446
576
  source: Iterable[TItem],
447
577
  key_selector: Callable[[TItem], Any],
448
578
  descending: bool = False,
449
- parent: Optional["OrderedIt[TItem]"] = None,
450
579
  ) -> None:
451
- super().__init__(source)
580
+ # Pass () to FlpIt to prevent storing a permanent reference to the raw source
581
+ super().__init__(())
452
582
 
453
- self._source = source
454
- self._key_selector = key_selector
455
- self._descending = descending
456
- self._parent = parent
583
+ self._state = _OrderState(source)
584
+ self._criteria: tuple[_OrderCriterion, ...] = (
585
+ (key_selector, descending),
586
+ )
457
587
 
458
588
  self._cached_result: Optional[list[TItem]] = None
459
- self._lock = Lock()
589
+ self._result_lock = Lock()
590
+
591
+ @classmethod
592
+ def _from_parent(
593
+ cls,
594
+ parent: OrderedIt[TItem],
595
+ key_selector: Callable[[TItem], Any],
596
+ descending: bool,
597
+ ) -> OrderedIt[TItem]:
598
+ """
599
+ Create a child ordering node sharing the parent's source state.
600
+
601
+ The FlpIt source is intentionally empty because OrderedIt overrides
602
+ __iter__ and uses _OrderState as its real source.
603
+ """
604
+ child = cls.__new__(cls)
605
+
606
+ # Do not retain the parent OrderedIt through FlpIt's _iterable.
607
+ FlpIt.__init__(child, ())
608
+
609
+ child._state = parent._state
610
+ child._criteria = (
611
+ *parent._criteria,
612
+ (key_selector, descending),
613
+ )
614
+ child._cached_result = None
615
+ child._result_lock = Lock()
616
+
617
+ return child
460
618
 
461
619
  def then_by(
462
620
  self,
463
621
  key_selector: Callable[[TItem], Any],
464
- ) -> "OrderedIt[TItem]":
465
- return OrderedIt(
466
- self._source,
622
+ ) -> OrderedIt[TItem]:
623
+ return self._from_parent(
624
+ self,
467
625
  key_selector,
468
626
  descending=False,
469
- parent=self,
470
627
  )
471
628
 
472
629
  def then_by_descending(
473
630
  self,
474
631
  key_selector: Callable[[TItem], Any],
475
- ) -> "OrderedIt[TItem]":
476
- return OrderedIt(
477
- self._source,
632
+ ) -> OrderedIt[TItem]:
633
+ return self._from_parent(
634
+ self,
478
635
  key_selector,
479
636
  descending=True,
480
- parent=self,
481
637
  )
482
638
 
483
639
  def __iter__(self) -> Iterator[TItem]:
484
640
  cached = self._cached_result
485
641
 
486
642
  if cached is None:
487
- with self._lock:
643
+ with self._result_lock:
488
644
  cached = self._cached_result
489
645
 
490
646
  if cached is None:
491
- # Collect the complete ordering chain.
492
- comparers: list[
493
- tuple[Callable[[TItem], Any], bool]
494
- ] = []
495
-
496
- node: Optional["OrderedIt[TItem]"] = self
497
-
498
- while node is not None:
499
- comparers.append(
500
- (node._key_selector, node._descending)
647
+ # Never mutate the shared source snapshot.
648
+ result = list(self._state.materialize())
649
+
650
+ # Python sort is stable, so apply the least-significant
651
+ # criterion first.
652
+ for selector, descending in reversed(self._criteria):
653
+ result.sort(
654
+ key=selector,
655
+ reverse=descending,
501
656
  )
502
- node = node._parent
503
-
504
- comparers.reverse()
505
-
506
- # Consume the source exactly once.
507
- items = list(self._source)
508
-
509
- class SortWrapper:
510
- __slots__ = ("obj", "keys")
511
-
512
- def __init__(self, obj: Any) -> None:
513
- self.obj = obj
514
- self.keys = [
515
- selector(obj)
516
- for selector, _ in comparers
517
- ]
518
-
519
- def __lt__(self, other: "SortWrapper") -> bool:
520
- for index, (_, descending) in enumerate(comparers):
521
- left = self.keys[index]
522
- right = other.keys[index]
523
-
524
- if left == right:
525
- continue
526
-
527
- return right < left if descending else left < right
528
-
529
- return False
530
-
531
- wrapped_items = [
532
- SortWrapper(item)
533
- for item in items
534
- ]
535
-
536
- wrapped_items.sort()
537
-
538
- # Store ONLY the actual result objects.
539
- cached = [
540
- wrapper.obj
541
- for wrapper in wrapped_items
542
- ]
543
657
 
658
+ cached = result
544
659
  self._cached_result = cached
545
660
 
546
- # Important: __iter__ is a generator function.
547
- # Release potentially large temporary structures
548
- # before yielding anything.
549
- del wrapped_items
550
- del items
551
- del comparers
552
-
553
661
  yield from cached
554
662
 
555
663
 
556
664
  class Grouping(FlpIt[TItem], Generic[TKey, TItem]):
557
665
  """Represents a collection of elements sharing a common key (.NET IGrouping<TKey, TElement>)."""
558
- __slots__ = ("_key")
666
+ __slots__ = ("_key", )
559
667
 
560
668
  def __init__(self, key: TKey, elements: Iterable[TItem]) -> None:
561
669
  self._key: TKey = key
@@ -618,9 +726,26 @@ class FlpList(UserList[TItem], Sequence[TItem], Generic[TItem]):
618
726
  self.data.append(first_item)
619
727
  self.data.extend(it)
620
728
 
621
- def to_list(self) -> "FlpList[TItem]":
622
- """Explicitly returns a new shallow copy instance to isolate mutations matching .NET."""
623
- return FlpList(self.data.copy())
729
+ @overload
730
+ def any(self) -> bool: ...
731
+
732
+ @overload
733
+ def any(self, predicate: Callable[[TItem], bool]) -> bool: ...
734
+
735
+ def any(
736
+ self,
737
+ predicate: Optional[Callable[[TItem], bool]] = None,
738
+ ) -> bool:
739
+ if predicate is None:
740
+ return len(self.data) > 0
741
+
742
+ return any(predicate(item) for item in self.data)
743
+
744
+ def all(
745
+ self,
746
+ predicate: Callable[[TItem], bool],
747
+ ) -> bool:
748
+ return all(predicate(item) for item in self.data)
624
749
 
625
750
  def append_linq(self, element: TItem) -> FlpIt[TItem]:
626
751
  """Appends an element to the sequence lazily, returning a FlpIt without mutating this list."""
@@ -777,14 +902,46 @@ class FlpList(UserList[TItem], Sequence[TItem], Generic[TItem]):
777
902
  def first(self, predicate: Optional[Callable[[TItem], bool]] = None) -> TItem:
778
903
  return FlpIt(self.data).first(predicate)
779
904
 
905
+ @overload
906
+ def last(self) -> TItem: ...
907
+
908
+ @overload
909
+ def last(self, predicate: Callable[[TItem], bool]) -> TItem: ...
910
+
911
+ def last(
912
+ self,
913
+ predicate: Optional[Callable[[TItem], bool]] = None,
914
+ ) -> TItem:
915
+ """
916
+ Returns the last element of the sequence, or the last element
917
+ satisfying the predicate.
918
+
919
+ Mirrors Enumerable.Last:
920
+ Last()
921
+ Last(predicate)
922
+
923
+ Raises EmptySequenceError if the sequence is empty or if no
924
+ element satisfies the predicate.
925
+ """
926
+ if predicate is None:
927
+ if not self.data:
928
+ raise EmptySequenceError()
929
+ return self.data[-1]
930
+
931
+ for item in reversed(self.data):
932
+ if predicate(item):
933
+ return item
934
+
935
+ raise NoMatchError()
936
+
780
937
  def first_or_default(
781
938
  self, default: TResult, predicate: Optional[Callable[[TItem], bool]] = None
782
939
  ) -> Union[TItem, TResult]:
783
940
  return FlpIt(self.data).first_or_default(default, predicate)
784
941
 
785
- def single(self, predicate: Optional[Callable[[TItem], bool]] = None) -> TItem:
942
+ def single(self, predicate: Optional[Callable[[TItem], bool]] = _SENTINEL) -> TItem:
786
943
  return FlpIt(self.data).single(predicate)
787
944
 
788
945
  def to_list(self) -> FlpList[TItem]:
789
946
  """Explicitly returns a shallow copy instance to isolate mutations."""
790
- return FlpList(self.data.copy())
947
+ return FlpList(self.data.copy())
File without changes
File without changes
File without changes