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.
@@ -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,16 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Sphinx-generated files
13
+ _build/
14
+
15
+ # Other
16
+ _*/
@@ -0,0 +1,6 @@
1
+ {
2
+ "MD024": false,
3
+ "line-length": {
4
+ "line_length": 100
5
+ }
6
+ }
@@ -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,5 @@
1
+ Changelog
2
+ ===========
3
+
4
+ .. include:: ../CHANGELOG.md
5
+ :parser: myst_parser.sphinx_
@@ -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,5 @@
1
+ README
2
+ ===========
3
+
4
+ .. include:: ../README.md
5
+ :parser: myst_parser.sphinx_
@@ -0,0 +1,9 @@
1
+ .. py:module:: maybetype
2
+ .. py:currentmodule:: maybetype
3
+
4
+ :py:mod:`~maybetype` module
5
+ ========================================
6
+
7
+ .. automodule:: maybetype
8
+ :members:
9
+ :special-members: __init__
@@ -0,0 +1,4 @@
1
+ furo>=2025.9.12.19
2
+ myst-parser>=4.0.1
3
+ sphinx>=6.0,<9.0
4
+ sphinx-autobuild>=2025.8.25
@@ -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,3 @@
1
+ import importlib.metadata
2
+
3
+ PROJECT_VERSION: str = importlib.metadata.version('maybetype')
@@ -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
+ ]