pydantic-linq 0.1.0__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.
- pydantic_linq-0.1.0/.claude/scheduled_tasks.lock +1 -0
- pydantic_linq-0.1.0/.github/workflows/ci.yml +44 -0
- pydantic_linq-0.1.0/.github/workflows/release.yml +63 -0
- pydantic_linq-0.1.0/.gitignore +5 -0
- pydantic_linq-0.1.0/LICENSE +21 -0
- pydantic_linq-0.1.0/PKG-INFO +90 -0
- pydantic_linq-0.1.0/README.md +65 -0
- pydantic_linq-0.1.0/examples/01_basics.py +37 -0
- pydantic_linq-0.1.0/examples/02_sorting_paging.py +36 -0
- pydantic_linq-0.1.0/examples/03_grouping_aggregation.py +47 -0
- pydantic_linq-0.1.0/examples/04_joins_sets.py +60 -0
- pydantic_linq-0.1.0/examples/05_pydantic_integration.py +54 -0
- pydantic_linq-0.1.0/examples/06_async.py +67 -0
- pydantic_linq-0.1.0/examples/07_sales_report.py +100 -0
- pydantic_linq-0.1.0/pyproject.toml +41 -0
- pydantic_linq-0.1.0/src/pydantic_linq/__init__.py +34 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/__init__.py +1 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/base.py +97 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/enumerable.py +49 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/grouping.py +19 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/operators/__init__.py +33 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/operators/aggregation.py +92 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/operators/concatenation.py +30 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/operators/conversion.py +51 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/operators/element.py +76 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/operators/equality.py +19 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/operators/filtering.py +23 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/operators/generation.py +40 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/operators/grouping.py +55 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/operators/joining.py +79 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/operators/partitioning.py +91 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/operators/projection.py +64 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/operators/quantifiers.py +27 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/operators/sets.py +59 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/operators/sorting.py +47 -0
- pydantic_linq-0.1.0/src/pydantic_linq/aio/ordered.py +38 -0
- pydantic_linq-0.1.0/src/pydantic_linq/core/__init__.py +8 -0
- pydantic_linq-0.1.0/src/pydantic_linq/core/equality.py +53 -0
- pydantic_linq-0.1.0/src/pydantic_linq/core/errors.py +8 -0
- pydantic_linq-0.1.0/src/pydantic_linq/core/grouping.py +12 -0
- pydantic_linq-0.1.0/src/pydantic_linq/core/sentinel.py +4 -0
- pydantic_linq-0.1.0/src/pydantic_linq/core/sorting.py +28 -0
- pydantic_linq-0.1.0/src/pydantic_linq/integrations/__init__.py +1 -0
- pydantic_linq-0.1.0/src/pydantic_linq/integrations/builtins.py +54 -0
- pydantic_linq-0.1.0/src/pydantic_linq/integrations/pydantic.py +17 -0
- pydantic_linq-0.1.0/src/pydantic_linq/py.typed +0 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/__init__.py +1 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/base.py +56 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/enumerable.py +54 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/grouping.py +49 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/operators/__init__.py +33 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/operators/aggregation.py +101 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/operators/concatenation.py +24 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/operators/conversion.py +58 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/operators/element.py +70 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/operators/equality.py +16 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/operators/filtering.py +19 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/operators/generation.py +57 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/operators/grouping.py +57 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/operators/joining.py +77 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/operators/partitioning.py +64 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/operators/projection.py +55 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/operators/quantifiers.py +23 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/operators/sets.py +57 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/operators/sorting.py +49 -0
- pydantic_linq-0.1.0/src/pydantic_linq/sync/ordered.py +28 -0
- pydantic_linq-0.1.0/tests/test_async_enumerable.py +130 -0
- pydantic_linq-0.1.0/tests/test_builtins.py +40 -0
- pydantic_linq-0.1.0/tests/test_enumerable.py +214 -0
- pydantic_linq-0.1.0/uv.lock +244 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"sessionId":"f6433955-3966-47db-80fb-3ba5bc285b56","pid":24568,"procStart":"134378175680740197","acquiredAt":1793345293783}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_call:
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
concurrency:
|
|
11
|
+
group: ci-${{ github.ref }}
|
|
12
|
+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
lint:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v7
|
|
22
|
+
- uses: astral-sh/setup-uv@v7
|
|
23
|
+
- run: uvx ruff check
|
|
24
|
+
- run: uv sync --locked && uvx pyright src
|
|
25
|
+
|
|
26
|
+
test:
|
|
27
|
+
name: test (${{ matrix.os }}, py${{ matrix.python }})
|
|
28
|
+
runs-on: ${{ matrix.os }}
|
|
29
|
+
strategy:
|
|
30
|
+
fail-fast: false
|
|
31
|
+
matrix:
|
|
32
|
+
os: [windows-latest, ubuntu-latest]
|
|
33
|
+
python: ["3.12", "3.13", "3.14"]
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/checkout@v7
|
|
36
|
+
- uses: astral-sh/setup-uv@v7
|
|
37
|
+
with:
|
|
38
|
+
python-version: ${{ matrix.python }}
|
|
39
|
+
enable-cache: true
|
|
40
|
+
- run: uv sync --locked
|
|
41
|
+
- run: uv run pytest -q
|
|
42
|
+
- name: Examples
|
|
43
|
+
shell: bash
|
|
44
|
+
run: for f in examples/*.py; do uv run python "$f" > /dev/null || exit 1; done
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
ci:
|
|
12
|
+
uses: ./.github/workflows/ci.yml
|
|
13
|
+
|
|
14
|
+
build:
|
|
15
|
+
needs: ci
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v7
|
|
19
|
+
- uses: astral-sh/setup-uv@v7
|
|
20
|
+
- name: Tag matches pyproject version
|
|
21
|
+
run: |
|
|
22
|
+
version=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
|
23
|
+
test "v$version" = "$GITHUB_REF_NAME" || { echo "::error::tag $GITHUB_REF_NAME != pyproject v$version"; exit 1; }
|
|
24
|
+
- run: uv build
|
|
25
|
+
- name: Smoke-test the wheel
|
|
26
|
+
run: uv run --isolated --no-project --with ./dist/*.whl python -c "import pydantic_linq; assert [1, 2].select(str).to_list() == ['1', '2']"
|
|
27
|
+
- uses: actions/upload-artifact@v7
|
|
28
|
+
with:
|
|
29
|
+
name: dist
|
|
30
|
+
path: dist/
|
|
31
|
+
|
|
32
|
+
pypi:
|
|
33
|
+
# PyPI trusted publisher: owner codejunction, repo pydantic-linq, workflow release.yml, environment pypi
|
|
34
|
+
needs: build
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
environment:
|
|
37
|
+
name: pypi
|
|
38
|
+
url: https://pypi.org/project/pydantic-linq/
|
|
39
|
+
permissions:
|
|
40
|
+
id-token: write
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/download-artifact@v8
|
|
43
|
+
with:
|
|
44
|
+
name: dist
|
|
45
|
+
path: dist/
|
|
46
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
47
|
+
with:
|
|
48
|
+
skip-existing: true
|
|
49
|
+
|
|
50
|
+
github-release:
|
|
51
|
+
needs: pypi
|
|
52
|
+
runs-on: ubuntu-latest
|
|
53
|
+
permissions:
|
|
54
|
+
contents: write
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/checkout@v7
|
|
57
|
+
- uses: actions/download-artifact@v8
|
|
58
|
+
with:
|
|
59
|
+
name: dist
|
|
60
|
+
path: dist/
|
|
61
|
+
- env:
|
|
62
|
+
GH_TOKEN: ${{ github.token }}
|
|
63
|
+
run: gh release create "$GITHUB_REF_NAME" dist/* --title "pydantic-linq $GITHUB_REF_NAME" --generate-notes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abhishek Dutta
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pydantic-linq
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: C# LINQ (Enumerable + AsyncEnumerable) for Python, typed and Pydantic-aware
|
|
5
|
+
Project-URL: Homepage, https://github.com/codejunction/pydantic-linq
|
|
6
|
+
Project-URL: Source, https://github.com/codejunction/pydantic-linq
|
|
7
|
+
Project-URL: Issues, https://github.com/codejunction/pydantic-linq/issues
|
|
8
|
+
Author-email: Abhishek Dutta <phoenixdutta@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: async,collections,functional,iterator,linq,pydantic,query
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: Pydantic :: 2
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Requires-Dist: forbiddenfruit>=0.1.4
|
|
23
|
+
Requires-Dist: pydantic>=2.7
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# pydantic-linq
|
|
27
|
+
|
|
28
|
+
C# LINQ (`System.Linq.Enumerable`) for Python. It is typed and works with Pydantic.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from pydantic import BaseModel
|
|
32
|
+
import pydantic_linq # adds LINQ operators to list / tuple / set / frozenset
|
|
33
|
+
|
|
34
|
+
class Row(BaseModel):
|
|
35
|
+
id: int
|
|
36
|
+
tag: str
|
|
37
|
+
|
|
38
|
+
rows = [Row(id=1, tag="a"), Row(id=2, tag="b"), Row(id=3, tag="a")]
|
|
39
|
+
|
|
40
|
+
rows.where(lambda r: r.tag == "a").select(lambda r: r.id).to_list() # [1, 3]
|
|
41
|
+
rows.group_by(lambda r: r.tag).select(lambda g: (g.key, len(g))).to_list()
|
|
42
|
+
rows.order_by_descending(lambda r: r.id).then_by(lambda r: r.tag).first()
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Async
|
|
46
|
+
|
|
47
|
+
`AsyncEnumerable` covers System.Linq.Async. It wraps any sync or async iterable. Sequence operators are deferred, and terminal operators are awaited:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
async def load_age(r: Row) -> int: ...
|
|
51
|
+
|
|
52
|
+
ages = await rows.to_async_enumerable().where(lambda r: r.id > 1).select(load_age).to_list()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A selector or predicate can be a plain function or an `async def`, so there are no separate `*Await` overloads. Terminal operators keep the sync names (`to_list`, `first`, `count`) and are awaited. The C# `Async` suffix is dropped.
|
|
56
|
+
|
|
57
|
+
## Conventions
|
|
58
|
+
|
|
59
|
+
- Method names are the C# names in snake_case: `OrderByDescending` becomes `order_by_descending`. `Except` becomes `except_`, because `except` is a Python keyword.
|
|
60
|
+
- Operators that return a sequence are deferred and can be enumerated again, as in .NET.
|
|
61
|
+
- Equality uses value semantics (`==`). Non-frozen (unhashable) pydantic models work in `distinct`, `union` and the other set operators.
|
|
62
|
+
- `cast(Model)` validates each element with a pydantic `TypeAdapter`, so dicts become models. `of_type(Model)` filters with `isinstance`.
|
|
63
|
+
- `Enumerable[Model]` can be a pydantic field type. It validates like `list[Model]` and serializes as a list.
|
|
64
|
+
- C# `KeyValuePair` becomes a `(key, value)` tuple. `IComparer` becomes a `cmp(a, b) -> int` function. A C# `Range` or `Index` (for example `^1`) becomes a slice or a negative int.
|
|
65
|
+
- Lambda overloads that take an index (`Select((x, i) => ...)`) map to `.index()`, followed by the operator.
|
|
66
|
+
- Empty-sequence errors raise `InvalidOperationError`, which subclasses `ValueError`.
|
|
67
|
+
|
|
68
|
+
## Builtin patching
|
|
69
|
+
|
|
70
|
+
Importing the package uses [forbiddenfruit](https://github.com/clarete/forbiddenfruit) to patch `list`, `tuple`, `set` and `frozenset`. A builtin method that already exists keeps its Python meaning, for example `list.count(x)` and `list.reverse()`. To get the LINQ version, use `rows.as_enumerable().count()`. Type checkers do not see the patched methods. Where you need static types, use `Enumerable(rows)`. To remove the patches, call `pydantic_linq.uninstall()`.
|
|
71
|
+
|
|
72
|
+
## Layout
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
src/pydantic_linq/
|
|
76
|
+
core/ errors, value-equality set, Grouping (no internal deps)
|
|
77
|
+
sync/
|
|
78
|
+
base.py EnumerableBase, deferred execution
|
|
79
|
+
operators/ one mixin per LINQ category: filtering, projection, partitioning, sets, sorting, ...
|
|
80
|
+
enumerable.py Enumerable = all mixins
|
|
81
|
+
ordered.py OrderedEnumerable (then_by)
|
|
82
|
+
grouping.py Grouping (an Enumerable with .key), Lookup
|
|
83
|
+
aio/ AsyncEnumerable, same layout as sync/
|
|
84
|
+
integrations/ pydantic field schema, builtin patching
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Examples
|
|
88
|
+
|
|
89
|
+
`examples/` runs from simple to complex: `01_basics` through `07_sales_report`, an end-to-end report pipeline. Run any of them with `uv run python examples/01_basics.py`.
|
|
90
|
+
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# pydantic-linq
|
|
2
|
+
|
|
3
|
+
C# LINQ (`System.Linq.Enumerable`) for Python. It is typed and works with Pydantic.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
import pydantic_linq # adds LINQ operators to list / tuple / set / frozenset
|
|
8
|
+
|
|
9
|
+
class Row(BaseModel):
|
|
10
|
+
id: int
|
|
11
|
+
tag: str
|
|
12
|
+
|
|
13
|
+
rows = [Row(id=1, tag="a"), Row(id=2, tag="b"), Row(id=3, tag="a")]
|
|
14
|
+
|
|
15
|
+
rows.where(lambda r: r.tag == "a").select(lambda r: r.id).to_list() # [1, 3]
|
|
16
|
+
rows.group_by(lambda r: r.tag).select(lambda g: (g.key, len(g))).to_list()
|
|
17
|
+
rows.order_by_descending(lambda r: r.id).then_by(lambda r: r.tag).first()
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Async
|
|
21
|
+
|
|
22
|
+
`AsyncEnumerable` covers System.Linq.Async. It wraps any sync or async iterable. Sequence operators are deferred, and terminal operators are awaited:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
async def load_age(r: Row) -> int: ...
|
|
26
|
+
|
|
27
|
+
ages = await rows.to_async_enumerable().where(lambda r: r.id > 1).select(load_age).to_list()
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
A selector or predicate can be a plain function or an `async def`, so there are no separate `*Await` overloads. Terminal operators keep the sync names (`to_list`, `first`, `count`) and are awaited. The C# `Async` suffix is dropped.
|
|
31
|
+
|
|
32
|
+
## Conventions
|
|
33
|
+
|
|
34
|
+
- Method names are the C# names in snake_case: `OrderByDescending` becomes `order_by_descending`. `Except` becomes `except_`, because `except` is a Python keyword.
|
|
35
|
+
- Operators that return a sequence are deferred and can be enumerated again, as in .NET.
|
|
36
|
+
- Equality uses value semantics (`==`). Non-frozen (unhashable) pydantic models work in `distinct`, `union` and the other set operators.
|
|
37
|
+
- `cast(Model)` validates each element with a pydantic `TypeAdapter`, so dicts become models. `of_type(Model)` filters with `isinstance`.
|
|
38
|
+
- `Enumerable[Model]` can be a pydantic field type. It validates like `list[Model]` and serializes as a list.
|
|
39
|
+
- C# `KeyValuePair` becomes a `(key, value)` tuple. `IComparer` becomes a `cmp(a, b) -> int` function. A C# `Range` or `Index` (for example `^1`) becomes a slice or a negative int.
|
|
40
|
+
- Lambda overloads that take an index (`Select((x, i) => ...)`) map to `.index()`, followed by the operator.
|
|
41
|
+
- Empty-sequence errors raise `InvalidOperationError`, which subclasses `ValueError`.
|
|
42
|
+
|
|
43
|
+
## Builtin patching
|
|
44
|
+
|
|
45
|
+
Importing the package uses [forbiddenfruit](https://github.com/clarete/forbiddenfruit) to patch `list`, `tuple`, `set` and `frozenset`. A builtin method that already exists keeps its Python meaning, for example `list.count(x)` and `list.reverse()`. To get the LINQ version, use `rows.as_enumerable().count()`. Type checkers do not see the patched methods. Where you need static types, use `Enumerable(rows)`. To remove the patches, call `pydantic_linq.uninstall()`.
|
|
46
|
+
|
|
47
|
+
## Layout
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
src/pydantic_linq/
|
|
51
|
+
core/ errors, value-equality set, Grouping (no internal deps)
|
|
52
|
+
sync/
|
|
53
|
+
base.py EnumerableBase, deferred execution
|
|
54
|
+
operators/ one mixin per LINQ category: filtering, projection, partitioning, sets, sorting, ...
|
|
55
|
+
enumerable.py Enumerable = all mixins
|
|
56
|
+
ordered.py OrderedEnumerable (then_by)
|
|
57
|
+
grouping.py Grouping (an Enumerable with .key), Lookup
|
|
58
|
+
aio/ AsyncEnumerable, same layout as sync/
|
|
59
|
+
integrations/ pydantic field schema, builtin patching
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Examples
|
|
63
|
+
|
|
64
|
+
`examples/` runs from simple to complex: `01_basics` through `07_sales_report`, an end-to-end report pipeline. Run any of them with `uv run python examples/01_basics.py`.
|
|
65
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Basics: filter, project and pick elements from a plain list of pydantic models."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
import pydantic_linq # noqa: F401 (adds the LINQ operators to list / tuple / set)
|
|
6
|
+
from pydantic_linq import Enumerable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Product(BaseModel):
|
|
10
|
+
name: str
|
|
11
|
+
price: float
|
|
12
|
+
stock: int
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
products = [
|
|
16
|
+
Product(name="Keyboard", price=49.0, stock=12),
|
|
17
|
+
Product(name="Mouse", price=19.5, stock=0),
|
|
18
|
+
Product(name="Monitor", price=219.0, stock=4),
|
|
19
|
+
Product(name="Cable", price=5.0, stock=120),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
# Where + Select, called straight on the list
|
|
23
|
+
in_stock = products.where(lambda p: p.stock > 0).select(lambda p: p.name).to_list()
|
|
24
|
+
print("in stock:", in_stock)
|
|
25
|
+
|
|
26
|
+
# Element operators
|
|
27
|
+
print("first over $100:", products.first(lambda p: p.price > 100).name)
|
|
28
|
+
print("first over $1000:", products.first_or_default(lambda p: p.price > 1000))
|
|
29
|
+
|
|
30
|
+
# Quantifiers and counting. list.count(x) keeps its Python meaning, so wrap for the LINQ count().
|
|
31
|
+
print("any sold out:", products.any(lambda p: p.stock == 0))
|
|
32
|
+
print("sold out count:", Enumerable(products).count(lambda p: p.stock == 0))
|
|
33
|
+
|
|
34
|
+
# Queries are deferred: nothing runs until you enumerate
|
|
35
|
+
cheap = products.where(lambda p: p.price < 20)
|
|
36
|
+
products.append(Product(name="Sticker", price=1.0, stock=500))
|
|
37
|
+
print("cheap (evaluated now):", cheap.select(lambda p: p.name).to_list())
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Sorting and paging: OrderBy / ThenBy, Skip / Take, Chunk."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
import pydantic_linq # noqa: F401
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Employee(BaseModel):
|
|
9
|
+
name: str
|
|
10
|
+
dept: str
|
|
11
|
+
salary: int
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
staff = [
|
|
15
|
+
Employee(name="Ava", dept="Eng", salary=120),
|
|
16
|
+
Employee(name="Ben", dept="Ops", salary=90),
|
|
17
|
+
Employee(name="Cleo", dept="Eng", salary=120),
|
|
18
|
+
Employee(name="Dan", dept="Eng", salary=105),
|
|
19
|
+
Employee(name="Eve", dept="Ops", salary=95),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
# Multi-key, stable sort: dept ascending, then salary descending, then name
|
|
23
|
+
ordered = staff.order_by(lambda e: e.dept).then_by_descending(lambda e: e.salary).then_by(lambda e: e.name)
|
|
24
|
+
print([f"{e.dept}:{e.name}:{e.salary}" for e in ordered])
|
|
25
|
+
|
|
26
|
+
# Paging
|
|
27
|
+
page_size = 2
|
|
28
|
+
for page in range(3):
|
|
29
|
+
names = ordered.skip(page * page_size).take(page_size).select(lambda e: e.name).to_list()
|
|
30
|
+
print(f"page {page + 1}:", names)
|
|
31
|
+
|
|
32
|
+
# Chunk does the same in one call
|
|
33
|
+
print("chunks:", ordered.select(lambda e: e.name).chunk(2).to_list())
|
|
34
|
+
|
|
35
|
+
# Take with a range: slice(1, -1) is C#'s Take(1..^1)
|
|
36
|
+
print("without top and bottom:", ordered.take(slice(1, -1)).select(lambda e: e.name).to_list())
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Grouping and aggregation: GroupBy, ToLookup, CountBy, AggregateBy, Sum / Average / MaxBy."""
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
import pydantic_linq # noqa: F401
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Sale(BaseModel):
|
|
11
|
+
region: str
|
|
12
|
+
rep: str
|
|
13
|
+
amount: float
|
|
14
|
+
day: date
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
sales = [
|
|
18
|
+
Sale(region="North", rep="Ann", amount=120.0, day=date(2026, 1, 3)),
|
|
19
|
+
Sale(region="North", rep="Bob", amount=80.0, day=date(2026, 1, 4)),
|
|
20
|
+
Sale(region="South", rep="Cid", amount=200.0, day=date(2026, 1, 4)),
|
|
21
|
+
Sale(region="North", rep="Ann", amount=60.0, day=date(2026, 2, 1)),
|
|
22
|
+
Sale(region="South", rep="Dee", amount=40.0, day=date(2026, 2, 2)),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# GroupBy with a result selector: one summary row per region
|
|
26
|
+
summary = sales.group_by(
|
|
27
|
+
lambda s: s.region,
|
|
28
|
+
result_selector=lambda region, items: (region, items.count(), items.sum(lambda s: s.amount)),
|
|
29
|
+
).to_list()
|
|
30
|
+
print("region, orders, total:", summary)
|
|
31
|
+
|
|
32
|
+
# Each Grouping is itself an Enumerable with a .key
|
|
33
|
+
for g in sales.group_by(lambda s: s.day.month):
|
|
34
|
+
print(f"month {g.key}: avg {g.average(lambda s: s.amount):.2f}, reps {g.select(lambda s: s.rep).distinct().to_list()}")
|
|
35
|
+
|
|
36
|
+
# A Lookup is an indexable group set; a missing key gives an empty sequence
|
|
37
|
+
by_rep = sales.to_lookup(lambda s: s.rep, lambda s: s.amount)
|
|
38
|
+
print("Ann:", by_rep["Ann"].to_list(), "Zed:", by_rep["Zed"].to_list())
|
|
39
|
+
|
|
40
|
+
# .NET 9 CountBy / AggregateBy
|
|
41
|
+
print("orders per rep:", sales.count_by(lambda s: s.rep).to_list())
|
|
42
|
+
print("revenue per rep:", sales.aggregate_by(lambda s: s.rep, 0.0, lambda acc, s: acc + s.amount).to_list())
|
|
43
|
+
|
|
44
|
+
# Scalar aggregates
|
|
45
|
+
print("total:", sales.sum(lambda s: s.amount))
|
|
46
|
+
print("biggest sale:", sales.max_by(lambda s: s.amount).rep)
|
|
47
|
+
print("running product:", [1, 2, 3, 4].aggregate(lambda a, b: a * b))
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Joins and set operations across two model lists."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
import pydantic_linq # noqa: F401
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Customer(BaseModel):
|
|
9
|
+
id: int
|
|
10
|
+
name: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Order(BaseModel):
|
|
14
|
+
customer_id: int
|
|
15
|
+
item: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
customers = [Customer(id=1, name="Ann"), Customer(id=2, name="Bob"), Customer(id=3, name="Cid")]
|
|
19
|
+
orders = [
|
|
20
|
+
Order(customer_id=1, item="book"),
|
|
21
|
+
Order(customer_id=1, item="pen"),
|
|
22
|
+
Order(customer_id=3, item="lamp"),
|
|
23
|
+
Order(customer_id=9, item="ghost"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# Inner join
|
|
27
|
+
print("join:", customers.join(orders, lambda c: c.id, lambda o: o.customer_id, lambda c, o: (c.name, o.item)).to_list())
|
|
28
|
+
|
|
29
|
+
# GroupJoin: every customer with their orders (possibly none)
|
|
30
|
+
print(
|
|
31
|
+
"group join:",
|
|
32
|
+
customers.group_join(
|
|
33
|
+
orders, lambda c: c.id, lambda o: o.customer_id, lambda c, os: (c.name, os.select(lambda o: o.item).to_list())
|
|
34
|
+
).to_list(),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# .NET 10 LeftJoin / RightJoin: the missing side is None
|
|
38
|
+
print(
|
|
39
|
+
"left join:",
|
|
40
|
+
customers.left_join(
|
|
41
|
+
orders, lambda c: c.id, lambda o: o.customer_id, lambda c, o: (c.name, o.item if o else None)
|
|
42
|
+
).to_list(),
|
|
43
|
+
)
|
|
44
|
+
print(
|
|
45
|
+
"right join:",
|
|
46
|
+
customers.right_join(
|
|
47
|
+
orders, lambda c: c.id, lambda o: o.customer_id, lambda c, o: (c.name if c else None, o.item)
|
|
48
|
+
).to_list(),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Set operations use value equality, so equal (non-frozen) models count as duplicates
|
|
52
|
+
batch_a = [Customer(id=1, name="Ann"), Customer(id=2, name="Bob")]
|
|
53
|
+
batch_b = [Customer(id=2, name="Bob"), Customer(id=4, name="Dee")]
|
|
54
|
+
print("union:", batch_a.union(batch_b).select(lambda c: c.name).to_list())
|
|
55
|
+
print("intersect:", batch_a.intersect(batch_b).select(lambda c: c.name).to_list())
|
|
56
|
+
print("except:", batch_a.except_(batch_b).select(lambda c: c.name).to_list())
|
|
57
|
+
|
|
58
|
+
# *By variants compare by a key; the second sequence holds keys
|
|
59
|
+
print("customers without orders:", customers.except_by(orders.select(lambda o: o.customer_id), lambda c: c.id)
|
|
60
|
+
.select(lambda c: c.name).to_list())
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Pydantic integration: validate raw data with cast(), use Enumerable[Model] as a field, round-trip JSON."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ValidationError
|
|
4
|
+
|
|
5
|
+
import pydantic_linq # noqa: F401
|
|
6
|
+
from pydantic_linq import Enumerable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LineItem(BaseModel):
|
|
10
|
+
sku: str
|
|
11
|
+
qty: int
|
|
12
|
+
unit_price: float
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Invoice(BaseModel):
|
|
16
|
+
number: str
|
|
17
|
+
lines: Enumerable[LineItem] # validated like list[LineItem]; behaves like LINQ
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def total(self) -> float:
|
|
21
|
+
return self.lines.sum(lambda line: line.qty * line.unit_price)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Raw rows, e.g. from an API or CSV: strings get coerced, dicts become models
|
|
25
|
+
raw = [
|
|
26
|
+
{"sku": "A-1", "qty": "2", "unit_price": "9.99"},
|
|
27
|
+
{"sku": "B-7", "qty": 1, "unit_price": 120},
|
|
28
|
+
]
|
|
29
|
+
items = raw.cast(LineItem).to_list()
|
|
30
|
+
print("cast:", items)
|
|
31
|
+
|
|
32
|
+
# strict=True disables coercion
|
|
33
|
+
try:
|
|
34
|
+
raw.cast(LineItem, strict=True).to_list()
|
|
35
|
+
except ValidationError as e:
|
|
36
|
+
print("strict cast failed:", e.error_count(), "errors")
|
|
37
|
+
|
|
38
|
+
invoice = Invoice(number="INV-1", lines=items)
|
|
39
|
+
print("total:", round(invoice.total, 2))
|
|
40
|
+
print("big lines:", invoice.lines.where(lambda line: line.unit_price > 50).select(lambda line: line.sku).to_list())
|
|
41
|
+
|
|
42
|
+
# Serializes as a plain list; validates back into an Enumerable
|
|
43
|
+
payload = invoice.model_dump_json()
|
|
44
|
+
print("json:", payload)
|
|
45
|
+
again = Invoice.model_validate_json(payload)
|
|
46
|
+
print("round-trip type:", type(again.lines).__name__, "count:", again.lines.count())
|
|
47
|
+
|
|
48
|
+
# Value equality: non-frozen models are unhashable, distinct() still works
|
|
49
|
+
dupes = [LineItem(sku="A-1", qty=1, unit_price=1.0)] * 3
|
|
50
|
+
print("distinct:", dupes.distinct().count())
|
|
51
|
+
|
|
52
|
+
# of_type filters by class, like OfType<T>
|
|
53
|
+
mixed = [items[0], "noise", 42, items[1]]
|
|
54
|
+
print("of_type:", mixed.of_type(LineItem).select(lambda line: line.sku).to_list())
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Async: AsyncEnumerable over async sources, with sync or async selectors."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
import pydantic_linq # noqa: F401
|
|
8
|
+
from pydantic_linq import AsyncEnumerable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class User(BaseModel):
|
|
12
|
+
id: int
|
|
13
|
+
name: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Profile(BaseModel):
|
|
17
|
+
user_id: int
|
|
18
|
+
followers: int
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def fetch_users():
|
|
22
|
+
"""An async generator source, e.g. a paginated API or a DB cursor."""
|
|
23
|
+
for i, name in enumerate(["ann", "bob", "cid", "dee"], start=1):
|
|
24
|
+
await asyncio.sleep(0.01)
|
|
25
|
+
yield User(id=i, name=name)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def fetch_profile(user: User) -> Profile:
|
|
29
|
+
"""An async selector, the System.Linq.Async SelectAwait overload."""
|
|
30
|
+
await asyncio.sleep(0.01)
|
|
31
|
+
return Profile(user_id=user.id, followers=user.id * 150)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def is_active(user: User) -> bool:
|
|
35
|
+
await asyncio.sleep(0)
|
|
36
|
+
return user.id != 2
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def main() -> None:
|
|
40
|
+
users = AsyncEnumerable(fetch_users())
|
|
41
|
+
|
|
42
|
+
# Async predicate + sync projection
|
|
43
|
+
names = await users.where(is_active).select(lambda u: u.name.title()).to_list()
|
|
44
|
+
print("active:", names)
|
|
45
|
+
|
|
46
|
+
# Async generator sources are one-shot (as in Python), so re-wrap for a second query
|
|
47
|
+
users = AsyncEnumerable(fetch_users())
|
|
48
|
+
popular = await (
|
|
49
|
+
users.select(fetch_profile)
|
|
50
|
+
.where(lambda p: p.followers > 200)
|
|
51
|
+
.order_by_descending(lambda p: p.followers)
|
|
52
|
+
.select(lambda p: p.user_id)
|
|
53
|
+
.to_list()
|
|
54
|
+
)
|
|
55
|
+
print("popular ids:", popular)
|
|
56
|
+
|
|
57
|
+
# Sync lists enter async with to_async_enumerable(); terminal operators are awaited
|
|
58
|
+
local = [User(id=10, name="eve"), User(id=11, name="fay")]
|
|
59
|
+
print("total followers:", await local.to_async_enumerable().select(fetch_profile).sum(lambda p: p.followers))
|
|
60
|
+
print("first:", (await local.to_async_enumerable().first(lambda u: u.id > 10)).name)
|
|
61
|
+
|
|
62
|
+
# Grouping yields AsyncGrouping, an AsyncEnumerable with a .key
|
|
63
|
+
async for g in AsyncEnumerable(fetch_users()).group_by(lambda u: u.id % 2 == 0):
|
|
64
|
+
print("even" if g.key else "odd", await g.select(lambda u: u.name).to_list())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
asyncio.run(main())
|