Repository: antonagestam/phantom-types
Branch: main
Commit: 458040665f70
Files: 104
Total size: 293.5 KB
Directory structure:
gitextract_1ljscji0/
├── .editorconfig
├── .github/
│ ├── CODE_OF_CONDUCT.md
│ ├── CONTRIBUTING.md
│ ├── dependabot.yaml
│ └── workflows/
│ ├── ci.yaml
│ └── release.yaml
├── .gitignore
├── .goose/
│ ├── check-manifest/
│ │ ├── manifest.json
│ │ └── requirements.txt
│ ├── node/
│ │ ├── manifest.json
│ │ └── package.json
│ └── python/
│ ├── manifest.json
│ └── requirements.txt
├── .readthedocs.yml
├── LICENSE
├── MANIFEST.in
├── Makefile
├── README.md
├── codecov.yml
├── docs/
│ ├── Makefile
│ ├── conf.py
│ ├── index.rst
│ └── pages/
│ ├── composing-types.rst
│ ├── external-wrappers.rst
│ ├── functional-composition.rst
│ ├── getting-started.rst
│ ├── implementation.rst
│ ├── predicates.rst
│ ├── pydantic-support.rst
│ └── types.rst
├── docs-requirements.txt
├── goose.yaml
├── mypy.ini
├── pyproject.toml
├── ruff.toml
├── setup.cfg
├── src/
│ └── phantom/
│ ├── __init__.py
│ ├── _base.py
│ ├── _hypothesis.py
│ ├── _utils/
│ │ ├── __init__.py
│ │ ├── misc.py
│ │ └── types.py
│ ├── boolean.py
│ ├── bounds.py
│ ├── datetime.py
│ ├── errors.py
│ ├── ext/
│ │ ├── __init__.py
│ │ └── phonenumbers.py
│ ├── fn.py
│ ├── interval.py
│ ├── iso3166.py
│ ├── negated.py
│ ├── predicates/
│ │ ├── __init__.py
│ │ ├── _base.py
│ │ ├── _utils.py
│ │ ├── boolean.py
│ │ ├── collection.py
│ │ ├── datetime.py
│ │ ├── generic.py
│ │ ├── interval.py
│ │ ├── numeric.py
│ │ └── re.py
│ ├── py.typed
│ ├── re.py
│ ├── schema.py
│ └── sized.py
├── tests/
│ ├── __init__.py
│ ├── ext/
│ │ ├── __init__.py
│ │ ├── test_hypothesis.py
│ │ ├── test_phonenumbers.py
│ │ └── test_phonenumbers.yaml
│ ├── predicates/
│ │ ├── __init__.py
│ │ ├── test_boolean.py
│ │ ├── test_collection.py
│ │ ├── test_datetime.py
│ │ ├── test_generic.py
│ │ ├── test_interval.py
│ │ ├── test_numeric.py
│ │ ├── test_re.py
│ │ ├── test_utils.py
│ │ └── utils.py
│ ├── pydantic/
│ │ ├── __init__.py
│ │ ├── test_datetime.py
│ │ └── test_schemas.py
│ ├── test_base.py
│ ├── test_boolean.py
│ ├── test_datetime.py
│ ├── test_datetime.yaml
│ ├── test_fn.py
│ ├── test_fn.yaml
│ ├── test_intersection.yaml
│ ├── test_interval.py
│ ├── test_interval.yaml
│ ├── test_iso3166.py
│ ├── test_iso3166.yaml
│ ├── test_negated.py
│ ├── test_negated.yaml
│ ├── test_re.py
│ ├── test_re.yaml
│ ├── test_sized.py
│ ├── test_sized.yaml
│ ├── test_utils.py
│ └── types.py
└── typing-requirements.txt
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
root = True
[*]
end_of_line = lf
insert_final_newline = True
indent_style = space
indent_size = 4
trim_trailing_whitespace = True
[*.py]
charset = utf-8
[*.{yml,yaml,md,toml}]
indent_size = 2
[Makefile]
indent_style = tab
================================================
FILE: .github/CODE_OF_CONDUCT.md
================================================
# Citizen Code of Conduct
## 1. Purpose
A primary goal of Phantom Types is to be inclusive to the largest number of
contributors, with the most varied and diverse backgrounds possible. As such, we are
committed to providing a friendly, safe and welcoming environment for all, regardless of
gender, sexual orientation, ability, ethnicity, socioeconomic status, and religion (or
lack thereof).
This code of conduct outlines our expectations for all those who participate in our
community, as well as the consequences for unacceptable behavior.
We invite all those who participate in Phantom Types to help us create safe and positive
experiences for everyone.
## 2. Open [Source/Culture/Tech] Citizenship
A supplemental goal of this Code of Conduct is to increase open [source/culture/tech]
citizenship by encouraging participants to recognize and strengthen the relationships
between our actions and their effects on our community.
Communities mirror the societies in which they exist and positive action is essential to
counteract the many forms of inequality and abuses of power that exist in society.
If you see someone who is making an extra effort to ensure our community is welcoming,
friendly, and encourages all participants to contribute to the fullest extent, we want
to know.
## 3. Expected Behavior
The following behaviors are expected and requested of all community members:
- Participate in an authentic and active way. In doing so, you contribute to the health
and longevity of this community.
- Exercise consideration and respect in your speech and actions.
- Attempt collaboration before conflict.
- Refrain from demeaning, discriminatory, or harassing behavior and speech.
- Be mindful of your surroundings and of your fellow participants. Alert community
leaders if you notice a dangerous situation, someone in distress, or violations of
this Code of Conduct, even if they seem inconsequential.
- Remember that community event venues may be shared with members of the public; please
be respectful to all patrons of these locations.
## 4. Unacceptable Behavior
The following behaviors are considered harassment and are unacceptable within our
community:
- Violence, threats of violence or violent language directed against another person.
- Sexist, racist, homophobic, transphobic, ableist or otherwise discriminatory jokes and
language.
- Posting or displaying sexually explicit or violent material.
- Posting or threatening to post other people's personally identifying information
("doxing").
- Personal insults, particularly those related to gender, sexual orientation, race,
religion, or disability.
- Inappropriate photography or recording.
- Inappropriate physical contact. You should have someone's consent before touching
them.
- Unwelcome sexual attention. This includes, sexualized comments or jokes; inappropriate
touching, groping, and unwelcomed sexual advances.
- Deliberate intimidation, stalking or following (online or in person).
- Advocating for, or encouraging, any of the above behavior.
- Sustained disruption of community events, including talks and presentations.
## 5. Weapons Policy
No weapons will be allowed at Phantom Types events, community spaces, or in other spaces
covered by the scope of this Code of Conduct. Weapons include but are not limited to
guns, explosives (including fireworks), and large knives such as those used for hunting
or display, as well as any other item used for the purpose of causing injury or harm to
others. Anyone seen in possession of one of these items will be asked to leave
immediately, and will only be allowed to return without the weapon. Community members
are further expected to comply with all state and local laws on this matter.
## 6. Consequences of Unacceptable Behavior
Unacceptable behavior from any community member, including sponsors and those with
decision-making authority, will not be tolerated.
Anyone asked to stop unacceptable behavior is expected to comply immediately.
If a community member engages in unacceptable behavior, the community organizers may
take any action they deem appropriate, up to and including a temporary ban or permanent
expulsion from the community without warning (and without refund in the case of a paid
event).
## 7. Addressing Grievances
If you feel you have been falsely or unfairly accused of violating this Code of Conduct,
you should notify with a concise description of your grievance. Your grievance will be
handled in accordance with our existing governing policies.
## 8. Scope
We expect all community participants (contributors, paid or otherwise; sponsors; and
other guests) to abide by this Code of Conduct in all community venues--online and
in-person--as well as in all one-on-one communications pertaining to community business.
This code of conduct and its related procedures also applies to unacceptable behavior
occurring outside the scope of community activities when such behavior has the potential
to adversely affect the safety and well-being of community members.
## 9. License and attribution
The Citizen Code of Conduct is distributed by
[Stumptown Syndicate](http://stumptownsyndicate.org) under a
[Creative Commons Attribution-ShareAlike license](http://creativecommons.org/licenses/by-sa/3.0/).
Portions of text derived from the
[Django Code of Conduct](https://www.djangoproject.com/conduct/) and the
[Geek Feminism Anti-Harassment Policy](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy).
_Revision 2.3. Posted 6 March 2017._
_Revision 2.2. Posted 4 February 2016._
_Revision 2.1. Posted 23 June 2014._
_Revision 2.0, adopted by the [Stumptown Syndicate](http://stumptownsyndicate.org) board
on 10 January 2013. Posted 17 March 2013._
================================================
FILE: .github/CONTRIBUTING.md
================================================
## Contributing Guidelines
All sorts of contributions are welcome, ranging from raising ideas, reporting bugs,
helping review open PRs, pointing out problematic design decisions et c, and of course
by contributing code and documentation changes via pull requests.
If your inquiry is less concrete, or you want to ask a question, please [feel free to
start a new discussion][discussion]. There aren't any stupid questions, and if your
inquiry has already been answered elsewhere, I'll do my best to direct you there.
[discussion]: https://github.com/antonagestam/phantom-types/discussions
I'll look at all PRs, but it's worth noting that the project has a high bar for merging
contributions, and all features are expected to be well tested and documented. If your
not sure what this means, it's totally OK to open draft PRs and ask questions.
The [README][readme] has instructions for how to set up a development environment and
testing code locally.
[readme]: https://github.com/antonagestam/phantom-types/blob/main/README.md#development
================================================
FILE: .github/dependabot.yaml
================================================
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: pip
directory: "/"
schedule:
interval: monthly
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: monthly
================================================
FILE: .github/workflows/ci.yaml
================================================
name: CI
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
lint:
name: Static analysis
uses: antonagestam/goose/.github/workflows/run.yaml@0.10.1
type-check:
name: Type check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: 3.12
cache: pip
cache-dependency-path: typing-requirements.txt
check-latest: true
- name: mypy cache
uses: actions/cache@v4
with:
path: .mypy_cache
key: "${{ runner.os }}-mypy-3.12-${{ hashFiles('typing-requirements.txt') }}"
restore-keys: |
${{ runner.os }}-mypy-3.12
${{ runner.os }}-mypy
- run: pip install --require-hashes --no-dependencies -r typing-requirements.txt
- run: pip install --no-dependencies .
- run: mypy
check-build:
name: Check packaging metadata
uses: less-action/reusables/.github/workflows/python-test-build.yaml@main
docs:
name: Build Sphinx Docs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
# Keep in sync with version in .readthedocs.yml.
python-version: "3.11"
cache: pip
cache-dependency-path: docs-requirements.txt
- name: Install dependencies
run: pip install --require-hashes --no-dependencies -r docs-requirements.txt
- name: Install package
run: pip install --no-dependencies .
- name: Build docs
run: sphinx-build -W -b html docs docs/_build
test:
name: Test
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: pyproject.toml
- name: Install minimum requirements
run: pip install --upgrade '.[test]'
- name: Run all tests that don't require extra dependencies
run: >-
coverage run --append -m pytest
-m "no_external or not external"
--ignore=src/phantom/ext
--ignore=tests/pydantic
--ignore=tests/ext
- name: Install extra requirements
run: pip install --upgrade '.[all,test]'
- name: Run all tests that require extra dependencies
run: >-
coverage run --append -m pytest
-m "external"
- name: Collect coverage
run: |
coverage report
coverage xml
- name: Report coverage
uses: codecov/codecov-action@v5
with:
files: "coverage.xml"
fail_ci_if_error: true
name: codecov-py${{ matrix.python-version }}
token: ${{ secrets.CODECOV_TOKEN }}
verbose: true
================================================
FILE: .github/workflows/release.yaml
================================================
name: Release
on:
release:
types: [published]
jobs:
build-and-publish:
name: Build and publish
runs-on: ubuntu-latest
permissions:
# permission required for trusted publishing
id-token: write
environment:
name: pypi
url: https://pypi.org/p/immoney
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: 3.12
cache: pip
cache-dependency-path: pyproject.toml
check-latest: true
- name: Install dependencies
run: python3 -m pip install --upgrade build pkginfo
- name: Build
run: python3 -m build --sdist --wheel .
- name: Inspect built wheel version
id: inspect-wheel-version
run: |
python3 << 'EOF' >> $GITHUB_OUTPUT
from pathlib import Path
from pkginfo import Wheel
[wheel_path] = Path("dist").glob("*.whl")
wheel = Wheel(wheel_path)
print(f"version={wheel.version}")
EOF
- name: Fail on version mismatch
if: ${{ steps.inspect-wheel-version.outputs.version != github.event.release.tag_name }}
run: |
echo "💥 The version of the built wheel does not match the release tag."
echo
echo "Release tag: '${{ github.event.release.tag_name }}'"
echo "Packaged version: '${{ steps.inspect-wheel-version.outputs.version }}'"
exit 1
- name: Publish
uses: pypa/gh-action-pypi-publish@release/v1
================================================
FILE: .gitignore
================================================
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
coverage.md
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Dynamic version file
src/phantom/_version.py
================================================
FILE: .goose/check-manifest/manifest.json
================================================
{"source_ecosystem":{"language":"python","version":"3.13"},"source_dependencies":["check-manifest","setuptools-scm==8.3.1","setuptools==80.9.0","wheel==0.45.1"],"lock_files":[{"path":"requirements.txt","checksum":"sha256:b77b12f702e9b9cc47fb51a7b3d507e4f102209f4b4ff7982edc70f558ee14f6"}],"checksum":"sha256:76f610fd622fe0781ea867ed95ad9ef365f69148c7713661b62c92c4e05ba04a","ecosystem_version":"3.13.7"}
================================================
FILE: .goose/check-manifest/requirements.txt
================================================
build==1.3.0 \
--hash=sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397 \
--hash=sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4
check-manifest==0.51 \
--hash=sha256:9801c7637675755a563f33e3c48ee59a59b37a7677297c05c910c16c5b9b6d67 \
--hash=sha256:f5f35ed561012fc2115bb070e42a748ac2e034cf8904ab4dfaae893859085ca4
packaging==25.0 \
--hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
--hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
pyproject-hooks==1.2.0 \
--hash=sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8 \
--hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913
setuptools==80.9.0 \
--hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \
--hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
setuptools-scm==8.3.1 \
--hash=sha256:332ca0d43791b818b841213e76b1971b7711a960761c5bea5fc5cdb5196fbce3 \
--hash=sha256:3d555e92b75dacd037d32bafdf94f97af51ea29ae8c7b234cf94b7a5bd242a63
wheel==0.45.1 \
--hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729 \
--hash=sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248
================================================
FILE: .goose/node/manifest.json
================================================
{"source_ecosystem":"node","source_dependencies":["prettier"],"lock_files":[{"path":"package-lock.json","checksum":"sha256:800168958486b7fda644290aed3749c91b62334f78e691cb6f4bd3c1e562c8dc"},{"path":"package.json","checksum":"sha256:3eae71af3c5bec28e9719c4cc2371efe5e4d149cf6317f46feb949148680d19f"}],"checksum":"sha256:6767446d081cac0f5cbd56c320e6952bf30741ca89498267c898088281a29694","ecosystem_version":"24.0.2"}
================================================
FILE: .goose/node/package.json
================================================
{"lockfileVersion":3,"dependencies":{"prettier":"*"}}
================================================
FILE: .goose/python/manifest.json
================================================
{"source_ecosystem":{"language":"python","version":"3.13"},"source_dependencies":["blacken-docs","check-jsonschema","editorconfig-checker","pre-commit-hooks","ruff"],"lock_files":[{"path":"requirements.txt","checksum":"sha256:c17077e77d3ce109826345fca16d1a5ca132f991f9297f99ad6514f5f5e8cd7d"}],"checksum":"sha256:3ad1408c2471b7ba9c2e1979543cca3a9d600822c7d79f15a5a9bd95b7d50ab3","ecosystem_version":"3.13.7"}
================================================
FILE: .goose/python/requirements.txt
================================================
attrs==25.4.0 \
--hash=sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11 \
--hash=sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373
black==25.9.0 \
--hash=sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175 \
--hash=sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619 \
--hash=sha256:154b06d618233fe468236ba1f0e40823d4eb08b26f5e9261526fde34916b9140 \
--hash=sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0 \
--hash=sha256:2ab0ce111ef026790e9b13bd216fa7bc48edd934ffc4cbf78808b235793cbc92 \
--hash=sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f \
--hash=sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa \
--hash=sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae \
--hash=sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a \
--hash=sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357 \
--hash=sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4 \
--hash=sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e \
--hash=sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d \
--hash=sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608 \
--hash=sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831 \
--hash=sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f \
--hash=sha256:ce41ed2614b706fd55fd0b4a6909d06b5bab344ffbfadc6ef34ae50adba3d4f7 \
--hash=sha256:d119957b37cc641596063cd7db2656c5be3752ac17877017b2ffcdb9dfc4d2b1 \
--hash=sha256:e3c1f4cd5e93842774d9ee4ef6cd8d17790e65f44f7cdbaab5f2cf8ccf22a823 \
--hash=sha256:e593466de7b998374ea2585a471ba90553283fb9beefcfa430d84a2651ed5933 \
--hash=sha256:ef69351df3c84485a8beb6f7b8f9721e2009e20ef80a8d619e2d1788b7816d47 \
--hash=sha256:f96b6726d690c96c60ba682955199f8c39abc1ae0c3a494a9c62c0184049a713
blacken-docs==1.20.0 \
--hash=sha256:2d5b6caf6e7da5694b1eba97f9132c1ab9f14f221c82205ec473a6e74fbb2c6d \
--hash=sha256:a0d842811ee07802dec920d3cf831e21f6eb017712748b488489aa3688770f1e
certifi==2025.10.5 \
--hash=sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de \
--hash=sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43
charset-normalizer==3.4.4 \
--hash=sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad \
--hash=sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93 \
--hash=sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394 \
--hash=sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89 \
--hash=sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc \
--hash=sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86 \
--hash=sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63 \
--hash=sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d \
--hash=sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f \
--hash=sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8 \
--hash=sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0 \
--hash=sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505 \
--hash=sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161 \
--hash=sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af \
--hash=sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152 \
--hash=sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318 \
--hash=sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72 \
--hash=sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4 \
--hash=sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e \
--hash=sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3 \
--hash=sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576 \
--hash=sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c \
--hash=sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1 \
--hash=sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8 \
--hash=sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1 \
--hash=sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2 \
--hash=sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44 \
--hash=sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26 \
--hash=sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88 \
--hash=sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016 \
--hash=sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede \
--hash=sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf \
--hash=sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a \
--hash=sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc \
--hash=sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0 \
--hash=sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84 \
--hash=sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db \
--hash=sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1 \
--hash=sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7 \
--hash=sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed \
--hash=sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8 \
--hash=sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133 \
--hash=sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e \
--hash=sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef \
--hash=sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14 \
--hash=sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2 \
--hash=sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0 \
--hash=sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d \
--hash=sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828 \
--hash=sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f \
--hash=sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf \
--hash=sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6 \
--hash=sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328 \
--hash=sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090 \
--hash=sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa \
--hash=sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381 \
--hash=sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c \
--hash=sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb \
--hash=sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc \
--hash=sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a \
--hash=sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec \
--hash=sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc \
--hash=sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac \
--hash=sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e \
--hash=sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313 \
--hash=sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569 \
--hash=sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3 \
--hash=sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d \
--hash=sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525 \
--hash=sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894 \
--hash=sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3 \
--hash=sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9 \
--hash=sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a \
--hash=sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9 \
--hash=sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14 \
--hash=sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25 \
--hash=sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50 \
--hash=sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf \
--hash=sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1 \
--hash=sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3 \
--hash=sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac \
--hash=sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e \
--hash=sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815 \
--hash=sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c \
--hash=sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6 \
--hash=sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6 \
--hash=sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e \
--hash=sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4 \
--hash=sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84 \
--hash=sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69 \
--hash=sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15 \
--hash=sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191 \
--hash=sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0 \
--hash=sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897 \
--hash=sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd \
--hash=sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2 \
--hash=sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 \
--hash=sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d \
--hash=sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074 \
--hash=sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3 \
--hash=sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224 \
--hash=sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838 \
--hash=sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a \
--hash=sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d \
--hash=sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d \
--hash=sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f \
--hash=sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8 \
--hash=sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490 \
--hash=sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966 \
--hash=sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9 \
--hash=sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3 \
--hash=sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e \
--hash=sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608
check-jsonschema==0.34.1 \
--hash=sha256:024ca6b1d645fdc33025f915ea4aa72a84df002b12209ee172a7bfd089f34832 \
--hash=sha256:39f3faea89a26e7de6c8090bc53dceecd30029a6daf04db7e5807a2142ef54b6
click==8.3.0 \
--hash=sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc \
--hash=sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4
editorconfig-checker==3.4.1 \
--hash=sha256:11468139bd1545b96a8ee54ed7dbc9f065082e0a0d6575a22050773bbcfed6c1
idna==3.11 \
--hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \
--hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902
jsonschema==4.25.1 \
--hash=sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63 \
--hash=sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85
jsonschema-specifications==2025.9.1 \
--hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \
--hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d
mypy-extensions==1.1.0 \
--hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \
--hash=sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558
packaging==25.0 \
--hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
--hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
pathspec==0.12.1 \
--hash=sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 \
--hash=sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712
platformdirs==4.5.0 \
--hash=sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312 \
--hash=sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3
pre-commit-hooks==6.0.0 \
--hash=sha256:76161b76d321d2f8ee2a8e0b84c30ee8443e01376121fd1c90851e33e3bd7ee2 \
--hash=sha256:76d8370c006f5026cdd638a397a678d26dda735a3c88137e05885a020f824034
pytokens==0.3.0 \
--hash=sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a \
--hash=sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3
referencing==0.37.0 \
--hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \
--hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8
regress==2025.10.1 \
--hash=sha256:02da99e41c6a97f6d2146d326541b4035ed2139c92b2f56ca7e464ceb84fe24f \
--hash=sha256:05d02b4d7179b85acf28d7329a488901d6baef5f5b337dcd52f53ea0ff980bc3 \
--hash=sha256:05e6c68021dff89fee7bcdab25e0819cff4c7f0761dc41f0fb609b0e9ebb6272 \
--hash=sha256:0680ac0b0a0058acb55b64aa56956732cf56b0baa84ea95e3fa124ab16da58aa \
--hash=sha256:089b6c5f0965962e8046493e5a1222a24e88d6f235fa8fe2424ec869e6ff613c \
--hash=sha256:08e99c44e4c3860352400af96b25a4ebb673c16d53c6367153631ec77d5130b8 \
--hash=sha256:0d1f3c465f415521ca259209dd2587c9a35785b35e98d62d4a7fba3c2bf1cfc8 \
--hash=sha256:0ee12c4e1c4e6e3609f41dd58065bc241945e912772f7320238d6544f0745950 \
--hash=sha256:102c4627f026db8d361ab61155e0f1093176555d60ddb1cc4c9b6f5bbe255c1f \
--hash=sha256:12430b2c7263a7b359d30dd0f2b179426d489df30da78cd21023376eb2fe2682 \
--hash=sha256:1460d95d39d956ba0fab8a7b614b7ee5486473b1b210f65d6a3043dc08462f38 \
--hash=sha256:148f15530807a63e24ca2dea3795546723d84c0b3a8e4152a24b58318f841b3c \
--hash=sha256:15a7c2777e8eeb153fe87327871669c3bd1aab4fe20c1fccf448616bc298350d \
--hash=sha256:15efcb8d568e712919cb56b78fffe08a09d5ed1844b43cccc3235c2da90d0e59 \
--hash=sha256:1624855927d72bb0f8281785fc110eb89702078abe1c42e2e6cbe872ec374277 \
--hash=sha256:1809ac971207bb2e098a46e08c2f241a259ac3bda48228f295a3c2cba49c6c0e \
--hash=sha256:189899161133e7c56e733a3fc939642611585c5599626352ee0849cd34d7f436 \
--hash=sha256:19b875513ebecc2b37a125d5794ce91fb4c203b06cb187e15d13b2a761df4621 \
--hash=sha256:212fe8c4f823730d1578d0fe8b5edf21b393074b90a8ec704d6d6c1c96ffe7dc \
--hash=sha256:25d8518285aa3ce67ddbede90a1a9ca6a5d23a1b8275dd5a9722af0c64b37b2a \
--hash=sha256:287f86b5c0bf3bc9c0abd45bf6745ba9c6a5624c3132b07631bac4403b45143f \
--hash=sha256:28941c80252119ef051ad67195fa8d155a0c8dc9ecb801786d94eeea6738e4c3 \
--hash=sha256:290a15652c3fafe387db02061d4ed9a100804ed5a162d069b5a0ac28b5df162a \
--hash=sha256:293be370961c6887efb82e466a15523ff24a702d444f44917ce318b222ffd229 \
--hash=sha256:30e70b966015beef7e9287feaf37ed9606744ceaa88be24b10824f5b4c354498 \
--hash=sha256:337ef62f785dc6e05c4a09be7a902980cf4d4f15346f4eca9eaf02745485440c \
--hash=sha256:3605ef64ab64658856ce8d6fda730dfb62c01002e2283bd6400df8e912d1b56a \
--hash=sha256:389c2278848cbfed81753a04ce8ea6c037271179cb9ef4decac7d3c65ae3330b \
--hash=sha256:39600db4e404155168d6f75c3dbb2ae03ef3e288b4a57c7a6562a1144bf07682 \
--hash=sha256:3d12e6a834ed5f6d9dc7e86ea8fd77d37bb13900129930151d87c3261c3a799e \
--hash=sha256:3d48ae257483ff7da43c15b8071b132a1e75e4be2242a44e2587b8492afae32e \
--hash=sha256:415dd30885dfdb57f281f7c284e4495e85a90aa66965e3f10cb1bd862f40c2e4 \
--hash=sha256:45695cd7ddc6a919863f243a09a9e737257f958c0d2af0e71e349c9d0f3048ad \
--hash=sha256:469b03b1deffb6d20ea4f95aa44ef0e0e5a9b47d56f709276b06a96338b570a0 \
--hash=sha256:46b92e1ec6092e6e989e4ad5f52f0a358e88355f70cf4dba9abce84f3cd513cf \
--hash=sha256:47c03bfd853651241fc11436fb14ef7c9b312bb9a9c2828aa5c93943e945f1ef \
--hash=sha256:4c559282471f4f0c9bf7b588985c4bec16de0a85e1a3e017dca39640e15118ce \
--hash=sha256:4d0bf23a6d996655ed88c822bb0123cc2e92a1df95079ce7408552c35ec05d47 \
--hash=sha256:5541da1f581377bc20d2f77e017453aa8f2c2f4bfe7679dee00e139ec700abe8 \
--hash=sha256:56123dbe783a2bab04d1a1850605c483d40f36196bc52d249aa245d06f866f78 \
--hash=sha256:56ef2f8ef9a102a7d42cbbc2f3c0c5e1b186bd8eaa78d564da566b0bf20653dc \
--hash=sha256:590abc9fa10255dcf84b4469f08cad9787001c38600080a033cd7a71f51cae02 \
--hash=sha256:5a2ddb0d1c0821b70dd50daa773c5f3fbb2155398c57809a2f54447958c9569f \
--hash=sha256:5c147e4d3799022bac9fc49fd042a51b7f746c41ed231fa6b496720dab0d2f9d \
--hash=sha256:5d961e81b169fce4f6c0e35f52bbfca9e20abf9674dd391c75708f0665ef4f6f \
--hash=sha256:63503a8f601a10e5d9d72ea6efb415a2838a4766736775322578ce5fe18cb233 \
--hash=sha256:6553c8ba57fa92ab3e9ef5c811d6214c80131bba06496bd5920e6e5a3d53ca8e \
--hash=sha256:6b52096ecbf39f50756e51efa9286f47c598572f6b8bb2119f855de817f38b8e \
--hash=sha256:6c1c968ee4dca933e0cd6cea51d9c6494c3d82650ce826e7a5d007c9de720da2 \
--hash=sha256:6c4c40a8c9f3e0119d2384a52b55dcc770461e1ead6ae7b41314999223116a15 \
--hash=sha256:6d26daee7d46905c8d4232f44c39ea788f10d39c166735177f603783b4ace4e8 \
--hash=sha256:6d451a88292c5a93f57cf754c71b3aa8b570ed159f9fd481554948c467e6105d \
--hash=sha256:701d1e4f2b30abfb39b27759a52e68cab8b76484b3d9b51b2d7f368807613f77 \
--hash=sha256:707e2846e784ecef666a6892753cf5d8441e4cf02bcc7fa10fd21527429815fc \
--hash=sha256:70c78c6cf679da47fbde85b75407fdfb4642477315ee94d6cdd62a0606941b83 \
--hash=sha256:722c408a3bc92b4904005e68244c28fa6df943290df8d670faf349414c86aabb \
--hash=sha256:7343ef7eae795e1449308d6d05131195f5af31ab1b2b6b405ff9370b64c1e7e1 \
--hash=sha256:77d63b338c2a4e56b4f05632d3fd94061ad47ee2b272b158b9c2e09545a4c6bb \
--hash=sha256:783b9c50760aab988e4d60dcb7c54eba3fe730d80f9a877f1dc52d14263f86b1 \
--hash=sha256:7ac0e197c7f1b5ffca42341518b6a03e2ea3cdd66516af9278492d2d2bfc9ce2 \
--hash=sha256:8051fac3696730bb84d7675c62c7073792a0e105233d4f8e1055f2cab9b04fce \
--hash=sha256:81733f0e7f583d181bc9fc187b3d766489bcf7e0c85eff26f1e067c942e4e45a \
--hash=sha256:8405a31f0a1475e1c9aa20c4d6e1465ef2f7259581c018a2e273083494ca9a61 \
--hash=sha256:877e05e7c570ee1e077e8b587cca8a318b7675f3c94c6c4e25d0d145abf7c0b6 \
--hash=sha256:8a9c00ede347a5a431e36d4fcf75e2ec9094feac2f6170f2ffbd5e32f7f7a4b4 \
--hash=sha256:8d725e10a99ff65b0ad83b8c20f05645d9c67dbc809e2bf3b1db230d3e15081b \
--hash=sha256:8e337ce3b150ca0ff599b1150b995ff6a2e32b5940e17ac3f30a29133960709e \
--hash=sha256:8f42578d920fc878fe19ed8e2fbe38edd212c451bb2fc5e0084716d5acd26c4c \
--hash=sha256:905c4e364526f89b33db8e69468b15d9c294a56af4d533f2700e0e1a363fbae7 \
--hash=sha256:9100da34f69e18ad6d545f5d74f8ee729e42ce200a73752dd6d94f8a373d0e71 \
--hash=sha256:933561ea90b2ac9a826e956a994b8a66635cd96467374281da992ceea8b0de4c \
--hash=sha256:966dcad04fe1821c7af9bf6dd33bbeca5c2649b7a3c5b2af57700e6d93335fd4 \
--hash=sha256:9670d957d156fa90e5582c4337ca1757b643859780896c8d853b015cd01456dc \
--hash=sha256:97307f87b128389d8b3f385c8e431fc318263281d1a1c0394606bc813aea05fc \
--hash=sha256:9e25096697b034848d8fc7910cdb38b7abc2bd2d7ade8767893359918ed4efd0 \
--hash=sha256:9fdbbea49bf2fe65f7272b0316e1343aff1ccbb85b58fab325778c416d648ed9 \
--hash=sha256:a542e38c8bb95f618674a5d2d248f1010547d7ff2e46a6cf4fa4b851459ba440 \
--hash=sha256:a67216efa72bb27db170f637d67eb9acdc167da5d617163b057803de7aed1e6d \
--hash=sha256:a6f0320f53e8fd8722211c0620fcd9bfecc0db05bf0d59385ee301f86b815671 \
--hash=sha256:a97649f21c875b7e95e10a59f0f487a518735f51a7aec7fc95fb2c3d9a3914d5 \
--hash=sha256:aaf5d102e05b109dde13363e705969b3ffdbbfeb880270187eed0871e75f0c8f \
--hash=sha256:ac04243bf4ba86196b2491bdacd9450b339b3e5e97192aab82234baac1f0a74f \
--hash=sha256:adaa80c97927d623ff72b920bcc637568f124eabe84559c7927a91253ff55d5e \
--hash=sha256:adf331c8938e0d8705fc5d05d08fab09c11cb4bcdf8a64fa21902972c4cd38f4 \
--hash=sha256:af290cfb3b7d4b14319a88feab4e86f54a2c43bd8189be5a0dcd8f847f832847 \
--hash=sha256:afee501f000666afe18531132edcaf0dc0178dd591cccf5b9596563e7456c118 \
--hash=sha256:b4bc2008b59e5124c1672d7fd9f203e5d4a4ff88ebaf4666e3281141e2d8db20 \
--hash=sha256:b5254f758206ba45776aefbd3c4223898890209b4dd3a743f2dc5cd1ebc5e9fd \
--hash=sha256:b6b5aa9f9408fdf260c73071282a28d29efbb4c30a4b95ab29863bea31987621 \
--hash=sha256:b99a73cfbdc5d99681aeb3aeaa2d88369023c96648cc785433d6a92c8d3a8394 \
--hash=sha256:be96403b244e3d6925e225b4da5fbc4f5dd6f06b07f751f00047aa48fd2c7fe3 \
--hash=sha256:bf743b00cac20f8e8d271f275df7bc192ebb4faa7aee8fe98484df338786dec6 \
--hash=sha256:c0ed3b4d0df960aeccb685f8de5001c19f426f1ab09fde715e5abdbf9c59b26a \
--hash=sha256:c34b13e7785554997e18725b896d404ba992cee5b691768476f14b48de0c393a \
--hash=sha256:c35c8cd42900d9195e5bf48d701dda28c204854fba7cc27d18f309745f57b8b5 \
--hash=sha256:c3962e4b980bfb844518beb1a3afce069674377ba99189fe339918fe7e7cbb7a \
--hash=sha256:c7ecba827aff7f951db40be777c32608a1b16bbeb7f02fcc97a2e9fc6702641f \
--hash=sha256:cb23c4ab28e75e35f033e4e392f01f6a9344a961e2f4ded56af5520b5841fe8d \
--hash=sha256:cba77808a756f1f117916c0afb0e79f01be1cb46ee51c77780e8afc59ddac76b \
--hash=sha256:cbf1371ae2cfe1a2d6e1bb21f73a557138bc45347021f1701f196b484789639a \
--hash=sha256:ccfc11e2b632a21d82a56b52a70fc002b990fcf4f6b30661724732776bea6f1d \
--hash=sha256:ce4e38ada2a39159d8a2523084e2b5ed94b3f7f7b196ba6b99c2d6c4ff634a87 \
--hash=sha256:d0f63f4c3f2e701c832e54434ee10bd0bd2c850b0ae4029829d2dd0c9a340d83 \
--hash=sha256:d48e91d14a366570685f76adaf9d108e3abc5522572e7e1d0d78c3cc3ebf0833 \
--hash=sha256:daece9f2fbaaadd23ef2cb31ffbaecd74f946938ff9b70d22c891c66d1435ad2 \
--hash=sha256:dc9046f25971e9d6dc3b57028de8991d0d7c346efcb0c15acbfecbfb8e4c1813 \
--hash=sha256:dcc0a8af0cdbc3d6e0d4725f113335d0a5ffbba86ae3ca18d2b5b352c5f2c8ed \
--hash=sha256:de16665fcc008db88729f52bab92e36d0c0534bcb3f334ff6d2c7574b16a3d6d \
--hash=sha256:e2a0a7da1d42fdff8068ab976a66beea572621514a130b37592489ae134a0e27 \
--hash=sha256:e5c441a6017a5a29bb38c573892d882485cc26937cb1ee12da8593723bf6c041 \
--hash=sha256:e5f35e04fe6382c236d60c98b3f0a4a22dea75398b99c9c9bf3fb9d386cd7ebb \
--hash=sha256:e7cc153fabb47b6f8dfc2903186934e07aa57ee1debe9b3569ac4779b43708ae \
--hash=sha256:ee3b7325ea849097d674020c72b2e6deb8f1018f085a7ecbd22831cd217b7f80 \
--hash=sha256:f0599f9cb12722300b6d4fcd6bd9b2be5bf233bc567c3ca503d8e392de23798a \
--hash=sha256:f3afe24e6474f5dbc448f865641c29bcbed4eb3b87ac9eb0e6755c4eff4f7111 \
--hash=sha256:f418c11a6bae820fad1334e0569336e2e7848cf4b81e503bfb038daf0e57ec12 \
--hash=sha256:faa057895de8e41301f9367b286bb3fd0cd80bc8523c8c80866fe746a33d13b6 \
--hash=sha256:fb61e653a325aea4681cf7b96ba9bbabc1aeb3f3d8fe877a07800024907398b9 \
--hash=sha256:fdcd839a87fae1e47bf376248589ebcb2e58d3a0fad66837d69856ae99ef3c93
requests==2.32.5 \
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf
rpds-py==0.28.0 \
--hash=sha256:03065002fd2e287725d95fbc69688e0c6daf6c6314ba38bdbaa3895418e09296 \
--hash=sha256:04c1b207ab8b581108801528d59ad80aa83bb170b35b0ddffb29c20e411acdc1 \
--hash=sha256:05cf1e74900e8da73fa08cc76c74a03345e5a3e37691d07cfe2092d7d8e27b04 \
--hash=sha256:0a403460c9dd91a7f23fc3188de6d8977f1d9603a351d5db6cf20aaea95b538d \
--hash=sha256:0cb7203c7bc69d7c1585ebb33a2e6074492d2fc21ad28a7b9d40457ac2a51ab7 \
--hash=sha256:0d3259ea9ad8743a75a43eb7819324cdab393263c91be86e2d1901ee65c314e0 \
--hash=sha256:1571ae4292649100d743b26d5f9c63503bb1fedf538a8f29a98dce2d5ba6b4e6 \
--hash=sha256:1a4c6b05c685c0c03f80dabaeb73e74218c49deea965ca63f76a752807397207 \
--hash=sha256:1e8ee6413cfc677ce8898d9cde18cc3a60fc2ba756b0dec5b71eb6eb21c49fa1 \
--hash=sha256:1f0cfd1c69e2d14f8c892b893997fa9a60d890a0c8a603e88dca4955f26d1edd \
--hash=sha256:23690b5827e643150cf7b49569679ec13fe9a610a15949ed48b85eb7f98f34ec \
--hash=sha256:2374e16cc9131022e7d9a8f8d65d261d9ba55048c78f3b6e017971a4f5e6353c \
--hash=sha256:24743a7b372e9a76171f6b69c01aedf927e8ac3e16c474d9fe20d552a8cb45c7 \
--hash=sha256:25dbade8fbf30bcc551cb352376c0ad64b067e4fc56f90e22ba70c3ce205988c \
--hash=sha256:28ea02215f262b6d078daec0b45344c89e161eab9526b0d898221d96fdda5f27 \
--hash=sha256:2e42456917b6687215b3e606ab46aa6bca040c77af7df9a08a6dcfe8a4d10ca5 \
--hash=sha256:2e8456b6ee5527112ff2354dd9087b030e3429e43a74f480d4a5ca79d269fd85 \
--hash=sha256:3114f4db69ac5a1f32e7e4d1cbbe7c8f9cf8217f78e6e002cedf2d54c2a548ed \
--hash=sha256:31eb671150b9c62409a888850aaa8e6533635704fe2b78335f9aaf7ff81eec4d \
--hash=sha256:389c29045ee8bbb1627ea190b4976a310a295559eaf9f1464a1a6f2bf84dde78 \
--hash=sha256:3aa4dc0fdab4a7029ac63959a3ccf4ed605fee048ba67ce89ca3168da34a1342 \
--hash=sha256:3c03002f54cc855860bfdc3442928ffdca9081e73b5b382ed0b9e8efe6e5e205 \
--hash=sha256:46959ef2e64f9e4a41fc89aa20dbca2b85531f9a72c21099a3360f35d10b0d5a \
--hash=sha256:48b55c1f64482f7d8bd39942f376bfdf2f6aec637ee8c805b5041e14eeb771db \
--hash=sha256:4b0cb8a906b1a0196b863d460c0222fb8ad0f34041568da5620f9799b83ccf0b \
--hash=sha256:4c6c4db5d73d179746951486df97fd25e92396be07fc29ee8ff9a8f5afbdfb27 \
--hash=sha256:4e27d3a5709cc2b3e013bf93679a849213c79ae0573f9b894b284b55e729e120 \
--hash=sha256:4fe0438ac4a29a520ea94c8c7f1754cdd8feb1bc490dfda1bfd990072363d527 \
--hash=sha256:5338742f6ba7a51012ea470bd4dc600a8c713c0c72adaa0977a1b1f4327d6592 \
--hash=sha256:5a7306c19b19005ad98468fcefeb7100b19c79fc23a5f24a12e06d91181193fa \
--hash=sha256:5ae8ee156d6b586e4292491e885d41483136ab994e719a13458055bec14cf370 \
--hash=sha256:5b43c6a3726efd50f18d8120ec0551241c38785b68952d240c45ea553912ac41 \
--hash=sha256:5cfa9af45e7c1140af7321fa0bef25b386ee9faa8928c80dc3a5360971a29e8c \
--hash=sha256:5d0145edba8abd3db0ab22b5300c99dc152f5c9021fab861be0f0544dc3cbc5f \
--hash=sha256:5d3fd16b6dc89c73a4da0b4ac8b12a7ecc75b2864b95c9e5afed8003cb50a728 \
--hash=sha256:5ee514e0f0523db5d3fb171f397c54875dbbd69760a414dccf9d4d7ad628b5bd \
--hash=sha256:5f3fa06d27fdcee47f07a39e02862da0100cb4982508f5ead53ec533cd5fe55e \
--hash=sha256:66e6fa8e075b58946e76a78e69e1a124a21d9a48a5b4766d15ba5b06869d1fa1 \
--hash=sha256:6796079e5d24fdaba6d49bda28e2c47347e89834678f2bc2c1b4fc1489c0fb01 \
--hash=sha256:6897bebb118c44b38c9cb62a178e09f1593c949391b9a1a6fe777ccab5934ee7 \
--hash=sha256:6aa1bfce3f83baf00d9c5fcdbba93a3ab79958b4c7d7d1f55e7fe68c20e63912 \
--hash=sha256:6b4f28583a4f247ff60cd7bdda83db8c3f5b05a7a82ff20dd4b078571747708f \
--hash=sha256:6e32dd207e2c4f8475257a3540ab8a93eff997abfa0a3fdb287cae0d6cd874b8 \
--hash=sha256:6f0c9266c26580e7243ad0d72fc3e01d6b33866cfab5084a6da7576bcf1c4f72 \
--hash=sha256:735f8495a13159ce6a0d533f01e8674cec0c57038c920495f87dcb20b3ddb48a \
--hash=sha256:76500820c2af232435cbe215e3324c75b950a027134e044423f59f5b9a1ba515 \
--hash=sha256:7a4e59c90d9c27c561eb3160323634a9ff50b04e4f7820600a2beb0ac90db578 \
--hash=sha256:7a52a5169c664dfb495882adc75c304ae1d50df552fbd68e100fdc719dee4ff9 \
--hash=sha256:7a69df082db13c7070f7b8b1f155fa9e687f1d6aefb7b0e3f7231653b79a067b \
--hash=sha256:7b0f9dceb221792b3ee6acb5438eb1f02b0cb2c247796a72b016dcc92c6de829 \
--hash=sha256:7b14b0c680286958817c22d76fcbca4800ddacef6f678f3a7c79a1fe7067fe37 \
--hash=sha256:7b6013db815417eeb56b2d9d7324e64fcd4fa289caeee6e7a78b2e11fc9b438a \
--hash=sha256:7b7d9d83c942855e4fdcfa75d4f96f6b9e272d42fffcb72cd4bb2577db2e2907 \
--hash=sha256:8014045a15b4d2b3476f0a287fcc93d4f823472d7d1308d47884ecac9e612be3 \
--hash=sha256:8455933b4bcd6e83fde3fefc987a023389c4b13f9a58c8d23e4b3f6d13f78c84 \
--hash=sha256:85beb8b3f45e4e32f6802fb6cd6b17f615ef6c6a52f265371fb916fae02814aa \
--hash=sha256:8a358a32dd3ae50e933347889b6af9a1bdf207ba5d1a3f34e1a38cd3540e6733 \
--hash=sha256:8aa23b6f0fc59b85b4c7d89ba2965af274346f738e8d9fc2455763602e62fd5f \
--hash=sha256:8d252db6b1a78d0a3928b6190156042d54c93660ce4d98290d7b16b5296fb7cc \
--hash=sha256:8f60c7ea34e78c199acd0d3cda37a99be2c861dd2b8cf67399784f70c9f8e57d \
--hash=sha256:961ca621ff10d198bbe6ba4957decca61aa2a0c56695384c1d6b79bf61436df5 \
--hash=sha256:9a5690671cd672a45aa8616d7374fdf334a1b9c04a0cac3c854b1136e92374fe \
--hash=sha256:9a7548b345f66f6695943b4ef6afe33ccd3f1b638bd9afd0f730dd255c249c9e \
--hash=sha256:9f1d92ecea4fa12f978a367c32a5375a1982834649cdb96539dcdc12e609ab1a \
--hash=sha256:a2036d09b363aa36695d1cc1a97b36865597f4478470b0697b5ee9403f4fe399 \
--hash=sha256:a3b695a8fa799dd2cfdb4804b37096c5f6dba1ac7f48a7fbf6d0485bcd060316 \
--hash=sha256:a410542d61fc54710f750d3764380b53bf09e8c4edbf2f9141a82aa774a04f7c \
--hash=sha256:a6fe887c2c5c59413353b7c0caff25d0e566623501ccfff88957fa438a69377d \
--hash=sha256:a805e9b3973f7e27f7cab63a6b4f61d90f2e5557cff73b6e97cd5b8540276d3d \
--hash=sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea \
--hash=sha256:ac9f83e7b326a3f9ec3ef84cda98fb0a74c7159f33e692032233046e7fd15da2 \
--hash=sha256:acbe5e8b1026c0c580d0321c8aae4b0a1e1676861d48d6e8c6586625055b606a \
--hash=sha256:ad50614a02c8c2962feebe6012b52f9802deec4263946cddea37aaf28dd25a66 \
--hash=sha256:ada7754a10faacd4f26067e62de52d6af93b6d9542f0df73c57b9771eb3ba9c4 \
--hash=sha256:adc8aa88486857d2b35d75f0640b949759f79dc105f50aa2c27816b2e0dd749f \
--hash=sha256:b1b553dd06e875249fd43efd727785efb57a53180e0fde321468222eabbeaafa \
--hash=sha256:b1cde22f2c30ebb049a9e74c5374994157b9b70a16147d332f89c99c5960737a \
--hash=sha256:b3072b16904d0b5572a15eb9d31c1954e0d3227a585fc1351aa9878729099d6c \
--hash=sha256:b670c30fd87a6aec281c3c9896d3bae4b205fd75d79d06dc87c2503717e46092 \
--hash=sha256:b8e1e9be4fa6305a16be628959188e4fd5cd6f1b0e724d63c6d8b2a8adf74ea6 \
--hash=sha256:b9699fa7990368b22032baf2b2dce1f634388e4ffc03dfefaaac79f4695edc95 \
--hash=sha256:b9b06fe1a75e05e0713f06ea0c89ecb6452210fd60e2f1b6ddc1067b990e08d9 \
--hash=sha256:bbdc5640900a7dbf9dd707fe6388972f5bbd883633eb68b76591044cfe346f7e \
--hash=sha256:bcf1d210dfee61a6c86551d67ee1031899c0fdbae88b2d44a569995d43797712 \
--hash=sha256:bd3bbba5def70b16cd1c1d7255666aad3b290fbf8d0fe7f9f91abafb73611a91 \
--hash=sha256:beb880a9ca0a117415f241f66d56025c02037f7c4efc6fe59b5b8454f1eaa50d \
--hash=sha256:c2a34fd26588949e1e7977cfcbb17a9a42c948c100cab890c6d8d823f0586457 \
--hash=sha256:c9a40040aa388b037eb39416710fbcce9443498d2eaab0b9b45ae988b53f5c67 \
--hash=sha256:cf128350d384b777da0e68796afdcebc2e9f63f0e9f242217754e647f6d32491 \
--hash=sha256:cf681ac76a60b667106141e11a92a3330890257e6f559ca995fbb5265160b56e \
--hash=sha256:d15431e334fba488b081d47f30f091e5d03c18527c325386091f31718952fe08 \
--hash=sha256:d2412be8d00a1b895f8ad827cc2116455196e20ed994bb704bf138fe91a42724 \
--hash=sha256:d61b355c3275acb825f8777d6c4505f42b5007e357af500939d4a35b19177259 \
--hash=sha256:d678e91b610c29c4b3d52a2c148b641df2b4676ffe47c59f6388d58b99cdc424 \
--hash=sha256:d7366b6553cdc805abcc512b849a519167db8f5e5c3472010cd1228b224265cb \
--hash=sha256:dcdcb890b3ada98a03f9f2bb108489cdc7580176cb73b4f2d789e9a1dac1d472 \
--hash=sha256:dd8d86b5d29d1b74100982424ba53e56033dc47720a6de9ba0259cf81d7cecaa \
--hash=sha256:e0a0311caedc8069d68fc2bf4c9019b58a2d5ce3cd7cb656c845f1615b577e1e \
--hash=sha256:e1460ebde1bcf6d496d80b191d854adedcc619f84ff17dc1c6d550f58c9efbba \
--hash=sha256:e3eb248f2feba84c692579257a043a7699e28a77d86c77b032c1d9fbb3f0219c \
--hash=sha256:e5bbc701eff140ba0e872691d573b3d5d30059ea26e5785acba9132d10c8c31d \
--hash=sha256:e5d9b86aa501fed9862a443c5c3116f6ead8bc9296185f369277c42542bd646b \
--hash=sha256:e5deca01b271492553fdb6c7fd974659dce736a15bae5dad7ab8b93555bceb28 \
--hash=sha256:e80848a71c78aa328fefaba9c244d588a342c8e03bda518447b624ea64d1ff56 \
--hash=sha256:e819e0e37a44a78e1383bf1970076e2ccc4dc8c2bbaa2f9bd1dc987e9afff628 \
--hash=sha256:e9e184408a0297086f880556b6168fa927d677716f83d3472ea333b42171ee3b \
--hash=sha256:edd267266a9b0448f33dc465a97cfc5d467594b600fe28e7fa2f36450e03053a \
--hash=sha256:efd489fec7c311dae25e94fe7eeda4b3d06be71c68f2cf2e8ef990ffcd2cd7e8 \
--hash=sha256:f0b2044fdddeea5b05df832e50d2a06fe61023acb44d76978e1b060206a8a476 \
--hash=sha256:f274f56a926ba2dc02976ca5b11c32855cbd5925534e57cfe1fda64e04d1add2 \
--hash=sha256:f296ea3054e11fc58ad42e850e8b75c62d9a93a9f981ad04b2e5ae7d2186ff9c \
--hash=sha256:f4794c6c3fbe8f9ac87699b131a1f26e7b4abcf6d828da46a3a52648c7930eba \
--hash=sha256:f586db2e209d54fe177e58e0bc4946bea5fb0102f150b1b2f13de03e1f0976f8 \
--hash=sha256:f5e7101145427087e493b9c9b959da68d357c28c562792300dd21a095118ed16 \
--hash=sha256:f9174471d6920cbc5e82a7822de8dfd4dcea86eb828b04fc8c6519a77b0ee51e
ruamel-yaml==0.18.16 \
--hash=sha256:048f26d64245bae57a4f9ef6feb5b552a386830ef7a826f235ffb804c59efbba \
--hash=sha256:a6e587512f3c998b2225d68aa1f35111c29fad14aed561a26e73fab729ec5e5a
ruamel-yaml-clib==0.2.14 \
--hash=sha256:090782b5fb9d98df96509eecdbcaffd037d47389a89492320280d52f91330d78 \
--hash=sha256:0a54e5e40a7a691a426c2703b09b0d61a14294d25cfacc00631aa6f9c964df0d \
--hash=sha256:10d9595b6a19778f3269399eff6bab642608e5966183abc2adbe558a42d4efc9 \
--hash=sha256:16a60d69f4057ad9a92f3444e2367c08490daed6428291aa16cefb445c29b0e9 \
--hash=sha256:18c041b28f3456ddef1f1951d4492dbebe0f8114157c1b3c981a4611c2020792 \
--hash=sha256:1c1acc3a0209ea9042cc3cfc0790edd2eddd431a2ec3f8283d081e4d5018571e \
--hash=sha256:1f118b707eece8cf84ecbc3e3ec94d9db879d85ed608f95870d39b2d2efa5dca \
--hash=sha256:2070bf0ad1540d5c77a664de07ebcc45eebd1ddcab71a7a06f26936920692beb \
--hash=sha256:26a8de280ab0d22b6e3ec745b4a5a07151a0f74aad92dd76ab9c8d8d7087720d \
--hash=sha256:275f938692013a3883edbd848edde6d9f26825d65c9a2eb1db8baa1adc96a05d \
--hash=sha256:27c070cf3888e90d992be75dd47292ff9aa17dafd36492812a6a304a1aedc182 \
--hash=sha256:29757bdb7c142f9595cc1b62ec49a3d1c83fab9cef92db52b0ccebaad4eafb98 \
--hash=sha256:4ccba93c1e5a40af45b2f08e4591969fa4697eae951c708f3f83dcbf9f6c6bb1 \
--hash=sha256:4f4a150a737fccae13fb51234d41304ff2222e3b7d4c8e9428ed1a6ab48389b8 \
--hash=sha256:557df28dbccf79b152fe2d1b935f6063d9cc431199ea2b0e84892f35c03bb0ee \
--hash=sha256:5ac5ff9425d8acb8f59ac5b96bcb7fd3d272dc92d96a7c730025928ffcc88a7a \
--hash=sha256:5bae1a073ca4244620425cd3d3aa9746bde590992b98ee8c7c8be8c597ca0d4e \
--hash=sha256:5e56ac47260c0eed992789fa0b8efe43404a9adb608608631a948cee4fc2b052 \
--hash=sha256:6aeadc170090ff1889f0d2c3057557f9cd71f975f17535c26a5d37af98f19c27 \
--hash=sha256:6d5472f63a31b042aadf5ed28dd3ef0523da49ac17f0463e10fda9c4a2773352 \
--hash=sha256:70eda7703b8126f5e52fcf276e6c0f40b0d314674f896fc58c47b0aef2b9ae83 \
--hash=sha256:7df6f6e9d0e33c7b1d435defb185095386c469109de723d514142632a7b9d07f \
--hash=sha256:7e4f9da7e7549946e02a6122dcad00b7c1168513acb1f8a726b1aaf504a99d32 \
--hash=sha256:803f5044b13602d58ea378576dd75aa759f52116a0232608e8fdada4da33752e \
--hash=sha256:808c7190a0fe7ae7014c42f73897cf8e9ef14ff3aa533450e51b1e72ec5239ad \
--hash=sha256:81f6d3b19bc703679a5705c6a16dabdc79823c71d791d73c65949be7f3012c02 \
--hash=sha256:83bbd8354f6abb3fdfb922d1ed47ad8d1db3ea72b0523dac8d07cdacfe1c0fcf \
--hash=sha256:8dd3c2cc49caa7a8d64b67146462aed6723a0495e44bf0aa0a2e94beaa8432f6 \
--hash=sha256:915748cfc25b8cfd81b14d00f4bfdb2ab227a30d6d43459034533f4d1c207a2a \
--hash=sha256:94f3efb718f8f49b031f2071ec7a27dd20cbfe511b4dfd54ecee54c956da2b31 \
--hash=sha256:9bd8fe07f49c170e09d76773fb86ad9135e0beee44f36e1576a201b0676d3d1d \
--hash=sha256:9bf6b699223afe6c7fe9f2ef76e0bfa6dd892c21e94ce8c957478987ade76cd8 \
--hash=sha256:a05ba88adf3d7189a974b2de7a9d56731548d35dc0a822ec3dc669caa7019b29 \
--hash=sha256:a0ac90efbc7a77b0d796c03c8cc4e62fd710b3f1e4c32947713ef2ef52e09543 \
--hash=sha256:a0cb71ccc6ef9ce36eecb6272c81afdc2f565950cdcec33ae8e6cd8f7fc86f27 \
--hash=sha256:a37f40a859b503304dd740686359fcf541d6fb3ff7fc10f539af7f7150917c68 \
--hash=sha256:a911aa73588d9a8b08d662b9484bc0567949529824a55d3885b77e8dd62a127a \
--hash=sha256:aef953f3b8bd0b50bd52a2e52fb54a6a2171a1889d8dea4a5959d46c6624c451 \
--hash=sha256:b28caeaf3e670c08cb7e8de221266df8494c169bd6ed8875493fab45be9607a4 \
--hash=sha256:b30110b29484adc597df6bd92a37b90e63a8c152ca8136aad100a02f8ba6d1b6 \
--hash=sha256:b5b0f7e294700b615a3bcf6d28b26e6da94e8eba63b079f4ec92e9ba6c0d6b54 \
--hash=sha256:c099cafc1834d3c5dac305865d04235f7c21c167c8dd31ebc3d6bbc357e2f023 \
--hash=sha256:d73a0187718f6eec5b2f729b0f98e4603f7bd9c48aa65d01227d1a5dcdfbe9e8 \
--hash=sha256:d8354515ab62f95a07deaf7f845886cc50e2f345ceab240a3d2d09a9f7d77853 \
--hash=sha256:dba72975485f2b87b786075e18a6e5d07dc2b4d8973beb2732b9b2816f1bad70 \
--hash=sha256:dd7546c851e59c06197a7c651335755e74aa383a835878ca86d2c650c07a2f85 \
--hash=sha256:df3ec9959241d07bc261f4983d25a1205ff37703faf42b474f15d54d88b4f8c9 \
--hash=sha256:e1d1735d97fd8a48473af048739379975651fab186f8a25a9f683534e6904179 \
--hash=sha256:e501c096aa3889133d674605ebd018471bc404a59cbc17da3c5924421c54d97c \
--hash=sha256:e7cb9ad1d525d40f7d87b6df7c0ff916a66bc52cb61b66ac1b2a16d0c1b07640 \
--hash=sha256:f4e97a1cf0b7a30af9e1d9dad10a5671157b9acee790d9e26996391f49b965a2 \
--hash=sha256:f8b2acb0ffdd2ce8208accbec2dca4a06937d556fdcaefd6473ba1b5daa7e3c4 \
--hash=sha256:fb04c5650de6668b853623eceadcdb1a9f2fee381f5d7b6bc842ee7c239eeec4 \
--hash=sha256:fbc08c02e9b147a11dfcaa1ac8a83168b699863493e183f7c0c8b12850b7d259 \
--hash=sha256:ff86876889ea478b1381089e55cf9e345707b312beda4986f823e1d95e8c0f59
ruff==0.14.4 \
--hash=sha256:1043c6811c2419e39011890f14d0a30470f19d47d197c4858b2787dfa698f6c8 \
--hash=sha256:26673da283b96fe35fa0c939bf8411abec47111644aa9f7cfbd3c573fb125d2c \
--hash=sha256:456daa2fa1021bc86ca857f43fe29d5d8b3f0e55e9f90c58c317c1dcc2afc7b5 \
--hash=sha256:5a443a83a1506c684e98acb8cb55abaf3ef725078be40237463dae4463366349 \
--hash=sha256:643b69cb63cd996f1fc7229da726d07ac307eae442dd8974dbc7cf22c1e18fff \
--hash=sha256:76158a7369b3979fa878612c623a7e5430c18b2fd1c73b214945c2d06337db67 \
--hash=sha256:81b40d27924f1f02dfa827b9c0712a13c0e4b108421665322218fc38caf615c2 \
--hash=sha256:9358d490ec030f1b51d048a7fd6ead418ed0826daf6149e95e30aa67c168af33 \
--hash=sha256:95643ffd209ce78bc113266b88fba3d39e0461f0cbc8b55fb92505030fb4a850 \
--hash=sha256:a9f3a936ac27fb7c2a93e4f4b943a662775879ac579a433291a6f69428722649 \
--hash=sha256:aa082a8f878deeba955531f975881828fd6afd90dfa757c2b0808aadb437136e \
--hash=sha256:c62da9a06779deecf4d17ed04939ae8b31b517643b26370c3be1d26f3ef7dbde \
--hash=sha256:d99c0b52b6f0598acede45ee78288e5e9b4409d1ce7f661f0fa36d4cbeadf9a4 \
--hash=sha256:dd09c292479596b0e6fec8cd95c65c3a6dc68e9ad17b8f2382130f87ff6a75bb \
--hash=sha256:e6604613ffbcf2297cd5dcba0e0ac9bd0c11dc026442dfbb614504e87c349518 \
--hash=sha256:f3b8f3b442d2b14c246e7aeca2e75915159e06a3540e2f4bed9f50d062d24469 \
--hash=sha256:f459a49fe1085a749f15414ca76f61595f1a2cc8778ed7c279b6ca2e1fd19df3 \
--hash=sha256:f5e649052a294fe00818650712083cddc6cc02744afaf37202c65df9ea52efa5 \
--hash=sha256:f911bba769e4a9f51af6e70037bb72b70b45a16db5ce73e1f72aefe6f6d62132
urllib3==2.5.0 \
--hash=sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760 \
--hash=sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc
================================================
FILE: .readthedocs.yml
================================================
# https://docs.readthedocs.io/en/stable/config-file/v2.html
version: 2
sphinx:
configuration: docs/conf.py
build:
os: ubuntu-22.04
tools:
# Keep in sync with docs build CI job.
python: "3.11"
python:
install:
- requirements: docs-requirements.txt
- method: pip
path: .
================================================
FILE: LICENSE
================================================
BSD 3-Clause License
Copyright (c) 2020-2022, Anton Agestam
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================
FILE: MANIFEST.in
================================================
exclude Makefile
exclude .editorconfig
recursive-exclude docs *
recursive-exclude .github *
recursive-exclude .goose *
recursive-exclude tests *
recursive-include src py.typed
exclude *.yaml
exclude *.yml
exclude *.toml
include README.md
include LICENSE
include pyproject.toml
exclude .gitignore
exclude setup.cfg
exclude mypy.ini
exclude *requirements.txt
================================================
FILE: Makefile
================================================
SHELL := /usr/bin/env bash
.PHONY: all
# Currently running typeguard on all modules except:
# - phantom.interval
# - phantom._base
# - phantom.ext.phonenumbers
typeguard_packages := \
phantom.boolean \
phantom.datetime \
phantom.fn \
phantom.iso3166 \
phantom.re \
phantom.schema \
phantom.sized \
phantom.utils \
phantom.predicates._base \
phantom.predicates.boolean \
phantom.predicates.collection \
phantom.predicates.datetime \
phantom.prediactes.generic \
phantom.predicates.interval \
phantom.predicates.numeric \
phantom.predicates.re \
phantom.predicates.utils
typeguard_arg := \
--typeguard-packages=$(shell echo $(typeguard_packages) | sed 's/ /,/g')
.PHONY: test
test:
pytest $(test) -m 'not no_external'
.PHONY: test-runtime
test-runtime:
pytest $(test) -k.py -m 'not no_external'
.PHONY: test-typeguard
test-typeguard:
pytest $(typeguard_arg) $(test)
.PHONY: test-typing
test-typing:
pytest $(test) -k.yaml
.PHONY: clean
clean:
rm -rf {**/,}*.egg-info **{/**,}/__pycache__ build dist .coverage coverage.xml
.PHONY: docs-requirements
docs-requirements: export UV_CUSTOM_COMPILE_COMMAND='make docs-requirements'
docs-requirements:
@uv pip compile --generate-hashes --strip-extras --extra=docs --upgrade --output-file=docs-requirements.txt pyproject.toml
.PHONY: docs-requirements
typing-requirements: export UV_CUSTOM_COMPILE_COMMAND='make typing-requirements'
typing-requirements:
@uv pip compile --generate-hashes --strip-extras --extra=type-check --upgrade --output-file=typing-requirements.txt pyproject.toml
================================================
FILE: README.md
================================================

phantom-types
[Phantom types][ghosts] for Python will help you make illegal states unrepresentable and
avoid shotgun parsing by enabling you to practice ["Parse, don't validate"][parse].
## Installation
```bash
$ python3 -m pip install phantom-types
```
#### Extras
There are a few extras available that can be used to either enable a feature or install
a compatible version of a third-party library.
| Extra name | Feature |
| ---------------- | ---------------------------------------------------------------------------------------------------------- |
| `[dateutil]` | Installs [python-dateutil]. Required for parsing strings with [`TZAware` and `TZNaive`][phantom-datetime]. |
| `[phonenumbers]` | Installs [phonenumbers]. Required to use [`phantom.ext.phonenumbers`][phantom-phonenumbers]. |
| `[pydantic]` | Installs [pydantic]. |
| `[hypothesis]` | Installs [hypothesis]. |
| `[all]` | Installs all of the above. |
[python-dateutil]: https://pypi.org/project/python-dateutil/
[phonenumbers]: https://pypi.org/project/phonenumbers/
[pydantic]: https://pypi.org/project/pydantic/
[hypothesis]: https://pypi.org/project/hypothesis/
[phantom-datetime]:
https://phantom-types.readthedocs.io/en/main/pages/types.html#module-phantom.datetime
[phantom-phonenumbers]:
https://phantom-types.readthedocs.io/en/main/pages/external-wrappers.html#module-phantom.ext.phonenumbers
```bash
$ python3 -m pip install phantom-types[all]
```
## Examples
By introducing a phantom type we can define a pre-condition for a function argument.
```python
from phantom import Phantom
from phantom.predicates.collection import contained
class Name(str, Phantom, predicate=contained({"Jane", "Joe"})): ...
def greet(name: Name):
print(f"Hello {name}!")
```
Now this will be a valid call.
```python
greet(Name.parse("Jane"))
```
... and so will this.
```python
joe = "Joe"
assert isinstance(joe, Name)
greet(joe)
```
But this will yield a static type checking error.
```python
greet("bird")
```
To be clear, the reason the first example passes is not because the type checker somehow
magically knows about our predicate, but because we provided the type checker with proof
through the `assert`. All the type checker cares about is that runtime cannot continue
executing past the assertion, unless the variable is a `Name`. If we move the calls
around like in the example below, the type checker would give an error for the `greet()`
call.
```python
joe = "Joe"
greet(joe)
assert isinstance(joe, Name)
```
### Runtime type checking
By combining phantom types with a runtime type-checker like [beartype] or [typeguard],
we can achieve the same level of security as you'd gain from using [contracts][dbc].
```python
import datetime
from beartype import beartype
from phantom.datetime import TZAware
@beartype
def soon(dt: TZAware) -> TZAware:
return dt + datetime.timedelta(seconds=10)
```
The `soon` function will now validate that both its argument and return value is
timezone aware, e.g. pre- and post conditions.
### Pydantic support
Phantom types are ready to use with [pydantic] and have [integrated
support][pydantic-support] out-of-the-box. Subclasses of `Phantom` work with both
pydantic's validation and its schema generation.
```python
class Name(str, Phantom, predicate=contained({"Jane", "Joe"})):
@classmethod
def __schema__(cls) -> Schema:
return super().__schema__() | {
"description": "Either Jane or Joe",
"format": "custom-name",
}
class Person(BaseModel):
name: Name
created: TZAware
print(json.dumps(Person.schema(), indent=2))
```
The code above outputs the following JSONSchema.
```json
{
"title": "Person",
"type": "object",
"properties": {
"name": {
"title": "Name",
"description": "Either Jane or Joe",
"format": "custom-name",
"type": "string"
},
"created": {
"title": "TZAware",
"description": "A date-time with timezone data.",
"type": "string",
"format": "date-time"
}
},
"required": ["name", "created"]
}
```
## Development
Install development requirements, preferably in a virtualenv:
```bash
$ python3 -m pip install .[all,test,type-check]
```
Run tests:
```bash
$ pytest
# or
$ make test
```
Run type checker:
```bash
$ mypy
```
Linters and formatters are set up with [goose], after installing it you can run it as:
```bash
# run all checks
$ goose run --select=all
# or just a single hook
$ goose run mypy --select=all
```
In addition to static type checking, the project is set up with [pytest-mypy-plugins] to
test that exposed mypy types work as expected, these checks will run together with the
rest of the test suite, but you can single them out with the following command.
```bash
$ make test-typing
```
[parse]: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/
[ghosts]: https://kataskeue.com/gdp.pdf
[build-status]:
https://github.com/antonagestam/phantom-types/actions?query=workflow%3ACI+branch%3Amain
[coverage]: https://codecov.io/gh/antonagestam/phantom-types
[typeguard]: https://github.com/agronholm/typeguard
[beartype]: https://github.com/beartype/beartype
[dbc]: https://en.wikipedia.org/wiki/Design_by_contract
[pydantic]: https://pydantic-docs.helpmanual.io/
[pydantic-support]:
https://phantom-types.readthedocs.io/en/stable/pages/pydantic-support.html
[goose]: https://github.com/antonagestam/goose
[pytest-mypy-plugins]: https://github.com/TypedDjango/pytest-mypy-plugins
================================================
FILE: codecov.yml
================================================
comment:
require_changes: true
================================================
FILE: docs/Makefile
================================================
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
================================================
FILE: docs/conf.py
================================================
# This file only contains a selection of the most common options. For a full list see
# the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
import os
import pathlib
import sys
# If extensions (or modules to document with autodoc) are in another directory, add
# these directories to sys.path here. If the directory is relative to the documentation
# root, use os.path.abspath to make it absolute, like shown here.
sys.path.insert(0, os.path.abspath("../src"))
import phantom
current_dir = pathlib.Path(__file__).resolve().parent
def get_copyright_from_license() -> str:
license_path = current_dir.parent / "LICENSE"
prefix = "Copyright (c) "
for line in license_path.read_text().split("\n"):
if line.startswith(prefix):
return line[len(prefix) :]
raise RuntimeError("Couldn't parse copyright from LICENSE")
# Project information
project = "phantom-types"
copyright = get_copyright_from_license() # noqa: A001
author = "Anton Agestam"
version = phantom.__version__
release = version
# Add any Sphinx extension module names here, as strings. They can be extensions coming
# with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.doctest",
"sphinx.ext.todo",
"sphinx.ext.coverage",
"sphinx.ext.viewcode",
"sphinx.ext.autosummary",
"sphinx.ext.napoleon",
"sphinx_autodoc_typehints",
] #
# Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"]
# List of patterns, relative to source directory, that match files and directories to
# ignore when looking for source files. This pattern also affects html_static_path and
# html_extra_path.
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# The theme to use for HTML and HTML Help pages.
html_theme = "furo"
# Set typing.TYPE_CHECKING to True to enable "expensive" typing imports.
set_type_checking_flag = True
typehints_fully_qualified = True
always_document_param_types = True
# Add any paths that contain custom static files (such as style sheets) here, relative
# to this directory. They are copied after the builtin static files, so a file named
# "default.css" will overwrite the builtin "default.css".
# html_static_path = []
# Keep source order instead of sorting members alphabetically.
autodoc_member_order = "bysource"
================================================
FILE: docs/index.rst
================================================
phantom-types
=============
This is the documentation of phantom-types, a library that let's you arbitrarily narrow
builtin types, without any runtime overhead. Full documentation and reference is
available here, you will hopefully find what you're looking for in the sections below.
Documentation sections
----------------------
.. toctree::
:maxdepth: 1
pages/getting-started.rst
pages/types.rst
pages/predicates.rst
pages/composing-types.rst
pages/functional-composition.rst
pages/external-wrappers.rst
pages/pydantic-support.rst
pages/implementation.rst
================================================
FILE: docs/pages/composing-types.rst
================================================
.. _composing:
Composing types
***************
Bounds
======
The bound of a phantom type is the type that its values will have at runtime, so when
checking if a value is an instance of a phantom type, it's first checked to be within
its bounds, so that the value can be safely passed as argument to the predicate
function of the type.
When subclassing, the bound of the new type must be a subtype of the bound of the super
class.
The bound of a phantom type is exposed as :attr:`phantom.Phantom.__bound__` for
introspection.
Resolution order
~~~~~~~~~~~~~~~~
The bound of a phantom type is resolved in the order: explicitly by class argument,
implicitly by base classes, or implicitly inheritance, e.g.:
.. code-block:: python
# Resolved by an explicit class arg:
class A(Phantom, bound=str, predicate=...):
...
# Resolved implicitly as any base classes before Phantom:
class B(str, Phantom, predicate=...):
...
# Resolves to str by inheritance from B:
class C(B):
...
Abstract bounds
~~~~~~~~~~~~~~~
It's sometimes useful to create base classes without specifying a bound type. To do so
the class can be made abstract by passing ``abstract=True`` as a class argument:
.. code-block:: python
class Base(Phantom, abstract=True):
...
class Concrete(str, Base):
...
This is for instance used by the shipped
:ref:`numeric interval types `.
Bound erasure
~~~~~~~~~~~~~
If a phantom type doesn't properly specify its bounds, in addition to risking passing
invalid arguments to its predicate function, it is also likely that a static type
checker might inadvertently erase the runtime type when type guarding.
As an example, this code will error on the access to ``dt.year`` because
``UTCDateTime.parse()`` has made the type checker erase the knowledge that dt is a
``datetime``.
.. code-block:: python
class UTCDateTime(Phantom, predicate=is_utc):
...
dt = UTCDateTime.parse(now())
dt.year # Error!
In this example we could remedy this by adding ``datetime`` as a base class and bound.
.. code-block:: python
class UTCDateTime(datetime.datetime, Phantom, predicate=is_utc):
...
Mutability
==========
Phantom types are completely incompatible with mutable data and should never be used to
narrow a mutable type. The reason is that there is no way for a type checker to detect
that a mutation changes an object to no longer satisfy the predicate of a phantom
type. For example:
.. code-block:: python
class Mutable:
def __init__(self, len: int):
self.len = len
def __len__(self) -> int:
return self.len
# A phantom type that checks that a list has more than 2 items.
class HasMany(Mutable, Phantom, predicate=count(greater(2))):
...
# The check will pass because the instantiated object *currently* satisfies the
# predicate, e.g. has len() > 2.
instance = HasMany.parse(Mutable(3))
# But! The object is mutable, so nothing is stopping us from altering it's length.
# At this point the object will no longer satisfy the HasMany predicate.
instance.len = 2
# There is no way for a type checker to know that the predicate isn't fulfilled
# anymore, so the revealed type here will still be HasMany.
reveal_type(instance) # Revealed type is HasMany
When subclassing from :py:class:`Phantom `, a check is made that raises
:py:class:`MutableType ` for known mutable types, such as
:py:class:`list`, :py:class:`set`, :py:class:`dict` and unfrozen dataclasses. In the
general case though, it isn't possible to detect mutability and so it's up to
developer discipline to make sure not to mix mutable data types with phantom types.
Metaclass conflicts
===================
Phantom types are implemented using a metaclass. When creating a phantom type that
narrows on a type that also uses a metaclass it's common to stumble into a metaclass
conflict. The usual solution to such situation is to create a new metaclass that
inherits both existing metaclasses and base the new type on it.
.. code-block:: python
from phantom import PhantomMeta
class NewMeta(PhantomMeta, OldMeta):
...
class New(Old, Phantom, metaclass=NewMeta):
...
================================================
FILE: docs/pages/external-wrappers.rst
================================================
External wrappers
=================
A collection of phantom types that wraps functionality of well maintained
implementations of third-party validation libraries. Importing from ``phantom.ext.*``
should be a hint that more dependencies need to be installed.
Phone numbers
-------------
.. automodule:: phantom.ext.phonenumbers
Types
^^^^^
.. autoclass:: phantom.ext.phonenumbers.PhoneNumber
:members:
:undoc-members:
:show-inheritance:
.. autoclass:: phantom.ext.phonenumbers.FormattedPhoneNumber
:members:
:undoc-members:
:show-inheritance:
Functions
^^^^^^^^^
.. autofunction:: phantom.ext.phonenumbers.is_phone_number
.. autofunction:: phantom.ext.phonenumbers.is_formatted_phone_number
.. autofunction:: phantom.ext.phonenumbers.normalize_phone_number
Exceptions
^^^^^^^^^^
.. autoexception:: phantom.ext.phonenumbers.InvalidPhoneNumber
:show-inheritance:
================================================
FILE: docs/pages/functional-composition.rst
================================================
Functional composition
======================
When composing predicates for phantom types it won't take long before you reach after
functional composition. Unfortunately, fully supporting a typed n-ary ``compose``
functions isn't yet feasible without shipping a custom mypy plugin. In lieu of having
that effort in place, a simpler but fully typed :py:func:`compose2
` function is shipped. The door, however, is left wide open for the
possibility of shipping an n-ary, generalized compose function in the future.
.. automodule:: phantom.fn
:members:
:undoc-members:
:show-inheritance:
================================================
FILE: docs/pages/getting-started.rst
================================================
Getting Started
===============
Creating phantom types
----------------------
Phantom types are created by subclassing :py:class:`Phantom ` and
providing a predicate function.
.. code-block:: python
from phantom import Phantom
# A boolean predicate that checks if a given string is a greeting. This function is
# of type ``Predicate[str]`` as it requires its argument to be a ``str``.
def is_greeting(instance: str) -> bool:
return instance.startswith(("Hello", "Hi"))
# Since our predicate requires its argument to be a ``str``, we must make the bound
# of the phantom type ``str`` as well. We do that by making it it's first base. Any
# base specified before Phantom is implicitly interpreted as its bound, unless an
# explicit bound is specified as a class argument.
class Greeting(str, Phantom, predicate=is_greeting):
...
hello = "Hello there"
# We can narrow types using mypy's type guards
assert isinstance(hello, Greeting)
# or explicitly when we need to
hi = Greeting.parse("Hi there")
# The runtime types are unchanged and will still be str for our greetings
assert type(hello) is str
assert type(hi) is str
# But their static types will be Greeting, retaining the information that our
# strings are not just any strs
if TYPE_CHECKING:
reveal_type(hello)
reveal_type(hi)
# As this string doesn't fulfill our __instancecheck__, it will not be an
# instance of Greeting.
assert not isinstance("Goodbye", Greeting)
A motivating example
--------------------
Imagine that you're working on implementing a ``head()`` function that should return the
first item of any given iterable. You start out with a simple implementation:
.. code-block:: python
def head(iterable: Iterable[T]) -> T:
return next(iter(iterable))
You go ahead and use this function across your project, until suddenly you run into a
subtle issue that you didn't think of: this function raises ``StopIteration`` when
passed an empty iterable. In functional programming terms this is due to the function
being *partial* it specifies that it takes ``Iterable`` as argument, but in reality we
would need a narrower type to describe the set of valid arguments, and make the function
*total*.
You need to deal with the problem at hand so you go ahead and adjust all the call sites
of your function, and you now end up either asserting that the iterables are non-empty,
or catching the `StopIteration`.
.. code-block:: python
items = get_values()
if not len(items):
return "empty"
return f"first element is: {head(items)}"
This works, and you could move on like this from here, but, you have now introduced
shotgun parsing into your application, since further down the processing line you need
to check the length if the iterable for other purposes. Shotgun parsing is an
anti-pattern that results in a program state that is hard to predict and will very
likely lead to bugs down the line. So how should you deal with this?
Using phantom types you can use the builtin :class:`phantom.sized.NonEmpty` type.
.. code-block:: python
def head(iterable: NonEmpty[T]) -> T:
return next(iter(iterable))
The implementation is identical but you've now altered the signature of the function so
that it's total, it can deal with *all* values of its argument type without raising an
exception.
By using the narrower type at the call sites, you avoid shotgun parsing, since the other
logic further down in the processing chain can rely on the type as well, and you won't
need to check the length of the iterable again.
.. code-block:: python
items = get_values()
if not isinstance(items, NonEmpty):
return "empty"
return f"first element is: {head(items)}"
This strategy works in all places where a function works on a narrower type than you can
describe with the builtin types of Python, not only this made-up example. You can narrow
strings, integers, datetimes, and any other arbitrary types to completely rid of
duplicated validation throughout a code base.
There's a set of phantom types that ships builtin that are helpful to build on top of,
although you might mostly use your own custom phantom types that describe the exact
values that your implementations require.
Using predicates
----------------
The phantom-types library relies heavily on boolean predicates. A boolean predicate is
simply a function that takes a single argument and returns either ``True`` or ``False``.
While using boolean predicates is not necessary to create phantom types, building up a
library of types doing so allows reusing small and easily testable functions to create a
plethora of specialized types. Boolean predicates are usually easy to reason about as
they are pure functions with only two possible return values.
Studying the phantom types shipped in this library is recommended for gaining deeper
insight into how to implement more complicated types.
Next steps
----------
- Check out the :ref:`builtin phantom types ` that is shipped with the library.
- Check out the basis of :ref:`predicates and predicate`
factories to build phantom types from.
- Read more in-depth about :ref:`composing phantom types `.
================================================
FILE: docs/pages/implementation.rst
================================================
Implementation
==============
How are phantom types implemented?
----------------------------------
phantom-types make use of Python's ``__instancecheck__`` protocol to make types work
with the same checks that are recognized as type guards by static type checkers, e.g.
``isinstance()``. Phantom types are never instantiated at runtime and so will not add
any processing-, or memory overhead. Instead the question of whether a value is properly
parsed before it is processed is deferred to the static type checker.
The choice to design the library around boolean predicates, and the fact that much of
the initially shipped builtin types use predicates, are heavily inspired by the
`fthomas/refined `_ library.
================================================
FILE: docs/pages/predicates.rst
================================================
.. _predicates:
Predicates and factories
========================
Predicates are functions that return a boolean value given a single argument. These
modules contain predicate functions, and functions that return predicates, that can be
composed and used for phantom types.
Boolean logic
-------------
.. automodule:: phantom.predicates.boolean
:members:
:undoc-members:
Collection
----------
.. automodule:: phantom.predicates.collection
:members:
:undoc-members:
Datetime
--------
.. automodule:: phantom.predicates.datetime
:members:
:undoc-members:
Generic
-------
.. automodule:: phantom.predicates.generic
:members:
Numeric intervals
-----------------
.. automodule:: phantom.predicates.interval
:members:
:undoc-members:
Numeric
-------
.. automodule:: phantom.predicates.numeric
:members:
:undoc-members:
Regular expressions
-------------------
.. automodule:: phantom.predicates.re
:members:
:undoc-members:
================================================
FILE: docs/pages/pydantic-support.rst
================================================
Pydantic Support
================
phantom-types supports pydantic_ out of the box by providing a
:func:`__get_validators__() ` hook
on the base :class:`Phantom ` class. Most of the shipped types also
implements full JSON Schema and OpenAPI support.
.. _pydantic: https://pydantic-docs.helpmanual.io/
To make a phantom type compatible with pydantic, all you need to do is override
:func:`Phantom.__schema__() `:
.. code-block:: python
from phantom import Phantom
from phantom.schema import Schema
class Name(str, Phantom, predicate=...):
@classmethod
def __schema__(cls) -> Schema:
return super().__schema__() | Schema(
description="A type for names",
format="name-format",
)
As can be seen in the example, ``__schema__()`` implementations are expected to return a
dict extending its ``super().__schema__()``, however this is not a requirement and any
:class:`Schema `-compatible ``dict`` can be returned.
================================================
FILE: docs/pages/types.rst
================================================
.. _types:
Types
=====
Base classes
------------
.. automodule:: phantom
.. autoclass:: Phantom
:members:
:undoc-members:
:show-inheritance:
:inherited-members:
:special-members: __schema__, __modify_schema__, __get_validators__, __bound__
Boolean
-------
.. automodule:: phantom.boolean
:members:
:undoc-members:
:show-inheritance:
Country codes
-------------
.. automodule:: phantom.iso3166
:members:
:undoc-members:
:show-inheritance:
Datetime
--------
.. automodule:: phantom.datetime
:members:
:undoc-members:
:show-inheritance:
Negated types
-------------
.. automodule:: phantom.negated
:members:
:undoc-members:
:show-inheritance:
.. _numeric-intervals:
Numeric intervals
-----------------
.. automodule:: phantom.interval
Base classes
^^^^^^^^^^^^
.. autoclass:: phantom.interval.Interval
:members: __check__
:undoc-members:
:show-inheritance:
.. autoclass:: phantom.interval.Exclusive
:members:
:undoc-members:
:show-inheritance:
.. autoclass:: phantom.interval.Inclusive
:members:
:undoc-members:
:show-inheritance:
.. autoclass:: phantom.interval.ExclusiveInclusive
:members:
:undoc-members:
:show-inheritance:
.. autoclass:: phantom.interval.InclusiveExclusive
:members:
:undoc-members:
:show-inheritance:
Concrete interval types
^^^^^^^^^^^^^^^^^^^^^^^
.. autoclass:: phantom.interval.Natural
:members:
:undoc-members:
:show-inheritance:
.. autoclass:: phantom.interval.NegativeInt
:members:
:undoc-members:
:show-inheritance:
.. autoclass:: phantom.interval.Portion
:members:
:undoc-members:
:show-inheritance:
Regular expressions
-------------------
.. automodule:: phantom.re
:members:
:undoc-members:
:show-inheritance:
Sized collections
-----------------
.. automodule:: phantom.sized
:members:
:undoc-members:
:show-inheritance:
================================================
FILE: docs-requirements.txt
================================================
# This file was autogenerated by uv via the following command:
# 'make docs-requirements'
accessible-pygments==0.0.5 \
--hash=sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872 \
--hash=sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7
# via furo
alabaster==1.0.0 \
--hash=sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e \
--hash=sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b
# via sphinx
babel==2.17.0 \
--hash=sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d \
--hash=sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2
# via sphinx
beartype==0.22.5 \
--hash=sha256:516a9096cc77103c96153474fa35c3ebcd9d36bd2ec8d0e3a43307ced0fa6341 \
--hash=sha256:d9743dd7cd6d193696eaa1e025f8a70fb09761c154675679ff236e61952dfba0
# via numerary
beautifulsoup4==4.14.2 \
--hash=sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e \
--hash=sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515
# via furo
certifi==2025.10.5 \
--hash=sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de \
--hash=sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43
# via requests
charset-normalizer==3.4.4 \
--hash=sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad \
--hash=sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93 \
--hash=sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394 \
--hash=sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89 \
--hash=sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc \
--hash=sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86 \
--hash=sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63 \
--hash=sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d \
--hash=sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f \
--hash=sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8 \
--hash=sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0 \
--hash=sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505 \
--hash=sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161 \
--hash=sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af \
--hash=sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152 \
--hash=sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318 \
--hash=sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72 \
--hash=sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4 \
--hash=sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e \
--hash=sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3 \
--hash=sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576 \
--hash=sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c \
--hash=sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1 \
--hash=sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8 \
--hash=sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1 \
--hash=sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2 \
--hash=sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44 \
--hash=sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26 \
--hash=sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88 \
--hash=sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016 \
--hash=sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede \
--hash=sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf \
--hash=sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a \
--hash=sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc \
--hash=sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0 \
--hash=sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84 \
--hash=sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db \
--hash=sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1 \
--hash=sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7 \
--hash=sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed \
--hash=sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8 \
--hash=sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133 \
--hash=sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e \
--hash=sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef \
--hash=sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14 \
--hash=sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2 \
--hash=sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0 \
--hash=sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d \
--hash=sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828 \
--hash=sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f \
--hash=sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf \
--hash=sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6 \
--hash=sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328 \
--hash=sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090 \
--hash=sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa \
--hash=sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381 \
--hash=sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c \
--hash=sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb \
--hash=sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc \
--hash=sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a \
--hash=sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec \
--hash=sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc \
--hash=sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac \
--hash=sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e \
--hash=sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313 \
--hash=sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569 \
--hash=sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3 \
--hash=sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d \
--hash=sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525 \
--hash=sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894 \
--hash=sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3 \
--hash=sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9 \
--hash=sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a \
--hash=sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9 \
--hash=sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14 \
--hash=sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25 \
--hash=sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50 \
--hash=sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf \
--hash=sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1 \
--hash=sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3 \
--hash=sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac \
--hash=sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e \
--hash=sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815 \
--hash=sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c \
--hash=sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6 \
--hash=sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6 \
--hash=sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e \
--hash=sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4 \
--hash=sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84 \
--hash=sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69 \
--hash=sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15 \
--hash=sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191 \
--hash=sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0 \
--hash=sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897 \
--hash=sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd \
--hash=sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2 \
--hash=sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 \
--hash=sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d \
--hash=sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074 \
--hash=sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3 \
--hash=sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224 \
--hash=sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838 \
--hash=sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a \
--hash=sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d \
--hash=sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d \
--hash=sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f \
--hash=sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8 \
--hash=sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490 \
--hash=sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966 \
--hash=sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9 \
--hash=sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3 \
--hash=sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e \
--hash=sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608
# via requests
docutils==0.21.2 \
--hash=sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f \
--hash=sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2
# via sphinx
furo==2025.9.25 \
--hash=sha256:2937f68e823b8e37b410c972c371bc2b1d88026709534927158e0cb3fac95afe \
--hash=sha256:3eac05582768fdbbc2bdfa1cdbcdd5d33cfc8b4bd2051729ff4e026a1d7e0a98
# via phantom-types (pyproject.toml)
hypothesis==6.147.0 \
--hash=sha256:72e6004ea3bd1460bdb4640b6389df23b87ba7a4851893fd84d1375635d3e507 \
--hash=sha256:de588807b6da33550d32f47bcd42b1a86d061df85673aa73e6443680249d185e
# via phantom-types (pyproject.toml)
idna==3.11 \
--hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \
--hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902
# via requests
imagesize==1.4.1 \
--hash=sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b \
--hash=sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a
# via sphinx
jinja2==3.1.6 \
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
# via sphinx
markupsafe==3.0.3 \
--hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \
--hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \
--hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \
--hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
--hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
--hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
--hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
--hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
--hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \
--hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \
--hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \
--hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
--hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \
--hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \
--hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
--hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
--hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \
--hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
--hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
--hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \
--hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \
--hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \
--hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \
--hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
--hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \
--hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
--hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
--hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
--hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
--hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
--hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \
--hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
--hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \
--hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \
--hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
--hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \
--hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
--hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
--hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
--hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \
--hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \
--hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
--hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
--hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \
--hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
--hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
--hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
--hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
--hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
--hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \
--hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
--hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \
--hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
--hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \
--hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \
--hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \
--hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \
--hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \
--hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \
--hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \
--hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
--hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
--hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
--hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \
--hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \
--hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
# via jinja2
numerary==0.4.4 \
--hash=sha256:ad955ddf7f5275f8e52f5520b2d6c654cc3bf1e3ae4bfb45664c9d51b208d0c6
# via phantom-types (pyproject.toml)
packaging==25.0 \
--hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
--hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
# via sphinx
phonenumbers==9.0.18 \
--hash=sha256:5537c61ba95b11b992c95e804da6e49193cc06b1224f632ade64631518a48ed1 \
--hash=sha256:d3354454ac31c97f8a08121df97a7145b8dca641f734c6f1518a41c2f60c5764
# via phantom-types (pyproject.toml)
pydantic==1.10.24 \
--hash=sha256:02f7a25e8949d8ca568e4bcef2ffed7881d7843286e7c3488bdd3b67f092059c \
--hash=sha256:076fff9da02ca716e4c8299c68512fdfbeac32fdefc9c160e6f80bdadca0993d \
--hash=sha256:093768eba26db55a88b12f3073017e3fdee319ef60d3aef5c6c04a4e484db193 \
--hash=sha256:0cbbf306124ae41cc153fdc2559b37faa1bec9a23ef7b082c1756d1315ceffe6 \
--hash=sha256:17e7610119483f03954569c18d4de16f4e92f1585f20975414033ac2d4a96624 \
--hash=sha256:1a1ae996daa3d43c530b8d0bacc7e2d9cb55e3991f0e6b7cc2cb61a0fb9f6667 \
--hash=sha256:25fb9a69a21d711deb5acefdab9ff8fb49e6cc77fdd46d38217d433bff2e3de2 \
--hash=sha256:265788a1120285c4955f8b3d52b3ea6a52c7a74db097c4c13a4d3567f0c6df3c \
--hash=sha256:2d1a5ef77efeb54def2695f2b8f4301aae8c7aa2b334bd15f61c18ef54317621 \
--hash=sha256:34109b0afa63b36eec2f2b115694e48ae5ee52f7d3c1baa0be36f80e586bda52 \
--hash=sha256:415c638ca5fd57b915a62dd38c18c8e0afe5adf5527be6f8ce16b4636b616816 \
--hash=sha256:49a6f0178063f15eaea6cbcb2dba04db0b73db9834bc7b1e1c4dbea28c7cd22f \
--hash=sha256:4a9e92b9c78d7f3cfa085c21c110e7000894446e24a836d006aabfc6ae3f1813 \
--hash=sha256:4d7336bfcdb8cb58411e6b498772ba2cff84a2ce92f389bae3a8f1bb2c840c49 \
--hash=sha256:50d9f8a207c07f347d4b34806dc576872000d9a60fd481ed9eb78ea8512e0666 \
--hash=sha256:52219b4e70c1db185cfd103a804e416384e1c8950168a2d4f385664c7c35d21a \
--hash=sha256:58d42a7c344882c00e3bb7c6c8c6f62db2e3aafa671f307271c45ad96e8ccf7a \
--hash=sha256:5a42033fac69b9f1f867ecc3a2159f0e94dceb1abfc509ad57e9e88d49774683 \
--hash=sha256:5ce0986799248082e9a5a026c9b5d2f9fa2e24d2afb9b0eace9104334a58fdc1 \
--hash=sha256:5da2775712dda8b89e701ed2a72d5d81d23dbc6af84089da8a0f61a0be439c8c \
--hash=sha256:5fc35569dfd15d3b3fc06a22abee0a45fdde0784be644e650a8769cd0b2abd94 \
--hash=sha256:6af36a8fb3072526b5b38d3f341b12d8f423188e7d185f130c0079fe02cdec7f \
--hash=sha256:6f25d2f792afcd874cc8339c1da1cc52739f4f3d52993ed1f6c263ef2afadc47 \
--hash=sha256:70152291488f8d2bbcf2027b5c28c27724c78a7949c91b466d28ad75d6d12702 \
--hash=sha256:75259be0558ca3af09192ad7b18557f2e9033ad4cbd48c252131f5292f6374fd \
--hash=sha256:7c8bbad6037a87effe9f3739bdf39851add6e0f7e101d103a601c504892ffa70 \
--hash=sha256:7e6d1af1bd3d2312079f28c9baf2aafb4a452a06b50717526e5ac562e37baa53 \
--hash=sha256:8057172868b0d98f95e6fcddcc5f75d01570e85c6308702dd2c50ea673bc197b \
--hash=sha256:82f951210ebcdb778b1d93075af43adcd04e9ebfd4f44b1baa8eeb21fbd71e36 \
--hash=sha256:874a78e4ed821258295a472e325eee7de3d91ba7a61d0639ce1b0367a3c63d4c \
--hash=sha256:8f2447ca88a7e14fd4d268857521fb37535c53a367b594fa2d7c2551af905993 \
--hash=sha256:956b30638272c51c85caaff76851b60db4b339022c0ee6eca677c41e3646255b \
--hash=sha256:9c377fc30d9ca40dbff5fd79c5a5e1f0d6fff040fa47a18851bb6b0bd040a5d8 \
--hash=sha256:a5bf94042efbc6ab56b18a5921f426ebbeefc04f554a911d76029e7be9057d01 \
--hash=sha256:af31565b12a7db5bfa5fe8c3a4f8fda4d32f5c2929998b1b241f1c22e9ab6e69 \
--hash=sha256:af8e2b3648128b8cadb1a71e2f8092a6f42d4ca123fad7a8d7ce6db8938b1db3 \
--hash=sha256:b644d6f14b2ce617d6def21622f9ba73961a16b7dffdba7f6692e2f66fa05d00 \
--hash=sha256:b66e4892d8ae005f436a5c5f1519ecf837574d8414b1c93860fb3c13943d9b37 \
--hash=sha256:bb3df10be3c7d264947180615819aeec0916f19650f2ba7309ed1fe546ead0d2 \
--hash=sha256:bed9d6eea5fabbc6978c42e947190c7bd628ddaff3b56fc963fe696c3710ccd6 \
--hash=sha256:c626596c1b95dc6d45f7129f10b6743fbb50f29d942d25a22b2ceead670c067d \
--hash=sha256:d255bebd927e5f1e026b32605684f7b6fc36a13e62b07cb97b29027b91657def \
--hash=sha256:d6e45dbc79a44e34c2c83ef1fcb56ff663040474dcf4dfc452db24a1de0f7574 \
--hash=sha256:e24435a9970dcb2b35648f2cf57505d4bd414fcca1a404c82e28d948183fe0a6 \
--hash=sha256:eef07ea2fba12f9188cfa2c50cb3eaa6516b56c33e2a8cc3cd288b4190ee6c0c \
--hash=sha256:ef14dfa7c98b314a3e449e92df6f1479cafe74c626952f353ff0176b075070de \
--hash=sha256:f154a8a46a0d950c055254f8f010ba07e742ac4404a3b6e281a31913ac45ccd0 \
--hash=sha256:fa0ebefc169439267e4b4147c7d458908788367640509ed32c90a91a63ebb579 \
--hash=sha256:fac7fbcb65171959973f3136d0792c3d1668bc01fd414738f0898b01f692f1b4 \
--hash=sha256:fc3f4a6544517380658b63b144c7d43d5276a343012913b7e5d18d9fba2f12bb
# via phantom-types (pyproject.toml)
pygments==2.19.2 \
--hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \
--hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b
# via
# accessible-pygments
# furo
# sphinx
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via phantom-types (pyproject.toml)
requests==2.32.5 \
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf
# via sphinx
roman-numerals-py==3.1.0 \
--hash=sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c \
--hash=sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d
# via sphinx
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil
snowballstemmer==3.0.1 \
--hash=sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064 \
--hash=sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895
# via sphinx
sortedcontainers==2.4.0 \
--hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \
--hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0
# via hypothesis
soupsieve==2.8 \
--hash=sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c \
--hash=sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f
# via beautifulsoup4
sphinx==8.2.3 \
--hash=sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348 \
--hash=sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3
# via
# phantom-types (pyproject.toml)
# furo
# sphinx-autodoc-typehints
# sphinx-basic-ng
sphinx-autodoc-typehints==3.5.2 \
--hash=sha256:0accd043619f53c86705958e323b419e41667917045ac9215d7be1b493648d8c \
--hash=sha256:5fcd4a3eb7aa89424c1e2e32bedca66edc38367569c9169a80f4b3e934171fdb
# via phantom-types (pyproject.toml)
sphinx-basic-ng==1.0.0b2 \
--hash=sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9 \
--hash=sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b
# via furo
sphinxcontrib-applehelp==2.0.0 \
--hash=sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1 \
--hash=sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5
# via sphinx
sphinxcontrib-devhelp==2.0.0 \
--hash=sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad \
--hash=sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2
# via sphinx
sphinxcontrib-htmlhelp==2.1.0 \
--hash=sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8 \
--hash=sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9
# via sphinx
sphinxcontrib-jsmath==1.0.1 \
--hash=sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178 \
--hash=sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8
# via sphinx
sphinxcontrib-qthelp==2.0.0 \
--hash=sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab \
--hash=sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb
# via sphinx
sphinxcontrib-serializinghtml==2.0.0 \
--hash=sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331 \
--hash=sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d
# via sphinx
typeguard==4.4.4 \
--hash=sha256:3a7fd2dffb705d4d0efaed4306a704c89b9dee850b688f060a8b1615a79e5f74 \
--hash=sha256:b5f562281b6bfa1f5492470464730ef001646128b180769880468bd84b68b09e
# via phantom-types (pyproject.toml)
typing-extensions==4.15.0 \
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
# via
# phantom-types (pyproject.toml)
# beautifulsoup4
# pydantic
# typeguard
urllib3==2.5.0 \
--hash=sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760 \
--hash=sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc
# via requests
================================================
FILE: goose.yaml
================================================
environments:
- ecosystem:
language: python
version: "3.13"
dependencies:
- ruff
- pre-commit-hooks
- editorconfig-checker
- blacken-docs
- check-jsonschema
- id: check-manifest
ecosystem:
language: python
version: "3.13"
dependencies:
- check-manifest
- setuptools==80.9.0
- setuptools-scm==8.3.1
- wheel==0.45.1
- ecosystem: node
dependencies:
- prettier
hooks:
- id: check-manifest
environment: check-manifest
command: check-manifest
parameterize: false
read_only: true
args: [--no-build-isolation]
- id: prettier
environment: node
command: prettier
types: [markdown]
args:
- --write
- --ignore-unknown
- --parser=markdown
- --print-width=88
- --prose-wrap=always
- id: check-case-conflict
environment: python
command: check-case-conflict
read_only: true
- id: check-merge-conflict
environment: python
command: check-merge-conflict
read_only: true
types: [text]
- id: python-debug-statements
environment: python
command: debug-statement-hook
read_only: true
types: [python]
- id: detect-private-key
environment: python
command: detect-private-key
read_only: true
types: [text]
- id: end-of-file-fixer
environment: python
command: end-of-file-fixer
types: [text]
- id: trailing-whitespace-fixer
environment: python
command: trailing-whitespace-fixer
types: [text]
- id: editorconfig-checker
environment: python
command: ec
args: [-disable-indent-size]
types: [text]
read_only: true
- id: ruff-check
environment: python
command: ruff
args: [check, --force-exclude, --fix]
types: [python]
- id: ruff-format
environment: python
command: ruff
args: [format, --force-exclude]
types: [python]
- id: blacken-docs
environment: python
command: blacken-docs
types: [markdown, python]
- id: check-github-workflows
environment: python
command: check-jsonschema
read_only: true
args: ["--builtin-schema", "vendor.github-workflows"]
types: [yaml]
limit:
- "^.github/workflows/"
================================================
FILE: mypy.ini
================================================
[mypy]
python_version = 3.10
pretty = True
files = src, tests
show_error_codes = True
show_error_context = True
show_error_code_links = True
strict = True
warn_unreachable = True
disallow_any_generics = False
[mypy-tests.*]
disallow_untyped_defs = False
disallow_untyped_calls = False
disallow_any_expr = False
disallow_untyped_decorators = False
disallow_incomplete_defs = False
================================================
FILE: pyproject.toml
================================================
[build-system]
requires = [
"setuptools==80.9.0",
"setuptools-scm==8.3.1",
"wheel==0.45.1",
]
build-backend = "setuptools.build_meta"
[tool.setuptools_scm]
version_file = "src/phantom/_version.py"
[tool.setuptools]
include-package-data = true
[tool.setuptools.dynamic]
readme = {file = "README.md", content-type = "text/markdown; charset=UTF-8"}
[tool.setuptools.packages.find]
where = ["src"]
namespaces = false
[project]
name = "phantom-types"
description = "Phantom types for Python"
requires-python = ">=3.10"
authors = [
{ name="Anton Agestam", email="git@antonagestam.se" },
]
license = {text = "BSD-3-Clause"}
classifiers = [
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Development Status :: 5 - Production/Stable",
]
dynamic = ["version", "readme"]
dependencies = [
# typeguard 4.3.0 breaks "intersection" protocols, see linked issue. I didn't figure
# out a way to work around this at the moment, so it needs to be pinned.
# https://github.com/antonagestam/phantom-types/issues/299
"typeguard>=4,!=4.3.*",
"typing_extensions>=4.3.0",
"numerary>=0.4.3",
]
[project.optional-dependencies]
phonenumbers = ["phonenumbers>=8.12.41"]
pydantic = ["pydantic>=1.9.0,<2"]
dateutil = ["python-dateutil>=2.8.2"]
hypothesis = ["hypothesis[zoneinfo]>=6.68.0"]
all = [
"phantom-types[phonenumbers]",
"phantom-types[pydantic]",
"phantom-types[dateutil]",
"phantom-types[hypothesis]",
]
test = [
"mypy>=1.16.1",
"pytest",
"pytest-mypy-plugins>=1.9.3",
"coverage",
]
type-check = [
"phantom-types[all]",
"mypy",
"pytest",
"types-python-dateutil",
]
docs = [
"phantom-types[all]",
"sphinx",
"sphinx-autodoc-typehints",
"furo",
]
[project.urls]
"Source Repository" = "https://github.com/antonagestam/phantom-types/"
"Documentation" = "https://phantom-types.readthedocs.io/en/stable/"
[tool.check-manifest]
ignore = ["src/phantom/_version.py"]
[tool.pip-tools]
generate-hashes = true
strip-extras = true
upgrade = true
unsafe-package = ["phantom-types"]
[tool.black]
target-version = ["py39"]
[tool.pytest.ini_options]
testpaths = ["tests", "src", "docs"]
addopts = "--mypy-ini-file=mypy.ini --mypy-only-local-stub --doctest-modules --import-mode=importlib"
markers = [
"external: mark tests that require extra dependencies",
"no_external: mark tests that will fail if run with extra dependencies",
]
================================================
FILE: ruff.toml
================================================
fix = true
target-version = "py310"
[lint]
extend-select = [
# bugbear
"B",
# comprehensions
"C4",
# mccabe
"C90",
# bandit
"S",
# blind exception
# Bare excepts are caught without this, but this also catches `except Exception: ...`.
"BLE",
# builtins
"A",
# Enforce valid noqa comments.
"RUF100",
# isort
"I",
# pycodestyle
"W",
# pyupgrade
"UP",
# debugger
"T10",
# print
"T20",
# quotes
"Q",
# return
# This gives 3 false positives, would be nice otherwise probably.
# "RET",
# simplify
"SIM",
# tidy imports
# We use this to only outlaw relative parent imports.
"TID",
]
extend-ignore = [
# There's no reason to outlaw asserts.
"S101",
# False positives.
"A005",
]
isort.force-single-line = true
isort.known-first-party = ["phantom", "tests"]
flake8-tidy-imports.ban-relative-imports = "parents"
mccabe.max-complexity = 10
================================================
FILE: setup.cfg
================================================
[coverage:run]
source = phantom
branch = True
[coverage:report]
skip_covered = True
show_missing = True
exclude_lines =
pragma: no cover
# ignore non-implementations
^\s*\.\.\.
^\s*if TYPE_CHECKING:
^\s*def [\w_]+\(.*?\) -> [\w_]+: ...$
================================================
FILE: src/phantom/__init__.py
================================================
"""
Use ``Phantom`` to create arbitrary phantom types using boolean predicates.
.. code-block:: python
from phantom import Phantom
def is_big(value: int) -> bool:
return value > 5
class Big(int, Phantom, predicate=is_big): ...
assert isinstance(10, Big) # this passes
"""
from ._base import Phantom
from ._base import PhantomBase
from ._base import PhantomMeta
from ._version import __version__
from ._version import __version_tuple__
from .bounds import get_bound_parser
from .errors import BoundError
from .predicates import Predicate
__all__ = (
"__version__",
"__version_tuple__",
"BoundError",
"Phantom",
"PhantomBase",
"PhantomMeta",
"get_bound_parser",
"Predicate",
)
================================================
FILE: src/phantom/_base.py
================================================
from __future__ import annotations
import abc
from collections.abc import Callable
from collections.abc import Iterable
from collections.abc import Iterator
from typing import Any
from typing import ClassVar
from typing import Generic
from typing import Protocol
from typing import TypeVar
from typing import runtime_checkable
from typing_extensions import Self
from . import _hypothesis
from ._utils.misc import BoundType
from ._utils.misc import UnresolvedClassAttribute
from ._utils.misc import fully_qualified_name
from ._utils.misc import is_not_known_mutable_type
from ._utils.misc import is_subtype
from ._utils.misc import resolve_class_attr
from .bounds import Parser
from .bounds import get_bound_parser
from .errors import BoundError
from .predicates import Predicate
from .schema import SchemaField
@runtime_checkable
class InstanceCheckable(Protocol):
@classmethod
@abc.abstractmethod
def __instancecheck__(cls, instance: object) -> bool: ...
class SupportsParse(Protocol):
@classmethod
@abc.abstractmethod
def parse(cls, instance: object) -> Self: ...
V = TypeVar("V", bound=SupportsParse)
class PhantomMeta(abc.ABCMeta):
"""
Metaclass that defers __instancecheck__ to derived classes and prevents actual
instance creation.
"""
def __instancecheck__(self, instance: object) -> bool:
if not issubclass(self, InstanceCheckable):
return False
return self.__instancecheck__(instance)
def __call__(cls: type[V], instance: object) -> V:
return cls.parse(instance)
T = TypeVar("T", covariant=True)
U = TypeVar("U")
Derived = TypeVar("Derived", bound="PhantomBase")
class PhantomBase(SchemaField, metaclass=PhantomMeta):
@classmethod
def parse(cls: type[Derived], instance: object) -> Derived:
"""
Parse an arbitrary value into a phantom type.
:raises TypeError:
"""
if not isinstance(instance, cls):
raise TypeError(
f"Could not parse {fully_qualified_name(cls)} from {instance!r}"
)
return instance
@classmethod
@abc.abstractmethod
def __instancecheck__(cls, instance: object) -> bool: ...
@classmethod
def __get_validators__(cls: type[Derived]) -> Iterator[Callable[[object], Derived]]:
"""Hook that makes phantom types compatible with pydantic."""
yield cls.parse
class AbstractInstanceCheck(TypeError): ...
class MutableType(TypeError): ...
class Phantom(PhantomBase, Generic[T]):
"""
Base class for predicate-based phantom types.
**Class arguments**
* ``predicate: Predicate[T] | None`` - Predicate function used for instance checks.
Can be ``None`` if the type is abstract.
* ``bound: type[T] | None`` - Bound used to check values before passing them to the
type's predicate function. This will often but not always be the same as the
runtime type that values of the phantom type are represented as. If this is not
provided as a class argument, it's attempted to be resolved in order from an
implicit bound (any bases of the type that come before ``Phantom``), or inherited
from super phantom types that provide a bound. Can be ``None`` if the type is
abstract.
* ``abstract: bool`` - Set to ``True`` to create an abstract phantom type. This
allows deferring definitions of ``predicate`` and ``bound`` to concrete subtypes.
"""
__predicate__: Predicate[T]
# The bound of a phantom type is the type that its values will have at
# runtime, so when checking if a value is an instance of a phantom type,
# it's first checked to be within its bounds, so that the value can be
# safely passed as argument to the predicate function.
#
# When subclassing, the bound of the new type must be a subtype of the bound
# of the super class.
__bound__: ClassVar[type]
__abstract__: ClassVar[bool]
def __init_subclass__(
cls,
predicate: Predicate[T] | None = None,
bound: type[T] | None = None,
abstract: bool = False,
**kwargs: Any,
) -> None:
super().__init_subclass__(**kwargs)
resolve_class_attr(cls, "__abstract__", abstract)
resolve_class_attr(cls, "__predicate__", predicate)
cls._resolve_bound(bound)
if _hypothesis.register_type_strategy is not None and not cls.__abstract__:
strategy = cls.__register_strategy__()
if strategy is not None:
_hypothesis.register_type_strategy(cls, strategy)
@classmethod
def _interpret_implicit_bound(cls) -> BoundType:
def discover_bounds() -> Iterable[type]:
for type_ in cls.__mro__:
if type_ is cls:
continue
if issubclass(type_, Phantom):
break
yield type_
else: # pragma: no cover
raise RuntimeError(f"{cls} is not a subclass of Phantom")
types = tuple(discover_bounds())
if len(types) == 1:
return types[0]
return types
@classmethod
def _resolve_bound(cls, class_arg: Any) -> None:
inherited = getattr(cls, "__bound__", None)
implicit = cls._interpret_implicit_bound()
if class_arg is not None:
bound = class_arg
elif implicit:
bound = implicit
elif inherited is not None:
bound = inherited
elif not getattr(cls, "__abstract__", False):
raise UnresolvedClassAttribute(
f"Concrete phantom type {cls.__qualname__} must define class attribute "
f"__bound__."
)
else:
return
if inherited is not None and not is_subtype(bound, inherited):
raise BoundError(
f"The bound of {cls.__qualname__} is not compatible with its "
f"inherited bounds."
)
if not is_not_known_mutable_type(bound):
raise MutableType(f"The bound of {cls.__qualname__} is mutable.")
cls.__bound__ = bound
@classmethod
def __instancecheck__(cls, instance: object) -> bool:
if cls.__abstract__:
raise AbstractInstanceCheck(
"Abstract phantom types cannot be used in instance checks"
)
bound_parser: Parser[T] = get_bound_parser(cls.__bound__)
try:
instance = bound_parser(instance)
except BoundError:
return False
return cls.__predicate__(instance)
@classmethod
def __register_strategy__(cls) -> _hypothesis.HypothesisStrategy | None:
return None
================================================
FILE: src/phantom/_hypothesis.py
================================================
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING
from typing import TypeAlias
from typing import TypeVar
if TYPE_CHECKING:
from hypothesis.strategies import SearchStrategy
else:
SearchStrategy = None
__all__ = ("HypothesisStrategy", "register_type_strategy", "SearchStrategy")
T = TypeVar("T")
HypothesisStrategy: TypeAlias = (
"SearchStrategy | Callable[[type[T]], SearchStrategy[T] | None]"
)
register_type_strategy: Callable[[type, HypothesisStrategy], None] | None
try:
from hypothesis.strategies import register_type_strategy # type: ignore[assignment]
except ImportError:
register_type_strategy = None
================================================
FILE: src/phantom/_utils/__init__.py
================================================
================================================
FILE: src/phantom/_utils/misc.py
================================================
# Workaround for something that looks like this bug
# https://github.com/pytest-dev/pytest/issues/4386
from __future__ import annotations
from collections.abc import MutableMapping
from collections.abc import MutableSequence
from collections.abc import MutableSet
from dataclasses import is_dataclass
from itertools import product
from types import UnionType
from typing import Final
from typing import NewType
from typing import TypeAlias
from typing import TypeGuard
from typing import Union
from typing import get_args
from typing import get_origin
class UnresolvedClassAttribute(NotImplementedError): ...
def resolve_class_attr(
cls: type,
name: str,
argument: object | None,
required: bool = True,
) -> None:
argument = getattr(cls, name, None) if argument is None else argument
if argument is not None:
setattr(cls, name, argument)
elif required and not getattr(cls, "__abstract__", False):
raise UnresolvedClassAttribute(
f"Concrete phantom type {cls.__qualname__} must define class attribute "
f"{name}."
)
BoundType: TypeAlias = type | tuple[type, ...]
def _is_union(type_: BoundType) -> bool:
return get_origin(type_) in (Union, UnionType)
def _is_intersection(type_: BoundType) -> bool:
return isinstance(type_, tuple)
def is_subtype(a: BoundType, b: BoundType) -> bool: # noqa: C901
"""
Return True if ``a`` is a subtype of ``b``. Supports single-level typing.Unions
and intersections represented as tuples respectively without nesting.
The cases that this function deals with can be divided into the following cases,
where T is neither a union or intersection:
1. Union, Union: Success if all types in a have a subclass in b.
2. T, Union: Success if a is a subclass of one or more types in b.
3. Union, T: Always fails (except when a single-type union, see 9).
4. T, Intersection: Success if a is a subclass of all types in b.
5. Intersection, T: Success if one type in a is a subclass of b.
6. Intersection, Union: Success if one type in a is a subclass of one type in b.
7. Union, Intersection: Always fails (except when a is a single-type union see 4).
8. Intersection, Intersection: Success if all items in b have a subclass in a.
9. T, T: Success if a is a subclass of b.
"""
if _is_union(a) and _is_union(b):
for a_part in get_args(a):
for b_part in get_args(b):
if issubclass(a_part, b_part):
break
else:
return False
return True
elif _is_intersection(a) and _is_union(b):
assert isinstance(a, tuple)
for a_part, b_part in product(a, get_args(b)):
if issubclass(a_part, b_part):
return True
return False
elif _is_intersection(a) and _is_intersection(b):
assert isinstance(a, tuple)
assert isinstance(b, tuple)
for b_part in b:
for a_part in a:
if issubclass(a_part, b_part):
break
else:
return False
return True
elif _is_union(a):
return False
elif _is_union(b):
assert isinstance(a, type)
return any(issubclass(a, b_part) for b_part in get_args(b))
elif _is_intersection(b):
assert isinstance(a, type)
assert isinstance(b, tuple)
return all(issubclass(a, b_part) for b_part in b)
elif _is_intersection(a):
assert isinstance(a, tuple)
return any(issubclass(a_part, b) for a_part in a)
assert isinstance(a, type)
return issubclass(a, b)
def fully_qualified_name(cls: type) -> str:
return f"{cls.__module__}.{cls.__qualname__}"
mutable: Final = (MutableSequence, MutableSet, MutableMapping)
NotKnownMutableType = NewType("NotKnownMutableType", type)
"""
Internal type to mark types that are not known to be mutable. The term immutable is
avoided here because there is no way to guarantee that a checked type isn't actually
mutable, so we don't want to communicate in strong terms here.
"""
def is_not_known_mutable_type(type_: BoundType) -> TypeGuard[NotKnownMutableType]:
return not (
any(is_subtype(type_, mutable_type) for mutable_type in mutable)
or (
is_dataclass(type_) and not type_.__dataclass_params__.frozen # type: ignore[attr-defined]
)
)
def is_not_known_mutable_instance(value: object) -> bool:
return not (
isinstance(value, mutable)
or (
is_dataclass(value)
# https://github.com/python/typeshed/pull/9947#issue-1641078469
and not value.__dataclass_params__.frozen # type: ignore[union-attr]
)
)
def is_union(value: object) -> TypeGuard[type]:
return get_origin(value) == Union or isinstance(value, UnionType)
================================================
FILE: src/phantom/_utils/types.py
================================================
from typing import Protocol
from typing import TypeVar
from typing import runtime_checkable
from numerary.protocol import CachingProtocolMeta
T_contra = TypeVar("T_contra", contravariant=True)
U_co = TypeVar("U_co", covariant=True)
@runtime_checkable
class _SupportsLt(Protocol[T_contra]):
def __lt__(self, other: T_contra) -> bool: ...
class SupportsLt(
_SupportsLt[T_contra],
Protocol[T_contra],
metaclass=CachingProtocolMeta,
): ...
@runtime_checkable
class _SupportsLe(Protocol[T_contra]):
def __le__(self, other: T_contra) -> bool: ...
class SupportsLe(
_SupportsLe[T_contra],
Protocol[T_contra],
metaclass=CachingProtocolMeta,
): ...
@runtime_checkable
class _SupportsGt(Protocol[T_contra]):
def __gt__(self, other: T_contra) -> bool: ...
class SupportsGt(
_SupportsGt[T_contra],
Protocol[T_contra],
metaclass=CachingProtocolMeta,
): ...
@runtime_checkable
class _SupportsGe(Protocol[T_contra]):
def __ge__(self, other: T_contra) -> bool: ...
class SupportsGe(
_SupportsGe[T_contra],
Protocol[T_contra],
metaclass=CachingProtocolMeta,
): ...
@runtime_checkable
class _SupportsEq(Protocol):
def __eq__(self, other: object) -> bool: ...
def __hash__(self) -> int: ...
class SupportsEq(
_SupportsEq,
Protocol,
metaclass=CachingProtocolMeta,
): ...
@runtime_checkable
class _Comparable(
SupportsLt[T_contra],
SupportsLe[T_contra],
SupportsGt[T_contra],
SupportsGe[T_contra],
SupportsEq,
Protocol[T_contra],
): ...
class Comparable(
_Comparable[T_contra],
Protocol[T_contra],
metaclass=CachingProtocolMeta,
): ...
@runtime_checkable
class _SupportsFloat(Protocol):
def __float__(self) -> float: ...
class SupportsFloat(_SupportsFloat, Protocol, metaclass=CachingProtocolMeta): ...
@runtime_checkable
class _FloatComparable(
SupportsFloat,
Comparable[T_contra],
Protocol[T_contra],
): ...
class FloatComparable(
_FloatComparable[T_contra],
Protocol[T_contra],
metaclass=CachingProtocolMeta,
): ...
@runtime_checkable
class _SupportsLeGe(SupportsLe[T_contra], SupportsGe[T_contra], Protocol[T_contra]): ...
class SupportsLeGe(
_SupportsLeGe[T_contra],
Protocol[T_contra],
metaclass=CachingProtocolMeta,
): ...
@runtime_checkable
class _SupportsLeGt(SupportsLe[T_contra], SupportsGt[T_contra], Protocol[T_contra]): ...
class SupportsLeGt(
_SupportsLeGt[T_contra],
Protocol[T_contra],
metaclass=CachingProtocolMeta,
): ...
@runtime_checkable
class _SupportsLtGe(SupportsLt[T_contra], SupportsGe[T_contra], Protocol[T_contra]): ...
class SupportsLtGe(
_SupportsLtGe[T_contra],
Protocol[T_contra],
metaclass=CachingProtocolMeta,
): ...
class _SupportsLtGt(SupportsLt[T_contra], SupportsGt[T_contra], Protocol[T_contra]): ...
class SupportsLtGt(
_SupportsLtGt[T_contra],
Protocol[T_contra],
metaclass=CachingProtocolMeta,
): ...
@runtime_checkable
class _SupportsMod(Protocol[T_contra, U_co]):
def __mod__(self, other: T_contra) -> U_co: ...
class SupportsMod(
_SupportsMod[T_contra, U_co],
Protocol[T_contra, U_co],
metaclass=CachingProtocolMeta,
): ...
================================================
FILE: src/phantom/boolean.py
================================================
"""
Types describing objects that coerce to either ``True`` or ``False`` respectively when
calling ``bool()`` on them.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from . import Phantom
from .predicates import boolean
if TYPE_CHECKING:
from hypothesis.strategies import SearchStrategy
class Truthy(Phantom[object], predicate=boolean.truthy, bound=object):
"""
>>> isinstance("Huzzah!", Truthy)
True
>>> isinstance((), Truthy)
False
"""
@classmethod
def __register_strategy__(cls) -> SearchStrategy:
from hypothesis.strategies import integers
from hypothesis.strategies import just
from hypothesis.strategies import lists
from hypothesis.strategies import text
return (
just(True)
| integers(min_value=1)
| integers(max_value=-1)
| lists(elements=integers(), min_size=1)
| text(min_size=1)
)
class Falsy(Phantom[object], predicate=boolean.falsy, bound=object):
"""
>>> isinstance((), Falsy)
True
>>> isinstance("Hej!", Falsy)
False
"""
@classmethod
def __register_strategy__(cls) -> SearchStrategy:
from hypothesis.strategies import just
return just(False) | just(0) | just(()) | just("") | just(b"")
================================================
FILE: src/phantom/bounds.py
================================================
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Iterable
from collections.abc import Sequence
from typing import Any
from typing import Final
from typing import TypeAlias
from typing import TypeVar
from typing import cast
from typing import get_args
from ._utils.misc import is_union
from .errors import BoundError
from .predicates.boolean import all_of
from .predicates.generic import of_complex_type
from .predicates.generic import of_type
__all__ = ("get_bound_parser", "parse_str", "Parser")
T = TypeVar("T", covariant=True)
Parser: TypeAlias = Callable[[object], T]
def display_bound(bound: Any) -> str:
if isinstance(bound, Iterable):
return f"Intersection[{', '.join(display_bound(part) for part in bound)}]"
if is_union(bound):
return (
f"typing.Union["
f"{', '.join(display_bound(part) for part in get_args(bound))}"
f"]"
)
return str(getattr(bound, "__name__", bound))
def get_bound_parser(bound: type[T] | Any) -> Parser[T]:
within_bound = (
# Interpret sequence as intersection
all_of(of_type(t) for t in bound)
if isinstance(bound, Sequence)
else of_complex_type(bound)
)
def parser(instance: object) -> T:
if not within_bound(instance):
raise BoundError(
f"Value is not within bound of {display_bound(bound)!r}: {instance!r}"
)
return cast(T, instance)
return parser
parse_str: Final[Callable[[object], str]] = get_bound_parser(str)
================================================
FILE: src/phantom/datetime.py
================================================
"""
Types for narrowing on the builtin datetime types.
These types can be used without installing any extra dependencies, however, to parse
strings, python-dateutil must be installed or a
:py:class:`phantom.errors.MissingDependency` error will be raised when calling parse.
You can install python-dateutil by using the ``[dateutil]`` or ``[all]`` extras.
"""
from __future__ import annotations
import datetime
from . import Phantom
from . import _hypothesis
from .bounds import parse_str
from .errors import MissingDependency
from .predicates.datetime import is_tz_aware
from .predicates.datetime import is_tz_naive
from .schema import Schema
try:
import dateutil.parser
parse_datetime_str = dateutil.parser.parse
DateutilParseError = dateutil.parser.ParserError
except ImportError as e:
exception = e
def parse_datetime_str(
*_: object,
**__: object,
) -> datetime.datetime:
raise MissingDependency(
"python-dateutil needs to be installed to use this type for parsing. It "
"can be installed with the phantom-types[dateutil] extra."
) from exception
class DateutilParseError(Exception): # type: ignore[no-redef]
...
__all__ = ("TZAware", "TZNaive")
def parse_datetime(value: object) -> datetime.datetime:
if isinstance(value, datetime.datetime):
return value
str_value = parse_str(value)
try:
return parse_datetime_str(str_value)
except DateutilParseError as exc:
raise TypeError("Could not parse datetime from given string") from exc
class TZAware(datetime.datetime, Phantom, predicate=is_tz_aware):
"""
A type for helping ensure that ``datetime`` objects are always timezone aware.
>>> isinstance(datetime.datetime.now(), TZAware)
False
>>> isinstance(datetime.datetime.now(tz=datetime.timezone.utc), TZAware)
True
"""
# A property of being aware is (dt.tzinfo != None), so we can safely narrow this
# attribute to not include None.
tzinfo: datetime.tzinfo
@classmethod
def parse(cls, instance: object) -> TZAware:
return super().parse(parse_datetime(instance))
@classmethod
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"description": "A date-time with timezone data.",
}
@classmethod
def __register_strategy__(cls) -> _hypothesis.SearchStrategy:
from hypothesis.strategies import datetimes
from hypothesis.strategies import timezones
return datetimes(timezones=timezones())
class TZNaive(datetime.datetime, Phantom, predicate=is_tz_naive):
"""
>>> isinstance(datetime.datetime.now(), TZNaive)
True
>>> isinstance(datetime.datetime.now(tz=datetime.timezone.utc), TZNaive)
False
"""
@classmethod
def parse(cls, instance: object) -> TZNaive:
return super().parse(parse_datetime(instance))
@classmethod
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"description": "A date-time without timezone data.",
"format": "date-time-naive",
}
@classmethod
def __register_strategy__(cls) -> _hypothesis.SearchStrategy:
from hypothesis.strategies import datetimes
from hypothesis.strategies import none
return datetimes(timezones=none())
================================================
FILE: src/phantom/errors.py
================================================
class BoundError(TypeError): ...
class MissingDependency(Exception): ...
================================================
FILE: src/phantom/ext/__init__.py
================================================
================================================
FILE: src/phantom/ext/phonenumbers.py
================================================
"""
Requires the phonenumbers_ package which can be installed with:
.. _phonenumbers: https://pypi.org/project/phonenumbers/
.. code-block:: bash
$ python3 -m pip install phantom-types[phonenumbers]
"""
from __future__ import annotations
from typing import Final
from typing import TypeGuard
from typing import cast
import phonenumbers
from phantom import Phantom
from phantom.bounds import parse_str
from phantom.fn import excepts
from phantom.schema import Schema
__all__ = (
"InvalidPhoneNumber",
"normalize_phone_number",
"is_phone_number",
"is_formatted_phone_number",
"PhoneNumber",
"FormattedPhoneNumber",
)
class InvalidPhoneNumber(phonenumbers.NumberParseException, TypeError):
INVALID: Final = 99
def __init__(self, error_type: int = INVALID, msg: str = "Invalid number") -> None:
super().__init__(error_type, msg)
def _deconstruct_phone_number(
phone_number: str, country_code: str | None = None
) -> phonenumbers.PhoneNumber:
try:
parsed_number = phonenumbers.parse(phone_number, region=country_code)
except phonenumbers.NumberParseException as e:
raise InvalidPhoneNumber(e.error_type, e._msg) from e
if not phonenumbers.is_valid_number(parsed_number):
raise InvalidPhoneNumber
return parsed_number
def normalize_phone_number(
phone_number: str,
country_code: str | None = None,
) -> FormattedPhoneNumber:
"""
Normalize ``phone_number`` using :py:const:`phonenumbers.PhoneNumberFormat.E164`.
:raises InvalidPhoneNumber:
"""
normalized = phonenumbers.format_number(
_deconstruct_phone_number(phone_number, country_code),
phonenumbers.PhoneNumberFormat.E164,
)
return cast(FormattedPhoneNumber, normalized)
is_phone_number = excepts(InvalidPhoneNumber)(_deconstruct_phone_number)
def is_formatted_phone_number(number: str) -> TypeGuard[FormattedPhoneNumber]:
try:
return number == normalize_phone_number(number)
except InvalidPhoneNumber:
return False
class PhoneNumber(str, Phantom, predicate=is_phone_number):
@classmethod
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"description": "A valid E.164 phone number.",
"type": "string",
"format": "E.164",
}
class FormattedPhoneNumber(PhoneNumber, predicate=is_formatted_phone_number):
@classmethod
def parse(cls, instance: object) -> FormattedPhoneNumber:
"""
Normalize number using :py:const:`phonenumbers.PhoneNumberFormat.E164`.
:raises InvalidPhoneNumber:
"""
return normalize_phone_number(parse_str(instance))
@classmethod
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"title": "PhoneNumber",
}
================================================
FILE: src/phantom/fn.py
================================================
from __future__ import annotations
import functools
from collections.abc import Callable
from functools import partial
from typing import Any
from typing import TypeVar
def _name(fn: Callable) -> str:
if isinstance(fn, partial):
fn = fn.func
try:
return fn.__qualname__
except AttributeError:
return str(fn)
AA = TypeVar("AA")
AR = TypeVar("AR")
BA = TypeVar("BA")
def compose2(a: Callable[[AA], AR], b: Callable[[BA], AA]) -> Callable[[BA], AR]:
"""
Returns a function composed from the two given functions ``a`` and ``b`` such that
calling ``compose2(a, b)(x)`` is equivalent to calling ``a(b(x))``.
>>> compose2("".join, reversed)("!olleH")
'Hello!'
"""
a_name = _name(a)
b_name = _name(b)
def c(arg: BA) -> AR:
return a(b(arg))
c.__name__ = f"{a_name}∘{b_name}"
c.__doc__ = f"Function composed as {a_name}({b_name}(_))."
return c
A = TypeVar("A")
def excepts(
exception: tuple[type[Exception], ...] | type[Exception],
negate: bool = False,
) -> Callable[[Callable[[A], Any]], Callable[[A], bool]]:
"""
Turn a unary function that raises an exception into a boolean predicate.
>>> def validate_positive(number: int) -> None:
... if number < 0: raise ValueError
>>> is_positive = excepts(ValueError)(validate_positive)
>>> is_positive(0), is_positive(-1)
(True, False)
"""
def decorator(fn: Callable[[A], Any]) -> Callable[[A], bool]:
@functools.wraps(fn)
def wrapper(arg: A) -> bool:
try:
fn(arg)
except exception:
return negate
return not negate
return wrapper
return decorator
================================================
FILE: src/phantom/interval.py
================================================
"""
Types for describing narrower sets of numbers than builtin numeric types like ``int``
and ``float``. Use the provided base classes to build custom intervals. For example, to
represent number in the closed range ``[0, 100]`` for a volume control you would define
a type like this:
.. code-block:: python
class VolumeLevel(int, Inclusive, low=0, high=100): ...
There is also a set of concrete ready-to-use interval types provided, that use predicate
functions from :py:mod:`phantom.predicates.interval`.
.. code-block:: python
def take_portion(portion: Portion, whole: Natural) -> float:
return portion * whole
All interval types fully support pydantic and appropriately adds inclusive or exclusive
minimums and maximums to their schema representations.
"""
from __future__ import annotations
from contextlib import suppress
from typing import Any
from typing import Final
from typing import Protocol
from typing import TypeVar
from . import Phantom
from . import Predicate
from . import _hypothesis
from ._utils.misc import resolve_class_attr
from ._utils.types import Comparable
from ._utils.types import FloatComparable
from ._utils.types import SupportsEq
from .predicates import interval
from .schema import Schema
N = TypeVar("N", bound=Comparable)
Derived = TypeVar("Derived", bound="Interval")
class IntervalCheck(Protocol):
def __call__(self, a: N, b: N) -> Predicate[N]: ...
inf: Final = float("inf")
neg_inf: Final = float("-inf")
class _NonScalarBounds(Exception): ...
def _get_scalar_int_bounds(
type_: type[Interval],
exclude_min: bool = False,
exclude_max: bool = False,
) -> tuple[int | None, int | None]:
low = type_.__low__ if type_.__low__ != neg_inf else None
high = type_.__high__ if type_.__high__ != inf else None
if low is not None:
try:
scalar_low = int(low) # type: ignore[call-overload]
except TypeError as exception:
raise _NonScalarBounds from exception
if exclude_min:
scalar_low += 1
else:
scalar_low = None
if high is not None:
try:
scalar_high = int(high) # type: ignore[call-overload]
except TypeError as exception:
raise _NonScalarBounds from exception
if exclude_max:
scalar_high -= 1
else:
scalar_high = None
return scalar_low, scalar_high
def _get_scalar_float_bounds(
type_: type[Interval],
) -> tuple[float | None, float | None]:
low = type_.__low__ if type_.__low__ != neg_inf else None
high = type_.__high__ if type_.__high__ != inf else None
if low is not None:
try:
low = float(low)
except TypeError as excpetion:
raise _NonScalarBounds from excpetion
if high is not None:
try:
high = float(high)
except TypeError as exception:
raise _NonScalarBounds from exception
return low, high
def _resolve_bound(
cls: type,
name: str,
argument: Comparable | None,
default: Comparable,
) -> None:
inherited = getattr(cls, name, None)
if argument is not None:
resolved = argument
elif inherited is not None:
resolved = inherited
else:
resolved = default
setattr(cls, name, resolved)
class Interval(Phantom[Comparable], bound=Comparable, abstract=True):
"""
Base class for all interval types, providing the following class arguments:
* ``check: IntervalCheck``
* ``low: Comparable`` (defaults to negative infinity)
* ``high: Comparable`` (defaults to positive infinity)
Concrete subclasses must specify their runtime type bound as their first base.
"""
__check__: IntervalCheck
__low__: FloatComparable
__high__: FloatComparable
def __init_subclass__(
cls,
check: IntervalCheck | None = None,
low: FloatComparable | None = None,
high: FloatComparable | None = None,
**kwargs: Any,
) -> None:
_resolve_bound(cls, "__low__", low, neg_inf)
_resolve_bound(cls, "__high__", high, inf)
resolve_class_attr(cls, "__check__", check)
if getattr(cls, "__check__", None) is None:
raise TypeError(f"{cls.__qualname__} must define an interval check")
super().__init_subclass__(
predicate=cls.__check__(cls.__low__, cls.__high__),
**kwargs,
)
@classmethod
def parse(cls: type[Derived], instance: object) -> Derived:
return super().parse(
cls.__bound__(instance) if isinstance(instance, str) else instance
)
def _format_limit(value: SupportsEq) -> str:
if value == inf:
return "∞"
if value == neg_inf:
return "-∞"
return str(value)
class Exclusive(Interval, check=interval.exclusive, abstract=True):
"""Uses :py:func:`phantom.predicates.interval.exclusive` as ``check``."""
@classmethod
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"description": (
f"A value in the exclusive range ({_format_limit(cls.__low__)}, "
f"{_format_limit(cls.__high__)})."
),
"exclusiveMinimum": float(cls.__low__) if cls.__low__ != neg_inf else None,
"exclusiveMaximum": float(cls.__high__) if cls.__high__ != inf else None,
}
@classmethod
def __register_strategy__(cls) -> _hypothesis.SearchStrategy | None:
from hypothesis.strategies import floats
from hypothesis.strategies import integers
with suppress(_NonScalarBounds): # pragma: no cover
if issubclass(cls.__bound__, int):
return integers(
*_get_scalar_int_bounds(cls, exclude_min=True, exclude_max=True)
)
if issubclass(cls.__bound__, float):
return floats(
*_get_scalar_float_bounds(cls), exclude_min=True, exclude_max=True
)
return None
class Inclusive(Interval, check=interval.inclusive, abstract=True):
"""Uses :py:func:`phantom.predicates.interval.inclusive` as ``check``."""
@classmethod
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"description": (
f"A value in the inclusive range [{_format_limit(cls.__low__)}, "
f"{_format_limit(cls.__high__)}]."
),
"minimum": float(cls.__low__) if cls.__low__ != neg_inf else None,
"maximum": float(cls.__high__) if cls.__high__ != inf else None,
}
@classmethod
def __register_strategy__(cls) -> _hypothesis.SearchStrategy | None:
from hypothesis.strategies import floats
from hypothesis.strategies import integers
with suppress(_NonScalarBounds): # pragma: no cover
if issubclass(cls.__bound__, int):
return integers(*_get_scalar_int_bounds(cls))
if issubclass(cls.__bound__, float):
return floats(*_get_scalar_float_bounds(cls))
return None
class ExclusiveInclusive(Interval, check=interval.exclusive_inclusive, abstract=True):
"""Uses :py:func:`phantom.predicates.interval.exclusive_inclusive` as ``check``."""
@classmethod
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"description": (
f"A value in the half-open range ({_format_limit(cls.__low__)}, "
f"{_format_limit(cls.__high__)}]."
),
"exclusiveMinimum": float(cls.__low__) if cls.__low__ != neg_inf else None,
"maximum": float(cls.__high__) if cls.__high__ != inf else None,
}
@classmethod
def __register_strategy__(cls) -> _hypothesis.SearchStrategy | None:
from hypothesis.strategies import floats
from hypothesis.strategies import integers
with suppress(_NonScalarBounds): # pragma: no cover
if issubclass(cls.__bound__, int):
return integers(*_get_scalar_int_bounds(cls, exclude_min=True))
if issubclass(cls.__bound__, float):
return floats(*_get_scalar_float_bounds(cls), exclude_min=True)
return None
class InclusiveExclusive(Interval, check=interval.inclusive_exclusive, abstract=True):
"""Uses :py:func:`phantom.predicates.interval.inclusive_exclusive` as ``check``."""
@classmethod
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"description": (
f"A value in the half-open range [{_format_limit(cls.__low__)}, "
f"{_format_limit(cls.__high__)})."
),
"minimum": float(cls.__low__) if cls.__low__ != neg_inf else None,
"exclusiveMaximum": float(cls.__high__) if cls.__high__ != inf else None,
}
@classmethod
def __register_strategy__(cls) -> _hypothesis.SearchStrategy | None:
from hypothesis.strategies import floats
from hypothesis.strategies import integers
with suppress(_NonScalarBounds): # pragma: no cover
if issubclass(cls.__bound__, int):
return integers(*_get_scalar_int_bounds(cls, exclude_max=True))
if issubclass(cls.__bound__, float):
return floats(*_get_scalar_float_bounds(cls), exclude_max=True)
return None
class Natural(int, InclusiveExclusive, low=0):
"""Represents integer values in the inclusive range ``[0, ∞)``."""
@classmethod
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"description": "An integer value in the inclusive range [0, ∞).",
}
class NegativeInt(int, ExclusiveInclusive, high=0):
"""Represents integer values in the inclusive range ``(-∞, 0]``."""
@classmethod
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"description": "An integer value in the inclusive range (-∞, 0].",
}
class Portion(float, Inclusive, low=0, high=1):
"""Represents float values in the inclusive range ``[0, 1]``."""
@classmethod
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"description": "A float value in the inclusive range [0, 1].",
}
================================================
FILE: src/phantom/iso3166.py
================================================
"""
Exposes a :py:class:`CountryCode` type that is a union of a :py:class:`Literal`
containing all ISO3166 alpha-2 codes, and a phantom type that parses alpha-2 codes at
runtime. This allows mixing statically known values with runtime-parsed values, like
so:
.. code-block:: python
countries: tuple[CountryCode] = ("SE", "DK", ParsedAlpha2.parse("FR"))
"""
from __future__ import annotations
from typing import Final
from typing import Literal
from typing import TypeAlias
from typing import cast
from typing import get_args
from phantom import Phantom
from phantom import _hypothesis
from phantom.bounds import parse_str
from phantom.predicates.collection import contained
from phantom.schema import Schema
__all__ = (
"LiteralAlpha2",
"ParsedAlpha2",
"Alpha2",
"CountryCode",
"is_alpha2_country_code",
"normalize_alpha2_country_code",
"InvalidCountryCode",
)
LiteralAlpha2: TypeAlias = Literal[
"AF",
"AX",
"AL",
"DZ",
"AS",
"AD",
"AO",
"AI",
"AQ",
"AG",
"AR",
"AM",
"AW",
"AU",
"AT",
"AZ",
"BS",
"BH",
"BD",
"BB",
"BY",
"BE",
"BZ",
"BJ",
"BM",
"BT",
"BO",
"BQ",
"BA",
"BW",
"BV",
"BR",
"IO",
"BN",
"BG",
"BF",
"BI",
"KH",
"CM",
"CA",
"CV",
"KY",
"CF",
"TD",
"CL",
"CN",
"CX",
"CC",
"CO",
"KM",
"CG",
"CD",
"CK",
"CR",
"CI",
"HR",
"CU",
"CW",
"CY",
"CZ",
"DK",
"DJ",
"DM",
"DO",
"EC",
"EG",
"SV",
"GQ",
"ER",
"EE",
"ET",
"FK",
"FO",
"FJ",
"FI",
"FR",
"GF",
"PF",
"TF",
"GA",
"GM",
"GE",
"DE",
"GH",
"GI",
"GR",
"GL",
"GD",
"GP",
"GU",
"GT",
"GG",
"GN",
"GW",
"GY",
"HT",
"HM",
"VA",
"HN",
"HK",
"HU",
"IS",
"IN",
"ID",
"IR",
"IQ",
"IE",
"IM",
"IL",
"IT",
"JM",
"JP",
"JE",
"JO",
"KZ",
"KE",
"KI",
"KP",
"KR",
"XK",
"KW",
"KG",
"LA",
"LV",
"LB",
"LS",
"LR",
"LY",
"LI",
"LT",
"LU",
"MO",
"MK",
"MG",
"MW",
"MY",
"MV",
"ML",
"MT",
"MH",
"MQ",
"MR",
"MU",
"YT",
"MX",
"FM",
"MD",
"MC",
"MN",
"ME",
"MS",
"MA",
"MZ",
"MM",
"NA",
"NR",
"NP",
"NL",
"NC",
"NZ",
"NI",
"NE",
"NG",
"NU",
"NF",
"MP",
"NO",
"OM",
"PK",
"PW",
"PS",
"PA",
"PG",
"PY",
"PE",
"PH",
"PN",
"PL",
"PT",
"PR",
"QA",
"RE",
"RO",
"RU",
"RW",
"BL",
"SH",
"KN",
"LC",
"MF",
"PM",
"VC",
"WS",
"SM",
"ST",
"SA",
"SN",
"RS",
"SC",
"SL",
"SG",
"SX",
"SK",
"SI",
"SB",
"SO",
"ZA",
"GS",
"SS",
"ES",
"LK",
"SD",
"SR",
"SJ",
"SZ",
"SE",
"CH",
"SY",
"TW",
"TJ",
"TZ",
"TH",
"TL",
"TG",
"TK",
"TO",
"TT",
"TN",
"TR",
"TM",
"TC",
"TV",
"UG",
"UA",
"AE",
"GB",
"US",
"UM",
"UY",
"UZ",
"VU",
"VE",
"VN",
"VG",
"VI",
"WF",
"EH",
"YE",
"ZM",
"ZW",
]
"""Literal of all ISO3166 alpha-2 codes. """
ALPHA2: Final = frozenset(get_args(LiteralAlpha2))
is_alpha2_country_code = contained(ALPHA2)
class InvalidCountryCode(TypeError): ...
def normalize_alpha2_country_code(country_code: str) -> ParsedAlpha2:
"""
Normalize mixed case country code.
:raises InvalidCountryCode:
"""
normalized = country_code.upper()
if not is_alpha2_country_code(normalized):
raise InvalidCountryCode
return cast(ParsedAlpha2, normalized)
class ParsedAlpha2(str, Phantom, predicate=is_alpha2_country_code):
@classmethod
def parse(cls, instance: object) -> ParsedAlpha2:
"""
Normalize mixed case country code.
:raises InvalidCountryCode:
"""
return normalize_alpha2_country_code(parse_str(instance))
@classmethod
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"description": "ISO3166-1 alpha-2 country code",
"examples": ["NR", "KZ", "ET", "VC", "AE", "NZ", "SX", "XK", "AX"],
"format": "iso3166-1 alpha-2",
"title": "Alpha2",
}
@classmethod
def __register_strategy__(cls) -> _hypothesis.SearchStrategy | None:
from hypothesis.strategies import sampled_from
return sampled_from(sorted(ALPHA2))
Alpha2: TypeAlias = LiteralAlpha2 | ParsedAlpha2
CountryCode: TypeAlias = Alpha2
================================================
FILE: src/phantom/negated.py
================================================
"""
This module provides a single type: :py:class:`SequenceNotStr`. This type is equivalent
to :py:class:`typing.Sequence` except it excludes values of type :py:class:`str` and
:py:class:`bytes` from the set of valid instances. This can be useful when you want to
eliminate the easy mistake of forgetting to wrap a string value in a containing
sequence.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Generic
from typing import TypeVar
from typing import get_args
from . import Phantom
from . import _hypothesis
from .predicates import boolean
from .predicates.generic import of_type
__all__ = ("SequenceNotStr",)
T = TypeVar("T")
class SequenceNotStr(
Sequence[T],
Phantom,
Generic[T],
# Note: We don't eliminate mutable types here like in PhantomSized. This is because
# the property of not being a str cannot change by mutation, so this specific
# phantom type is safe to use with mutable types.
predicate=boolean.negate(of_type((str, bytes))),
):
@classmethod
def __register_strategy__(cls) -> _hypothesis.HypothesisStrategy:
from hypothesis.strategies import from_type
from hypothesis.strategies import tuples
def create_strategy(
type_: type[T],
) -> _hypothesis.SearchStrategy[tuple[T, ...]] | None:
(inner_type,) = get_args(type_)
return tuples(from_type(inner_type))
return create_strategy
================================================
FILE: src/phantom/predicates/__init__.py
================================================
from ._base import Predicate
__all__ = ("Predicate",)
================================================
FILE: src/phantom/predicates/_base.py
================================================
from collections.abc import Callable
from typing import TypeAlias
from typing import TypeVar
T_contra = TypeVar("T_contra", bound=object, contravariant=True)
Predicate: TypeAlias = Callable[[T_contra], bool]
================================================
FILE: src/phantom/predicates/_utils.py
================================================
from collections.abc import Callable
from functools import partial
from typing import TypeVar
def _explode_partial(obj: partial) -> str:
positional_args = ", ".join(map(repr, obj.args))
keyword_args = ", ".join(
f"{name}={value!r}" for name, value in obj.keywords.items()
)
args = ", ".join((positional_args, keyword_args))
return f"{obj.func.__qualname__}({args})"
def _name_or_repr(obj: object) -> str:
if isinstance(obj, partial):
return _explode_partial(obj)
try:
return str(obj.__qualname__) # type: ignore[attr-defined]
except AttributeError:
return repr(obj)
B = TypeVar("B", bound=Callable)
def bind_name(wrapped: Callable, *values: object) -> Callable[[B], B]:
name = (
f"{wrapped.__qualname__}({', '.join(_name_or_repr(value) for value in values)})"
)
def decorator(inner: B) -> B:
inner.__qualname__ = inner.__name__ = name
return inner
return decorator
================================================
FILE: src/phantom/predicates/boolean.py
================================================
from collections.abc import Iterable
from typing import Literal
from typing import TypeVar
from . import Predicate
from ._utils import bind_name
T_contra = TypeVar("T_contra", bound=object, contravariant=True)
def true(_value: object) -> Literal[True]:
"""Always return :py:const:`True`."""
return True
def false(_value: object) -> Literal[False]:
"""Always return :py:const:`False`."""
return False
def negate(predicate: Predicate[T_contra]) -> Predicate[T_contra]:
"""Negate a given predicate."""
@bind_name(negate, predicate)
def check(value: T_contra) -> bool:
return not predicate(value)
return check
def truthy(value: object) -> bool:
"""Return :py:const:`True` for truthy objects."""
return bool(value)
def falsy(value: object) -> bool:
"""Return :py:const:`True` for falsy objects."""
return negate(truthy)(value)
def both(p: Predicate[T_contra], q: Predicate[T_contra]) -> Predicate[T_contra]:
"""
Create a new predicate that succeeds when both of the given predicates succeed.
"""
@bind_name(both, p, q)
def check(value: T_contra) -> bool:
return p(value) and q(value)
return check
def either(p: Predicate[T_contra], q: Predicate[T_contra]) -> Predicate[T_contra]:
"""
Create a new predicate that succeeds when at least one of the given predicates
succeed.
"""
@bind_name(either, p, q)
def check(value: T_contra) -> bool:
return p(value) or q(value)
return check
def xor(p: Predicate[T_contra], q: Predicate[T_contra]) -> Predicate[T_contra]:
"""
Create a new predicate that succeeds when one of the given predicates succeed, but
not both.
"""
@bind_name(xor, p, q)
def check(value: T_contra) -> bool:
return p(value) ^ q(value)
return check
def all_of(predicates: Iterable[Predicate[T_contra]]) -> Predicate[T_contra]:
"""Create a new predicate that succeeds when all of the given predicates succeed."""
predicates = tuple(predicates)
@bind_name(all_of, *predicates)
def check(value: T_contra) -> bool:
return all(p(value) for p in predicates)
return check
def any_of(predicates: Iterable[Predicate[T_contra]]) -> Predicate[T_contra]:
"""
Create a new predicate that succeeds when at least one of the given predicates
succeed.
"""
predicates = tuple(predicates)
@bind_name(any_of, *predicates)
def check(value: T_contra) -> bool:
return any(p(value) for p in predicates)
return check
def one_of(predicates: Iterable[Predicate[T_contra]]) -> Predicate[T_contra]:
"""
Create a new predicate that succeeds when exactly one of the given predicates
succeed.
"""
predicates = tuple(predicates)
@bind_name(one_of, *predicates)
def check(value: T_contra) -> bool:
return sum(p(value) for p in predicates) == 1
return check
================================================
FILE: src/phantom/predicates/collection.py
================================================
from collections.abc import Container
from collections.abc import Iterable
from collections.abc import Sized
from typing import TypeVar
from . import Predicate
from ._utils import bind_name
def contains(value: object) -> Predicate[Container]:
"""Create a new predicate that succeeds when its argument contains ``value``."""
@bind_name(contains, value)
def compare(container: Container) -> bool:
return value in container
return compare
def contained(container: Container) -> Predicate[object]:
"""
Create a new predicate that succeeds when its argument is contained by
``container``.
"""
@bind_name(contained, container)
def compare(value: object) -> bool:
return value in container
return compare
def count(predicate: Predicate[int]) -> Predicate[Sized]:
"""
Create a predicate that succeeds when the size of its argument satisfies the given
``predicate``.
"""
@bind_name(count, predicate)
def compare(sized: Sized) -> bool:
return predicate(len(sized))
return compare
_O = TypeVar("_O", bound=object)
def exists(predicate: Predicate[_O]) -> Predicate[Iterable]:
"""
Create a predicate that succeeds when one or more items in its argument satisfies
``predicate``.
"""
@bind_name(exists, predicate)
def compare(iterable: Iterable) -> bool:
return any(predicate(item) for item in iterable)
return compare
def every(predicate: Predicate[_O]) -> Predicate[Iterable]:
"""
Create a predicate that succeeds when all items in its argument satisfy
``predicate``.
"""
@bind_name(every, predicate)
def compare(iterable: Iterable) -> bool:
return all(predicate(item) for item in iterable)
return compare
================================================
FILE: src/phantom/predicates/datetime.py
================================================
import datetime
from .boolean import negate
def is_tz_aware(dt: datetime.datetime) -> bool:
"""Return :py:const:`True` if ``dt`` is timezone aware."""
# https://docs.python.org/3/library/datetime.html#determining-if-an-object-is-aware-or-naive
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
def is_tz_naive(dt: datetime.datetime) -> bool:
"""Return :py:const:`True` if ``dt`` is timezone naive."""
return negate(is_tz_aware)(dt)
================================================
FILE: src/phantom/predicates/generic.py
================================================
import typeguard
from typeguard import CollectionCheckStrategy
from typeguard import ForwardRefPolicy
from . import Predicate
from ._utils import bind_name
def equal(a: object) -> Predicate[object]:
"""Create a new predicate that succeeds when its argument is equal to ``a``."""
@bind_name(equal, a)
def check(b: object) -> bool:
return a == b
return check
def identical(a: object) -> Predicate[object]:
"""Create a new predicate that succeeds when its argument is identical to ``a``."""
@bind_name(identical, a)
def check(b: object) -> bool:
return a is b
return check
def of_type(t: type | tuple[type, ...]) -> Predicate[object]:
"""
Create a new predicate that succeeds when its argument is an instance of ``t``.
"""
@bind_name(of_type, t)
def check(a: object) -> bool:
return isinstance(a, t)
return check
def of_complex_type(t: type) -> Predicate[object]:
@bind_name(of_complex_type, t)
def check(a: object) -> bool:
try:
typeguard.check_type(
value=a,
expected_type=t,
typecheck_fail_callback=None,
forward_ref_policy=ForwardRefPolicy.ERROR,
collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS,
)
except typeguard.TypeCheckError:
return False
return True
return check
================================================
FILE: src/phantom/predicates/interval.py
================================================
"""
Functions that create new predicates that succeed when their argument is strictly or non
strictly between the upper and lower bounds. There are corresponding phantom types that
use these predicates in :py:mod:`phantom.interval`.
"""
from typing import TypeVar
from phantom._utils.types import SupportsLeGe
from phantom._utils.types import SupportsLeGt
from phantom._utils.types import SupportsLtGe
from phantom._utils.types import SupportsLtGt
from ._base import Predicate
from ._utils import bind_name
T = TypeVar("T")
def exclusive(low: T, high: T) -> Predicate[SupportsLtGt[T]]:
"""
Create a predicate that succeeds when its argument is in the range ``(low, high)``.
"""
@bind_name(exclusive, low, high)
def check(value: SupportsLtGt[T]) -> bool:
return low < value < high
return check
def exclusive_inclusive(low: T, high: T) -> Predicate[SupportsLeGt[T]]:
"""
Create a predicate that succeeds when its argument is in the range ``(low, high]``.
"""
@bind_name(exclusive_inclusive, low, high)
def check(value: SupportsLeGt[T]) -> bool:
return low < value <= high
return check
def inclusive_exclusive(low: T, high: T) -> Predicate[SupportsLtGe[T]]:
"""
Create a predicate that succeeds when its argument is in the range ``[low, high)``.
"""
@bind_name(inclusive_exclusive, low, high)
def check(value: SupportsLtGe[T]) -> bool:
return low <= value < high
return check
def inclusive(low: T, high: T) -> Predicate[SupportsLeGe[T]]:
"""
Create a predicate that succeeds when its argument is in the range ``[low, high]``.
"""
@bind_name(inclusive, low, high)
def check(value: SupportsLeGe[T]) -> bool:
return low <= value <= high
return check
================================================
FILE: src/phantom/predicates/numeric.py
================================================
from typing import TypeVar
from phantom._utils.types import SupportsGe
from phantom._utils.types import SupportsGt
from phantom._utils.types import SupportsLe
from phantom._utils.types import SupportsLt
from phantom._utils.types import SupportsMod
from ._base import Predicate
from ._utils import bind_name
from .boolean import negate
from .generic import equal
T = TypeVar("T")
U = TypeVar("U")
def less(n: T) -> Predicate[SupportsLt[T]]:
"""
Create a new predicate that succeeds when its argument is strictly less than ``n``.
"""
@bind_name(less, n)
def check(value: SupportsLt[T]) -> bool:
return value < n
return check
def le(n: T) -> Predicate[SupportsLe[T]]:
"""
Create a new predicate that succeeds when its argument is less than or equal to
``n``.
"""
@bind_name(le, n)
def check(value: SupportsLe[T]) -> bool:
return value <= n
return check
def greater(n: T) -> Predicate[SupportsGt[T]]:
"""
Create a new predicate that succeeds when its argument is strictly greater than
``n``.
"""
@bind_name(greater, n)
def check(value: SupportsGt[T]) -> bool:
return value > n
return check
def ge(n: T) -> Predicate[SupportsGe[T]]:
"""
Create a new predicate that succeeds when its argument is greater than or equal to
``n``.
"""
@bind_name(ge, n)
def check(value: SupportsGe[T]) -> bool:
return value >= n
return check
def positive(n: SupportsGt[int]) -> bool:
"""Return :py:const:`True` when ``n`` is strictly greater than zero."""
return greater(0)(n)
def non_positive(n: SupportsLe[int]) -> bool:
"""Return :py:const:`True` when ``n`` is less than or equal to zero."""
return le(0)(n)
def negative(n: SupportsLt[int]) -> bool:
"""Return :py:const:`True` when ``n`` is strictly less than zero."""
return less(0)(n)
def non_negative(n: SupportsGe[int]) -> bool:
"""Return :py:const:`True` when ``n`` is greater than or equal to zero."""
return ge(0)(n)
def modulo(n: T, p: Predicate[U]) -> Predicate[SupportsMod[T, U]]:
"""
Create a new predicate that succeeds when its argument modulo ``n`` satisfies the
given predicate ``p``.
"""
@bind_name(modulo, n, p)
def check(value: SupportsMod[T, U]) -> bool:
return p(value % n)
return check
def even(n: int) -> bool:
"""Return :py:const:`True` when ``n`` is even."""
return modulo(2, equal(0))(n)
def odd(n: int) -> bool:
"""Return :py:const:`True` when ``n`` is odd."""
return negate(even)(n)
================================================
FILE: src/phantom/predicates/re.py
================================================
from re import Pattern
from . import Predicate
from ._utils import bind_name
def is_match(pattern: Pattern[str]) -> Predicate[str]:
"""
Create a new predicate that succeeds when the start of its argument matches the
given ``pattern``.
"""
@bind_name(is_match, pattern.pattern)
def match(instance: str) -> bool:
return pattern.match(instance) is not None
return match
def is_full_match(pattern: Pattern[str]) -> Predicate[str]:
"""
Create a new predicate that succeeds when its whole argument matches the given
``pattern``.
"""
@bind_name(is_full_match, pattern.pattern)
def full_match(instance: str) -> bool:
return pattern.fullmatch(instance) is not None
return full_match
================================================
FILE: src/phantom/py.typed
================================================
================================================
FILE: src/phantom/re.py
================================================
"""
Types for representing strings that match a pattern.
.. code-block:: python
class Greeting(Match, pattern=r"^(Hi|Hello)"): ...
assert isinstance("Hello Jane!", Greeting)
"""
from __future__ import annotations
import re
from re import Pattern
from typing import Any
from . import Phantom
from . import _hypothesis
from ._utils.misc import resolve_class_attr
from .predicates.re import is_full_match
from .predicates.re import is_match
from .schema import Schema
__all__ = ("Match", "FullMatch")
def _compile(pattern: Pattern[str] | str) -> Pattern[str]:
if not isinstance(pattern, Pattern):
return re.compile(pattern)
return pattern
class Match(str, Phantom, abstract=True):
"""
Takes ``pattern: Pattern[str] | str`` as class argument as either a compiled
:py:class:`Pattern` or a :py:class:`str` to be compiled. Uses the
:py:func:`phantom.predicates.re.is_match` predicate.
"""
__pattern__: Pattern[str]
def __init_subclass__(cls, pattern: Pattern[str] | str, **kwargs: Any) -> None:
resolve_class_attr(cls, "__pattern__", _compile(pattern))
super().__init_subclass__(predicate=is_match(cls.__pattern__), **kwargs)
@classmethod
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"description": (
"A string starting with a match of the format regular expression."
),
"format": str(cls.__pattern__.pattern),
}
class FullMatch(str, Phantom, abstract=True):
"""
Takes ``pattern: Pattern[str] | str`` as class argument as either a compiled
:py:class:`Pattern` or a :py:class:`str` to be compiled. Uses the
:py:func:`phantom.predicates.re.is_full_match` predicate.
"""
__pattern__: Pattern[str]
def __init_subclass__(cls, pattern: Pattern[str] | str, **kwargs: Any) -> None:
resolve_class_attr(cls, "__pattern__", _compile(pattern))
super().__init_subclass__(predicate=is_full_match(cls.__pattern__), **kwargs)
@classmethod
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"description": "A string that matches the format regular expression.",
"format": str(cls.__pattern__.pattern),
}
@classmethod
def __register_strategy__(cls) -> _hypothesis.SearchStrategy | None:
from hypothesis.strategies import from_regex
return from_regex(cls.__pattern__, fullmatch=True)
================================================
FILE: src/phantom/schema.py
================================================
from collections.abc import Sequence
from typing import Literal
from typing_extensions import TypedDict
from typing_extensions import final
class Schema(TypedDict, total=False):
title: str
description: str
type: Literal["array", "string", "float", "number"]
format: str
examples: Sequence[object]
minimum: float | None
maximum: float | None
exclusiveMinimum: float | None
exclusiveMaximum: float | None
minItems: int | None
maxItems: int | None
minLength: int | None
maxLength: int | None
class SchemaField:
@classmethod
@final
def __modify_schema__(cls, field_schema: dict) -> None:
"""
This final method is called by pydantic and collects overrides from
:func:`Phantom.__schema__() `. Override
:func:`__schema__() ` to provide custom schema
representations for phantom types.
"""
field_schema.update(
{key: value for key, value in cls.__schema__().items() if value is not None}
)
@classmethod
def __schema__(cls) -> Schema:
"""
Hook for providing schema metadata. Override in subclasses to customize a types
schema representation. See pydantic's documentation on ``__modify_schema__()``
for more information. This hook differs to pydantic's ``__modify_schema__()``
and expects subclasses to instantiate new dicts instead of mutating a given one.
Example:
.. code-block:: python
class Name(str, Phantom, predicate=...):
@classmethod
def __schema__(cls):
return {**super().__schema__(), "description": "A name type"}
"""
return {"title": cls.__name__}
================================================
FILE: src/phantom/sized.py
================================================
"""
Types describing collections with size boundaries. These types should only be used with
immutable collections. There is a naive check that eliminates some of the most common
mutable collections in the instance check. However, a guaranteed check is probably
impossible to implement, so some amount of developer discipline is required.
Sized types are created by subclassing :py:class:`PhantomBound` and providing a minimum,
maximum, or both as the ``min`` and ``max`` class arguments. For instance,
:py:class:`NonEmpty` is implemented using ``min=1``.
This made-up type would describe sized collections with between 5 and 10 ints:
.. code-block:: python
class SpecificSize(PhantomBound[int], min=5, max=10): ...
This example creates a type that accepts strings with 255 or less characters:
.. code-block:: python
class SizedStr(str, PhantomBound[str], max=255): ...
"""
from __future__ import annotations
from collections.abc import Iterable
from collections.abc import Sized
# This is the closest I could find to documentation of _ProtocolMeta.
# https://github.com/python/cpython/commit/74d7f76e2c953fbfdb7ce01b7319d91d471cc5ef
from typing import Any
from typing import Generic
from typing import Protocol
from typing import TypeVar
from typing import _ProtocolMeta
from typing import get_args
from typing import runtime_checkable
from . import Phantom
from . import PhantomMeta
from . import Predicate
from . import _hypothesis
from ._utils.misc import is_not_known_mutable_instance
from .predicates import boolean
from .predicates import collection
from .predicates import interval
from .predicates import numeric
from .schema import Schema
__all__ = (
"SizedIterable",
"PhantomSized",
"PhantomBound",
"NonEmpty",
"NonEmptyStr",
"Empty",
)
T = TypeVar("T", bound=object, covariant=True)
@runtime_checkable
class SizedIterable(Sized, Iterable[T], Protocol[T]):
"""Intersection of :py:class:`typing.Sized` and :py:class:`typing.Iterable`."""
class SizedIterablePhantomMeta(PhantomMeta, _ProtocolMeta): ...
class PhantomSized(
Phantom[Sized],
SizedIterable[T],
Generic[T],
metaclass=SizedIterablePhantomMeta,
bound=SizedIterable,
abstract=True,
):
"""
Takes class argument ``len: Predicate[int]``.
Discouraged in favor of :py:class:`PhantomBound`, which better supports automatic
schema generation.
"""
def __init_subclass__(
cls,
len: Predicate[int], # noqa: A002
**kwargs: Any,
) -> None:
super().__init_subclass__(
predicate=boolean.both(
is_not_known_mutable_instance,
collection.count(len),
),
**kwargs,
)
@classmethod
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"type": "array",
}
class UnresolvedBounds(Exception): ...
class LSPViolation(Exception): ...
class PhantomBound(
Phantom[Sized],
SizedIterable[T],
Generic[T],
metaclass=SizedIterablePhantomMeta,
bound=SizedIterable,
abstract=True,
):
"""Takes class arguments ``min: int``, ``max: int``."""
__min__: int | None
__max__: int | None
def __init_subclass__(
cls,
min: int | None = None, # noqa: A002
max: int | None = None, # noqa: A002
abstract: bool = False,
**kwargs: Any,
) -> None:
inherited_min = getattr(cls, "__min__", None)
inherited_max = getattr(cls, "__max__", None)
cls.__min__ = inherited_min if min is None else min
cls.__max__ = inherited_max if max is None else max
# Note: There's possibly value in generalizing this, to be able to declaratively
# describe the relationship between an attribute and its inherited value.
if (
cls.__min__ is not None
and inherited_min is not None
and cls.__min__ < inherited_min
):
raise LSPViolation(
f"Cannot set a smaller min than inherited ({cls.__min__} < "
f"{inherited_min})."
)
if (
cls.__max__ is not None
and inherited_max is not None
and cls.__max__ > inherited_max
):
raise LSPViolation(
f"Cannot set a larger max than inherited ({cls.__max__} > "
f"{inherited_max})."
)
if cls.__min__ is not None and cls.__max__ is not None:
size = interval.inclusive(cls.__min__, cls.__max__)
elif cls.__min__ is not None:
size = numeric.ge(cls.__min__)
elif cls.__max__ is not None:
size = numeric.le(cls.__max__)
elif abstract:
super().__init_subclass__(abstract=abstract, **kwargs)
return
else:
raise UnresolvedBounds(
f"Concrete type {cls.__qualname__} must provide either min or max, or "
f"both."
)
super().__init_subclass__(
predicate=boolean.both(
is_not_known_mutable_instance,
collection.count(size),
),
abstract=abstract,
**kwargs,
)
@classmethod
def __schema__(cls) -> Schema:
return (
{
**super().__schema__(),
"type": "string",
"minLength": cls.__min__,
"maxLength": cls.__max__,
}
if str in cls.__mro__
else {
**super().__schema__(),
"type": "array",
"minItems": cls.__min__,
"maxItems": cls.__max__,
}
)
@classmethod
def __register_strategy__(cls) -> _hypothesis.HypothesisStrategy:
from hypothesis.strategies import DrawFn
from hypothesis.strategies import composite
from hypothesis.strategies import from_type
from hypothesis.strategies import lists
from hypothesis.strategies import text
def create_strategy(type_: type[T]) -> _hypothesis.SearchStrategy[T] | None:
min_size = cls.__min__ or 0
if cls.__bound__ is str:
return text( # type: ignore[return-value]
min_size=min_size,
max_size=cls.__max__,
)
(inner_type,) = get_args(type_)
@composite
def tuples(draw: DrawFn) -> tuple:
strategy = lists(
from_type(inner_type),
min_size=min_size,
max_size=cls.__max__,
)
return tuple(draw(strategy))
return tuples() # type: ignore[return-value]
return create_strategy
class NonEmpty(PhantomBound[T], Generic[T], min=1):
"""A sized collection with at least one item."""
@classmethod
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"description": "A non-empty array.",
}
class NonEmptyStr(str, NonEmpty[str]):
"""A sized str with at least one character."""
@classmethod
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"description": "A non-empty string.",
}
class Empty(PhantomBound[T], Generic[T], max=0):
"""A sized collection with exactly zero items."""
@classmethod
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"description": "An empty array.",
}
@classmethod
def __register_strategy__(cls) -> _hypothesis.SearchStrategy:
from hypothesis.strategies import just
return just(())
================================================
FILE: tests/__init__.py
================================================
================================================
FILE: tests/ext/__init__.py
================================================
================================================
FILE: tests/ext/test_hypothesis.py
================================================
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import fields
from functools import total_ordering
from typing import Generic
from typing import TypeVar
from typing import get_origin
from typing import get_type_hints
from hypothesis import given
from hypothesis import settings
from hypothesis.strategies import builds
from phantom.boolean import Falsy
from phantom.boolean import Truthy
from phantom.datetime import TZAware
from phantom.datetime import TZNaive
from phantom.interval import Exclusive
from phantom.interval import ExclusiveInclusive
from phantom.interval import Inclusive
from phantom.interval import InclusiveExclusive
from phantom.interval import Natural
from phantom.interval import NegativeInt
from phantom.interval import Portion
from phantom.iso3166 import ParsedAlpha2
from phantom.negated import SequenceNotStr
from phantom.re import FullMatch
from phantom.sized import Empty
from phantom.sized import NonEmpty
from phantom.sized import NonEmptyStr
from phantom.sized import PhantomBound
from tests.types import FloatExc
from tests.types import FloatExcInc
from tests.types import FloatInc
from tests.types import FloatIncExc
from tests.types import IntExc
from tests.types import IntExcInc
from tests.types import IntInc
from tests.types import IntIncExc
class TensFloat(float, InclusiveExclusive, low=10, high=20): ...
class TensInt(int, InclusiveExclusive, low=10, high=20): ...
class Url(
FullMatch,
pattern=r"https?://www\.[A-z]+\.(com|se|org)",
): ...
T = TypeVar("T", bound=object, covariant=True)
class Few(PhantomBound[T], Generic[T], min=5, max=15): ...
@total_ordering
class Inf:
def __eq__(self, other):
return False
def __lt__(self, other):
return False
# Test can create types that don't map to a Hypothesis strategy.
class InmappableInc(int, Inclusive, low=Inf(), high=100): ...
class InmappableExc(float, Exclusive, low=Inf(), high=100): ...
class InmappableIncExc(int, InclusiveExclusive, low=Inf(), high=100): ...
class InmappableExcInc(float, ExclusiveInclusive, low=Inf(), high=100): ...
@dataclass
class Model:
tz_aware: TZAware
tz_naive: TZNaive
truthy: Truthy
falsy: Falsy
tens_float: TensFloat
tens_int: TensInt
natural: Natural
negative_int: NegativeInt
portion: Portion
float_inc: FloatInc
int_inc: IntInc
float_exc: FloatExc
int_exc: IntExc
float_inc_exc: FloatIncExc
int_inc_exc: IntIncExc
float_exc_inc: FloatExcInc
int_exc_inc: IntExcInc
parsed_alpha_2: ParsedAlpha2
url: Url
not_str: SequenceNotStr[str | int]
non_empty: NonEmpty[int]
non_empty_str: NonEmptyStr
empty: Empty
ints: Few[int]
mixed: Few[int | str]
@given(builds(Model))
@settings(max_examples=10)
def test_can_generate_hypothesis_values(model: Model) -> None:
hints = get_type_hints(Model)
for field in fields(Model):
type_ = hints[field.name]
inner_type = get_origin(type_) or type_
assert isinstance(getattr(model, field.name), inner_type)
================================================
FILE: tests/ext/test_phonenumbers.py
================================================
import pytest
from typing_extensions import assert_type
from phantom.errors import BoundError
from phantom.ext.phonenumbers import FormattedPhoneNumber
from phantom.ext.phonenumbers import InvalidPhoneNumber
from phantom.ext.phonenumbers import PhoneNumber
from phantom.ext.phonenumbers import _deconstruct_phone_number
from phantom.ext.phonenumbers import is_formatted_phone_number
from phantom.ext.phonenumbers import is_phone_number
from phantom.ext.phonenumbers import normalize_phone_number
pytestmark = [pytest.mark.external]
class TestPhoneNumber:
def test_unparsable_number_is_not_instance(self):
assert not isinstance("+46", PhoneNumber)
def test_invalid_number_is_not_instance(self):
assert not isinstance("+467012345678", PhoneNumber)
def test_unformatted_number_is_instance(self):
assert isinstance("+46 (701) 234567", PhoneNumber)
class TestFormattedPhoneNumber:
def test_unparsable_number_is_not_instance(self):
assert not isinstance("+46", FormattedPhoneNumber)
def test_invalid_number_is_not_instance(self):
assert not isinstance("+467012345678", FormattedPhoneNumber)
def test_unformatted_number_is_not_instance(self):
assert not isinstance("+46 (701) 234567", FormattedPhoneNumber)
def test_formatted_number_is_instance(self):
assert isinstance("+46701234567", FormattedPhoneNumber)
def test_normalizes_unformatted_number(self):
number = FormattedPhoneNumber.parse("+46 (701) 234567")
assert number == "+46701234567"
assert isinstance(number, FormattedPhoneNumber)
def test_parse_raises_for_invalid_phone_number(self):
with pytest.raises(InvalidPhoneNumber):
FormattedPhoneNumber.parse("+46")
def test_raises_type_error_for_out_of_bound_type(self):
"""Since we override parse we need to test the bound check"""
value = 123
with pytest.raises(
BoundError, match=rf"Value is not within bound of 'str': {value}"
):
FormattedPhoneNumber.parse(123)
class TestDeconstructPhoneNumber:
def test_can_parse_international_phone_number_without_country_code(self):
result = _deconstruct_phone_number("123456789", "SE")
assert result.country_code == 46
assert result.national_number == 123456789
def test_can_parse_international_phone_number_with_country_code(self):
result = _deconstruct_phone_number("+46123456789", "NO")
assert result.country_code == 46
assert result.national_number == 123456789
def test_can_parse_national_phone_number_with_country_code(self):
result = _deconstruct_phone_number("0123456789", "SE")
assert result.country_code == 46
assert result.national_number == 123456789
def test_raises_invalid_phone_number_for_insufficient_country_data(self):
with pytest.raises(InvalidPhoneNumber) as exc_info:
_deconstruct_phone_number("0701")
assert exc_info.value.error_type == InvalidPhoneNumber.INVALID_COUNTRY_CODE
def test_raises_invalid_phone_number_for_parse_exception(self):
with pytest.raises(InvalidPhoneNumber) as exc_info:
_deconstruct_phone_number("0")
assert exc_info.value.error_type == InvalidPhoneNumber.NOT_A_NUMBER
def test_raises_invalid_phone_number_for_invalid_phone_number(self):
with pytest.raises(InvalidPhoneNumber) as exc_info:
_deconstruct_phone_number("+461234567890")
assert exc_info.value.error_type == InvalidPhoneNumber.INVALID
class TestNormalizePhoneNumber:
def test_can_normalize_national_number_with_country_code(self):
assert normalize_phone_number("(123) 456 789", "SE") == "+46123456789"
def test_can_normalize_international_number_without_country_code(self):
assert normalize_phone_number("+46 (123) 456 789") == "+46123456789"
class TestIsPhoneNumber:
def test_returns_true_for_valid_number(self):
assert is_phone_number("+46 (123) 456789") is True
def test_returns_false_for_invalid_number(self):
assert is_phone_number("+461234567890") is False
class TestIsFormattedPhoneNumber:
def test_returns_true_for_formatted_number(self) -> None:
value = "+46123456789"
assert is_formatted_phone_number(value)
assert_type(value, FormattedPhoneNumber)
def test_returns_false_for_unformatted_number(self):
assert is_formatted_phone_number("+46 (123) 456 789") is False
================================================
FILE: tests/ext/test_phonenumbers.yaml
================================================
- case: bound_is_not_subtype
main: |
from phantom.ext.phonenumbers import PhoneNumber
from phantom.ext.phonenumbers import FormattedPhoneNumber
def takes_phone_number(n: PhoneNumber) -> None:
...
takes_phone_number("") # E: Argument 1 to "takes_phone_number" has incompatible type "str"; expected "PhoneNumber" [arg-type]
def takes_formatted_phone_number(n: FormattedPhoneNumber) -> None:
...
takes_formatted_phone_number("") # E: Argument 1 to "takes_formatted_phone_number" has incompatible type "str"; expected "FormattedPhoneNumber" [arg-type]
- case: can_instantiate
main: |
from phantom.ext.phonenumbers import PhoneNumber
from phantom.ext.phonenumbers import FormattedPhoneNumber
def takes_phone_number(n: PhoneNumber) -> None:
...
takes_phone_number(PhoneNumber.parse(""))
def takes_formatted_phone_number(n: FormattedPhoneNumber) -> None:
...
takes_formatted_phone_number(FormattedPhoneNumber.parse(""))
- case: can_infer
main: |
from phantom.ext.phonenumbers import PhoneNumber
from phantom.ext.phonenumbers import FormattedPhoneNumber
def takes_phone_number(n: PhoneNumber) -> None:
...
n = ""
assert isinstance(n, PhoneNumber)
takes_phone_number(n)
def takes_formatted_phone_number(n: FormattedPhoneNumber) -> None:
...
f = ""
assert isinstance(f, FormattedPhoneNumber)
takes_formatted_phone_number(f)
================================================
FILE: tests/predicates/__init__.py
================================================
import pytest
pytest.register_assert_rewrite("tests.predicates.utils")
================================================
FILE: tests/predicates/test_boolean.py
================================================
from collections.abc import Iterable
import pytest
from phantom import Predicate
from phantom.predicates import boolean
from .utils import assert_predicate_name_equals
class TestTrue:
@pytest.mark.parametrize("value", [0, False, 1, "test", ()])
def test_returns_true_for_any_given_value(self, value: object) -> None:
assert boolean.true(value) is True
class TestFalse:
@pytest.mark.parametrize("value", [0, False, 1, "test", ()])
def test_returns_false_for_any_given_value(self, value: object) -> None:
assert boolean.false(value) is False
class TestNegate:
def test_can_negate_true(self):
assert boolean.negate(boolean.true)(object()) is False
def test_can_negate_false(self):
assert boolean.negate(boolean.false)(0) is True
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(boolean.negate(boolean.true), "negate(true)")
parametrize_truthy = pytest.mark.parametrize("value", [1, "a", (0,), [""], True])
parametrize_falsy = pytest.mark.parametrize("value", [0, "", (), [], False])
class TestTruthy:
@parametrize_truthy
def test_returns_true_for_truthy_value(self, value: object) -> None:
assert boolean.truthy(value) is True
@parametrize_falsy
def test_returns_false_for_falsy_value(self, value: object) -> None:
assert boolean.truthy(value) is False
class TestFalsy:
@parametrize_falsy
def test_returns_true_for_falsy_value(self, value: object) -> None:
assert boolean.falsy(value) is True
@parametrize_truthy
def test_returns_false_for_truthy_value(self, value: object) -> None:
assert boolean.falsy(value) is False
class TestBoth:
def test_returns_true_for_two_succeeding_predicates(self) -> None:
assert boolean.both(boolean.true, boolean.true)(0) is True
@pytest.mark.parametrize(
"a, b",
[
(boolean.false, boolean.true),
(boolean.true, boolean.false),
(boolean.false, boolean.false),
],
)
def test_returns_false_for_falsy_predicate(
self, a: Predicate, b: Predicate
) -> None:
assert boolean.both(a, b)(0) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(
boolean.both(boolean.true, boolean.false), "both(true, false)"
)
class TestEither:
@pytest.mark.parametrize(
"a, b",
[
(boolean.false, boolean.true),
(boolean.true, boolean.false),
(boolean.true, boolean.true),
],
)
def test_returns_true_for_truthy_predicate(
self, a: Predicate, b: Predicate
) -> None:
assert boolean.either(a, b)(0) is True
def test_returns_false_for_two_falsy_predicates(self) -> None:
assert boolean.either(boolean.false, boolean.false)(0) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(
boolean.either(boolean.false, boolean.true), "either(false, true)"
)
class TestXor:
@pytest.mark.parametrize(
"a, b",
[
(boolean.false, boolean.true),
(boolean.true, boolean.false),
],
)
def test_returns_true_for_two_different_bools(
self, a: Predicate, b: Predicate
) -> None:
assert boolean.xor(a, b)(0) is True
@pytest.mark.parametrize(
"a, b",
[
(boolean.false, boolean.false),
(boolean.true, boolean.true),
],
)
def test_returns_false_for_two_equal_bools(
self, a: Predicate, b: Predicate
) -> None:
assert boolean.xor(a, b)(0) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(
boolean.xor(boolean.true, boolean.false), "xor(true, false)"
)
parametrize_all_true = pytest.mark.parametrize(
"predicates",
[
(boolean.true,),
(boolean.true, boolean.true),
(boolean.true, boolean.true, boolean.true),
],
)
parametrize_some_false = pytest.mark.parametrize(
"predicates",
[
(boolean.true, boolean.false),
(boolean.false, boolean.true),
(boolean.true, boolean.false, boolean.true),
],
)
parametrize_all_false = pytest.mark.parametrize(
"predicates",
[
(boolean.false,),
(boolean.false, boolean.false),
(boolean.false, boolean.false, boolean.false),
],
)
class TestAllOf:
def test_returns_true_for_empty_set_of_predicates(self) -> None:
predicate: Predicate[int] = boolean.all_of(())
assert predicate(0) is True
@parametrize_all_true
def test_returns_true_for_succeeding_predicates(
self, predicates: Iterable[Predicate]
) -> None:
assert boolean.all_of(predicates)(0) is True
@parametrize_some_false
def test_returns_false_for_some_failing_predicate(
self, predicates: Iterable[Predicate]
) -> None:
assert boolean.all_of(predicates)(0) is False
@parametrize_all_false
def test_returns_false_for_only_failing_predicate(
self, predicates: Iterable[Predicate]
) -> None:
assert boolean.all_of(predicates)(0) is False
@parametrize_some_false
def test_materializes_generated_predicates(
self, predicates: Iterable[Predicate]
) -> None:
predicate = boolean.all_of(predicate for predicate in predicates)
assert predicate(0) is False
assert predicate(0) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(
boolean.all_of([boolean.true, boolean.false]), "all_of(true, false)"
)
class TestAnyOf:
def test_returns_false_for_empty_set_of_predicates(self) -> None:
predicate: Predicate[int] = boolean.any_of(())
assert predicate(0) is False
@parametrize_all_true
def test_returns_true_for_succeeding_predicates(
self, predicates: Iterable[Predicate]
) -> None:
assert boolean.any_of(predicates)(0) is True
@parametrize_some_false
def test_returns_true_for_some_failing_predicate(
self, predicates: Iterable[Predicate]
) -> None:
assert boolean.any_of(predicates)(0) is True
@parametrize_all_false
def test_returns_false_for_only_failing_predicate(
self, predicates: Iterable[Predicate]
) -> None:
assert boolean.any_of(predicates)(0) is False
@parametrize_some_false
def test_materializes_generated_predicates(
self, predicates: Iterable[Predicate]
) -> None:
predicate = boolean.any_of(predicate for predicate in predicates)
assert predicate(0) is True
assert predicate(0) is True
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(
boolean.any_of([boolean.false, boolean.true]), "any_of(false, true)"
)
class TestOneOf:
def test_returns_false_for_empty_set_of_predicates(self) -> None:
predicate: Predicate[int] = boolean.one_of(())
assert predicate(0) is False
@pytest.mark.parametrize(
"predicates",
[
(boolean.true, boolean.true),
(boolean.true, boolean.true, boolean.true),
(boolean.false, boolean.true, boolean.true),
(boolean.true, boolean.true, boolean.false),
(boolean.true, boolean.false, boolean.true),
],
)
def test_returns_false_for_more_than_one_succeeding_predicates(
self, predicates: Iterable[Predicate]
) -> None:
assert boolean.one_of(predicates)(0) is False
@pytest.mark.parametrize(
"predicates",
[
(boolean.true,),
(boolean.true, boolean.false),
(boolean.false, boolean.true),
(boolean.true, boolean.false, boolean.false),
(boolean.false, boolean.true, boolean.false),
(boolean.false, boolean.false, boolean.true),
],
)
def test_returns_true_for_one_succeeding_predicate(
self, predicates: Iterable[Predicate]
) -> None:
assert boolean.one_of(predicates)(0) is True
@parametrize_all_false
def test_returns_false_for_only_failing_predicate(
self, predicates: Iterable[Predicate]
) -> None:
assert boolean.one_of(predicates)(0) is False
@pytest.mark.parametrize(
"predicates",
[
(boolean.true,),
(boolean.false, boolean.true),
(boolean.false, boolean.true, boolean.false),
],
)
def test_materializes_generated_predicates(
self, predicates: Iterable[Predicate]
) -> None:
predicate = boolean.one_of(predicate for predicate in predicates)
assert predicate(0) is True
assert predicate(0) is True
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(
boolean.one_of([boolean.true, boolean.false]), "one_of(true, false)"
)
================================================
FILE: tests/predicates/test_collection.py
================================================
from collections.abc import Container
from collections.abc import Iterable
from collections.abc import Sized
import pytest
from phantom import Predicate
from phantom.predicates import collection
from phantom.predicates import generic
from phantom.predicates import numeric
from .utils import assert_predicate_name_equals
class TestContains:
@pytest.mark.parametrize(
"item, container",
[
(1, (1, 2)),
("b", "abc"),
],
)
def test_returns_true_for_container_with_item(
self, item: object, container: Container
) -> None:
assert collection.contains(item)(container) is True
@pytest.mark.parametrize(
"item, container",
[
(1, (2, 3)),
("d", "abc"),
],
)
def test_returns_false_for_container_without_item(
self, item: object, container: Container
) -> None:
assert collection.contains(item)(container) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(
collection.contains("needle"), "contains('needle')"
)
class TestContained:
@pytest.mark.parametrize(
"container, item",
[
((1, 2), 1),
("abc", "b"),
],
)
def test_returns_true_for_item_in_container(
self, container: Container, item: object
) -> None:
assert collection.contained(container)(item) is True
@pytest.mark.parametrize(
"container, item",
[
((2, 3), 1),
("abc", "d"),
],
)
def test_returns_false_for_item_not_in_container(
self, container: Container, item: object
) -> None:
assert collection.contained(container)(item) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(
collection.contained((1, 2, 3)), "contained((1, 2, 3))"
)
class TestCount:
@pytest.mark.parametrize(
"predicate, sized",
[
(generic.equal(1), [0]),
(numeric.le(3), ("a", "b")),
(numeric.ge(3), "abc"),
],
)
def test_returns_true_for_size_matching_predicate(
self, predicate: Predicate, sized: Sized
) -> None:
assert collection.count(predicate)(sized) is True
@pytest.mark.parametrize(
"predicate, sized",
[
(generic.equal(1), [0, 0]),
(numeric.le(3), ("a", "b", 1, 2)),
(numeric.ge(3), "ab"),
],
)
def test_returns_false_for_size_failing_predicate(
self, predicate: Predicate, sized: Sized
) -> None:
assert collection.count(predicate)(sized) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(collection.count(numeric.ge(3)), "count(ge(3))")
class TestExists:
@pytest.mark.parametrize(
"predicate, iterable",
[
(generic.identical(...), ["a", "b", ...]),
(numeric.greater(0), [-3, 0, 1, 0]),
(generic.equal("a"), "cbad"),
],
)
def test_returns_true_for_iterable_containing_satisfying_item(
self, predicate: Predicate[object], iterable: Iterable
) -> None:
assert collection.exists(predicate)(iterable) is True
@pytest.mark.parametrize(
"predicate, iterable",
[
(generic.identical(...), ["a", "b", "c"]),
(numeric.greater(1), [-3, 0, 1, 0]),
(generic.equal("a"), "cbed"),
],
)
def test_returns_false_for_iterable_not_containing_satisfying_item(
self, predicate: Predicate[object], iterable: Iterable
) -> None:
assert collection.exists(predicate)(iterable) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(
collection.exists(numeric.less(-3)), "exists(less(-3))"
)
class TestEvery:
@pytest.mark.parametrize(
"predicate, iterable",
[
(generic.identical(1), [1, 1, 1]),
(generic.identical(1), []),
(numeric.greater(0), [1, 2]),
(generic.equal("a"), "aa"),
],
)
def test_returns_true_for_complete_iterable(
self, predicate: Predicate[object], iterable: Iterable
) -> None:
assert collection.every(predicate)(iterable) is True
@pytest.mark.parametrize(
"predicate, iterable",
[
(generic.identical(...), [..., "b", "c"]),
(numeric.greater(1), [-3, 0, 1, 0, 2]),
(generic.equal("a"), "abc"),
],
)
def test_returns_false_for_incomplete_iterable(
self, predicate: Predicate[object], iterable: Iterable
) -> None:
assert collection.every(predicate)(iterable) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(
collection.every(numeric.greater(42)), "every(greater(42))"
)
================================================
FILE: tests/predicates/test_datetime.py
================================================
import datetime
import pytest
from phantom.predicates.datetime import is_tz_aware
from phantom.predicates.datetime import is_tz_naive
parametrize_aware = pytest.mark.parametrize(
"dt", (datetime.datetime.now(tz=datetime.timezone.utc),)
)
parametrize_naive = pytest.mark.parametrize(
"dt", (datetime.datetime.now(), datetime.datetime(1969, 12, 23))
)
class TestIsTZAware:
@parametrize_aware
def test_returns_true_for_aware_dt(self, dt: datetime.datetime) -> None:
assert is_tz_aware(dt) is True
@parametrize_naive
def test_returns_false_for_naive_dt(self, dt: datetime.datetime) -> None:
assert is_tz_aware(dt) is False
class TestIsTZNaive:
@parametrize_naive
def test_returns_true_for_naive_dt(self, dt: datetime.datetime) -> None:
assert is_tz_naive(dt) is True
@parametrize_aware
def test_returns_false_for_aware_dt(self, dt: datetime.datetime) -> None:
assert is_tz_naive(dt) is False
================================================
FILE: tests/predicates/test_generic.py
================================================
import pytest
from phantom.predicates import generic
from .utils import assert_predicate_name_equals
class TestEqual:
@pytest.mark.parametrize("a, b", [(1, 1.0), (False, 0), (True, 1)])
def test_returns_true_for_equal_values(self, a: object, b: object) -> None:
assert generic.equal(a)(b) is True
assert generic.equal(b)(a) is True
@pytest.mark.parametrize("a, b", [(2, 1), (1, 1.1), (True, False)])
def test_returns_false_for_non_equal_values(self, a: object, b: object) -> None:
assert generic.equal(a)(b) is False
assert generic.equal(b)(a) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(generic.equal("hello"), "equal('hello')")
class TestIdentical:
@pytest.mark.parametrize(
"a, b", [(1, 1), ("a", "a"), (True, True), (False, False), ((), ())]
)
def test_returns_true_for_identical_values(self, a: object, b: object) -> None:
assert generic.identical(a)(b) is True
assert generic.identical(b)(a) is True
@pytest.mark.parametrize("a, b", [([], []), (1, 1.0), (False, 0), (True, 1)])
def test_returns_false_for_different_values(self, a: object, b: object) -> None:
assert generic.identical(a)(b) is False
assert generic.identical(b)(a) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(generic.identical("hello"), "identical('hello')")
class TestOfType:
@pytest.mark.parametrize("instance,types", [(1, int), (1, (int, float))])
def test_returns_true_for_instance_of_types(
self,
instance: object,
types: type | tuple[type, ...],
) -> None:
assert generic.of_type(types)(instance) is True
@pytest.mark.parametrize("instance,types", [(1, float), ("", (int, float))])
def test_returns_false_for_instance_of_other_type(
self,
instance: object,
types: type | tuple[type, ...],
) -> None:
assert generic.of_type(types)(instance) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(generic.of_type(int), "of_type(int)")
================================================
FILE: tests/predicates/test_interval.py
================================================
from typing import TypeAlias
import pytest
from phantom.predicates import interval
from .utils import assert_predicate_name_equals
Boundaries: TypeAlias = tuple[float, float]
parametrize_inside = pytest.mark.parametrize(
"value, boundaries",
[
(1.0001, (1, 2)),
(1.9999, (1, 2)),
(1.00001, (1.0, 2)),
(1.99999, (1, 2.0)),
],
)
parametrize_on_edge = pytest.mark.parametrize(
"value, boundaries",
[
(1, (1, 2)),
(2, (1, 2)),
(1.0, (1.0, 2)),
(2.0, (1, 2.0)),
],
)
parametrize_outside = pytest.mark.parametrize(
"value, boundaries",
[
(0, (1, 2)),
(3, (1, 2)),
(0.99999, (1.0, 2)),
(2.00001, (1, 2.0)),
(20, (1, 3)),
],
)
class TestExclusive:
def test_returns_true_for_middle_value(self) -> None:
assert interval.exclusive(1, 3)(2) is True
@parametrize_inside
def test_returns_true_for_inside_value(
self, value: float, boundaries: Boundaries
) -> None:
assert interval.exclusive(*boundaries)(value) is True
@parametrize_on_edge
def test_returns_false_for_edge_value(
self, value: float, boundaries: Boundaries
) -> None:
assert interval.exclusive(*boundaries)(value) is False
@parametrize_outside
def test_returns_false_for_outside_value(
self, value: float, boundaries: Boundaries
) -> None:
assert interval.exclusive(*boundaries)(value) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(interval.exclusive(0, 1), "exclusive(0, 1)")
class TestInclusive:
def test_returns_true_for_middle_value(self) -> None:
assert interval.inclusive(1, 3)(2) is True
@parametrize_inside
def test_returns_true_for_inside_value(
self, value: float, boundaries: Boundaries
) -> None:
assert interval.inclusive(*boundaries)(value) is True
@parametrize_on_edge
def test_returns_true_for_edge_value(
self, value: float, boundaries: Boundaries
) -> None:
assert interval.inclusive(*boundaries)(value) is True
@parametrize_outside
def test_returns_false_for_outside_value(
self, value: float, boundaries: Boundaries
) -> None:
assert interval.inclusive(*boundaries)(value) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(interval.inclusive(0, 1), "inclusive(0, 1)")
class TestInclusiveExclusive:
def test_returns_true_for_middle_value(self) -> None:
assert interval.inclusive_exclusive(1, 3)(2) is True
def test_lower_bound(self) -> None:
intv = interval.inclusive_exclusive(5, 10)
assert intv(5) is True
assert intv(4.9999) is False
def test_upper_bound(self) -> None:
intv = interval.inclusive_exclusive(5, 10)
assert intv(10) is False
assert intv(9.9999) is True
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(
interval.inclusive_exclusive(0, 1), "inclusive_exclusive(0, 1)"
)
class TestExclusiveInclusive:
def test_returns_true_for_middle_value(self) -> None:
assert interval.exclusive_inclusive(1, 3)(2) is True
def test_lower_bound(self) -> None:
intv = interval.exclusive_inclusive(5, 10)
assert intv(5.0001) is True
assert intv(5) is False
def test_upper_bound(self) -> None:
intv = interval.exclusive_inclusive(5, 10)
assert intv(10) is True
assert intv(10.00001) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(
interval.exclusive_inclusive(0, 1), "exclusive_inclusive(0, 1)"
)
================================================
FILE: tests/predicates/test_numeric.py
================================================
import pytest
from phantom.predicates import numeric
from .utils import assert_predicate_name_equals
class TestLess:
def test_returns_true_for_values_below_limit(self) -> None:
predicate = numeric.less(10)
assert predicate(9) is True
assert predicate(9.999) is True
assert predicate(-5) is True
def test_returns_false_for_values_above_limit(self) -> None:
predicate = numeric.less(10)
assert predicate(10) is False
assert predicate(11) is False
assert predicate(123) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(numeric.less(100), "less(100)")
class TestLE:
def test_returns_true_for_values_below_limit(self) -> None:
predicate = numeric.le(10)
assert predicate(9) is True
assert predicate(9.999) is True
assert predicate(-5) is True
assert predicate(10) is True
def test_returns_false_for_values_above_limit(self) -> None:
predicate = numeric.le(10)
assert predicate(10.0001) is False
assert predicate(11) is False
assert predicate(123) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(numeric.le(-100), "le(-100)")
class TestGreater:
def test_returns_true_for_values_above_limit(self) -> None:
predicate = numeric.greater(120)
assert predicate(121) is True
assert predicate(120.0001) is True
assert predicate(1200) is True
def test_returns_false_for_values_below_limit(self) -> None:
predicate = numeric.greater(120)
assert predicate(120) is False
assert predicate(119.9999) is False
assert predicate(-120) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(numeric.greater(10), "greater(10)")
class TestGE:
def test_returns_true_for_values_above_limit(self) -> None:
predicate = numeric.ge(120)
assert predicate(121) is True
assert predicate(120.0001) is True
assert predicate(1200) is True
assert predicate(120) is True
def test_returns_false_for_values_below_limit(self) -> None:
predicate = numeric.ge(120)
assert predicate(119.9999) is False
assert predicate(-120) is False
assert predicate(119) is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(numeric.ge(10.134), "ge(10.134)")
class TestPositive:
def test_limits(self) -> None:
assert numeric.positive(0) is False
assert numeric.positive(-1) is False
assert numeric.positive(1) is True
assert numeric.positive(0.0001) is True
class TestNonPositive:
def test_limits(self) -> None:
assert numeric.non_positive(0) is True
assert numeric.non_positive(-1) is True
assert numeric.non_positive(1) is False
assert numeric.non_positive(0.0001) is False
class TestNegative:
def test_limits(self) -> None:
assert numeric.negative(0) is False
assert numeric.negative(-1) is True
assert numeric.negative(1) is False
assert numeric.negative(-0.0001) is True
class TestNonNegative:
def test_limits(self) -> None:
assert numeric.non_negative(0) is True
assert numeric.non_negative(-1) is False
assert numeric.non_negative(1) is True
assert numeric.non_negative(-0.0001) is False
class TestModulo:
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(
numeric.modulo(2, numeric.greater(99)), "modulo(2, greater(99))"
)
parametrize_even = pytest.mark.parametrize("value", [-8296, 0, 2, 32, 9314])
parametrize_odd = pytest.mark.parametrize("value", [-13829, -1, 1, 31, 10023])
class TestEven:
@parametrize_even
def test_returns_true_for_even_value(self, value: int) -> None:
assert numeric.even(value) is True
@parametrize_odd
def test_returns_false_for_odd_value(self, value: int) -> None:
assert numeric.even(value) is False
class TestOdd:
@parametrize_odd
def test_returns_true_for_odd_value(self, value: int) -> None:
assert numeric.odd(value) is True
@parametrize_even
def test_returns_false_for_even_value(self, value: int) -> None:
assert numeric.odd(value) is False
================================================
FILE: tests/predicates/test_re.py
================================================
import re
from phantom.predicates.re import is_full_match
from phantom.predicates.re import is_match
from .utils import assert_predicate_name_equals
pattern = re.compile("abc@")
abc_match = is_match(pattern)
abc_full_match = is_full_match(pattern)
class TestIsMatch:
def test_returns_true_for_matching_string(self) -> None:
assert abc_match("abc@") is True
assert abc_match("abc@ extra") is True
def test_returns_false_for_non_matching_string(self) -> None:
assert abc_match("abd@") is False
assert abc_match("extra abc@") is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(abc_match, "is_match('abc@')")
class TestIsFullMatch:
def test_returns_true_for_matching_string(self) -> None:
assert abc_full_match("abc@") is True
def test_returns_false_for_non_matching_string(self) -> None:
assert abc_full_match("abc@ extra") is False
assert abc_full_match("abd@") is False
assert abc_full_match("extra abc@") is False
def test_repr_contains_bound_parameter(self):
assert_predicate_name_equals(abc_full_match, "is_full_match('abc@')")
================================================
FILE: tests/predicates/test_utils.py
================================================
from functools import partial
from phantom.predicates import boolean
from .utils import assert_predicate_name_equals
def foo(a: int, b: int, c: int) -> bool:
return a > b > c
class TestFunctionRepr:
def test_explodes_partial_arguments(self):
predicate = partial(foo, 10, b=5)
assert_predicate_name_equals(boolean.negate(predicate), "negate(foo(10, b=5))")
predicate = partial(foo, 23, c=31)
assert_predicate_name_equals(boolean.negate(predicate), "negate(foo(23, c=31))")
================================================
FILE: tests/predicates/utils.py
================================================
from phantom import Predicate
def assert_predicate_name_equals(predicate: Predicate, expected_name: str) -> None:
assert predicate.__name__ == expected_name
assert predicate.__qualname__ == expected_name
assert repr(predicate).startswith(f" bool:
return True
assert isinstance("a", AlwaysTrue) is True
================================================
FILE: tests/test_boolean.py
================================================
import pytest
from phantom.boolean import Falsy
from phantom.boolean import Truthy
parametrize_truthy = pytest.mark.parametrize("v", (object(), ("a",), 1, True))
parametrize_falsy = pytest.mark.parametrize("v", ((), 0, False))
class TestTruthy:
@parametrize_truthy
def test_truthy_value_is_instance(self, v):
assert isinstance(v, Truthy)
@parametrize_falsy
def test_falsy_value_is_not_instance(self, v):
assert not isinstance(v, Truthy)
@parametrize_truthy
def test_instantiation_returns_instance(self, v):
assert v is Truthy.parse(v)
@parametrize_falsy
def test_instantiation_raises_for_falsy_value(self, v):
with pytest.raises(TypeError):
Truthy.parse(v)
class TestFalsy:
@parametrize_falsy
def test_falsy_value_is_instance(self, v):
assert isinstance(v, Falsy)
@parametrize_truthy
def test_truthy_value_is_not_instance(self, v):
assert not isinstance(v, Falsy)
@parametrize_falsy
def test_instantiation_returns_instance(self, v):
assert v is Falsy.parse(v)
@parametrize_truthy
def test_instantiation_raises_for_truthy_value(self, v):
with pytest.raises(TypeError):
Falsy.parse(v)
================================================
FILE: tests/test_datetime.py
================================================
import datetime
import pytest
from phantom import BoundError
from phantom.datetime import TZAware
from phantom.datetime import TZNaive
from phantom.errors import MissingDependency
parametrize_aware = pytest.mark.parametrize(
"dt",
(
datetime.datetime.now(tz=datetime.timezone.utc),
datetime.datetime(1969, 12, 23, tzinfo=datetime.timezone.utc),
datetime.datetime.min.replace(tzinfo=datetime.timezone.utc),
datetime.datetime.max.replace(tzinfo=datetime.timezone.utc),
),
)
parametrize_naive = pytest.mark.parametrize(
"dt",
(
datetime.datetime.now(),
datetime.datetime(1969, 12, 23),
datetime.datetime.min,
datetime.datetime.max,
),
)
parametrize_invalid_type = pytest.mark.parametrize(
"value",
(
object(),
datetime.time(),
datetime.timedelta(),
1,
1.1,
(),
# https://github.com/pydantic/pydantic/security/advisories/GHSA-5jqp-qgf6-3pvh
float("inf"),
float("-inf"),
),
)
parametrize_invalid_str = pytest.mark.parametrize(
"value",
(
# https://github.com/pydantic/pydantic/security/advisories/GHSA-5jqp-qgf6-3pvh
"infinity",
"inf",
"-infinity",
"-inf",
"2022-13-24",
"20222-12-24",
"2022-12-32",
"foo",
),
)
min_utc = datetime.datetime.min.replace(tzinfo=datetime.timezone.utc)
max_utc = datetime.datetime.max.replace(tzinfo=datetime.timezone.utc)
parametrize_aware_str = pytest.mark.parametrize(
"value, expected",
[
(min_utc.isoformat(), min_utc),
(max_utc.isoformat(), max_utc),
(
"2022-09-24T10:40:20+00:00",
datetime.datetime(2022, 9, 24, 10, 40, 20, 0, tzinfo=datetime.timezone.utc),
),
(
"2022-09-24T10:40:20.779388+00:00",
datetime.datetime(
2022, 9, 24, 10, 40, 20, 779388, tzinfo=datetime.timezone.utc
),
),
],
)
parametrize_naive_str = pytest.mark.parametrize(
"value, expected",
[
(datetime.datetime.min.isoformat(), datetime.datetime.min),
(datetime.datetime.max.isoformat(), datetime.datetime.max),
(
"2022-09-24T10:40:20",
datetime.datetime(2022, 9, 24, 10, 40, 20, 0),
),
(
"2022-09-24T10:40:20.779388",
datetime.datetime(2022, 9, 24, 10, 40, 20, 779388),
),
],
)
class TestTZAware:
@parametrize_aware
def test_aware_datetime_is_instance(self, dt: datetime.datetime):
assert isinstance(dt, TZAware)
@parametrize_naive
def test_naive_datetime_is_not_instance(self, dt: datetime.datetime):
assert not isinstance(dt, TZAware)
@parametrize_naive
def test_instantiation_raises_for_naive_datetime_instance(
self, dt: datetime.datetime
):
with pytest.raises(TypeError):
TZAware.parse(dt)
@parametrize_aware
def test_instantiation_returns_instance(self, dt: datetime.datetime):
assert dt is TZAware.parse(dt)
@parametrize_invalid_type
def test_parse_rejects_non_str_object(self, value: object):
with pytest.raises(BoundError):
TZAware.parse(value)
@pytest.mark.external
@parametrize_invalid_str
def test_parse_rejects_invalid_str(self, value: object):
with pytest.raises(TypeError):
TZAware.parse(value)
@pytest.mark.external
@parametrize_naive_str
def test_parse_rejects_naive_str(self, value: str, expected: datetime.datetime):
with pytest.raises(TypeError):
TZAware.parse(value)
@pytest.mark.external
@parametrize_aware_str
def test_can_parse_valid_str(self, value: str, expected: datetime.datetime):
assert TZAware.parse(value) == expected
@pytest.mark.no_external
@parametrize_aware_str
def test_parse_str_without_dateutil_raises_missing_dependency(
self,
value: str,
expected: datetime.datetime,
):
with pytest.raises(MissingDependency):
TZAware.parse(value)
class TestTZNaive:
@parametrize_naive
def test_naive_datetime_is_instance(self, dt: datetime.datetime):
assert isinstance(dt, TZNaive)
@parametrize_aware
def test_aware_datetime_is_not_instance(self, dt: datetime.datetime):
assert not isinstance(dt, TZNaive)
@parametrize_aware
def test_instantiation_raises_for_aware_datetime_instance(
self, dt: datetime.datetime
):
with pytest.raises(TypeError):
TZNaive.parse(dt)
@parametrize_naive
def test_instantiation_returns_instance(self, dt: datetime.datetime):
assert dt is TZNaive.parse(dt)
@parametrize_invalid_type
def test_parse_rejects_non_str_object(self, value: object):
with pytest.raises(BoundError):
TZNaive.parse(value)
@pytest.mark.external
@parametrize_invalid_str
def test_parse_rejects_invalid_str(self, value: object):
with pytest.raises(TypeError):
TZNaive.parse(value)
@pytest.mark.external
@parametrize_aware_str
def test_parse_rejects_aware_str(self, value: str, expected: datetime.datetime):
with pytest.raises(TypeError):
TZNaive.parse(value)
@pytest.mark.external
@parametrize_naive_str
def test_can_parse_valid_str(self, value: str, expected: datetime.datetime):
assert TZNaive.parse(value) == expected
@pytest.mark.no_external
@parametrize_naive_str
def test_parse_str_without_dateutil_raises_missing_dependency(
self,
value: str,
expected: datetime.datetime,
):
with pytest.raises(MissingDependency):
TZNaive.parse(value)
================================================
FILE: tests/test_datetime.yaml
================================================
- case: calling_function_with_unknown_raises
main: |
import datetime
from phantom.datetime import TZAware
def take_aware(a: TZAware) -> TZAware:
return a
take_aware(datetime.datetime.now()) # E: Argument 1 to "take_aware" has incompatible type "datetime"; expected "TZAware" [arg-type]
- case: calling_function_with_known
main: |
import datetime
from phantom.datetime import TZAware
def take_aware(a: TZAware) -> TZAware:
return a
b = take_aware(
TZAware.parse(datetime.datetime.now(tz=datetime.timezone.utc))
)
reveal_type(b) # N: Revealed type is "phantom.datetime.TZAware"
- case: narrowing_to_tz_aware_makes_tzinfo_non_optional
main: |
import datetime
from phantom.datetime import TZAware
dt: datetime.datetime
reveal_type(dt.tzinfo) # N: Revealed type is "datetime.tzinfo | None"
assert isinstance(dt, TZAware)
reveal_type(dt.tzinfo) # N: Revealed type is "datetime.tzinfo"
- case: bound_is_not_erased
main: |
import datetime
from phantom.datetime import TZAware, TZNaive
o: datetime.datetime
aware = TZAware.parse(o)
naive = TZNaive.parse(o)
reveal_type(aware.month) # N: Revealed type is "builtins.int"
reveal_type(naive.month) # N: Revealed type is "builtins.int"
================================================
FILE: tests/test_fn.py
================================================
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Sequence
from functools import partial
from operator import add
from operator import attrgetter
from operator import itemgetter
from operator import mul
from typing import TypeVar
import pytest
from phantom import Predicate
from phantom.fn import _name
from phantom.fn import compose2
from phantom.fn import excepts
from phantom.predicates.boolean import both
from phantom.predicates.collection import count
from phantom.predicates.collection import every
from phantom.predicates.generic import equal
class Test_name:
class Nested:
def method(self): ...
@pytest.mark.parametrize(
"function, expected",
[
(lambda: None, "Test_name."),
(partial(int), "int"),
(Nested.method, "Test_name.Nested.method"),
(attrgetter("attrib"), "operator.attrgetter('attrib')"),
(itemgetter("key"), "operator.itemgetter('key')"),
],
)
def test_can_get_name_of(self, function: Callable, expected: str) -> None:
assert _name(function) == expected
def reversed_str(value: str) -> str:
return "".join(reversed(value))
AA = TypeVar("AA")
AR = TypeVar("AR")
BA = TypeVar("BA")
class TestCompose2:
@pytest.mark.parametrize(
"a, b, argument, expected, name",
[
(str.title, reversed_str, "test", "Tset", "str.title∘reversed_str"),
(reversed_str, str.title, "test", "tseT", "reversed_str∘str.title"),
(partial(add, 7), partial(mul, 3), 5, 22, "add∘mul"),
(partial(mul, 3), partial(add, 7), 5, 36, "mul∘add"),
],
)
def test_can_compose_two(
self,
a: Callable[[AA], AR],
b: Callable[[BA], AA],
argument: BA,
expected: AR,
name: str,
) -> None:
composed = compose2(a, b)
assert composed(argument) == expected
assert composed.__name__ == name
def test_can_compose_complex_predicate(self) -> None:
as_parts: Callable[[str], list[str]] = partial(str.split, sep=".")
is_valid_parts: Predicate[Sequence[str]] = both(
count(equal(3)),
every(str.isidentifier),
)
is_valid_name = compose2(is_valid_parts, as_parts)
assert is_valid_name("three.part.name") is True
assert is_valid_name("two.parts") is False
assert is_valid_name("not identifier.not.valid") is False
assert is_valid_name("") is False
class BaseError(Exception): ...
class Error(BaseError): ...
class ErrorA(Error): ...
class ErrorB(Error): ...
def dummy_function(val: type[Exception]) -> None:
if val is not None:
raise val
class TestExcepts:
@pytest.mark.parametrize(
"function, argument, return_value",
[
(excepts(Error)(dummy_function), ErrorA, False),
(excepts((ErrorA, ErrorB))(dummy_function), ErrorA, False),
(excepts((ErrorA, ErrorB))(dummy_function), None, True),
(excepts((ErrorA, ErrorB), negate=True)(dummy_function), None, False),
(excepts(Error, negate=True)(dummy_function), ErrorB, True),
],
)
def test_returns_bool(
self,
function: Callable,
argument: object,
return_value: bool,
) -> None:
assert function(argument) is return_value
def test_reraises(self) -> None:
with pytest.raises(BaseError):
excepts(Error)(dummy_function)(BaseError)
================================================
FILE: tests/test_fn.yaml
================================================
- case: test_compose2_can_infer_types
disable_cache: true
regex: true
main: |
from typing import Callable
from phantom import Predicate
from phantom.fn import compose2
from predicates import is_valid_name
reveal_type(is_valid_name)
is_valid_name(1)
is_valid_name([])
reveal_type(is_valid_name(""))
def takes_str_predicate(predicate: Predicate[str]) -> None: ...
takes_str_predicate(is_valid_name)
reveal_type(compose2(int, str)(2))
def takes_int_str_composed(fn: Callable[[int], int]) -> None: ...
takes_int_str_composed(compose2(int, str))
reveal_type(compose2(str, int)("3"))
def takes_str_int_composed(fn: Callable[[str], str]) -> None: ...
takes_str_int_composed(compose2(str, int))
out: |
main:6: note: Revealed type is "def \(builtins\.str\*?\) -> builtins\.bool\*?"
main:7: error: Argument 1 has incompatible type "int"; expected "str" \[arg-type\]
main:8: error: Argument 1 has incompatible type "list\[Never\]"; expected "str" \[arg-type\]
main:9: note: Revealed type is "builtins.bool\*?"
main:13: note: Revealed type is "builtins.int\*?"
main:17: note: Revealed type is "builtins.str\*?"
files:
- path: predicates.py
content: |
from __future__ import annotations
from functools import partial
from typing import Callable
from typing import Sequence
from phantom import Predicate
from phantom.fn import compose2
from phantom.predicates.boolean import both
from phantom.predicates.collection import count
from phantom.predicates.collection import every
from phantom.predicates.generic import equal
as_parts: Callable[[str], list[str]] = partial(str.split, sep=".")
is_valid_parts: Predicate[Sequence[str]] = both(
count(equal(3)),
every(str.isidentifier),
)
is_valid_name = compose2(is_valid_parts, as_parts)
================================================
FILE: tests/test_intersection.yaml
================================================
- case: asserting_twice_results_in_intersection_type
main: |
from phantom.interval import Portion, Exclusive
class Positive(float, Exclusive, min=0): ...
n = .5
assert isinstance(n, Positive)
assert isinstance(n, Portion)
reveal_type(n) # N: Revealed type is "main."
================================================
FILE: tests/test_interval.py
================================================
from __future__ import annotations
from decimal import Decimal
from functools import total_ordering
import pytest
from phantom.interval import Inclusive
from phantom.interval import Interval
from phantom.interval import Natural
from phantom.interval import NegativeInt
from phantom.interval import Portion
from phantom.interval import _get_scalar_float_bounds
from phantom.interval import _get_scalar_int_bounds
from phantom.interval import _NonScalarBounds
from phantom.predicates import interval
from .types import FloatInc
from .types import FloatIncExc
from .types import IntExc
from .types import IntExcInc
class TestInterval:
def test_subclassing_without_check_raises(self):
with pytest.raises(TypeError, match="A must define an interval check$"):
class A(Interval, abstract=False): ...
def test_parse_coerces_str(self):
class Great(int, Inclusive, low=10): ...
assert Great.parse("10") == 10
def test_allows_decimal_bound(self):
class A(
Decimal,
Interval,
check=interval.exclusive,
low=Decimal("1.15"),
high=Decimal("2.36"),
): ...
assert not isinstance(2, A)
assert not isinstance(1.98, A)
assert isinstance(Decimal("1.98"), A)
def test_subclass_inherits_bounds(self):
class A(int, Inclusive, low=-10, high=10): ...
class B(A): ...
assert B.__check__ is A.__check__
assert isinstance(-10, B)
assert isinstance(10, B)
assert not isinstance(-11, B)
assert not isinstance(11, B)
class C(A, low=0): ...
assert C.__check__ is A.__check__
assert isinstance(0, C)
assert isinstance(10, C)
assert not isinstance(-1, C)
assert not isinstance(11, C)
class D(A, high=0): ...
assert D.__check__ is A.__check__
assert isinstance(-10, D)
assert isinstance(0, D)
assert not isinstance(-11, D)
assert not isinstance(1, D)
parametrize_negative_ints = pytest.mark.parametrize("i", (-10, -1, -0, +0))
parametrize_positive_ints = pytest.mark.parametrize("i", (10, 1, +0, -0))
class TestNegativeInt:
@parametrize_negative_ints
def test_negative_int_is_instance(self, i):
assert isinstance(i, NegativeInt)
def test_positive_int_is_not_instance(self):
assert not isinstance(1, NegativeInt)
assert not isinstance(10, NegativeInt)
def test_instantiation_raises_for_positive_int(self):
with pytest.raises(TypeError):
NegativeInt.parse(1)
with pytest.raises(TypeError):
NegativeInt.parse(10)
@parametrize_negative_ints
def test_instantiation_returns_instance(self, i):
assert i is NegativeInt.parse(i)
class TestNatural:
@parametrize_positive_ints
def test_positive_int_is_instance(self, i):
assert isinstance(i, Natural)
def test_negative_int_is_not_instance(self):
assert not isinstance(-1, Natural)
assert not isinstance(-10, Natural)
def test_instantiation_raises_for_positive_int(self):
with pytest.raises(TypeError):
Natural.parse(-1)
with pytest.raises(TypeError):
Natural.parse(-10)
with pytest.raises(TypeError):
Natural(-1)
@parametrize_positive_ints
def test_instantiation_returns_instance(self, i):
assert i is Natural.parse(i)
assert i is Natural(i)
parametrize_portion_values = pytest.mark.parametrize(
"i", (0.0, 1.0, -0.0, +1.0, 0.8652559794322651)
)
# TODO: Use math.nextafter on Python 3.9 to test as close to limit as possible
parametrize_non_portion_values = pytest.mark.parametrize("i", (-1, -0.1, 1.1, 2))
class TestPortion:
@parametrize_portion_values
def test_value_inside_range_is_instance(self, i):
assert isinstance(i, Portion)
@parametrize_non_portion_values
def test_value_outside_range_is_instance(self, i):
assert not isinstance(i, Portion)
@parametrize_portion_values
def test_instantiation_returns_instance(self, i):
assert i is Portion.parse(i)
assert i is Portion(i)
@parametrize_non_portion_values
def test_instantiation_raises_for_non_portion_values(self, i):
with pytest.raises(TypeError):
Portion.parse(i)
with pytest.raises(TypeError):
Portion(i)
class TestGetScalarIntBounds:
@pytest.mark.parametrize(
("type_", "exclude_min", "exclude_max", "expected_low", "expected_high"),
(
(FloatInc, False, False, 0, 100),
(IntExc, True, True, 1, 99),
(IntExcInc, True, False, 1, 100),
(FloatIncExc, False, True, 0, 99),
(Natural, False, False, 0, None),
(NegativeInt, False, False, None, 0),
),
)
def test_returns_correct_bounds(
self,
type_: type[Interval],
exclude_min: bool,
exclude_max: bool,
expected_low: int | None,
expected_high: int | None,
):
(low, high) = _get_scalar_int_bounds(type_, exclude_min, exclude_max)
assert low == expected_low
assert high == expected_high
def test_raises_non_scalar_bounds_for_non_int_lower_bound(self):
@total_ordering
class Inf:
def __eq__(self, other):
return False
def __lt__(self, other):
return False
class Int(int, Inclusive, low=Inf(), high=100): ...
with pytest.raises(_NonScalarBounds):
_get_scalar_int_bounds(Int)
def test_raises_non_scalar_bounds_for_non_int_upper_bound(self):
@total_ordering
class Inf:
def __eq__(self, other):
return False
def __lt__(self, other):
return False
class Int(int, Inclusive, low=0, high=Inf()): ...
with pytest.raises(_NonScalarBounds):
_get_scalar_int_bounds(Int)
class TestGetScalarFloatBounds:
@pytest.mark.parametrize(
("type_", "expected_low", "expected_high"),
(
(FloatInc, 0, 100),
(Natural, 0, None),
(NegativeInt, None, 0),
(Portion, 0, 1),
),
)
def test_returns_correct_bounds(
self,
type_: type[Interval],
expected_low: int,
expected_high: int,
):
(low, high) = _get_scalar_float_bounds(type_)
assert low == expected_low
assert high == expected_high
def test_raises_non_scalar_bounds_for_non_int_lower_bound(self):
@total_ordering
class Inf:
def __eq__(self, other):
return False
def __lt__(self, other):
return False
class Int(float, Inclusive, low=Inf(), high=100): ...
with pytest.raises(_NonScalarBounds):
_get_scalar_float_bounds(Int)
def test_raises_non_scalar_bounds_for_non_int_upper_bound(self):
@total_ordering
class Inf:
def __eq__(self, other):
return False
def __lt__(self, other):
return False
class Int(float, Inclusive, low=0, high=Inf()): ...
with pytest.raises(_NonScalarBounds):
_get_scalar_float_bounds(Int)
================================================
FILE: tests/test_interval.yaml
================================================
- case: calling_function_with_unknown_raises
main: |
from phantom.interval import Natural
def take_nat(a: Natural) -> Natural:
return a
take_nat(1) # E: Argument 1 to "take_nat" has incompatible type "int"; expected "Natural" [arg-type]
- case: calling_function_with_known_1
main: |
from phantom.interval import Natural
def take_nat(a: Natural) -> Natural:
return a
b = take_nat(Natural.parse(1))
reveal_type(b) # N: Revealed type is "phantom.interval.Natural"
- case: calling_function_with_known_2
main: |
from phantom.interval import Natural
def from_int(a: int) -> Natural:
if not isinstance(a, Natural):
raise TypeError
reveal_type(a) # N: Revealed type is "phantom.interval.Natural"
return a
n = from_int(-1)
reveal_type(n) # N: Revealed type is "phantom.interval.Natural"
- case: test_overload
main: |
from typing import overload
from phantom.interval import Natural, NegativeInt
@overload
def add(a: Natural, b: Natural) -> Natural: ...
@overload
def add(a: NegativeInt, b: NegativeInt) -> NegativeInt: ...
@overload
def add(a: int, b: int) -> int: ...
def add(a: int, b: int) -> int:
return a + b
a = Natural.parse(1)
b = NegativeInt.parse(-1)
reveal_type(add(a, a)) # N: Revealed type is "phantom.interval.Natural"
reveal_type(add(b, b)) # N: Revealed type is "phantom.interval.NegativeInt"
reveal_type(add(a, b)) # N: Revealed type is "builtins.int"
reveal_type(add(b, a)) # N: Revealed type is "builtins.int"
================================================
FILE: tests/test_iso3166.py
================================================
import pytest
from phantom.iso3166 import InvalidCountryCode
from phantom.iso3166 import ParsedAlpha2
from phantom.iso3166 import normalize_alpha2_country_code
class TestNormalizeAlpha2CountryCode:
@pytest.mark.parametrize(
"given, normalized",
(("sE", "SE"), ("dk", "DK"), ("IE", "IE")),
)
def test_normalizes_mixed_case_valid_country_code(
self, given: str, normalized: str
) -> None:
assert normalize_alpha2_country_code(given) == normalized
@pytest.mark.parametrize("invalid", ("UK", "not a country code"))
def test_raises_for_invalid_country_code(self, invalid: str) -> None:
with pytest.raises(InvalidCountryCode):
normalize_alpha2_country_code(invalid)
class TestAlpha2:
@pytest.mark.parametrize("invalid", ("SP", "DA", "AV", 1))
def test_invalid_country_code_is_not_instance(self, invalid: object) -> None:
assert not isinstance(invalid, ParsedAlpha2)
@pytest.mark.parametrize("country_code", ("PS", "AD", "VA"))
def test_valid_country_code_is_instance(self, country_code: str) -> None:
assert isinstance(country_code, ParsedAlpha2)
def test_normalizes_valid_country_code(self) -> None:
country_code = ParsedAlpha2.parse("ps")
assert country_code == "PS"
assert isinstance(country_code, ParsedAlpha2)
@pytest.mark.parametrize("invalid", ("SP", "DA", "AV"))
def test_raises_for_invalid_country_code(self, invalid: str) -> None:
with pytest.raises(InvalidCountryCode):
ParsedAlpha2.parse(invalid)
================================================
FILE: tests/test_iso3166.yaml
================================================
- case: can_instantiate
main: |
from phantom.iso3166 import Alpha2, ParsedAlpha2
def takes_country_code(a: Alpha2) -> None:
...
takes_country_code(ParsedAlpha2.parse(""))
- case: can_use_literal
main: |
from phantom.iso3166 import Alpha2
def takes_country_code(a: Alpha2) -> None:
...
takes_country_code("FR")
- case: can_infer
main: |
from phantom.iso3166 import ParsedAlpha2, Alpha2
def takes_country_code(a: Alpha2) -> None:
...
a = ""
assert isinstance(a, ParsedAlpha2)
takes_country_code(a)
- case: bound_is_not_subtype
main: |
from phantom.iso3166 import CountryCode
def takes_country_code(a: CountryCode) -> None:
...
takes_country_code("fr")
out: |
main:6: error: Argument 1 to "takes_country_code" has incompatible type "Literal['fr']"; expected "Literal['AF', 'AX', 'AL', 'DZ', 'AS', 'AD', 'AO', 'AI', 'AQ', 'AG', 'AR', 'AM', 'AW', 'AU', 'AT', 'AZ', 'BS', 'BH', 'BD', 'BB', 'BY', 'BE', 'BZ', 'BJ', 'BM', 'BT', 'BO', 'BQ', 'BA', 'BW', 'BV', 'BR', 'IO', 'BN', 'BG', 'BF', 'BI', 'KH', 'CM', 'CA', 'CV', 'KY', 'CF', 'TD', 'CL', 'CN', 'CX', 'CC', 'CO', 'KM', 'CG', 'CD', 'CK', 'CR', 'CI', 'HR', 'CU', 'CW', 'CY', 'CZ', 'DK', 'DJ', 'DM', 'DO', 'EC', 'EG', 'SV', 'GQ', 'ER', 'EE', 'ET', 'FK', 'FO', 'FJ', 'FI', 'FR', 'GF', 'PF', 'TF', 'GA', 'GM', 'GE', 'DE', 'GH', 'GI', 'GR', 'GL', 'GD', 'GP', 'GU', 'GT', 'GG', 'GN', 'GW', 'GY', 'HT', 'HM', 'VA', 'HN', 'HK', 'HU', 'IS', 'IN', 'ID', 'IR', 'IQ', 'IE', 'IM', 'IL', 'IT', 'JM', 'JP', 'JE', 'JO', 'KZ', 'KE', 'KI', 'KP', 'KR', 'XK', 'KW', 'KG', 'LA', 'LV', 'LB', 'LS', 'LR', 'LY', 'LI', 'LT', 'LU', 'MO', 'MK', 'MG', 'MW', 'MY', 'MV', 'ML', 'MT', 'MH', 'MQ', 'MR', 'MU', 'YT', 'MX', 'FM', 'MD', 'MC', 'MN', 'ME', 'MS', 'MA', 'MZ', 'MM', 'NA', 'NR', 'NP', 'NL', 'NC', 'NZ', 'NI', 'NE', 'NG', 'NU', 'NF', 'MP', 'NO', 'OM', 'PK', 'PW', 'PS', 'PA', 'PG', 'PY', 'PE', 'PH', 'PN', 'PL', 'PT', 'PR', 'QA', 'RE', 'RO', 'RU', 'RW', 'BL', 'SH', 'KN', 'LC', 'MF', 'PM', 'VC', 'WS', 'SM', 'ST', 'SA', 'SN', 'RS', 'SC', 'SL', 'SG', 'SX', 'SK', 'SI', 'SB', 'SO', 'ZA', 'GS', 'SS', 'ES', 'LK', 'SD', 'SR', 'SJ', 'SZ', 'SE', 'CH', 'SY', 'TW', 'TJ', 'TZ', 'TH', 'TL', 'TG', 'TK', 'TO', 'TT', 'TN', 'TR', 'TM', 'TC', 'TV', 'UG', 'UA', 'AE', 'GB', 'US', 'UM', 'UY', 'UZ', 'VU', 'VE', 'VN', 'VG', 'VI', 'WF', 'EH', 'YE', 'ZM', 'ZW'] | ParsedAlpha2" [arg-type]
================================================
FILE: tests/test_negated.py
================================================
from typing import get_args
from typing import get_origin
import pytest
from phantom.negated import SequenceNotStr
parametrize_instances = pytest.mark.parametrize(
"value",
(
("foo", "bar", "baz"),
(1, 2, object()),
(b"hello", b"there"),
[],
["foo"],
[b"bar"],
),
)
parametrize_non_instances = pytest.mark.parametrize(
"value",
(
"",
"foo",
object(),
b"",
b"foo",
{},
set(),
frozenset(),
),
)
class TestSequenceNotStr:
@parametrize_instances
def test_is_instance(self, value: object):
assert isinstance(value, SequenceNotStr)
@parametrize_non_instances
def test_is_not_instance(self, value: object):
assert not isinstance(value, SequenceNotStr)
@parametrize_instances
def test_parse_returns_instance(self, value: object):
assert SequenceNotStr.parse(value) is value
@parametrize_non_instances
def test_parse_raises_for_non_instances(self, value: object):
with pytest.raises(TypeError):
SequenceNotStr.parse(value)
def test_subscription_returns_type_alias(self):
alias = SequenceNotStr[str]
assert get_origin(alias) is SequenceNotStr
(arg,) = get_args(alias)
assert arg is str
================================================
FILE: tests/test_negated.yaml
================================================
- case: test_subscripted
main: |
from phantom.negated import SequenceNotStr
def greet(names: SequenceNotStr[str]) -> str:
return f"Hello {', '.join(names)}"
seq = SequenceNotStr[str].parse(("Jane", "Joe"))
greeting = greet(seq)
reveal_type(greeting) # N: Revealed type is "builtins.str"
- case: test_annotated
main: |
from phantom.negated import SequenceNotStr
def greet(names: SequenceNotStr[str]) -> str:
return f"Hello {', '.join(names)}"
seq: SequenceNotStr[str] = SequenceNotStr.parse(("Jane", "Joe"))
greeting = greet(seq)
reveal_type(greeting) # N: Revealed type is "builtins.str"
- case: test_unspecified_inner_type
main: |
from phantom.negated import SequenceNotStr
from typing import Tuple
def fn(values: SequenceNotStr) -> SequenceNotStr[object]:
return values
seq: Tuple[object, ...] = (1, 2, object())
assert isinstance(seq, SequenceNotStr)
reveal_type(seq) # N: Revealed type is "main."
values = fn(seq)
reveal_type(values) # N: Revealed type is "phantom.negated.SequenceNotStr[builtins.object]"
================================================
FILE: tests/test_re.py
================================================
from __future__ import annotations
import re
import pytest
from phantom.re import FullMatch
from phantom.re import Match
class MatchPatternInstance(Match, pattern=re.compile(r"abc")): ...
class MatchPatternStr(Match, pattern=r"abc"): ...
parametrize_match = pytest.mark.parametrize(
"match_type", (MatchPatternInstance, MatchPatternStr)
)
class TestMatch:
@parametrize_match
def test_non_matching_string_is_not_instance(self, match_type: type[Match]):
assert not isinstance("abd", match_type)
@parametrize_match
def test_matching_string_is_instance(self, match_type: type[Match]):
assert isinstance("abcd", match_type)
@parametrize_match
def test_instantiation_raises_for_non_matching_string(
self, match_type: type[Match]
):
with pytest.raises(TypeError):
match_type.parse("b")
@parametrize_match
def test_instantiation_returns_instance(self, match_type: type[Match]):
s = "abc"
assert s is match_type.parse(s)
class FullMatchPatternInstance(FullMatch, pattern=re.compile(r"abc")): ...
class FullMatchStr(FullMatch, pattern=r"abc"): ...
parametrize_full_match = pytest.mark.parametrize(
"full_match_type", (FullMatchPatternInstance, FullMatchStr)
)
class TestFullMatch:
@parametrize_full_match
def test_non_matching_string_is_not_instance(
self, full_match_type: type[FullMatch]
):
assert not isinstance("abcd", full_match_type)
@parametrize_full_match
def test_matching_string_is_instance(self, full_match_type: type[FullMatch]):
assert isinstance("abc", full_match_type)
@parametrize_full_match
def test_instantiation_raises_for_non_matching_string(
self, full_match_type: type[FullMatch]
):
with pytest.raises(TypeError):
full_match_type.parse("b")
@parametrize_full_match
def test_instantiation_returns_instance(self, full_match_type: type[FullMatch]):
s = "abc"
assert s is full_match_type.parse(s)
================================================
FILE: tests/test_re.yaml
================================================
- case: str_is_not_subtype_of_match
main: |
import re
from phantom.re import Match
class A(Match, pattern=re.compile(r"^abc$")):
...
def take_a(a: A) -> A:
return a
take_a("c") # E: Argument 1 to "take_a" has incompatible type "str"; expected "A" [arg-type]
- case: str_is_not_subtype_of_full_match
main: |
import re
from phantom.re import FullMatch
class A(FullMatch, pattern=re.compile(r"^abc$")):
...
def take_a(a: A) -> A:
return a
take_a("c") # E: Argument 1 to "take_a" has incompatible type "str"; expected "A" [arg-type]
- case: can_instantiate_match
main: |
import re
from phantom.re import Match
class A(Match, pattern=re.compile(r"^abc$")):
...
def take_a(a: A) -> A:
return a
a = take_a(A.parse("abc"))
reveal_type(a) # N: Revealed type is "main.A"
- case: can_instantiate_full_match
main: |
import re
from phantom.re import FullMatch
class A(FullMatch, pattern=re.compile(r"^abc$")):
...
def take_a(a: A) -> A:
return a
a = take_a(A.parse("abc"))
reveal_type(a) # N: Revealed type is "main.A"
- case: can_infer_match
main: |
import re
from phantom.re import Match
class A(Match, pattern=re.compile(r"^abc$")):
...
def take_a(a: A) -> A:
return a
a = "abc"
assert isinstance(a, A)
a = take_a(a)
- case: can_infer_full_match
main: |
import re
from phantom.re import FullMatch
class A(FullMatch, pattern=re.compile(r"^abc$")):
...
def take_a(a: A) -> A:
return a
a = "abc"
assert isinstance(a, A)
a = take_a(a)
================================================
FILE: tests/test_sized.py
================================================
from dataclasses import dataclass
from typing import Final
from typing import Generic
from typing import TypeVar
from typing import get_args
from typing import get_origin
import pytest
from phantom.predicates.numeric import odd
from phantom.sized import Empty
from phantom.sized import LSPViolation
from phantom.sized import NonEmpty
from phantom.sized import NonEmptyStr
from phantom.sized import PhantomBound
from phantom.sized import PhantomSized
from phantom.sized import UnresolvedBounds
@dataclass
class MutableDataclass:
value: str = "foo"
parametrize_non_empty: Final = pytest.mark.parametrize(
"container",
((1,), frozenset({1}), "foo"),
)
parametrize_empty: Final = pytest.mark.parametrize(
"container",
((), frozenset(), ""),
)
parametrize_mutable: Final = pytest.mark.parametrize(
"container",
([], [1], set(), {1}, {}, {1: 2}, MutableDataclass()),
)
T = TypeVar("T")
class OddSize(PhantomSized[T], Generic[T], len=odd): ...
class TestPhantomSized:
odd_length = pytest.mark.parametrize(
"container",
[
(1,),
(1, 2, 3),
(1, 2, 3, 4, 5),
frozenset({1}),
frozenset({1, 2, 3, 4, 5}),
],
)
even_length = pytest.mark.parametrize(
"container",
[
(),
(1, 1),
(1, 1, 1, 2, 3, 6),
frozenset(),
frozenset({1, 2}),
],
)
@odd_length
def test_value_fulfilling_predicate_is_instance(self, container: object):
assert isinstance(container, OddSize)
@even_length
def test_value_not_fulfilling_predicate_is_not_instance(self, container: object):
assert not isinstance(container, OddSize)
@even_length
def test_instantiation_raises_for_invalid_length(self, container: object):
with pytest.raises(TypeError):
OddSize.parse(container)
@parametrize_mutable
def test_instantiation_raises_for_mutable(self, container: object):
with pytest.raises(TypeError):
OddSize.parse(container)
@parametrize_non_empty
def test_instantiation_returns_instance(self, container: object):
assert container is OddSize.parse(container)
def test_subscription_returns_type_alias(self):
alias = OddSize[tuple]
assert get_origin(alias) is OddSize
(arg,) = get_args(alias)
assert arg is tuple
class Tens(PhantomBound[T], Generic[T], min=10, max=19): ...
class TestPhantomBound:
valid = pytest.mark.parametrize(
"container",
[10 * (1,), 19 * (2,)],
)
invalid = pytest.mark.parametrize(
"container",
[9 * (1,), 20 * (2,)],
)
@valid
def test_value_fulfilling_predicate_is_instance(self, container: object):
assert isinstance(container, Tens)
@invalid
def test_value_not_fulfilling_predicate_is_not_instance(self, container: object):
assert not isinstance(container, Tens)
@invalid
def test_instantiation_raises_for_invalid_length(self, container: object):
with pytest.raises(TypeError):
Tens.parse(container)
@parametrize_mutable
def test_instantiation_raises_for_mutable(self, container: object):
with pytest.raises(TypeError):
Tens.parse(container)
@valid
def test_instantiation_returns_instance(self, container: object):
assert container is Tens.parse(container)
def test_subscription_returns_type_alias(self):
alias = Tens[tuple]
assert get_origin(alias) is Tens
(arg,) = get_args(alias)
assert arg is tuple
def test_raises_lsp_violation_when_attempting_to_decrease_min(self):
with pytest.raises(LSPViolation):
class Lower(Tens, min=9): ...
def test_raises_lsp_violation_when_attempting_to_increase_max(self):
with pytest.raises(LSPViolation):
class Higher(Tens, max=20): ...
def test_can_narrow_range_in_subclass(self):
class Fewer(Tens, min=11, max=18): ...
assert isinstance(11 * (0,), Fewer)
assert isinstance(18 * (0,), Fewer)
assert not isinstance(10 * (0,), Fewer)
assert not isinstance(19 * (0,), Fewer)
def test_abstract_subclass_can_omit_bounds(self):
class A(PhantomBound, abstract=True): ...
class B(A, min=10, max=20): ...
assert B.__min__ == 10
assert B.__max__ == 20
def test_raises_unresolved_bounds_when_concrete_subclass_omits_bounds(self):
with pytest.raises(UnresolvedBounds):
class A(PhantomBound): ...
class TestNonEmpty:
@parametrize_non_empty
def test_non_empty_container_is_instance(self, container):
assert isinstance(container, NonEmpty)
@parametrize_empty
def test_empty_container_is_instance(self, container):
assert not isinstance(container, NonEmpty)
@parametrize_empty
def test_instantiation_raises_for_empty_container(self, container):
with pytest.raises(TypeError):
NonEmpty.parse(container)
@parametrize_mutable
def test_instantiation_raises_for_mutable(self, container):
with pytest.raises(TypeError):
NonEmpty.parse(container)
@parametrize_non_empty
def test_instantiation_returns_instance(self, container):
assert container is NonEmpty.parse(container)
def test_subscription_returns_type_alias(self):
alias = NonEmpty[tuple]
assert get_origin(alias) is NonEmpty
(arg,) = get_args(alias)
assert arg is tuple
class TestEmpty:
@parametrize_non_empty
def test_non_empty_container_is_instance(self, container):
assert not isinstance(container, Empty)
@parametrize_empty
def test_empty_container_is_instance(self, container):
assert isinstance(container, Empty)
@parametrize_non_empty
def test_instantiation_raises_for_non_empty_container(self, container):
with pytest.raises(TypeError):
Empty.parse(container)
@parametrize_mutable
def test_instantiation_raises_for_mutable(self, container):
with pytest.raises(TypeError):
Empty.parse(container)
@parametrize_empty
def test_instantiation_returns_instance(self, container):
assert container is Empty.parse(container)
def test_subscription_returns_type_alias(self):
alias = Empty[frozenset]
assert get_origin(alias) is Empty
(arg,) = get_args(alias)
assert arg is frozenset
parametrize_non_empty_strs: Final = pytest.mark.parametrize(
"value",
("foo", "bar", " "),
)
class TestNonEmptyStr:
@parametrize_non_empty_strs
def test_non_empty_str_is_instance(self, value: str):
assert isinstance(value, NonEmptyStr)
def test_empty_str_is_not_instance(self):
assert not isinstance("", NonEmptyStr)
def test_instantiation_raises_for_empty_str(self):
with pytest.raises(TypeError):
NonEmptyStr.parse("")
@pytest.mark.parametrize(
"value",
(b"", b"foo", [], ["foo"], (), ("foo",)),
)
def test_instantiation_raises_for_non_str(self, value: object):
with pytest.raises(TypeError):
NonEmptyStr.parse(value)
@parametrize_non_empty_strs
def test_instantiation_returns_instance(self, value: str):
assert value is NonEmptyStr.parse(value)
================================================
FILE: tests/test_sized.yaml
================================================
- case: test_subtype
main: |
from phantom.sized import NonEmpty
from typing import Tuple
class AtLeastOneInt(Tuple[int, ...], NonEmpty[int]): ...
def fst(things: AtLeastOneInt) -> int:
return things[0]
f = fst(AtLeastOneInt.parse((1, 2)))
reveal_type(f) # N: Revealed type is "builtins.int"
- case: test_subscripted
main: |
from phantom.sized import NonEmpty
def fst(col: NonEmpty[int]) -> int:
return next(iter(col))
l = NonEmpty[int].parse((0,))
i = fst(l)
reveal_type(i) # N: Revealed type is "builtins.int"
- case: test_annotated
main: |
from phantom.sized import NonEmpty
def fst(col: NonEmpty[int]) -> int:
return next(iter(col))
l: NonEmpty[int] = NonEmpty.parse((0,))
i = fst(l)
reveal_type(i) # N: Revealed type is "builtins.int"
- case: test_str
main: |
from phantom.sized import NonEmpty
def name_length(name: NonEmpty[str]) -> int:
return len(name)
i = name_length(NonEmpty.parse("foo"))
reveal_type(i) # N: Revealed type is "builtins.int"
================================================
FILE: tests/test_utils.py
================================================
from typing import Union
import pytest
from phantom._utils.misc import BoundType
from phantom._utils.misc import is_subtype
class A: ...
class B: ...
class C: ...
class AAndB(A, B): ...
class SubOfA(A): ...
class TestIsSubtype:
# The cases are annotated with the numbered cases in the docstring of the function.
@pytest.mark.parametrize(
"a, b",
[
# 1
(int | float, int | float),
# 2
(int, int | float),
# Special case of 3
(Union[int], int), # noqa: UP007
# 4
(AAndB, (A, B)),
(AAndB, (A,)),
# 5
((B, SubOfA), A),
# 6
((SubOfA, C), A | B),
# Special case of 7
(Union[int], (int,)), # noqa: UP007
# 8
((AAndB, SubOfA), (A, B)),
# 9
(SubOfA, A),
],
)
def test_returns_true_for_valid_subtype(self, a: BoundType, b: BoundType) -> None:
assert is_subtype(a, b) is True
# The cases are annotated with the numbered cases in the docstring of the function.
@pytest.mark.parametrize(
"a, b",
[
# 1
(int | float, Union[int]), # noqa: UP007
(int | float, A | B),
# 2
(str, int | float),
# 3
(int | float, float),
# 4
(SubOfA, (A, B)),
(SubOfA, (B,)),
# 5
((A, B), SubOfA),
# 6
((int, C), A | B),
# 7
(int | float, (int, float)),
# 8
((AAndB, SubOfA), (A, B, C)),
# 9
(SubOfA, B),
(A, B),
],
)
def test_returns_false_for_valid_subtype(self, a: BoundType, b: BoundType) -> None:
assert is_subtype(a, b) is False
================================================
FILE: tests/types.py
================================================
from phantom.interval import Exclusive
from phantom.interval import ExclusiveInclusive
from phantom.interval import Inclusive
from phantom.interval import InclusiveExclusive
class FloatInc(float, Inclusive, low=0, high=100): ...
class IntInc(int, Inclusive, low=0, high=100): ...
class FloatExc(float, Exclusive, low=0, high=100): ...
class IntExc(int, Exclusive, low=0, high=100): ...
class FloatIncExc(float, InclusiveExclusive, low=0, high=100): ...
class IntIncExc(int, InclusiveExclusive, low=0, high=100): ...
class FloatExcInc(float, ExclusiveInclusive, low=0, high=100): ...
class IntExcInc(int, ExclusiveInclusive, low=0, high=100): ...
================================================
FILE: typing-requirements.txt
================================================
# This file was autogenerated by uv via the following command:
# 'make typing-requirements'
beartype==0.22.5 \
--hash=sha256:516a9096cc77103c96153474fa35c3ebcd9d36bd2ec8d0e3a43307ced0fa6341 \
--hash=sha256:d9743dd7cd6d193696eaa1e025f8a70fb09761c154675679ff236e61952dfba0
# via numerary
hypothesis==6.147.0 \
--hash=sha256:72e6004ea3bd1460bdb4640b6389df23b87ba7a4851893fd84d1375635d3e507 \
--hash=sha256:de588807b6da33550d32f47bcd42b1a86d061df85673aa73e6443680249d185e
# via phantom-types (pyproject.toml)
iniconfig==2.3.0 \
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
# via pytest
mypy==1.18.2 \
--hash=sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914 \
--hash=sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b \
--hash=sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b \
--hash=sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc \
--hash=sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544 \
--hash=sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86 \
--hash=sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d \
--hash=sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075 \
--hash=sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e \
--hash=sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac \
--hash=sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b \
--hash=sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34 \
--hash=sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37 \
--hash=sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b \
--hash=sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428 \
--hash=sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893 \
--hash=sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce \
--hash=sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8 \
--hash=sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c \
--hash=sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf \
--hash=sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341 \
--hash=sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e \
--hash=sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba \
--hash=sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed \
--hash=sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f \
--hash=sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d \
--hash=sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8 \
--hash=sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764 \
--hash=sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d \
--hash=sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0 \
--hash=sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c \
--hash=sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133 \
--hash=sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986 \
--hash=sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6 \
--hash=sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074 \
--hash=sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb \
--hash=sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e \
--hash=sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66
# via phantom-types (pyproject.toml)
mypy-extensions==1.1.0 \
--hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \
--hash=sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558
# via mypy
numerary==0.4.4 \
--hash=sha256:ad955ddf7f5275f8e52f5520b2d6c654cc3bf1e3ae4bfb45664c9d51b208d0c6
# via phantom-types (pyproject.toml)
packaging==25.0 \
--hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
--hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
# via pytest
pathspec==0.12.1 \
--hash=sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 \
--hash=sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712
# via mypy
phonenumbers==9.0.18 \
--hash=sha256:5537c61ba95b11b992c95e804da6e49193cc06b1224f632ade64631518a48ed1 \
--hash=sha256:d3354454ac31c97f8a08121df97a7145b8dca641f734c6f1518a41c2f60c5764
# via phantom-types (pyproject.toml)
pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
# via pytest
pydantic==1.10.24 \
--hash=sha256:02f7a25e8949d8ca568e4bcef2ffed7881d7843286e7c3488bdd3b67f092059c \
--hash=sha256:076fff9da02ca716e4c8299c68512fdfbeac32fdefc9c160e6f80bdadca0993d \
--hash=sha256:093768eba26db55a88b12f3073017e3fdee319ef60d3aef5c6c04a4e484db193 \
--hash=sha256:0cbbf306124ae41cc153fdc2559b37faa1bec9a23ef7b082c1756d1315ceffe6 \
--hash=sha256:17e7610119483f03954569c18d4de16f4e92f1585f20975414033ac2d4a96624 \
--hash=sha256:1a1ae996daa3d43c530b8d0bacc7e2d9cb55e3991f0e6b7cc2cb61a0fb9f6667 \
--hash=sha256:25fb9a69a21d711deb5acefdab9ff8fb49e6cc77fdd46d38217d433bff2e3de2 \
--hash=sha256:265788a1120285c4955f8b3d52b3ea6a52c7a74db097c4c13a4d3567f0c6df3c \
--hash=sha256:2d1a5ef77efeb54def2695f2b8f4301aae8c7aa2b334bd15f61c18ef54317621 \
--hash=sha256:34109b0afa63b36eec2f2b115694e48ae5ee52f7d3c1baa0be36f80e586bda52 \
--hash=sha256:415c638ca5fd57b915a62dd38c18c8e0afe5adf5527be6f8ce16b4636b616816 \
--hash=sha256:49a6f0178063f15eaea6cbcb2dba04db0b73db9834bc7b1e1c4dbea28c7cd22f \
--hash=sha256:4a9e92b9c78d7f3cfa085c21c110e7000894446e24a836d006aabfc6ae3f1813 \
--hash=sha256:4d7336bfcdb8cb58411e6b498772ba2cff84a2ce92f389bae3a8f1bb2c840c49 \
--hash=sha256:50d9f8a207c07f347d4b34806dc576872000d9a60fd481ed9eb78ea8512e0666 \
--hash=sha256:52219b4e70c1db185cfd103a804e416384e1c8950168a2d4f385664c7c35d21a \
--hash=sha256:58d42a7c344882c00e3bb7c6c8c6f62db2e3aafa671f307271c45ad96e8ccf7a \
--hash=sha256:5a42033fac69b9f1f867ecc3a2159f0e94dceb1abfc509ad57e9e88d49774683 \
--hash=sha256:5ce0986799248082e9a5a026c9b5d2f9fa2e24d2afb9b0eace9104334a58fdc1 \
--hash=sha256:5da2775712dda8b89e701ed2a72d5d81d23dbc6af84089da8a0f61a0be439c8c \
--hash=sha256:5fc35569dfd15d3b3fc06a22abee0a45fdde0784be644e650a8769cd0b2abd94 \
--hash=sha256:6af36a8fb3072526b5b38d3f341b12d8f423188e7d185f130c0079fe02cdec7f \
--hash=sha256:6f25d2f792afcd874cc8339c1da1cc52739f4f3d52993ed1f6c263ef2afadc47 \
--hash=sha256:70152291488f8d2bbcf2027b5c28c27724c78a7949c91b466d28ad75d6d12702 \
--hash=sha256:75259be0558ca3af09192ad7b18557f2e9033ad4cbd48c252131f5292f6374fd \
--hash=sha256:7c8bbad6037a87effe9f3739bdf39851add6e0f7e101d103a601c504892ffa70 \
--hash=sha256:7e6d1af1bd3d2312079f28c9baf2aafb4a452a06b50717526e5ac562e37baa53 \
--hash=sha256:8057172868b0d98f95e6fcddcc5f75d01570e85c6308702dd2c50ea673bc197b \
--hash=sha256:82f951210ebcdb778b1d93075af43adcd04e9ebfd4f44b1baa8eeb21fbd71e36 \
--hash=sha256:874a78e4ed821258295a472e325eee7de3d91ba7a61d0639ce1b0367a3c63d4c \
--hash=sha256:8f2447ca88a7e14fd4d268857521fb37535c53a367b594fa2d7c2551af905993 \
--hash=sha256:956b30638272c51c85caaff76851b60db4b339022c0ee6eca677c41e3646255b \
--hash=sha256:9c377fc30d9ca40dbff5fd79c5a5e1f0d6fff040fa47a18851bb6b0bd040a5d8 \
--hash=sha256:a5bf94042efbc6ab56b18a5921f426ebbeefc04f554a911d76029e7be9057d01 \
--hash=sha256:af31565b12a7db5bfa5fe8c3a4f8fda4d32f5c2929998b1b241f1c22e9ab6e69 \
--hash=sha256:af8e2b3648128b8cadb1a71e2f8092a6f42d4ca123fad7a8d7ce6db8938b1db3 \
--hash=sha256:b644d6f14b2ce617d6def21622f9ba73961a16b7dffdba7f6692e2f66fa05d00 \
--hash=sha256:b66e4892d8ae005f436a5c5f1519ecf837574d8414b1c93860fb3c13943d9b37 \
--hash=sha256:bb3df10be3c7d264947180615819aeec0916f19650f2ba7309ed1fe546ead0d2 \
--hash=sha256:bed9d6eea5fabbc6978c42e947190c7bd628ddaff3b56fc963fe696c3710ccd6 \
--hash=sha256:c626596c1b95dc6d45f7129f10b6743fbb50f29d942d25a22b2ceead670c067d \
--hash=sha256:d255bebd927e5f1e026b32605684f7b6fc36a13e62b07cb97b29027b91657def \
--hash=sha256:d6e45dbc79a44e34c2c83ef1fcb56ff663040474dcf4dfc452db24a1de0f7574 \
--hash=sha256:e24435a9970dcb2b35648f2cf57505d4bd414fcca1a404c82e28d948183fe0a6 \
--hash=sha256:eef07ea2fba12f9188cfa2c50cb3eaa6516b56c33e2a8cc3cd288b4190ee6c0c \
--hash=sha256:ef14dfa7c98b314a3e449e92df6f1479cafe74c626952f353ff0176b075070de \
--hash=sha256:f154a8a46a0d950c055254f8f010ba07e742ac4404a3b6e281a31913ac45ccd0 \
--hash=sha256:fa0ebefc169439267e4b4147c7d458908788367640509ed32c90a91a63ebb579 \
--hash=sha256:fac7fbcb65171959973f3136d0792c3d1668bc01fd414738f0898b01f692f1b4 \
--hash=sha256:fc3f4a6544517380658b63b144c7d43d5276a343012913b7e5d18d9fba2f12bb
# via phantom-types (pyproject.toml)
pygments==2.19.2 \
--hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \
--hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b
# via pytest
pytest==9.0.0 \
--hash=sha256:8f44522eafe4137b0f35c9ce3072931a788a21ee40a2ed279e817d3cc16ed21e \
--hash=sha256:e5ccdf10b0bac554970ee88fc1a4ad0ee5d221f8ef22321f9b7e4584e19d7f96
# via phantom-types (pyproject.toml)
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via phantom-types (pyproject.toml)
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil
sortedcontainers==2.4.0 \
--hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \
--hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0
# via hypothesis
typeguard==4.4.4 \
--hash=sha256:3a7fd2dffb705d4d0efaed4306a704c89b9dee850b688f060a8b1615a79e5f74 \
--hash=sha256:b5f562281b6bfa1f5492470464730ef001646128b180769880468bd84b68b09e
# via phantom-types (pyproject.toml)
types-python-dateutil==2.9.0.20251108 \
--hash=sha256:a4a537f0ea7126f8ccc2763eec9aa31ac8609e3c8e530eb2ddc5ee234b3cd764 \
--hash=sha256:d8a6687e197f2fa71779ce36176c666841f811368710ab8d274b876424ebfcaa
# via phantom-types (pyproject.toml)
typing-extensions==4.15.0 \
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
# via
# phantom-types (pyproject.toml)
# mypy
# pydantic
# typeguard