py-maybetype 0.5.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.
- py_maybetype-0.5.0/.github/workflows/publish-to-pypi.yml +75 -0
- py_maybetype-0.5.0/.github/workflows/python-lint-test.yml +33 -0
- py_maybetype-0.5.0/.gitignore +16 -0
- py_maybetype-0.5.0/.markdownlint.json +6 -0
- py_maybetype-0.5.0/.python-version +1 -0
- py_maybetype-0.5.0/.readthedocs.yaml +24 -0
- py_maybetype-0.5.0/CHANGELOG.md +77 -0
- py_maybetype-0.5.0/LICENSE +21 -0
- py_maybetype-0.5.0/PKG-INFO +136 -0
- py_maybetype-0.5.0/README.md +107 -0
- py_maybetype-0.5.0/docs/changelog.rst +5 -0
- py_maybetype-0.5.0/docs/conf.py +30 -0
- py_maybetype-0.5.0/docs/index.rst +16 -0
- py_maybetype-0.5.0/docs/readme.rst +5 -0
- py_maybetype-0.5.0/docs/reference/index.rst +9 -0
- py_maybetype-0.5.0/docs/requirements.txt +4 -0
- py_maybetype-0.5.0/maybetype/__init__.py +185 -0
- py_maybetype-0.5.0/maybetype/const.py +3 -0
- py_maybetype-0.5.0/pyproject.toml +46 -0
- py_maybetype-0.5.0/ruff.toml +31 -0
- py_maybetype-0.5.0/tests/test_maybe.py +178 -0
- py_maybetype-0.5.0/uv.lock +108 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
name: Publish Python distribution to PyPI and TestPyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
name: Build distribution
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v6
|
|
14
|
+
with:
|
|
15
|
+
persist-credentials: false
|
|
16
|
+
- name: Set up Python
|
|
17
|
+
uses: actions/setup-python@v6
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.x"
|
|
20
|
+
- name: Install pypa/build
|
|
21
|
+
run: >-
|
|
22
|
+
python3 -m
|
|
23
|
+
pip install
|
|
24
|
+
build
|
|
25
|
+
--user
|
|
26
|
+
- name: Build binary wheel and source tarball
|
|
27
|
+
run: python3 -m build
|
|
28
|
+
- name: Store distributions
|
|
29
|
+
uses: actions/upload-artifact@v5
|
|
30
|
+
with:
|
|
31
|
+
name: python-package-distributions
|
|
32
|
+
path: dist/
|
|
33
|
+
|
|
34
|
+
publish-to-pypi:
|
|
35
|
+
name: >-
|
|
36
|
+
Publish Python distribution to PyPI
|
|
37
|
+
if: startswith(github.ref, 'refs/tags/')
|
|
38
|
+
needs:
|
|
39
|
+
- build
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
environment:
|
|
42
|
+
name: pypi
|
|
43
|
+
url: https://pypi.org/p/maybetype
|
|
44
|
+
permissions:
|
|
45
|
+
id-token: write
|
|
46
|
+
steps:
|
|
47
|
+
- name: Download all the dists
|
|
48
|
+
uses: actions/downlad-artifact@v6
|
|
49
|
+
with:
|
|
50
|
+
name: python-package-distributions
|
|
51
|
+
path: dist/
|
|
52
|
+
- name: Publish distribution to PyPI
|
|
53
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
54
|
+
|
|
55
|
+
publish-to-testpypi:
|
|
56
|
+
name: Publish Python distribution to TestPyPI
|
|
57
|
+
needs:
|
|
58
|
+
- build
|
|
59
|
+
runs-on: ubuntu-latest
|
|
60
|
+
environment:
|
|
61
|
+
name: testpypi
|
|
62
|
+
url: https://test.pypi.org/p/maybetype
|
|
63
|
+
permissions:
|
|
64
|
+
id-token: write
|
|
65
|
+
steps:
|
|
66
|
+
- name: Download all the dists
|
|
67
|
+
uses: actions/download-artifact@v6
|
|
68
|
+
with:
|
|
69
|
+
name: python-package-distributions
|
|
70
|
+
path: dist/
|
|
71
|
+
- name: Publish distribution to TestPyPI
|
|
72
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
73
|
+
with:
|
|
74
|
+
verbose: true
|
|
75
|
+
repository-url: https://test.pypi.org/legacy/
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: Build project, lint, test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ "**" ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ "**" ]
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
build:
|
|
14
|
+
name: Lint and test
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
- name: Set up Python 3.12
|
|
20
|
+
uses: actions/setup-python@v3
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.12"
|
|
23
|
+
- name: Install project + dependencies
|
|
24
|
+
run: |
|
|
25
|
+
python -m pip install --upgrade pip
|
|
26
|
+
pip install .[dev]
|
|
27
|
+
- name: Lint with ruff
|
|
28
|
+
run: |
|
|
29
|
+
ruff check .
|
|
30
|
+
- name: Test
|
|
31
|
+
run: |
|
|
32
|
+
python -m doctest maybetype/*.py
|
|
33
|
+
pytest -v
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# .readthedocs.yaml
|
|
2
|
+
# Read the Docs configuration file
|
|
3
|
+
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
|
4
|
+
|
|
5
|
+
# Required
|
|
6
|
+
version: 2
|
|
7
|
+
|
|
8
|
+
# Set the OS, Python version and other tools you might need
|
|
9
|
+
build:
|
|
10
|
+
os: ubuntu-24.04
|
|
11
|
+
tools:
|
|
12
|
+
python: "3.12"
|
|
13
|
+
|
|
14
|
+
# Build documentation in the "docs/" directory with Sphinx
|
|
15
|
+
sphinx:
|
|
16
|
+
configuration: docs/conf.py
|
|
17
|
+
|
|
18
|
+
python:
|
|
19
|
+
install:
|
|
20
|
+
- requirements: docs/requirements.txt
|
|
21
|
+
- method: pip
|
|
22
|
+
path: .
|
|
23
|
+
extra_requirements:
|
|
24
|
+
- docs
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.5.0] - 2026-01-18
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Added `__match_args__` attribute to `Maybe` to allow using its value in `match`/`case` pattern matching
|
|
13
|
+
- Added method `Maybe.unwrap_or()`
|
|
14
|
+
- Added classes `Some` and `_Nothing`
|
|
15
|
+
- `_Nothing` is intended to be used through the `Nothing` singleton instance, rather than
|
|
16
|
+
constructing new instances of it, since effectively all `_Nothing` instances should behave the
|
|
17
|
+
same
|
|
18
|
+
- Instances of `Some` are **always truthy**, and `Nothing` is **always falsy**
|
|
19
|
+
- Added function `maybe()`
|
|
20
|
+
- Returns either a `Some` instance or the `Nothing` singleton depending on the given value and
|
|
21
|
+
predicate, similar to the previous functionality of `Maybe.__init__()`
|
|
22
|
+
- Usage should now replace direct instancing of `Maybe`
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- `Maybe.__init__()` body moved to `maybe()` function, now simply assigns the passed `val` to its
|
|
27
|
+
`val` attribute
|
|
28
|
+
- A warning is now issued if `Maybe`'s constructor is called directly
|
|
29
|
+
- Replaced uses of `NoReturn` with `Never`
|
|
30
|
+
- `Maybe.get()` now directly checks for the `__getitem__` method on the wrapped value instead of
|
|
31
|
+
checking `Sequence | Mapping`
|
|
32
|
+
- Anywhere `Maybe(None)` would have been returned now returns the `Nothing` singleton
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- `Maybe.attr_or()` no longer has a default argument of `None` for its `default` parameter, as the
|
|
37
|
+
docstring describes
|
|
38
|
+
|
|
39
|
+
## [0.4.0] - 2026-01-02
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- Added module `const`
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- Updated all docstrings to use reStructuredText markup for Sphinx docs generation
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- Fixed the example in `Maybe.cat`'s docstring incorrectly attempting to use `.cat()` on the list
|
|
52
|
+
of `Maybe` objects, now correctly shows usage of `Maybe.cat(vals)`
|
|
53
|
+
|
|
54
|
+
## [0.3.1] - 2025-12-31
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- Corrected the docstring of `Maybe.__init__()`'s description of the `just_condition` parameter,
|
|
59
|
+
which incorrectly stated `just_condition` must return `True` for the constructor to return
|
|
60
|
+
`Maybe(None)`—the opposite is true, `Maybe(None)` is returned if `just_condition` returns `False`
|
|
61
|
+
|
|
62
|
+
## [0.3.0] - 2025-12-27
|
|
63
|
+
|
|
64
|
+
### Added
|
|
65
|
+
|
|
66
|
+
- Added the `just_condition` argument to `Maybe`'s constructor, a function that takes the
|
|
67
|
+
to-be-wrapped value as an argument, which allows defining a custom condition in which
|
|
68
|
+
`Maybe(None)` will be returned if the function returns `False`
|
|
69
|
+
- e.g. `Maybe(0) == Maybe(0)`, however `Maybe(0, lambda x: x > 0) == Maybe(None)`
|
|
70
|
+
|
|
71
|
+
## [0.2.0] - 2025-12-08
|
|
72
|
+
|
|
73
|
+
### Added
|
|
74
|
+
|
|
75
|
+
- Added test module `tests/test_maybe.py`
|
|
76
|
+
|
|
77
|
+
## [0.1.0] - 2025-12-07
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Seth "Violet" Gibbs
|
|
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,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-maybetype
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: A basic implementation of a maybe/option type in Python, largely inspired by Rust's Option.
|
|
5
|
+
Project-URL: Homepage, https://github.com/svioletg/py-maybetype
|
|
6
|
+
Project-URL: Repository, https://github.com/svioletg/py-maybetype
|
|
7
|
+
Project-URL: Documentation, https://py-maybetype.readthedocs.io/en/latest/
|
|
8
|
+
Project-URL: Changelog, https://github.com/svioletg/py-maybetype/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/svioletg/py-maybetype/issues
|
|
10
|
+
Author: Seth 'Violet' Gibbs
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=9.0.2; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff>=0.14.8; extra == 'dev'
|
|
23
|
+
Provides-Extra: docs
|
|
24
|
+
Requires-Dist: furo>=2025.12.19; extra == 'docs'
|
|
25
|
+
Requires-Dist: myst-parser>=4.0.1; extra == 'docs'
|
|
26
|
+
Requires-Dist: sphinx-autobuild>=2025.8.25; extra == 'docs'
|
|
27
|
+
Requires-Dist: sphinx<9.0,>=6.0; extra == 'docs'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# py-maybetype
|
|
31
|
+
|
|
32
|
+
Documentation: <https://py-maybetype.readthedocs.io/en/latest/>
|
|
33
|
+
|
|
34
|
+
A basic implementation of a maybe/option type in Python, largely inspired by Rust's `Option`.
|
|
35
|
+
This was created as part of a separate project I had been working on, but I decided to make it into
|
|
36
|
+
its own package as I wanted to use it elsewhere and its scope grew. This is not meant to be a 1:1
|
|
37
|
+
replication or replacement for Rust's `Option` or Haskell's `Maybe`, but rather just an
|
|
38
|
+
interperetation of the idea that I feel works for Python.
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
Install the package with `pip` using the repository link:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install git+https://github.com/svioletg/py-maybetype
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Call the `maybe()` function with a `T | None` value to return a `Maybe[T]`—either a `Some` instance
|
|
49
|
+
containing the wrapped value, or the `Nothing` singleton.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from maybetype import Maybe, maybe
|
|
53
|
+
|
|
54
|
+
# Only the maybe() function should be used,
|
|
55
|
+
# the Maybe class is only imported here for type annotations
|
|
56
|
+
|
|
57
|
+
def try_int(x: str) -> int | None:
|
|
58
|
+
"""Attempts to convert a string of digits into an `int`, returning `None` if not possible."""
|
|
59
|
+
try:
|
|
60
|
+
return int(x)
|
|
61
|
+
except ValueError:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
num1: Maybe[int] = maybe(try_int('5'))
|
|
65
|
+
num2: Maybe[int] = maybe(try_int('five'))
|
|
66
|
+
|
|
67
|
+
print(num1.unwrap()) # 5
|
|
68
|
+
print(num2.unwrap()) # (raises ValueError)
|
|
69
|
+
|
|
70
|
+
# Some() instances are always truthy, Nothing is falsy
|
|
71
|
+
|
|
72
|
+
assert bool(num1) is True
|
|
73
|
+
assert bool(num2) is False
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
This example in particular can also be done with `Maybe`'s built-in `int()` class method:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
num1: Maybe[int] = Maybe.int('5')
|
|
80
|
+
num2: Maybe[int] = Maybe.int('five')
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The `maybe` constructor can be given an optional predicate argument to specify a custom condition
|
|
84
|
+
for which `Some(value)` is returned. This argument must be a `Callable` that returns `bool`,
|
|
85
|
+
where returning `False` causes the constructor to return `Nothing`.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import re
|
|
89
|
+
import uuid
|
|
90
|
+
|
|
91
|
+
from maybetype import maybe
|
|
92
|
+
|
|
93
|
+
def is_valid_uuid(s: str) -> bool:
|
|
94
|
+
return re.match(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}|[0-9a-f]{32}", s) is not None
|
|
95
|
+
|
|
96
|
+
assert maybe('3b1bcc3a-41d5-49a5-8273-10cc605e31f9', is_valid_uuid)
|
|
97
|
+
assert maybe('3b1bcc3a41d549a5827310cc605e31f9', is_valid_uuid)
|
|
98
|
+
assert not maybe('qwertyuiopasdfghjklzxcvbnm', is_valid_uuid)
|
|
99
|
+
assert not maybe('nf0cmmdq-l0gt-rq5a-upry-706trht3ocv9', is_valid_uuid)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`Maybe` instances can also be used in `match`/`case` pattern matching to access the wrapped value,
|
|
103
|
+
like so:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from maybetype import maybe, Some
|
|
107
|
+
|
|
108
|
+
match maybe(1):
|
|
109
|
+
case Some(val):
|
|
110
|
+
print('Value: ', val)
|
|
111
|
+
case _:
|
|
112
|
+
print('No value')
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Other examples
|
|
116
|
+
|
|
117
|
+
Converting a `str | None` timestamp into a `datetime` object if not `None`, otherwise returning `None`:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from datetime import datetime
|
|
121
|
+
from maybetype import maybe
|
|
122
|
+
|
|
123
|
+
date_str = '2025-09-06T030000'
|
|
124
|
+
date = maybe('2025-09-06T030000').then(datetime.fromisoformat)
|
|
125
|
+
# date == datetime.datetime(2025, 9, 6, 3, 0)
|
|
126
|
+
|
|
127
|
+
date_str = None
|
|
128
|
+
date = maybe(date_str).then(datetime.fromisoformat)
|
|
129
|
+
# date == None
|
|
130
|
+
|
|
131
|
+
date_str = ''
|
|
132
|
+
date = maybe(date_str or None).then(datetime.fromisoformat)
|
|
133
|
+
# date == None
|
|
134
|
+
# Maybe does not treat falsy values as None, only strictly x-is-None values
|
|
135
|
+
# Without `or None` here, datetime.fromisoformat would have raised a ValueError
|
|
136
|
+
```
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# py-maybetype
|
|
2
|
+
|
|
3
|
+
Documentation: <https://py-maybetype.readthedocs.io/en/latest/>
|
|
4
|
+
|
|
5
|
+
A basic implementation of a maybe/option type in Python, largely inspired by Rust's `Option`.
|
|
6
|
+
This was created as part of a separate project I had been working on, but I decided to make it into
|
|
7
|
+
its own package as I wanted to use it elsewhere and its scope grew. This is not meant to be a 1:1
|
|
8
|
+
replication or replacement for Rust's `Option` or Haskell's `Maybe`, but rather just an
|
|
9
|
+
interperetation of the idea that I feel works for Python.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
Install the package with `pip` using the repository link:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install git+https://github.com/svioletg/py-maybetype
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Call the `maybe()` function with a `T | None` value to return a `Maybe[T]`—either a `Some` instance
|
|
20
|
+
containing the wrapped value, or the `Nothing` singleton.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from maybetype import Maybe, maybe
|
|
24
|
+
|
|
25
|
+
# Only the maybe() function should be used,
|
|
26
|
+
# the Maybe class is only imported here for type annotations
|
|
27
|
+
|
|
28
|
+
def try_int(x: str) -> int | None:
|
|
29
|
+
"""Attempts to convert a string of digits into an `int`, returning `None` if not possible."""
|
|
30
|
+
try:
|
|
31
|
+
return int(x)
|
|
32
|
+
except ValueError:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
num1: Maybe[int] = maybe(try_int('5'))
|
|
36
|
+
num2: Maybe[int] = maybe(try_int('five'))
|
|
37
|
+
|
|
38
|
+
print(num1.unwrap()) # 5
|
|
39
|
+
print(num2.unwrap()) # (raises ValueError)
|
|
40
|
+
|
|
41
|
+
# Some() instances are always truthy, Nothing is falsy
|
|
42
|
+
|
|
43
|
+
assert bool(num1) is True
|
|
44
|
+
assert bool(num2) is False
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This example in particular can also be done with `Maybe`'s built-in `int()` class method:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
num1: Maybe[int] = Maybe.int('5')
|
|
51
|
+
num2: Maybe[int] = Maybe.int('five')
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The `maybe` constructor can be given an optional predicate argument to specify a custom condition
|
|
55
|
+
for which `Some(value)` is returned. This argument must be a `Callable` that returns `bool`,
|
|
56
|
+
where returning `False` causes the constructor to return `Nothing`.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import re
|
|
60
|
+
import uuid
|
|
61
|
+
|
|
62
|
+
from maybetype import maybe
|
|
63
|
+
|
|
64
|
+
def is_valid_uuid(s: str) -> bool:
|
|
65
|
+
return re.match(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}|[0-9a-f]{32}", s) is not None
|
|
66
|
+
|
|
67
|
+
assert maybe('3b1bcc3a-41d5-49a5-8273-10cc605e31f9', is_valid_uuid)
|
|
68
|
+
assert maybe('3b1bcc3a41d549a5827310cc605e31f9', is_valid_uuid)
|
|
69
|
+
assert not maybe('qwertyuiopasdfghjklzxcvbnm', is_valid_uuid)
|
|
70
|
+
assert not maybe('nf0cmmdq-l0gt-rq5a-upry-706trht3ocv9', is_valid_uuid)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`Maybe` instances can also be used in `match`/`case` pattern matching to access the wrapped value,
|
|
74
|
+
like so:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from maybetype import maybe, Some
|
|
78
|
+
|
|
79
|
+
match maybe(1):
|
|
80
|
+
case Some(val):
|
|
81
|
+
print('Value: ', val)
|
|
82
|
+
case _:
|
|
83
|
+
print('No value')
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Other examples
|
|
87
|
+
|
|
88
|
+
Converting a `str | None` timestamp into a `datetime` object if not `None`, otherwise returning `None`:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from datetime import datetime
|
|
92
|
+
from maybetype import maybe
|
|
93
|
+
|
|
94
|
+
date_str = '2025-09-06T030000'
|
|
95
|
+
date = maybe('2025-09-06T030000').then(datetime.fromisoformat)
|
|
96
|
+
# date == datetime.datetime(2025, 9, 6, 3, 0)
|
|
97
|
+
|
|
98
|
+
date_str = None
|
|
99
|
+
date = maybe(date_str).then(datetime.fromisoformat)
|
|
100
|
+
# date == None
|
|
101
|
+
|
|
102
|
+
date_str = ''
|
|
103
|
+
date = maybe(date_str or None).then(datetime.fromisoformat)
|
|
104
|
+
# date == None
|
|
105
|
+
# Maybe does not treat falsy values as None, only strictly x-is-None values
|
|
106
|
+
# Without `or None` here, datetime.fromisoformat would have raised a ValueError
|
|
107
|
+
```
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from maybetype.const import PROJECT_VERSION
|
|
2
|
+
|
|
3
|
+
# Configuration file for the Sphinx documentation builder.
|
|
4
|
+
#
|
|
5
|
+
# For the full list of built-in configuration values, see the documentation:
|
|
6
|
+
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
|
7
|
+
|
|
8
|
+
# -- Project information -----------------------------------------------------
|
|
9
|
+
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
|
10
|
+
|
|
11
|
+
project = 'py-maybetype'
|
|
12
|
+
author = 'Seth "Violet" Gibbs'
|
|
13
|
+
release = PROJECT_VERSION
|
|
14
|
+
|
|
15
|
+
# -- General configuration ---------------------------------------------------
|
|
16
|
+
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
|
17
|
+
|
|
18
|
+
extensions = [
|
|
19
|
+
'sphinx.ext.autodoc',
|
|
20
|
+
'myst_parser',
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
templates_path = ['_templates']
|
|
24
|
+
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
|
25
|
+
|
|
26
|
+
# -- Options for HTML output -------------------------------------------------
|
|
27
|
+
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
|
28
|
+
|
|
29
|
+
html_theme = 'furo'
|
|
30
|
+
html_static_path = ['_static']
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
py-maybetype documentation
|
|
2
|
+
===============================
|
|
3
|
+
|
|
4
|
+
.. toctree::
|
|
5
|
+
:maxdepth: 2
|
|
6
|
+
:caption: Contents:
|
|
7
|
+
|
|
8
|
+
readme.rst
|
|
9
|
+
changelog.rst
|
|
10
|
+
reference/index.rst
|
|
11
|
+
|
|
12
|
+
Indices and tables
|
|
13
|
+
==================
|
|
14
|
+
|
|
15
|
+
* :ref:`genindex`
|
|
16
|
+
* :ref:`modindex`
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
from collections.abc import Callable, Iterable
|
|
3
|
+
from typing import Any, Never
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Maybe[T]:
|
|
7
|
+
"""
|
|
8
|
+
Wraps a value that may be ``T`` or ``None``, providing methods for conditionally using that value or
|
|
9
|
+
short-circuiting to ``None`` without longer checks.
|
|
10
|
+
"""
|
|
11
|
+
__match_args__ = ('val',)
|
|
12
|
+
|
|
13
|
+
def __init__(self, val: T | None) -> None:
|
|
14
|
+
warnings.warn(
|
|
15
|
+
'Direct instancing of Maybe() is not recommended as of v0.5.0, use the maybe() function instead',
|
|
16
|
+
stacklevel=2,
|
|
17
|
+
)
|
|
18
|
+
self.val = val
|
|
19
|
+
|
|
20
|
+
def __repr__(self) -> str:
|
|
21
|
+
return f'{self.__class__.__name__}({self.val!r})'
|
|
22
|
+
|
|
23
|
+
def __bool__(self) -> bool:
|
|
24
|
+
return self.val is not None
|
|
25
|
+
|
|
26
|
+
def __eq__(self, other: object) -> bool:
|
|
27
|
+
"""
|
|
28
|
+
Returns ``False`` if the compared object is not a ``Maybe`` instance, otherwise compares their wrapped values.
|
|
29
|
+
"""
|
|
30
|
+
if not isinstance(other, Maybe):
|
|
31
|
+
return False
|
|
32
|
+
return self.val == other.val
|
|
33
|
+
|
|
34
|
+
def __hash__(self) -> int:
|
|
35
|
+
return self.val.__hash__()
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def cat(vals: 'Iterable[Maybe[T]]') -> list[T]:
|
|
39
|
+
"""
|
|
40
|
+
Takes an iterable of ``Maybe`` and returns a list of the unwrapped values of all ``Some`` objects.
|
|
41
|
+
|
|
42
|
+
>>> vals = [maybe(5), maybe(None), maybe(10), maybe(None)]
|
|
43
|
+
>>> assert vals == [Some(5), Nothing, Some(10), Nothing]
|
|
44
|
+
>>> assert Maybe.cat(vals) == [5, 10]
|
|
45
|
+
"""
|
|
46
|
+
return [i.unwrap() for i in vals if i]
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def int(val: Any) -> 'Maybe[int]': # noqa: ANN401
|
|
50
|
+
"""
|
|
51
|
+
Attempts to convert ``val`` to an ``int``, returning a ``Some``-wrapped ``int`` if successful, or
|
|
52
|
+
``Nothing`` on failure.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
i: int = int(val)
|
|
56
|
+
return Some(i)
|
|
57
|
+
except ValueError:
|
|
58
|
+
return Nothing
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def map[A, B](fn: 'Callable[[A], Maybe[B]]', vals: Iterable[A]) -> list[B]:
|
|
62
|
+
"""Maps ``fn`` onto ``vals``, taking the unwrapped values of ``Some``s and discarding ``Nothing``s."""
|
|
63
|
+
return [i.unwrap() for i in map(fn, vals) if i]
|
|
64
|
+
|
|
65
|
+
def attr[V](self, name: str, typ: type[V] | None = None, *, err: bool = False) -> 'Maybe[V]':
|
|
66
|
+
"""
|
|
67
|
+
Attempts to access an attribute ``name`` on the wrapped object, returning a ``Some`` instance wrapping the
|
|
68
|
+
the value if it exists, or ``Nothing`` otherwise.
|
|
69
|
+
|
|
70
|
+
:param typ: Specifies the generic type of the resulting ``Maybe``. Note that the potential value returned by
|
|
71
|
+
this method will not be coerced to the given type at runtime; this argument is only for typing purposes.
|
|
72
|
+
:param err: If ``True``, ``AttributeError`` is raised instead of returning ``Nothing`` if ``name`` does not
|
|
73
|
+
exist.
|
|
74
|
+
"""
|
|
75
|
+
return Some(getattr(self.val, name)) if err else maybe(getattr(self.val, name, None))
|
|
76
|
+
|
|
77
|
+
def attr_or[V](self, name: str, default: V) -> V:
|
|
78
|
+
"""
|
|
79
|
+
Similar to the ``attr`` method, but unwraps the result if the attribute exists or returns the required default
|
|
80
|
+
value otherwise.
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
return self.attr(name, err=True).unwrap()
|
|
84
|
+
except AttributeError:
|
|
85
|
+
return default
|
|
86
|
+
|
|
87
|
+
def get[V](self,
|
|
88
|
+
accessor: Any, # noqa: ANN401
|
|
89
|
+
_typ: type[V] | None = None,
|
|
90
|
+
*,
|
|
91
|
+
err: bool = False,
|
|
92
|
+
default: V | None = None,
|
|
93
|
+
) -> 'Maybe[V]':
|
|
94
|
+
"""
|
|
95
|
+
Attempts to access an item by ``accessor`` on the wrapped object, assuming the wrapped value implements
|
|
96
|
+
``__getitem__``. If it does not, or if the value does not exist (list index out of range, key does not exist on
|
|
97
|
+
a dictionary, etc.), ``Nothing`` is returned.
|
|
98
|
+
|
|
99
|
+
:param typ: Specifies the generic type of the resulting ``Maybe``. Note that the potential value returned by
|
|
100
|
+
this method will not be coerced to the given type at runtime; this argument is only for typing purposes.
|
|
101
|
+
:param err: By default, ``IndexError`` and ``KeyError`` are not raised when ``__getitem__`` is called on the
|
|
102
|
+
wrapped value, and ``Nothing`` is returned instead. Setting ``err`` to ``True`` allows these errors to
|
|
103
|
+
be raised as they normally would. Note that if ``__getitem__`` did not exist on the wrapped value in the
|
|
104
|
+
first placed (such as with ``Nothing``), no error is raised, and ``Nothing`` is returned regardless.
|
|
105
|
+
:param default: Specifies an alternate value to return a ``Some`` of instead of returning ``Nothing``.
|
|
106
|
+
"""
|
|
107
|
+
if hasattr(self.val, '__getitem__'):
|
|
108
|
+
try:
|
|
109
|
+
return Some(self.val.__getitem__(accessor)) # type: ignore
|
|
110
|
+
except (IndexError, KeyError):
|
|
111
|
+
if err:
|
|
112
|
+
raise
|
|
113
|
+
return maybe(default)
|
|
114
|
+
|
|
115
|
+
def then[R](self, func: Callable[[T], R]) -> R | None:
|
|
116
|
+
"""
|
|
117
|
+
Calls ``func`` with the wrapped value as the argument and returns its value, or returns ``None`` if the wrapped
|
|
118
|
+
value is ``None``.
|
|
119
|
+
|
|
120
|
+
:param func: A ``Callable`` which takes a type of the possible wrapped value (``T``) and can return any type
|
|
121
|
+
(``R``).
|
|
122
|
+
"""
|
|
123
|
+
return func(self.val) if self.val is not None else None
|
|
124
|
+
|
|
125
|
+
def this_or(self, other: T) -> 'Maybe[T]':
|
|
126
|
+
"""Returns the original wrapped value if not ``None``, otherwise returns a ``Some``-wrapped ``other``."""
|
|
127
|
+
return self if self else Some(other)
|
|
128
|
+
|
|
129
|
+
def unwrap(self,
|
|
130
|
+
exc: Exception | Callable[..., Never] | None = None,
|
|
131
|
+
*exc_args: object,
|
|
132
|
+
) -> T:
|
|
133
|
+
"""
|
|
134
|
+
Returns the wrapped value if it is not ``None``, otherwise raises ``ValueError`` by default.
|
|
135
|
+
|
|
136
|
+
:param exc: The exception to raise if the wrapped value is ``None``. Can be either an ``Exception`` object, or a
|
|
137
|
+
``Callable`` which takes any arguments and does not return. If given ``None``, the default behavior is to
|
|
138
|
+
raise a ``ValueError`` with the message ``Maybe[<type>] unwrapped into None``.
|
|
139
|
+
:param exc_args: Arguments to call ``exc`` with, if ``exc`` is a ``Callable``. Otherwise, this argument is not
|
|
140
|
+
used.
|
|
141
|
+
"""
|
|
142
|
+
if self.val is None:
|
|
143
|
+
if isinstance(exc, Exception):
|
|
144
|
+
raise exc
|
|
145
|
+
if isinstance(exc, Callable):
|
|
146
|
+
exc(*exc_args)
|
|
147
|
+
raise ValueError(f'Maybe[{T.__name__}] unwrapped into None')
|
|
148
|
+
return self.val
|
|
149
|
+
|
|
150
|
+
def unwrap_or(self, other: T) -> T:
|
|
151
|
+
"""Returns the wrapped value if it is not ``None``, otherwise returns ``other``."""
|
|
152
|
+
return self.val if self.val is not None else other
|
|
153
|
+
|
|
154
|
+
class Some[T](Maybe[T]):
|
|
155
|
+
def __init__(self, val: T) -> None:
|
|
156
|
+
self.val = val
|
|
157
|
+
|
|
158
|
+
def __bool__(self) -> bool:
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
class _Nothing[T](Maybe[T]):
|
|
162
|
+
__match_args__ = ()
|
|
163
|
+
|
|
164
|
+
def __init__(self, _: None = None) -> None:
|
|
165
|
+
self.val = None
|
|
166
|
+
|
|
167
|
+
def __repr__(self) -> str:
|
|
168
|
+
return 'Nothing'
|
|
169
|
+
|
|
170
|
+
def __bool__(self) -> bool:
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
Nothing = _Nothing()
|
|
174
|
+
|
|
175
|
+
def maybe[T](val: T | None, predicate: Callable[[T], bool] = lambda v: v is not None) -> Maybe[T]:
|
|
176
|
+
"""
|
|
177
|
+
Returns a ``Some`` instance wrapping ``val`` if either ``val`` is not ``None`` or ``predicate(val)`` is ``True``,
|
|
178
|
+
otherwise returns the ``Nothing`` singleton.
|
|
179
|
+
|
|
180
|
+
:param val: A value to wrap.
|
|
181
|
+
:param predicate: An optional function that takes ``val`` and, if it returns ``False``, discards ``val``
|
|
182
|
+
and returns a ``Nothing`` instance. Regardless of the predicate, ``Nothing`` is always returned if
|
|
183
|
+
``val`` is ``None``.
|
|
184
|
+
"""
|
|
185
|
+
return Nothing if (val is None) or not predicate(val) else Some(val)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "py-maybetype"
|
|
3
|
+
version = "0.5.0"
|
|
4
|
+
description = "A basic implementation of a maybe/option type in Python, largely inspired by Rust's Option."
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Seth 'Violet' Gibbs" }
|
|
7
|
+
]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
license-files = ["LICENSE"]
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
dependencies = []
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/svioletg/py-maybetype"
|
|
24
|
+
Repository = "https://github.com/svioletg/py-maybetype"
|
|
25
|
+
Documentation = "https://py-maybetype.readthedocs.io/en/latest/"
|
|
26
|
+
Changelog = "https://github.com/svioletg/py-maybetype/blob/main/CHANGELOG.md"
|
|
27
|
+
Issues = "https://github.com/svioletg/py-maybetype/issues"
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = [
|
|
31
|
+
"pytest>=9.0.2",
|
|
32
|
+
"ruff>=0.14.8",
|
|
33
|
+
]
|
|
34
|
+
docs = [
|
|
35
|
+
"furo>=2025.12.19",
|
|
36
|
+
"myst-parser>=4.0.1",
|
|
37
|
+
"sphinx>=6.0,<9.0",
|
|
38
|
+
"sphinx-autobuild>=2025.8.25",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["hatchling >= 1.26"]
|
|
43
|
+
build-backend = "hatchling.build"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["maybetype"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
target-version = "py312"
|
|
2
|
+
output-format = "concise"
|
|
3
|
+
|
|
4
|
+
line-length = 120
|
|
5
|
+
|
|
6
|
+
[lint]
|
|
7
|
+
select = [
|
|
8
|
+
"A", "ANN",
|
|
9
|
+
"B",
|
|
10
|
+
"C4", "COM",
|
|
11
|
+
"DTZ",
|
|
12
|
+
"E", "ERA",
|
|
13
|
+
"F", "FBT", "FURB",
|
|
14
|
+
"I", "ISC",
|
|
15
|
+
"LOG",
|
|
16
|
+
"N",
|
|
17
|
+
"PT", "PIE", "PLC", "PLE", "PLR", "PLW",
|
|
18
|
+
"RET", "RSE", "RUF",
|
|
19
|
+
"S", "SIM", "SLF",
|
|
20
|
+
"T20", "TID", "TRY",
|
|
21
|
+
"UP",
|
|
22
|
+
"W",
|
|
23
|
+
]
|
|
24
|
+
fixable = ["COM812", "F401", "I", "UP045", "W292", "W293"]
|
|
25
|
+
ignore = ["ANN003", "PLR0912", "PLR0913", "RET505", "S311", "TRY003", "UP015"]
|
|
26
|
+
|
|
27
|
+
[lint.flake8-implicit-str-concat]
|
|
28
|
+
allow-multiline = false
|
|
29
|
+
|
|
30
|
+
[lint.per-file-ignores]
|
|
31
|
+
"tests/*" = ["FBT001", "S101"]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from string import ascii_lowercase
|
|
6
|
+
from types import EllipsisType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from maybetype import Maybe, Nothing, Some, maybe
|
|
12
|
+
|
|
13
|
+
ALPHANUMERIC: str = ascii_lowercase + '0123456789'
|
|
14
|
+
MAYBE_UNWRAP_NONE_REGEX: re.Pattern[str] = re.compile(r"Maybe\[.*\] unwrapped into None")
|
|
15
|
+
|
|
16
|
+
def test_maybe_none_unwrap_error() -> None:
|
|
17
|
+
m_none: Maybe[Any] = Nothing
|
|
18
|
+
assert bool(m_none) is False
|
|
19
|
+
with pytest.raises(ValueError, match=MAYBE_UNWRAP_NONE_REGEX):
|
|
20
|
+
m_none.unwrap()
|
|
21
|
+
with pytest.raises(TypeError, match='Custom error message'):
|
|
22
|
+
m_none.unwrap(exc=TypeError('Custom error message'))
|
|
23
|
+
|
|
24
|
+
def test_maybe_none_is_nothing() -> None:
|
|
25
|
+
assert maybe(None) is Nothing
|
|
26
|
+
|
|
27
|
+
def test_maybe_this_or() -> None:
|
|
28
|
+
assert Maybe.int('10').this_or(0).unwrap() == 10 # noqa: PLR2004
|
|
29
|
+
assert Maybe.int('ten').this_or(0).unwrap() == 0
|
|
30
|
+
with pytest.raises(ValueError, match=MAYBE_UNWRAP_NONE_REGEX):
|
|
31
|
+
Maybe.int('ten').unwrap()
|
|
32
|
+
|
|
33
|
+
@pytest.mark.parametrize(('val', 'default'),
|
|
34
|
+
[
|
|
35
|
+
(5, 10),
|
|
36
|
+
(5, None),
|
|
37
|
+
('string', 'fallback'),
|
|
38
|
+
('string', None),
|
|
39
|
+
],
|
|
40
|
+
)
|
|
41
|
+
def test_maybe_unwrap_or(val: object, default: object) -> None:
|
|
42
|
+
assert maybe(val).unwrap_or(default) == val
|
|
43
|
+
assert maybe(None).unwrap_or(default) == default
|
|
44
|
+
|
|
45
|
+
@pytest.mark.parametrize(('val', 'then_fn'),
|
|
46
|
+
[
|
|
47
|
+
# (wrapped value, (.then() function, expected return val), expected wrapped value after function)
|
|
48
|
+
(0, (lambda i: i * 10, 0, ...)),
|
|
49
|
+
(1, (lambda i: i * 10, 10, ...)),
|
|
50
|
+
('', (lambda s: s.upper(), '', ...)),
|
|
51
|
+
('string', (lambda s: s.upper(), 'STRING', ...)),
|
|
52
|
+
('a,b,c', (lambda s: s.split(','), ['a', 'b', 'c'], ...)),
|
|
53
|
+
([1, 2, 3], (lambda l: l.append(4), None, [1, 2, 3, 4])), # noqa: E741
|
|
54
|
+
({'a': 1, 'b': 2}, (lambda d: d.get('a'), 1, ...)),
|
|
55
|
+
],
|
|
56
|
+
ids=[
|
|
57
|
+
'int_zero',
|
|
58
|
+
'int_one',
|
|
59
|
+
'str_empty',
|
|
60
|
+
'str_nonempty',
|
|
61
|
+
'str_split',
|
|
62
|
+
'list',
|
|
63
|
+
'dict',
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
def test_maybe_then[T, R, A](val: T, then_fn: tuple[Callable[[T], R], R, A | EllipsisType]) -> None:
|
|
67
|
+
m: Maybe[T] = maybe(val)
|
|
68
|
+
assert bool(m) is True
|
|
69
|
+
assert m.unwrap() == val
|
|
70
|
+
m_before = deepcopy(m.val)
|
|
71
|
+
assert m.then(then_fn[0]) == then_fn[1]
|
|
72
|
+
assert m.unwrap() == (then_fn[2] if then_fn[2] is not Ellipsis else m_before)
|
|
73
|
+
assert maybe(None).then(then_fn[0]) is None
|
|
74
|
+
|
|
75
|
+
def test_maybe_attr() -> None:
|
|
76
|
+
@dataclass
|
|
77
|
+
class A:
|
|
78
|
+
x: int
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class B(A):
|
|
82
|
+
y: float
|
|
83
|
+
|
|
84
|
+
m_none: Maybe[A] = maybe(None)
|
|
85
|
+
assert m_none.attr('x').val is None
|
|
86
|
+
assert m_none.attr_or('x', 2) == 2 # noqa: PLR2004
|
|
87
|
+
|
|
88
|
+
m_a: Maybe[A] = maybe(A(1))
|
|
89
|
+
assert m_a.attr('x').unwrap() == 1
|
|
90
|
+
assert m_a.attr_or('x', 2) == 1
|
|
91
|
+
assert m_a.attr('y').val is None
|
|
92
|
+
assert m_a.attr_or('y', 2) == 2 # noqa: PLR2004
|
|
93
|
+
|
|
94
|
+
m_b: Maybe[B] = maybe(B(1, 2.0))
|
|
95
|
+
assert m_b.attr('x').unwrap() == 1
|
|
96
|
+
assert m_b.attr_or('x', 2) == 1
|
|
97
|
+
assert m_b.attr('y').unwrap() == 2.0 # noqa: PLR2004
|
|
98
|
+
assert m_b.attr_or('y', 3) == 2.0 # noqa: PLR2004
|
|
99
|
+
|
|
100
|
+
@pytest.mark.parametrize(('val', 'accessor', 'result'),
|
|
101
|
+
[
|
|
102
|
+
(None, 1, maybe(None)),
|
|
103
|
+
([1, 2, 3], 1, maybe(2)),
|
|
104
|
+
([1, 2, 3], 3, maybe(None)),
|
|
105
|
+
([], 1, maybe(None)),
|
|
106
|
+
({'a': 1, 'b': 2}, 'a', maybe(1)),
|
|
107
|
+
({'a': 1, 'b': 2}, 'c', maybe(None)),
|
|
108
|
+
({}, 'a', maybe(None)),
|
|
109
|
+
],
|
|
110
|
+
ids=[
|
|
111
|
+
'none',
|
|
112
|
+
'list_populated',
|
|
113
|
+
'list_populated_out_of_range',
|
|
114
|
+
'list_empty',
|
|
115
|
+
'dict_populated',
|
|
116
|
+
'dict_populated_no_key',
|
|
117
|
+
'dict_empty',
|
|
118
|
+
],
|
|
119
|
+
)
|
|
120
|
+
def test_maybe_get(val: object, accessor: object, result: object) -> None:
|
|
121
|
+
m: Maybe = maybe(val)
|
|
122
|
+
assert m.get(accessor) == result
|
|
123
|
+
|
|
124
|
+
def test_maybe_cat() -> None:
|
|
125
|
+
assert Maybe.cat(map(Maybe.int, ALPHANUMERIC)) == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
|
126
|
+
|
|
127
|
+
def test_maybe_cat_failure() -> None:
|
|
128
|
+
with pytest.raises(AttributeError, match='has no attribute \'unwrap\''):
|
|
129
|
+
Maybe.cat([1, 2, 3]) # type: ignore
|
|
130
|
+
|
|
131
|
+
def test_maybe_map() -> None:
|
|
132
|
+
assert Maybe.map(Maybe.int, ALPHANUMERIC) == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
|
133
|
+
|
|
134
|
+
def is_valid_uuid(s: str) -> bool:
|
|
135
|
+
return re.match(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}|[0-9a-f]{32}", s) is not None
|
|
136
|
+
|
|
137
|
+
@pytest.mark.parametrize(('value', 'predicate', 'expected_bool'),
|
|
138
|
+
[
|
|
139
|
+
(0, lambda a: a > 0, False),
|
|
140
|
+
([], lambda a: len(a) > 0, False),
|
|
141
|
+
([], lambda a: 'x' in a, False),
|
|
142
|
+
({}, lambda a: len(a) > 0, False),
|
|
143
|
+
({}, lambda a: 'x' in a, False),
|
|
144
|
+
('3b1bcc3a-41d5-49a5-8273-10cc605e31f9', is_valid_uuid, True),
|
|
145
|
+
('3b1bcc3a41d549a5827310cc605e31f9', is_valid_uuid, True),
|
|
146
|
+
('qwertyuiopasdfghjklzxcvbnm', is_valid_uuid, False),
|
|
147
|
+
('nf0cmmdq-l0gt-rq5a-upry-706trht3ocv9', is_valid_uuid, False),
|
|
148
|
+
],
|
|
149
|
+
)
|
|
150
|
+
def test_maybe_with_predicate[T](value: T, predicate: Callable[[T], bool], expected_bool: bool) -> None:
|
|
151
|
+
assert bool(maybe(value, predicate)) is expected_bool
|
|
152
|
+
if expected_bool is False:
|
|
153
|
+
assert maybe(value, predicate) is Nothing
|
|
154
|
+
|
|
155
|
+
@pytest.mark.parametrize(('value', 'predicate', 'expected'),
|
|
156
|
+
[
|
|
157
|
+
(0, lambda a: a > 0, None),
|
|
158
|
+
([], lambda a: len(a) > 0, None),
|
|
159
|
+
([], lambda a: 'x' in a, None),
|
|
160
|
+
({}, lambda a: len(a) > 0, None),
|
|
161
|
+
({}, lambda a: 'x' in a, None),
|
|
162
|
+
('3b1bcc3a-41d5-49a5-8273-10cc605e31f9', is_valid_uuid, ...),
|
|
163
|
+
('3b1bcc3a41d549a5827310cc605e31f9', is_valid_uuid, ...),
|
|
164
|
+
('qwertyuiopasdfghjklzxcvbnm', is_valid_uuid, None),
|
|
165
|
+
('nf0cmmdq-l0gt-rq5a-upry-706trht3ocv9', is_valid_uuid, None),
|
|
166
|
+
],
|
|
167
|
+
)
|
|
168
|
+
def test_maybe_pattern_matching[T](value: T, predicate: Callable[[T], bool], expected: T | None) -> None:
|
|
169
|
+
if expected is Ellipsis:
|
|
170
|
+
expected = value
|
|
171
|
+
|
|
172
|
+
match maybe(value, predicate):
|
|
173
|
+
case Some(value):
|
|
174
|
+
result = value
|
|
175
|
+
case _:
|
|
176
|
+
result = None
|
|
177
|
+
|
|
178
|
+
assert result == expected
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.12"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "colorama"
|
|
7
|
+
version = "0.4.6"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
10
|
+
wheels = [
|
|
11
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[package]]
|
|
15
|
+
name = "iniconfig"
|
|
16
|
+
version = "2.3.0"
|
|
17
|
+
source = { registry = "https://pypi.org/simple" }
|
|
18
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
|
19
|
+
wheels = [
|
|
20
|
+
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[[package]]
|
|
24
|
+
name = "maybetype"
|
|
25
|
+
version = "0.1.0"
|
|
26
|
+
source = { virtual = "." }
|
|
27
|
+
|
|
28
|
+
[package.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
{ name = "pytest" },
|
|
31
|
+
{ name = "ruff" },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[package.metadata]
|
|
35
|
+
requires-dist = [
|
|
36
|
+
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" },
|
|
37
|
+
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.8" },
|
|
38
|
+
]
|
|
39
|
+
provides-extras = ["dev"]
|
|
40
|
+
|
|
41
|
+
[[package]]
|
|
42
|
+
name = "packaging"
|
|
43
|
+
version = "25.0"
|
|
44
|
+
source = { registry = "https://pypi.org/simple" }
|
|
45
|
+
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
|
46
|
+
wheels = [
|
|
47
|
+
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[[package]]
|
|
51
|
+
name = "pluggy"
|
|
52
|
+
version = "1.6.0"
|
|
53
|
+
source = { registry = "https://pypi.org/simple" }
|
|
54
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
|
55
|
+
wheels = [
|
|
56
|
+
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[[package]]
|
|
60
|
+
name = "pygments"
|
|
61
|
+
version = "2.19.2"
|
|
62
|
+
source = { registry = "https://pypi.org/simple" }
|
|
63
|
+
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
|
64
|
+
wheels = [
|
|
65
|
+
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
[[package]]
|
|
69
|
+
name = "pytest"
|
|
70
|
+
version = "9.0.2"
|
|
71
|
+
source = { registry = "https://pypi.org/simple" }
|
|
72
|
+
dependencies = [
|
|
73
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
74
|
+
{ name = "iniconfig" },
|
|
75
|
+
{ name = "packaging" },
|
|
76
|
+
{ name = "pluggy" },
|
|
77
|
+
{ name = "pygments" },
|
|
78
|
+
]
|
|
79
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
|
80
|
+
wheels = [
|
|
81
|
+
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
[[package]]
|
|
85
|
+
name = "ruff"
|
|
86
|
+
version = "0.14.8"
|
|
87
|
+
source = { registry = "https://pypi.org/simple" }
|
|
88
|
+
sdist = { url = "https://files.pythonhosted.org/packages/ed/d9/f7a0c4b3a2bf2556cd5d99b05372c29980249ef71e8e32669ba77428c82c/ruff-0.14.8.tar.gz", hash = "sha256:774ed0dd87d6ce925e3b8496feb3a00ac564bea52b9feb551ecd17e0a23d1eed", size = 5765385, upload-time = "2025-12-04T15:06:17.669Z" }
|
|
89
|
+
wheels = [
|
|
90
|
+
{ url = "https://files.pythonhosted.org/packages/48/b8/9537b52010134b1d2b72870cc3f92d5fb759394094741b09ceccae183fbe/ruff-0.14.8-py3-none-linux_armv6l.whl", hash = "sha256:ec071e9c82eca417f6111fd39f7043acb53cd3fde9b1f95bbed745962e345afb", size = 13441540, upload-time = "2025-12-04T15:06:14.896Z" },
|
|
91
|
+
{ url = "https://files.pythonhosted.org/packages/24/00/99031684efb025829713682012b6dd37279b1f695ed1b01725f85fd94b38/ruff-0.14.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8cdb162a7159f4ca36ce980a18c43d8f036966e7f73f866ac8f493b75e0c27e9", size = 13669384, upload-time = "2025-12-04T15:06:51.809Z" },
|
|
92
|
+
{ url = "https://files.pythonhosted.org/packages/72/64/3eb5949169fc19c50c04f28ece2c189d3b6edd57e5b533649dae6ca484fe/ruff-0.14.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e2fcbefe91f9fad0916850edf0854530c15bd1926b6b779de47e9ab619ea38f", size = 12806917, upload-time = "2025-12-04T15:06:08.925Z" },
|
|
93
|
+
{ url = "https://files.pythonhosted.org/packages/c4/08/5250babb0b1b11910f470370ec0cbc67470231f7cdc033cee57d4976f941/ruff-0.14.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9d70721066a296f45786ec31916dc287b44040f553da21564de0ab4d45a869b", size = 13256112, upload-time = "2025-12-04T15:06:23.498Z" },
|
|
94
|
+
{ url = "https://files.pythonhosted.org/packages/78/4c/6c588e97a8e8c2d4b522c31a579e1df2b4d003eddfbe23d1f262b1a431ff/ruff-0.14.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c87e09b3cd9d126fc67a9ecd3b5b1d3ded2b9c7fce3f16e315346b9d05cfb52", size = 13227559, upload-time = "2025-12-04T15:06:33.432Z" },
|
|
95
|
+
{ url = "https://files.pythonhosted.org/packages/23/ce/5f78cea13eda8eceac71b5f6fa6e9223df9b87bb2c1891c166d1f0dce9f1/ruff-0.14.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d62cb310c4fbcb9ee4ac023fe17f984ae1e12b8a4a02e3d21489f9a2a5f730c", size = 13896379, upload-time = "2025-12-04T15:06:02.687Z" },
|
|
96
|
+
{ url = "https://files.pythonhosted.org/packages/cf/79/13de4517c4dadce9218a20035b21212a4c180e009507731f0d3b3f5df85a/ruff-0.14.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1af35c2d62633d4da0521178e8a2641c636d2a7153da0bac1b30cfd4ccd91344", size = 15372786, upload-time = "2025-12-04T15:06:29.828Z" },
|
|
97
|
+
{ url = "https://files.pythonhosted.org/packages/00/06/33df72b3bb42be8a1c3815fd4fae83fa2945fc725a25d87ba3e42d1cc108/ruff-0.14.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25add4575ffecc53d60eed3f24b1e934493631b48ebbc6ebaf9d8517924aca4b", size = 14990029, upload-time = "2025-12-04T15:06:36.812Z" },
|
|
98
|
+
{ url = "https://files.pythonhosted.org/packages/64/61/0f34927bd90925880394de0e081ce1afab66d7b3525336f5771dcf0cb46c/ruff-0.14.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c943d847b7f02f7db4201a0600ea7d244d8a404fbb639b439e987edcf2baf9a", size = 14407037, upload-time = "2025-12-04T15:06:39.979Z" },
|
|
99
|
+
{ url = "https://files.pythonhosted.org/packages/96/bc/058fe0aefc0fbf0d19614cb6d1a3e2c048f7dc77ca64957f33b12cfdc5ef/ruff-0.14.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb6e8bf7b4f627548daa1b69283dac5a296bfe9ce856703b03130732e20ddfe2", size = 14102390, upload-time = "2025-12-04T15:06:46.372Z" },
|
|
100
|
+
{ url = "https://files.pythonhosted.org/packages/af/a4/e4f77b02b804546f4c17e8b37a524c27012dd6ff05855d2243b49a7d3cb9/ruff-0.14.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7aaf2974f378e6b01d1e257c6948207aec6a9b5ba53fab23d0182efb887a0e4a", size = 14230793, upload-time = "2025-12-04T15:06:20.497Z" },
|
|
101
|
+
{ url = "https://files.pythonhosted.org/packages/3f/52/bb8c02373f79552e8d087cedaffad76b8892033d2876c2498a2582f09dcf/ruff-0.14.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e5758ca513c43ad8a4ef13f0f081f80f08008f410790f3611a21a92421ab045b", size = 13160039, upload-time = "2025-12-04T15:06:49.06Z" },
|
|
102
|
+
{ url = "https://files.pythonhosted.org/packages/1f/ad/b69d6962e477842e25c0b11622548df746290cc6d76f9e0f4ed7456c2c31/ruff-0.14.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f74f7ba163b6e85a8d81a590363bf71618847e5078d90827749bfda1d88c9cdf", size = 13205158, upload-time = "2025-12-04T15:06:54.574Z" },
|
|
103
|
+
{ url = "https://files.pythonhosted.org/packages/06/63/54f23da1315c0b3dfc1bc03fbc34e10378918a20c0b0f086418734e57e74/ruff-0.14.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:eed28f6fafcc9591994c42254f5a5c5ca40e69a30721d2ab18bb0bb3baac3ab6", size = 13469550, upload-time = "2025-12-04T15:05:59.209Z" },
|
|
104
|
+
{ url = "https://files.pythonhosted.org/packages/70/7d/a4d7b1961e4903bc37fffb7ddcfaa7beb250f67d97cfd1ee1d5cddb1ec90/ruff-0.14.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:21d48fa744c9d1cb8d71eb0a740c4dd02751a5de9db9a730a8ef75ca34cf138e", size = 14211332, upload-time = "2025-12-04T15:06:06.027Z" },
|
|
105
|
+
{ url = "https://files.pythonhosted.org/packages/5d/93/2a5063341fa17054e5c86582136e9895db773e3c2ffb770dde50a09f35f0/ruff-0.14.8-py3-none-win32.whl", hash = "sha256:15f04cb45c051159baebb0f0037f404f1dc2f15a927418f29730f411a79bc4e7", size = 13151890, upload-time = "2025-12-04T15:06:11.668Z" },
|
|
106
|
+
{ url = "https://files.pythonhosted.org/packages/02/1c/65c61a0859c0add13a3e1cbb6024b42de587456a43006ca2d4fd3d1618fe/ruff-0.14.8-py3-none-win_amd64.whl", hash = "sha256:9eeb0b24242b5bbff3011409a739929f497f3fb5fe3b5698aba5e77e8c833097", size = 14537826, upload-time = "2025-12-04T15:06:26.409Z" },
|
|
107
|
+
{ url = "https://files.pythonhosted.org/packages/6d/63/8b41cea3afd7f58eb64ac9251668ee0073789a3bc9ac6f816c8c6fef986d/ruff-0.14.8-py3-none-win_arm64.whl", hash = "sha256:965a582c93c63fe715fd3e3f8aa37c4b776777203d8e1d8aa3cc0c14424a4b99", size = 13634522, upload-time = "2025-12-04T15:06:43.212Z" },
|
|
108
|
+
]
|