flpit 0.1.6__tar.gz → 0.2.0.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.5
2
2
  Name: flpit
3
- Version: 0.1.6
3
+ Version: 0.2.0.dev2
4
4
  Summary: Fluent LINQ for Python — flip your iterables
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -28,11 +28,23 @@ Description-Content-Type: text/markdown
28
28
  > Where Python's language or type system requires a different API contract, the contract is adapted accordingly.
29
29
  > The implementation is actively developed, and semantic deviations may still exist.
30
30
 
31
+ > **⚠️ Breaking Change — `FlpList` — v0.2.0**
32
+ >
33
+ > `FlpList` no longer derives from `collections.UserList`.
34
+ >
35
+ > **Retained:** Python collection behavior such as iteration, `len()`, `in`, indexing, and slicing.
36
+ >
37
+ > **LINQ:** `append()`, `concat()`, and other LINQ operations remain available with lazy semantics.
38
+ >
39
+ > **`List<T>` semantics:** `add()`, `add_range()`, and other mutating operations follow the `List<T>` API where applicable.
40
+ >
41
+ > **Removed:** `copy()`, `+`, `*`, and other `UserList`-specific operations that are not part of the `FlpList` API.
42
+
31
43
  ## ⚡ Key Features
32
44
 
33
- * **⚡ Lazy Evaluation (`FlpIt`):** Deferred, streaming execution where the underlying operation permits it. `FlpIt` wraps a standard Python `Iterable[T]`.
34
- * **📦 Materialized Container (`FlpList`):** Eager, mutable container backed by `collections.UserList`.
35
- * **🎯 LINQ Semantics:** Operators such as `.any()`, `.first()`, `.single()`, `.order_by()`, and `.then_by()` follow .NET LINQ semantics.
45
+ * **⚡ Lazy Evaluation (`FlpIt`):** Deferred, streaming execution wrapping any standard Python `Iterable[T]`.
46
+ * **📦 Materialized Container (`FlpList`):** Eager collection implementing Python's `Collection` protocol (with indexing and slicing), retaining familiar collection access while providing .NET `List<T>`-style semantics and fluent LINQ query operations.
47
+ * **🎯 LINQ Semantics:** Familiar operators like `.any()`, `.first()`, `.single()`, `.order_by()`, and `.then_by()` with predictable, composable behavior.
36
48
  * **🔀 Independent Query Pipelines:** Derived queries are independent and never mutate or retroactively affect separately created queries.
37
49
  * **🧠 Lazy Internal Materialization:** Some operators, such as ordering and grouping, require internal materialization during deferred execution, while the query itself remains lazy.
38
50
  * **🔄 Source Enumeration Semantics** — FlpIt preserves the enumeration characteristics of its source. A query over a re-enumerable source can be enumerated repeatedly; a one-shot source such as a Python generator remains one-shot. FlpIt does not clone or rewind arbitrary Python iterators.
@@ -186,26 +198,31 @@ list(query) # [0, 1, 2]
186
198
  list(query) # []
187
199
  ```
188
200
 
189
- ## Intentional Deviation from Python List Contracts (`.count()`)
201
+ ## Intentional Semantic Deviation: `count()`
190
202
 
191
- To faithfully provide standard .NET LINQ semantics, `FlpList` deliberately uses LINQ-style count() semantics instead of Python list.count(value) semantics of Python's standard `list.count(value)`.
203
+ `FlpList` is a LINQ-oriented collection. Its query methods follow LINQ semantics even when those semantics differ from Python's built-in collection methods.
192
204
 
193
- * **Vanilla Python `list.count(value)`** counts occurrences of a specific *value*.
194
- * **FlpIt `FlpList.count()`** counts all elements.
195
- * **FlpIt `FlpList.count(predicate)`** counts elements matching the predicate.
205
+ In Python, `list.count(value)` counts occurrences of a specific value:
196
206
 
197
- ```python
198
- # Pure LINQ semantics instead of standard list behavior
199
- total_evens = eager_list.count(lambda x: x % 2 == 0)
207
+ ```
208
+ [1, 2, 2, 3].count(2) # 2
200
209
  ```
201
210
 
202
- Because `FlpList` is an explicit, fluent wrapper designed to enforce LINQ behavior, this signature change is a deliberate design choice. If you need the native Python behavior to count occurrences of a specific item value, use the explicitly provided fallback method:
211
+ In LINQ, `Count()` returns the total number of elements, or elements matching a predicate:
203
212
 
204
- ```python
205
- # Use the fallback for traditional Python value-counting
206
- occurrences = eager_list.count_item(4)
213
+ ```
214
+ FlpList([1, 2, 2, 3]).count() # 4
215
+ FlpList([1, 2, 2, 3]).count(lambda x: x > 1) # 3
216
+ ```
217
+
218
+ `FlpList.count()` deliberately follows the LINQ meaning. Python-style value counting remains available through normal Python operations when needed:
219
+
220
+ ```
221
+ list(FlpList([1, 2, 2, 3])).count(2) # 2
207
222
  ```
208
223
 
224
+ This behavior is a core part of `FlpList`'s LINQ-oriented contract, not an incomplete `list` implementation.
225
+
209
226
  ## 🔗 Independent Query Pipelines
210
227
 
211
228
  Query pipelines can be composed independently without mutating the queries they were derived from:
@@ -260,14 +277,22 @@ Lazy execution does not mean that every operator is streaming. Some operators mu
260
277
  `group_by()` produces a query of groupings rather than terminating the pipeline:
261
278
 
262
279
  ```python
263
- groups = query.group_by(lambda x: x % 3)
280
+ from typing import NamedTuple
281
+
282
+ class GroupResult(NamedTuple):
283
+ remainder: int
284
+ values: list[int]
264
285
 
265
286
  result = (
266
- groups
267
- .where(...)
268
- .select(...)
287
+ flp.it(range(1, 11))
288
+ .group_by(lambda x: x % 2)
289
+ .select(lambda group: GroupResult(
290
+ remainder=group.key,
291
+ values=list(group),
292
+ ))
269
293
  .to_list()
270
294
  )
295
+
271
296
  ```
272
297
 
273
298
  Terminal operators instead evaluate the query and return a result rather than another query:
@@ -328,4 +353,32 @@ uv add flpit
328
353
 
329
354
  ## 📜 License
330
355
 
331
- Distributed under the MIT License. See `LICENSE` for more information.
356
+ Distributed under the MIT License. See `LICENSE` for more information.
357
+
358
+
359
+ ### Migrated .NET Unit Tests
360
+
361
+ > Migrated from the official .NET Runtime LINQ Tests.
362
+ > Note: The test cases exercise both core types, `FlpIt` and `FlpList`, with transitions between lazy and eager execution occurring as part of the individual test cases.
363
+
364
+ ```
365
+ ================================================================
366
+ | Test Module File | Test Cases |
367
+ ----------------------------------------------------------------
368
+ | tests/nettests/ | |
369
+ | ├── test_all.py | 80 |
370
+ | ├── test_any.py | 90 |
371
+ | ├── test_average.py | 572 |
372
+ | ├── test_count.py | 37 |
373
+ | ├── test_generic_list.py | 95 |
374
+ | ├── test_last.py | 29 |
375
+ | ├── test_order_by.py | 100 |
376
+ | ├── test_order_descending_by.py | 45 |
377
+ | ├── test_sum.py | 134 |
378
+ | ├── test_take.py | 39 |
379
+ | ├── test_then_by.py | 40 |
380
+ | └── test_then_descending_by.py | 34 |
381
+ ----------------------------------------------------------------
382
+ | GRAND TOTALS | 1295 |
383
+ ================================================================
384
+ ```
@@ -8,11 +8,23 @@
8
8
  > Where Python's language or type system requires a different API contract, the contract is adapted accordingly.
9
9
  > The implementation is actively developed, and semantic deviations may still exist.
10
10
 
11
+ > **⚠️ Breaking Change — `FlpList` — v0.2.0**
12
+ >
13
+ > `FlpList` no longer derives from `collections.UserList`.
14
+ >
15
+ > **Retained:** Python collection behavior such as iteration, `len()`, `in`, indexing, and slicing.
16
+ >
17
+ > **LINQ:** `append()`, `concat()`, and other LINQ operations remain available with lazy semantics.
18
+ >
19
+ > **`List<T>` semantics:** `add()`, `add_range()`, and other mutating operations follow the `List<T>` API where applicable.
20
+ >
21
+ > **Removed:** `copy()`, `+`, `*`, and other `UserList`-specific operations that are not part of the `FlpList` API.
22
+
11
23
  ## ⚡ Key Features
12
24
 
13
- * **⚡ Lazy Evaluation (`FlpIt`):** Deferred, streaming execution where the underlying operation permits it. `FlpIt` wraps a standard Python `Iterable[T]`.
14
- * **📦 Materialized Container (`FlpList`):** Eager, mutable container backed by `collections.UserList`.
15
- * **🎯 LINQ Semantics:** Operators such as `.any()`, `.first()`, `.single()`, `.order_by()`, and `.then_by()` follow .NET LINQ semantics.
25
+ * **⚡ Lazy Evaluation (`FlpIt`):** Deferred, streaming execution wrapping any standard Python `Iterable[T]`.
26
+ * **📦 Materialized Container (`FlpList`):** Eager collection implementing Python's `Collection` protocol (with indexing and slicing), retaining familiar collection access while providing .NET `List<T>`-style semantics and fluent LINQ query operations.
27
+ * **🎯 LINQ Semantics:** Familiar operators like `.any()`, `.first()`, `.single()`, `.order_by()`, and `.then_by()` with predictable, composable behavior.
16
28
  * **🔀 Independent Query Pipelines:** Derived queries are independent and never mutate or retroactively affect separately created queries.
17
29
  * **🧠 Lazy Internal Materialization:** Some operators, such as ordering and grouping, require internal materialization during deferred execution, while the query itself remains lazy.
18
30
  * **🔄 Source Enumeration Semantics** — FlpIt preserves the enumeration characteristics of its source. A query over a re-enumerable source can be enumerated repeatedly; a one-shot source such as a Python generator remains one-shot. FlpIt does not clone or rewind arbitrary Python iterators.
@@ -166,26 +178,31 @@ list(query) # [0, 1, 2]
166
178
  list(query) # []
167
179
  ```
168
180
 
169
- ## Intentional Deviation from Python List Contracts (`.count()`)
181
+ ## Intentional Semantic Deviation: `count()`
170
182
 
171
- To faithfully provide standard .NET LINQ semantics, `FlpList` deliberately uses LINQ-style count() semantics instead of Python list.count(value) semantics of Python's standard `list.count(value)`.
183
+ `FlpList` is a LINQ-oriented collection. Its query methods follow LINQ semantics even when those semantics differ from Python's built-in collection methods.
172
184
 
173
- * **Vanilla Python `list.count(value)`** counts occurrences of a specific *value*.
174
- * **FlpIt `FlpList.count()`** counts all elements.
175
- * **FlpIt `FlpList.count(predicate)`** counts elements matching the predicate.
185
+ In Python, `list.count(value)` counts occurrences of a specific value:
176
186
 
177
- ```python
178
- # Pure LINQ semantics instead of standard list behavior
179
- total_evens = eager_list.count(lambda x: x % 2 == 0)
187
+ ```
188
+ [1, 2, 2, 3].count(2) # 2
180
189
  ```
181
190
 
182
- Because `FlpList` is an explicit, fluent wrapper designed to enforce LINQ behavior, this signature change is a deliberate design choice. If you need the native Python behavior to count occurrences of a specific item value, use the explicitly provided fallback method:
191
+ In LINQ, `Count()` returns the total number of elements, or elements matching a predicate:
183
192
 
184
- ```python
185
- # Use the fallback for traditional Python value-counting
186
- occurrences = eager_list.count_item(4)
193
+ ```
194
+ FlpList([1, 2, 2, 3]).count() # 4
195
+ FlpList([1, 2, 2, 3]).count(lambda x: x > 1) # 3
196
+ ```
197
+
198
+ `FlpList.count()` deliberately follows the LINQ meaning. Python-style value counting remains available through normal Python operations when needed:
199
+
200
+ ```
201
+ list(FlpList([1, 2, 2, 3])).count(2) # 2
187
202
  ```
188
203
 
204
+ This behavior is a core part of `FlpList`'s LINQ-oriented contract, not an incomplete `list` implementation.
205
+
189
206
  ## 🔗 Independent Query Pipelines
190
207
 
191
208
  Query pipelines can be composed independently without mutating the queries they were derived from:
@@ -240,14 +257,22 @@ Lazy execution does not mean that every operator is streaming. Some operators mu
240
257
  `group_by()` produces a query of groupings rather than terminating the pipeline:
241
258
 
242
259
  ```python
243
- groups = query.group_by(lambda x: x % 3)
260
+ from typing import NamedTuple
261
+
262
+ class GroupResult(NamedTuple):
263
+ remainder: int
264
+ values: list[int]
244
265
 
245
266
  result = (
246
- groups
247
- .where(...)
248
- .select(...)
267
+ flp.it(range(1, 11))
268
+ .group_by(lambda x: x % 2)
269
+ .select(lambda group: GroupResult(
270
+ remainder=group.key,
271
+ values=list(group),
272
+ ))
249
273
  .to_list()
250
274
  )
275
+
251
276
  ```
252
277
 
253
278
  Terminal operators instead evaluate the query and return a result rather than another query:
@@ -308,4 +333,32 @@ uv add flpit
308
333
 
309
334
  ## 📜 License
310
335
 
311
- Distributed under the MIT License. See `LICENSE` for more information.
336
+ Distributed under the MIT License. See `LICENSE` for more information.
337
+
338
+
339
+ ### Migrated .NET Unit Tests
340
+
341
+ > Migrated from the official .NET Runtime LINQ Tests.
342
+ > Note: The test cases exercise both core types, `FlpIt` and `FlpList`, with transitions between lazy and eager execution occurring as part of the individual test cases.
343
+
344
+ ```
345
+ ================================================================
346
+ | Test Module File | Test Cases |
347
+ ----------------------------------------------------------------
348
+ | tests/nettests/ | |
349
+ | ├── test_all.py | 80 |
350
+ | ├── test_any.py | 90 |
351
+ | ├── test_average.py | 572 |
352
+ | ├── test_count.py | 37 |
353
+ | ├── test_generic_list.py | 95 |
354
+ | ├── test_last.py | 29 |
355
+ | ├── test_order_by.py | 100 |
356
+ | ├── test_order_descending_by.py | 45 |
357
+ | ├── test_sum.py | 134 |
358
+ | ├── test_take.py | 39 |
359
+ | ├── test_then_by.py | 40 |
360
+ | └── test_then_descending_by.py | 34 |
361
+ ----------------------------------------------------------------
362
+ | GRAND TOTALS | 1295 |
363
+ ================================================================
364
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flpit"
3
- version = "0.1.6"
3
+ version = "0.2.0.dev2"
4
4
  description = "Fluent LINQ for Python — flip your iterables"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -27,6 +27,7 @@ dev = [
27
27
  "pyrefly>=1.3.1",
28
28
  "pyright>=1.1.414",
29
29
  "pytest-benchmark>=5.3.0",
30
+ "mypy>=2.4.0",
30
31
  ]
31
32
 
32
33
 
@@ -1,19 +1,21 @@
1
1
  from __future__ import annotations
2
- from threading import Lock
2
+ from typing import SupportsIndex
3
+ import decimal
4
+ from decimal import Decimal, Overflow, Inexact
3
5
 
4
6
  import builtins
5
7
  from collections import UserList
6
8
  from functools import wraps
7
9
  from itertools import islice
10
+
8
11
  from typing import (
9
12
  Any,
10
13
  Callable,
11
14
  Generic,
12
15
  Iterable,
13
16
  Iterator,
17
+ Collection,
14
18
  List,
15
- Optional,
16
- Sequence,
17
19
  Set,
18
20
  Type,
19
21
  TypeVar,
@@ -22,7 +24,6 @@ from typing import (
22
24
  overload,
23
25
  cast as typing_cast,
24
26
  )
25
-
26
27
  TItem = TypeVar("TItem")
27
28
  TResult = TypeVar("TResult")
28
29
  TKey = TypeVar("TKey")
@@ -63,6 +64,14 @@ class PredicateNoneError(TypeError):
63
64
  def __init__(self):
64
65
  super().__init__("Value cannot be None. (Argument 'predicate')")
65
66
 
67
+ class CollectionNoneError(TypeError):
68
+ def __init__(self):
69
+ super().__init__("Value cannot be None. (Argument 'collection')")
70
+
71
+ class SelectorNoneError(TypeError):
72
+ def __init__(self):
73
+ super().__init__("Value cannot be None. (Parameter 'keySelector')")
74
+
66
75
 
67
76
  def _guard_empty(func: Callable[..., Any]) -> Callable[..., Any]:
68
77
  """Catches Python's native empty-sequence ValueError and re-raises LINQ-compliant error."""
@@ -71,12 +80,23 @@ def _guard_empty(func: Callable[..., Any]) -> Callable[..., Any]:
71
80
  try:
72
81
  return func(self, *args, **kwargs)
73
82
  except ValueError as e:
74
- if not self.data:
83
+ if not self._FlpList__list.data:
75
84
  raise EmptySequenceError() from e
76
85
  raise
77
86
  return wrapper
78
87
 
79
88
 
89
+ # noinspection unresolved-references,protected-member
90
+ def dotnet_context() -> decimal._ContextManager: # awesome that type was not intended
91
+ # 1. Create and configure pure Context object
92
+ ctx = decimal.getcontext().copy()
93
+ ctx.prec = max(ctx.prec, 29)
94
+ ctx.traps[Overflow] = True
95
+ ctx.traps[Inexact] = True
96
+
97
+ return decimal.localcontext(ctx)
98
+
99
+
80
100
  class _FactoryIterable(Iterable[TItem], Generic[TItem]):
81
101
  """
82
102
  Wraps a generator factory function into a clean Iterable[T].
@@ -439,41 +459,61 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
439
459
  return self._extreme(builtins.max, key=key_selector)
440
460
 
441
461
  def average(
442
- self, selector: Callable[[TItem], Union[int, float]] | _Sentinel = _SENTINEL
443
- ) -> float:
462
+ self, selector: Callable[[TItem], int | float | Decimal] | _Sentinel = _SENTINEL
463
+ ) -> float | int | Decimal | None:
444
464
  """Computes the arithmetic mean of the sequence, optionally applying a selector."""
445
- total = 0.0
446
- count = 0
447
- query = (selector(x) for x in self) if selector is not _SENTINEL else self
448
- for item in query:
449
- total += float(item) # type: ignore[arg-type]
450
- count += 1
451
- if count == 0:
452
- raise EmptySequenceError()
453
- return total / count
465
+ with dotnet_context():
466
+ total, count = self.__sum_and_count(selector)
454
467
 
455
- avg = average
468
+ if total is None:
469
+ return None
456
470
 
457
- def average_by(self, key_selector: Callable[[TItem], Union[int, float]]) -> float:
458
- """Computes the average of a sequence of numeric values projected by a key selector."""
459
- total = 0.0
460
- count = 0
461
- for item in self:
462
- total += float(key_selector(item))
463
- count += 1
464
- if count == 0:
465
- raise EmptySequenceError()
466
- return total / count
471
+ if isinstance(total, Decimal):
472
+ return total / Decimal(count)
467
473
 
468
- avg_by = average_by
474
+ assert total is not None
475
+ assert isinstance(total, Decimal) or isinstance(total, int) or isinstance(total, float)
476
+ return total / count
477
+
478
+ avg = average
469
479
 
470
480
  def sum(
471
- self, selector: Callable[[TItem], Union[int, float]] | _Sentinel = _SENTINEL
472
- ) -> Union[int, float]:
481
+ self, selector: Callable[[TItem], int | float | Decimal] | _Sentinel = _SENTINEL
482
+ ) -> Union[int, float, Decimal]:
473
483
  """Calculates the sum of the sequence, optionally applying a selector."""
474
- if selector is not _SENTINEL:
475
- return builtins.sum(selector(x) for x in self)
476
- return builtins.sum(self) # type: ignore[arg-type]
484
+ with dotnet_context():
485
+ total, count = self.__sum_and_count(selector)
486
+
487
+ if count == 0:
488
+ return 0
489
+
490
+ assert total is not None
491
+ assert isinstance(total, Decimal) or isinstance(total, int) or isinstance(total, float)
492
+ return total
493
+
494
+ def __sum_and_count(self, selector: Callable[[TItem], int | float | Decimal]) -> tuple[Decimal | float | int | TItem | None, int]:
495
+ if selector is None:
496
+ raise SelectorNoneError()
497
+
498
+ total = None
499
+ count: int = 0
500
+ query = (selector(x) for x in self) if selector is not _SENTINEL else self
501
+
502
+ for item in query:
503
+ if item is not None:
504
+ if total is None:
505
+ total = item
506
+ else:
507
+ try:
508
+ total += item
509
+ except (Overflow, Inexact):
510
+ raise OverflowError(
511
+ "System.OverflowException: Value was either too large or too small for a Decimal."
512
+ )
513
+ count += 1
514
+
515
+ return total, count
516
+
477
517
 
478
518
  def count(
479
519
  self,
@@ -533,152 +573,85 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
533
573
  """Explicitly materializes the query into a FlpList."""
534
574
  return FlpList(self)
535
575
 
576
+ from functools import partial
536
577
 
537
- class _OrderState(Generic[TItem]):
538
- """
539
- Shared lazy snapshot for an OrderBy/ThenBy chain.
540
-
541
- The upstream source is consumed at most once. After materialization,
542
- the original source reference is released.
543
- """
544
-
545
- __slots__ = ("_source", "_items", "_lock")
546
-
547
- def __init__(self, source: Iterable[TItem]) -> None:
548
- self._source: Optional[Iterable[TItem]] = source
549
- self._items: Optional[list[TItem]] = None
550
- self._lock = Lock()
551
-
552
- def materialize(self) -> list[TItem]:
553
- items = self._items
554
-
555
- if items is not None:
556
- return items
557
-
558
- with self._lock:
559
- items = self._items
578
+ class _NoneOrderKey:
579
+ __slots__ = ()
560
580
 
561
- if items is None:
562
- source = self._source
581
+ def __lt__(self, other: object) -> bool:
582
+ return other is not self
563
583
 
564
- if source is None:
565
- raise RuntimeError("OrderState source is unavailable")
584
+ def __gt__(self, other: object) -> bool:
585
+ return False
566
586
 
567
- items = list(source)
587
+ def __eq__(self, other: object) -> bool:
588
+ return other is self
568
589
 
569
- self._source = None
570
- self._items = items
571
590
 
572
- return items
591
+ _NONE_ORDER_KEY = _NoneOrderKey()
573
592
 
593
+ def _none_aware_key(
594
+ selector: Callable[[TItem], Any],
595
+ item: TItem,
596
+ ) -> Any:
597
+ key = selector(item)
598
+ return _NONE_ORDER_KEY if key is None else key
574
599
 
575
600
  class OrderedIt(FlpIt[TItem]):
576
- """
577
- Lazy, memoized ordered iterable.
578
-
579
- The source is materialized once per OrderBy chain.
580
- ThenBy nodes share that source snapshot but maintain independent
581
- ordered-result caches.
582
- """
583
-
584
- __slots__ = (
585
- "_state",
586
- "_criteria",
587
- "_cached_result",
588
- "_result_lock",
589
- )
601
+ __slots__ = ("_criteria",)
590
602
 
591
603
  def __init__(
592
604
  self,
593
605
  source: Iterable[TItem],
594
- key_selector: Callable[[TItem], Any],
606
+ selector: Callable[[TItem], Any],
595
607
  descending: bool = False,
596
- ) -> None:
597
- # Pass () to FlpIt to prevent storing a permanent reference to the raw source
598
- super().__init__(())
599
-
600
-
601
- self._state = _OrderState(source)
602
- self._criteria: tuple[tuple[Callable[[TItem], Any], bool], ...] = (
603
- (key_selector, descending),
604
- )
608
+ *,
609
+ _criteria: tuple[tuple[Callable[[TItem], Any], bool], ...] | None = None,
610
+ ):
611
+ if selector is None:
612
+ raise SelectorNoneError()
605
613
 
606
- self._cached_result: Optional[list[TItem]] = None
607
- self._result_lock = Lock()
614
+ super().__init__(source)
608
615
 
609
- @classmethod
610
- def _from_parent(
611
- cls,
612
- parent: OrderedIt[TItem],
613
- key_selector: Callable[[TItem], Any],
614
- descending: bool,
615
- ) -> OrderedIt[TItem]:
616
- """
617
- Create a child ordering node sharing the parent's source state.
618
-
619
- The FlpIt source is intentionally empty because OrderedIt overrides
620
- __iter__ and uses _OrderState as its real source.
621
- """
622
- child = cls.__new__(cls)
616
+ if _criteria is None:
617
+ self._criteria = ((selector, descending),)
618
+ else:
619
+ self._criteria = _criteria + ((selector, descending),)
623
620
 
624
- # Do not retain the parent OrderedIt through FlpIt's _iterable.
625
- FlpIt.__init__(child, ())
621
+ @override
622
+ def __iter__(self) -> Iterator[TItem]:
623
+ items = list(self._iterable)
626
624
 
627
- child._state = parent._state
628
- child._criteria = (
629
- *parent._criteria,
630
- (key_selector, descending),
631
- )
632
- child._cached_result = None
633
- child._result_lock = Lock()
625
+ for selector, descending in reversed(self._criteria):
626
+ items.sort(
627
+ key=partial(_none_aware_key, selector),
628
+ reverse=descending,
629
+ )
634
630
 
635
- return child
631
+ yield from items
636
632
 
637
633
  def then_by(
638
634
  self,
639
635
  key_selector: Callable[[TItem], Any],
640
- ) -> OrderedIt[TItem]:
641
- return self._from_parent(
642
- self,
636
+ ) -> "OrderedIt[TItem]":
637
+ return OrderedIt(
638
+ self._iterable,
643
639
  key_selector,
644
- descending=False,
640
+ False,
641
+ _criteria=self._criteria,
645
642
  )
646
643
 
647
644
  def then_by_descending(
648
645
  self,
649
646
  key_selector: Callable[[TItem], Any],
650
- ) -> OrderedIt[TItem]:
651
- return self._from_parent(
652
- self,
647
+ ) -> "OrderedIt[TItem]":
648
+ return OrderedIt(
649
+ self._iterable,
653
650
  key_selector,
654
- descending=True,
651
+ True,
652
+ _criteria=self._criteria,
655
653
  )
656
654
 
657
- @override
658
- def __iter__(self) -> Iterator[TItem]:
659
- cached = self._cached_result
660
-
661
- if cached is None:
662
- with self._result_lock:
663
- cached = self._cached_result
664
-
665
- if cached is None:
666
- # Never mutate the shared source snapshot.
667
- result = list(self._state.materialize())
668
-
669
- # Python sort is stable, so apply the least-significant
670
- # criterion first.
671
- for selector, descending in reversed(self._criteria):
672
- result.sort(
673
- key=selector,
674
- reverse=descending,
675
- )
676
-
677
- cached = result
678
- self._cached_result = cached
679
-
680
- yield from cached
681
-
682
655
 
683
656
  class Grouping(FlpIt[TItem], Generic[TKey, TItem]):
684
657
  """Represents a collection of elements sharing a common key (.NET IGrouping<TKey, TElement>)."""
@@ -704,7 +677,7 @@ class Grouping(FlpIt[TItem], Generic[TKey, TItem]):
704
677
  return self.key == other.key and self.to_list() == other.to_list()
705
678
 
706
679
 
707
- class FlpList(UserList[TItem], Sequence[TItem], Generic[TItem]):
680
+ class FlpList(Collection[TItem], Generic[TItem]):
708
681
  """
709
682
  | Fluent List
710
683
  A materialized list extending UserList that yields lazy FlpIt instances for query operations.
@@ -718,7 +691,88 @@ class FlpList(UserList[TItem], Sequence[TItem], Generic[TItem]):
718
691
  else:
719
692
  init_list = typing_cast(Iterable[TItem], source)
720
693
 
721
- super().__init__(init_list)
694
+ self.__list: UserList[TItem] = UserList[TItem](init_list)
695
+
696
+ # =====================================================================
697
+ # 1. THE ABC CONTRACT
698
+ # =====================================================================
699
+ def __iter__(self) -> Iterator[Any]:
700
+ return self.__list.__iter__()
701
+
702
+ def __len__(self) -> int:
703
+ """Evaluates the stream to get total count."""
704
+ return self.__list.__len__()
705
+
706
+ def __contains__(self, item: TItem) -> bool:
707
+ return self.__list.__contains__(item)
708
+
709
+ # =====================================================================
710
+ # 2. Keep some of the Python UserList like behavior
711
+ # =====================================================================
712
+
713
+ def __repr__(self):
714
+ return self.__list.__repr__()
715
+
716
+ def __str__(self) -> str:
717
+ return self.__list.__str__()
718
+
719
+ def __eq__(self, other: Any) -> bool:
720
+ if isinstance(other, FlpList):
721
+ return self.__list.__eq__(other.__list)
722
+ return self.__list.__eq__(other)
723
+
724
+ def __lt__(self, other: Any) -> bool:
725
+ target = other.__list if isinstance(other, FlpList) else other
726
+ return self.__list.__lt__(target)
727
+
728
+ def __le__(self, other: Any) -> bool:
729
+ target = other.__list if isinstance(other, FlpList) else other
730
+ return self.__list.__le__(target)
731
+
732
+ def __gt__(self, other: Any) -> bool:
733
+ target = other.__list if isinstance(other, FlpList) else other
734
+ return self.__list.__gt__(target)
735
+
736
+ def __ge__(self, other: Any) -> bool:
737
+ target = other.__list if isinstance(other, FlpList) else other
738
+ return self.__list.__ge__(target)
739
+
740
+
741
+ @overload
742
+ def __getitem__(self, index: SupportsIndex) -> TItem: ...
743
+
744
+ @overload
745
+ def __getitem__(self, index: slice) -> FlpList[TItem]: ...
746
+
747
+ def __getitem__(self, index: SupportsIndex | slice):
748
+ if isinstance(index, slice):
749
+ return self.__class__(self.__list[index])
750
+ else:
751
+ return self.__list[index]
752
+
753
+ @overload
754
+ def __setitem__(self, index: SupportsIndex, value: TItem) -> None: ...
755
+
756
+ @overload
757
+ def __setitem__(self, index: slice, value: Iterable[TItem]) -> None: ...
758
+
759
+ def __setitem__(
760
+ self,
761
+ index: SupportsIndex | slice,
762
+ value: TItem | Iterable[TItem],
763
+ ) -> None:
764
+ if isinstance(index, slice):
765
+ if not isinstance(value, Iterable):
766
+ raise TypeError("slice assignment requires an iterable value")
767
+ self.__list.data[index] = value
768
+ else:
769
+ if isinstance(value, Iterable):
770
+ raise TypeError("item assignment requires a non-iterable value")
771
+ self.__list.data[index] = value
772
+
773
+ # =====================================================================
774
+ # 3. Put .NET List<T> mechanics on top
775
+ # =====================================================================
722
776
 
723
777
  def add(self, item: TItem) -> None:
724
778
  """
@@ -728,9 +782,9 @@ class FlpList(UserList[TItem], Sequence[TItem], Generic[TItem]):
728
782
  - type checks
729
783
  - manual of_type(...) filter if you don't trust your checks
730
784
  """
731
- self.data.append(item)
785
+ self.__list.data.append(item)
732
786
 
733
- def add_range(self, items: Iterable[TItem]) -> None:
787
+ def add_range(self, collection: Iterable[TItem]) -> None:
734
788
  """
735
789
  | Adds an Iterable sequence or stream.
736
790
  Optimizes paths based on input type without destroying volatile generators.
@@ -739,21 +793,131 @@ class FlpList(UserList[TItem], Sequence[TItem], Generic[TItem]):
739
793
  - type checks
740
794
  - manual of_type(...) filter if you don't trust your checks
741
795
  """
742
- # 1. Optimized Path: Fast memory extensions for pre-materialized sequences
743
- if isinstance(items, (Sequence, list, tuple, UserList)):
744
- self.data.extend(items)
745
- return
796
+ if collection is None:
797
+ raise CollectionNoneError()
798
+
799
+ if isinstance(collection, UserList):
800
+ self.__list.data.extend(collection.data)
801
+ elif isinstance(collection, FlpList):
802
+ self.__list.data.extend(collection.__list.data)
803
+ else:
804
+ self.__list.data.extend(collection)
805
+
806
+ def clear(self) -> None:
807
+ self.__list.data.clear()
808
+
809
+ def contains(self, item: TItem) -> bool:
810
+ return item in self.__list.data
811
+
812
+ def exists(self, predicate: Callable[[TItem], bool]) -> bool:
813
+ return any(predicate(item) for item in self.__list.data)
814
+
815
+ def find(self, predicate: Callable[[TItem], bool]) -> TItem | None:
816
+ if predicate is None:
817
+ raise PredicateNoneError()
818
+
819
+ for item in self.__list.data:
820
+ if predicate(item):
821
+ return item
822
+
823
+ return None
824
+
825
+ def find_all(self, predicate: Callable[[TItem], bool]) -> FlpList[TItem]:
826
+ if predicate is None:
827
+ raise PredicateNoneError()
828
+
829
+ return FlpList(item for item in self.__list.data if predicate(item))
746
830
 
747
- # 2. Stream Path: Volatile one-shot generator handling
748
- it = iter(items)
831
+ def insert(self, index: int, item: TItem) -> None:
832
+ if index < 0 or index > len(self.__list.data):
833
+ raise IndexError(f"Index {index} is out of range.")
834
+
835
+ self.__list.data.insert(index, item)
836
+
837
+ def insert_range(self, index: int, collection: Iterable[TItem]) -> None:
838
+ if collection is None:
839
+ raise CollectionNoneError()
840
+
841
+ if index < 0 or index > len(self.__list.data):
842
+ raise IndexError(f"Index {index} is out of range.")
843
+
844
+ self.__list.data[index:index] = list(collection)
845
+
846
+ def remove(self, item: TItem) -> bool:
749
847
  try:
750
- first_item = next(it)
751
- except StopIteration:
848
+ self.__list.data.remove(item)
849
+ return True
850
+ except ValueError:
851
+ return False
852
+
853
+ def remove_all(self, predicate: Callable[[TItem], bool]) -> int:
854
+ if predicate is None:
855
+ raise PredicateNoneError()
856
+
857
+ original_length = len(self.__list.data)
858
+
859
+ self.__list.data[:] = [
860
+ item
861
+ for item in self.__list.data
862
+ if not predicate(item)
863
+ ]
864
+
865
+ return original_length - len(self.__list.data)
866
+
867
+ def remove_at(self, index: int) -> None:
868
+ if index < 0 or index > len(self.__list.data):
869
+ raise IndexError(f"Index {index} is out of range.")
870
+
871
+ del self.__list.data[index]
872
+
873
+ def remove_range(self, index: int, count: int) -> None:
874
+ size = len(self.__list.data)
875
+
876
+ if index < 0 or index > size:
877
+ raise IndexError("index")
878
+
879
+ if count < 0:
880
+ raise IndexError("count")
881
+
882
+ if count > size - index:
883
+ raise IndexError("The range is invalid.")
884
+
885
+ del self.__list.data[index : index + count]
886
+
887
+ @overload
888
+ def reverse(self) -> None: ...
889
+
890
+ @overload
891
+ def reverse(self, index: int, count: int) -> None: ...
892
+
893
+ def reverse(self, index: int = 0, count: int | None = None) -> None:
894
+ if count is None:
895
+ self.__list.data.reverse()
752
896
  return
753
897
 
754
- # Append the tracked peek-element and stream the remainder safely
755
- self.data.append(first_item)
756
- self.data.extend(it)
898
+ size = len(self.__list.data)
899
+
900
+ # Apply your List<T>-compatible argument validation here.
901
+ if index < 0 or index > size:
902
+ raise IndexError("Non-negative number required.")
903
+
904
+ if count < 0:
905
+ raise IndexError("Non-negative number required.")
906
+
907
+ if index + count > size:
908
+ raise IndexError(
909
+ "Offset and length were out of bounds for the array or "
910
+ "count is greater than the number of elements from index "
911
+ "to the end of the source collection."
912
+ )
913
+
914
+ self.__list.data[index:index + count] = reversed(
915
+ self.__list.data[index:index + count]
916
+ )
917
+
918
+ # =====================================================================
919
+ # 4. IEnumerable add-ons
920
+ # =====================================================================
757
921
 
758
922
  @overload
759
923
  def any(self) -> bool: ...
@@ -766,55 +930,55 @@ class FlpList(UserList[TItem], Sequence[TItem], Generic[TItem]):
766
930
  predicate: Callable[[TItem], bool] | _Sentinel = _SENTINEL,
767
931
  ) -> bool:
768
932
  if predicate is _SENTINEL:
769
- return len(self.data) > 0
933
+ return len(self.__list.data) > 0
770
934
 
771
- return any(predicate(item) for item in self.data)
935
+ return any(predicate(item) for item in self.__list.data)
772
936
 
773
937
  def all(
774
938
  self,
775
939
  predicate: Callable[[TItem], bool],
776
940
  ) -> bool:
777
- return all(predicate(item) for item in self.data)
941
+ return all(predicate(item) for item in self.__list.data)
778
942
 
779
- def append_linq(self, element: TItem) -> FlpIt[TItem]:
943
+ def append(self, element: TItem) -> FlpIt[TItem]:
780
944
  """Appends an element to the sequence lazily, returning a FlpIt without mutating this list."""
781
- return FlpIt(self.data).append(element)
945
+ return FlpIt(self.__list.data).append(element)
782
946
 
783
947
  def prepend(self, element: TItem) -> FlpIt[TItem]:
784
948
  """Prepends an element to the sequence lazily, returning a FlpIt without mutating this list."""
785
- return FlpIt(self.data).prepend(element)
949
+ return FlpIt(self.__list.data).prepend(element)
786
950
 
787
951
  # noinspection unused-parameter
788
952
  def as_type(self, target_type: Type[TResult]) -> FlpList[TResult]:
789
953
  return self # type: ignore[return-value]
790
954
 
791
955
  def where(self, predicate: Callable[[TItem], bool]) -> FlpIt[TItem]:
792
- return FlpIt(self.data).where(predicate)
956
+ return FlpIt(self.__list.data).where(predicate)
793
957
 
794
958
  def select(self, selector: Callable[[TItem], TResult]) -> FlpIt[TResult]:
795
- return FlpIt(self.data).select(selector)
959
+ return FlpIt(self.__list.data).select(selector)
796
960
 
797
961
  def select_many(
798
962
  self, selector: Callable[[TItem], Iterable[TResult]]
799
963
  ) -> FlpIt[TResult]:
800
- return FlpIt(self.data).select_many(selector)
964
+ return FlpIt(self.__list.data).select_many(selector)
801
965
 
802
966
  def take(self, count: int) -> FlpIt[TItem]:
803
- return FlpIt(self.data).take(count)
967
+ return FlpIt(self.__list.data).take(count)
804
968
 
805
969
  def cast(self, target_type: Type[TResult]) -> FlpIt[TResult]:
806
- return FlpIt(self.data).cast(target_type)
970
+ return FlpIt(self.__list.data).cast(target_type)
807
971
 
808
972
  def of_type(self, target_type: Type[TResult]) -> FlpIt[TResult]:
809
- return FlpIt(self.data).of_type(target_type)
973
+ return FlpIt(self.__list.data).of_type(target_type)
810
974
 
811
975
  def distinct(self) -> FlpIt[TItem]:
812
- return FlpIt(self.data).distinct()
976
+ return FlpIt(self.__list.data).distinct()
813
977
 
814
978
  def distinct_by(
815
979
  self, key_selector: Callable[[TItem], TKey]
816
980
  ) -> FlpIt[TItem]:
817
- return FlpIt(self.data).distinct_by(key_selector)
981
+ return FlpIt(self.__list.data).distinct_by(key_selector)
818
982
 
819
983
  @overload
820
984
  def zip(self, second: Iterable[TOther]) -> FlpIt[tuple[TItem, TOther]]: ...
@@ -831,25 +995,25 @@ class FlpList(UserList[TItem], Sequence[TItem], Generic[TItem]):
831
995
  second: Iterable[TOther],
832
996
  result_selector: Callable[[TItem, TOther], Any] | _Sentinel = _SENTINEL,
833
997
  ) -> FlpIt[Any]:
834
- return FlpIt(self.data).zip(second, result_selector)
998
+ return FlpIt(self.__list.data).zip(second, result_selector)
835
999
 
836
1000
  def chunk(self, size: int) -> FlpIt[FlpList[TItem]]:
837
- return FlpIt(self.data).chunk(size)
1001
+ return FlpIt(self.__list.data).chunk(size)
838
1002
 
839
1003
  def order_by(
840
1004
  self, key_selector: Callable[[TItem], Any]
841
1005
  ) -> OrderedIt[TItem]:
842
- return FlpIt(self.data).order_by(key_selector)
1006
+ return FlpIt(self.__list.data).order_by(key_selector)
843
1007
 
844
1008
  def order_by_descending(
845
1009
  self, key_selector: Callable[[TItem], Any]
846
1010
  ) -> OrderedIt[TItem]:
847
- return FlpIt(self.data).order_by_descending(key_selector)
1011
+ return FlpIt(self.__list.data).order_by_descending(key_selector)
848
1012
 
849
1013
  def group_by(
850
1014
  self, key_selector: Callable[[TItem], TKey]
851
1015
  ) -> FlpIt[Grouping[TKey, TItem]]:
852
- return FlpIt(self.data).group_by(key_selector)
1016
+ return FlpIt(self.__list.data).group_by(key_selector)
853
1017
 
854
1018
  @overload
855
1019
  def aggregate(self, func: Callable[[TItem, TItem], TItem]) -> TItem: ...
@@ -864,41 +1028,37 @@ class FlpList(UserList[TItem], Sequence[TItem], Generic[TItem]):
864
1028
  func: Callable[[Any, Any], Any],
865
1029
  seed: Any = _SENTINEL,
866
1030
  ) -> TItem | TAccumulate:
867
- return FlpIt(self.data).aggregate(func, seed=seed)
1031
+ return FlpIt(self.__list.data).aggregate(func, seed=seed)
868
1032
 
869
1033
  @_guard_empty
870
1034
  def min(self) -> TItem:
871
- return builtins.min(self.data) # pyrefly: ignore [bad-specialization]
1035
+ return builtins.min(self.__list.data) # pyrefly: ignore [bad-specialization]
872
1036
 
873
1037
  @_guard_empty
874
1038
  def min_by(self, key_selector: Callable[[TItem], Any]) -> TItem:
875
- return builtins.min(self.data, key=key_selector)
1039
+ return builtins.min(self.__list.data, key=key_selector)
876
1040
 
877
1041
  @_guard_empty
878
1042
  def max(self) -> TItem:
879
- return builtins.max(self.data) # pyrefly: ignore [bad-specialization]
1043
+ return builtins.max(self.__list.data) # pyrefly: ignore [bad-specialization]
880
1044
 
881
1045
  @_guard_empty
882
1046
  def max_by(self, key_selector: Callable[[TItem], Any]) -> TItem:
883
- return builtins.max(self.data, key=key_selector)
1047
+ return builtins.max(self.__list.data, key=key_selector)
884
1048
 
885
1049
  def sum(
886
- self, selector: Callable[[TItem], int | float] | _Sentinel = _SENTINEL
887
- ) -> int | float:
1050
+ self, selector: Callable[[TItem], int | float | Decimal] | _Sentinel = _SENTINEL
1051
+ ) -> int | float | Decimal:
888
1052
  """Calculates the sum of elements, optionally applying a selector."""
889
- return FlpIt(self.data).sum(selector)
1053
+ return FlpIt(self.__list.data).sum(selector)
890
1054
 
891
1055
  def average(
892
- self, selector: Callable[[TItem], int | float] | _Sentinel = _SENTINEL
893
- ) -> float:
1056
+ self, selector: Callable[[TItem], int | float | Decimal] | _Sentinel = _SENTINEL
1057
+ ) -> float | int | Decimal | None:
894
1058
  """Calculates the arithmetic mean, optionally applying a selector."""
895
- return FlpIt(self.data).average(selector)
1059
+ return FlpIt(self.__list.data).average(selector)
896
1060
 
897
- def count_item(self, item: TItem) -> int:
898
- return self.data.count(item) # just redirect how the count redirected before
899
-
900
- @override
901
- def count( # type: ignore # pyrefly: ignore [bad-override-param-name] # pyright: ignore[reportIncompatibleMethodOverride]
1061
+ def count(
902
1062
  self,
903
1063
  predicate: Callable[[TItem], bool] | _Sentinel = _SENTINEL,
904
1064
  ) -> int:
@@ -906,22 +1066,21 @@ class FlpList(UserList[TItem], Sequence[TItem], Generic[TItem]):
906
1066
  Counts elements using LINQ semantics.
907
1067
 
908
1068
  Supported forms:
909
-
910
- count() -> total number of elements
911
- count(predicate) -> number of elements satisfying the predicate
1069
+ - count() -> total number of elements
1070
+ - count(predicate) -> number of elements satisfying the predicate
912
1071
  """
913
1072
  if predicate is _SENTINEL:
914
- return len(self.data)
1073
+ return len(self.__list.data)
915
1074
 
916
- return FlpIt(self.data).count(predicate)
1075
+ return FlpIt(self.__list.data).count(predicate)
917
1076
 
918
1077
  def element_at(self, index: int) -> TItem:
919
- if index < 0 or index >= len(self.data):
1078
+ if index < 0 or index >= len(self.__list.data):
920
1079
  raise IndexError("Index out of range")
921
- return self.data[index]
1080
+ return self.__list.data[index]
922
1081
 
923
1082
  def first(self, predicate: Callable[[TItem], bool] | _Sentinel = _SENTINEL) -> TItem:
924
- return FlpIt(self.data).first(predicate)
1083
+ return FlpIt(self.__list.data).first(predicate)
925
1084
 
926
1085
  @overload
927
1086
  def last(self) -> TItem: ...
@@ -948,11 +1107,11 @@ class FlpList(UserList[TItem], Sequence[TItem], Generic[TItem]):
948
1107
  raise PredicateNoneError()
949
1108
 
950
1109
  if predicate is _SENTINEL:
951
- if not self.data:
1110
+ if not self.__list.data:
952
1111
  raise EmptySequenceError()
953
- return self.data[-1]
1112
+ return self.__list.data[-1]
954
1113
 
955
- for item in reversed(self.data):
1114
+ for item in reversed(self.__list.data):
956
1115
  if predicate(item):
957
1116
  return item
958
1117
 
@@ -961,11 +1120,11 @@ class FlpList(UserList[TItem], Sequence[TItem], Generic[TItem]):
961
1120
  def first_or_default(
962
1121
  self, default: TResult, predicate: Callable[[TItem], bool] | _Sentinel = _SENTINEL
963
1122
  ) -> TItem | TResult:
964
- return FlpIt(self.data).first_or_default(default, predicate)
1123
+ return FlpIt(self.__list.data).first_or_default(default, predicate)
965
1124
 
966
1125
  def single(self, predicate: Callable[[TItem], bool] | _Sentinel = _SENTINEL) -> TItem:
967
- return FlpIt(self.data).single(predicate)
1126
+ return FlpIt(self.__list.data).single(predicate)
968
1127
 
969
1128
  def to_list(self) -> FlpList[TItem]:
970
1129
  """Explicitly returns a shallow copy instance to isolate mutations."""
971
- return FlpList(self.data.copy())
1130
+ return FlpList(self.__list.data.copy())
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes