├── .copier-answers.yml ├── .github ├── ISSUE_TEMPLATE.md ├── TEST_FAIL_TEMPLATE.md ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── pyproject.toml ├── src └── fieldz │ ├── __init__.py │ ├── _functions.py │ ├── _repr.py │ ├── _types.py │ ├── adapters │ ├── __init__.py │ ├── _attrs.py │ ├── _dataclasses.py │ ├── _dataclassy.py │ ├── _msgspec.py │ ├── _named_tuple.py │ ├── _pydantic.py │ ├── _typed_dict.py │ └── protocol.py │ └── py.typed └── tests ├── test_annotations.py ├── test_fieldz.py ├── test_pydantic.py └── test_repr.py /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Do not edit - changes here will be overwritten by Copier 2 | _commit: v1.0 3 | _src_path: gh:pydev-guide/pyrepo-copier 4 | author_email: talley.lambert@gmail.com 5 | author_name: Talley Lambert 6 | github_username: pyapp-kit 7 | mode: tooling 8 | module_name: fieldz 9 | project_name: fieldz 10 | project_short_description: Utilities for providing compatibility with many dataclass-like 11 | libraries 12 | 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * fieldz version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.github/TEST_FAIL_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "{{ env.TITLE }}" 3 | labels: [bug] 4 | --- 5 | The {{ workflow }} workflow failed on {{ date | date("YYYY-MM-DD HH:mm") }} UTC 6 | 7 | The most recent failing test was on {{ env.PLATFORM }} py{{ env.PYTHON }} 8 | with commit: {{ sha }} 9 | 10 | Full run: https://github.com/{{ repo }}/actions/runs/{{ env.RUN_ID }} 11 | 12 | (This post will be updated if another test fails, as long as this issue remains open.) 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | commit-message: 10 | prefix: "ci(dependabot):" 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: [v*] 7 | pull_request: 8 | workflow_dispatch: 9 | schedule: 10 | - cron: "0 0 * * *" # run daily 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | check-manifest: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v5 21 | - run: pipx run check-manifest 22 | 23 | test: 24 | uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2 25 | with: 26 | os: ${{ matrix.os }} 27 | python-version: ${{ matrix.python-version }} 28 | pip-post-installs: ${{ matrix.pydantic }} 29 | pip-install-pre-release: ${{ github.event_name == 'schedule' }} 30 | coverage-upload: artifact 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | python-version: ["3.8", "3.10", "3.12"] 35 | os: [ubuntu-latest, macos-latest, windows-latest] 36 | pydantic: ["pydantic", "'pydantic<2'"] 37 | include: 38 | - python-version: "3.9" 39 | os: ubuntu-latest 40 | pydantic: "pydantic" 41 | - python-version: "3.11" 42 | os: ubuntu-latest 43 | pydantic: "pydantic" 44 | 45 | upload_coverage: 46 | if: always() 47 | needs: [test] 48 | uses: pyapp-kit/workflows/.github/workflows/upload-coverage.yml@v2 49 | secrets: inherit 50 | 51 | deploy: 52 | name: Deploy 53 | needs: test 54 | if: success() && startsWith(github.ref, 'refs/tags/') && github.event_name != 'schedule' 55 | runs-on: ubuntu-latest 56 | 57 | permissions: 58 | id-token: write # for trusted publishing on PyPi 59 | contents: write # allows writing releases 60 | 61 | steps: 62 | - uses: actions/checkout@v5 63 | with: 64 | fetch-depth: 0 65 | 66 | - name: 🐍 Set up Python 67 | uses: actions/setup-python@v6 68 | with: 69 | python-version: "3.x" 70 | 71 | - name: 👷 Build 72 | run: | 73 | python -m pip install build 74 | python -m build 75 | 76 | - name: 🚢 Publish to PyPI 77 | uses: pypa/gh-action-pypi-publish@release/v1 78 | 79 | - uses: softprops/action-gh-release@v2 80 | with: 81 | generate_release_notes: true 82 | files: "./dist/*" 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | .DS_Store 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # dotenv 86 | .env 87 | 88 | # virtualenv 89 | .venv 90 | venv/ 91 | ENV/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # ruff 107 | .ruff_cache/ 108 | 109 | # IDE settings 110 | .vscode/ 111 | .idea/ 112 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | autofix_commit_msg: "style(pre-commit.ci): auto fixes [...]" 4 | autoupdate_commit_msg: "ci(pre-commit.ci): autoupdate" 5 | 6 | repos: 7 | - repo: https://github.com/abravalheri/validate-pyproject 8 | rev: v0.24.1 9 | hooks: 10 | - id: validate-pyproject 11 | 12 | - repo: https://github.com/astral-sh/ruff-pre-commit 13 | rev: v0.13.2 14 | hooks: 15 | - id: ruff-check 16 | args: [--fix] 17 | - id: ruff-format 18 | 19 | - repo: https://github.com/pre-commit/mirrors-mypy 20 | rev: v1.18.2 21 | hooks: 22 | - id: mypy 23 | files: "^src/" 24 | additional_dependencies: 25 | - pydantic>=2 26 | - attrs 27 | - dataclassy 28 | - msgspec 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Talley Lambert 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fieldz 2 | 3 | [![License](https://img.shields.io/pypi/l/fieldz.svg?color=green)](https://github.com/pyapp-kit/fieldz/raw/main/LICENSE) 4 | [![PyPI](https://img.shields.io/pypi/v/fieldz.svg?color=green)](https://pypi.org/project/fieldz) 5 | [![Python Version](https://img.shields.io/pypi/pyversions/fieldz.svg?color=green)](https://python.org) 6 | [![CI](https://github.com/pyapp-kit/fieldz/actions/workflows/ci.yml/badge.svg)](https://github.com/pyapp-kit/fieldz/actions/workflows/ci.yml) 7 | [![codecov](https://codecov.io/gh/pyapp-kit/fieldz/branch/main/graph/badge.svg)](https://codecov.io/gh/pyapp-kit/fieldz) 8 | 9 | Unified API for working with multiple dataclass-like libraries 10 | 11 | ## Dataclass patterns 12 | 13 | There are many libraries that implement a similar dataclass-like pattern! 14 | 15 | ### [`dataclasses.dataclass`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass) 16 | 17 | ```python 18 | import dataclasses 19 | 20 | @dataclasses.dataclass 21 | class SomeDataclass: 22 | a: int = 0 23 | b: str = "b" 24 | c: list[int] = dataclasses.field(default_factory=list) 25 | ``` 26 | 27 | ### [`pydantic.BaseModel`](https://docs.pydantic.dev/latest/) 28 | 29 | ```python 30 | import pydantic 31 | 32 | class SomePydanticModel(pydantic.BaseModel): 33 | a: int = 0 34 | b: str = "b" 35 | c: list[int] = pydantic.Field(default_factory=list) 36 | ``` 37 | 38 | ### [`attrs.define`](https://www.attrs.org/en/stable/overview.html) 39 | 40 | ```python 41 | import attrs 42 | 43 | @attrs.define 44 | class SomeAttrsModel: 45 | a: int = 0 46 | b: str = "b" 47 | c: list[int] = attrs.field(factory=list) 48 | ``` 49 | 50 | ### [`msgspec.Struct`](https://jcristharif.com/msgspec/) 51 | 52 | ```python 53 | import msgspec 54 | 55 | class SomeMsgspecStruct(msgspec.Struct): 56 | a: int = 0 57 | b: str = "b" 58 | c: list[int] = msgspec.field(default_factory=list) 59 | ``` 60 | 61 | etc... 62 | 63 | ## Unified API 64 | 65 | These are all awesome libraries, and each has its own strengths and weaknesses. 66 | Sometimes, however, you just want to be able to query basic information about a 67 | dataclass-like object, such as getting field names or types, or converting it to 68 | a dictionary. 69 | 70 | `fieldz` provides a unified API for these operations (following or 71 | extending the API from `dataclasses` when possible). 72 | 73 | ```python 74 | def fields(obj: Any) -> tuple[Field, ...]: 75 | """Return a tuple of fieldz.Field objects for the object.""" 76 | 77 | def replace(obj: Any, /, **changes: Any) -> Any: 78 | """Return a copy of obj with the specified changes.""" 79 | 80 | def asdict(obj: Any) -> dict[str, Any]: 81 | """Return a dict representation of obj.""" 82 | 83 | def astuple(obj: Any) -> tuple[Any, ...]: 84 | """Return a tuple representation of obj.""" 85 | 86 | def params(obj: Any) -> DataclassParams: 87 | """Return parameters used to define the dataclass.""" 88 | ``` 89 | 90 | The `fieldz.Field` and `fieldz.DataclassParam` objects are 91 | simple dataclasses that match the protocols of `dataclasses.Field` and the 92 | (private) `dataclasses._DataclassParams` objects, respectively. The field object 93 | also adds a `native_field` attribute that is the original field object from the 94 | underlying library. 95 | 96 | ### Example 97 | 98 | ```python 99 | from fieldz import Field, fields 100 | 101 | standardized_fields = ( 102 | Field(name="a", type=int, default=0), 103 | Field(name="b", type=str, default="b"), 104 | Field(name="c", type=list[int], default_factory=list), 105 | ) 106 | 107 | assert ( 108 | fields(SomeDataclass) 109 | == fields(SomePydanticModel) 110 | == fields(SomeAttrsModel) 111 | == fields(SomeMsgspecStruct) 112 | == standardized_fields 113 | ) 114 | ``` 115 | 116 | ### Supported libraries 117 | 118 | - [x] [`dataclasses`](https://docs.python.org/3/library/dataclasses.html) 119 | - [x] [`collections.namedtuple`](https://docs.python.org/3/library/collections.html#collections.namedtuple) 120 | - [x] [`pydantic`](https://docs.pydantic.dev/latest/) (v1 and v2) 121 | - [x] [`attrs`](https://www.attrs.org/en/stable/overview.html) 122 | - [x] [`msgspec`](https://jcristharif.com/msgspec/) 123 | - [x] [`dataclassy`](https://github.com/biqqles/dataclassy) 124 | - [x] [`sqlmodel`](https://sqlmodel.tiangolo.com) (it's just pydantic) 125 | 126 | ... maybe someday? 127 | 128 | - [ ] [`pyfields`](https://smarie.github.io/python-pyfields/) 129 | - [ ] [`marshmallow`](https://marshmallow.readthedocs.io/en/stable/quickstart.html) 130 | - [ ] [`sqlalchemy`](https://docs.sqlalchemy.org/en/20/orm/quickstart.html) 131 | - [ ] [`django`](https://docs.djangoproject.com/en/dev/topics/db/models/) 132 | - [ ] [`peewee`](http://docs.peewee-orm.com/en/latest/peewee/models.html#models) 133 | - [ ] [`pyrsistent`](https://github.com/tobgu/pyrsistent/) 134 | - [ ] [`recordclass`](https://pypi.org/project/recordclass/) 135 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # https://peps.python.org/pep-0517/ 2 | [build-system] 3 | requires = ["hatchling", "hatch-vcs"] 4 | build-backend = "hatchling.build" 5 | 6 | [tool.hatch.version] 7 | source = "vcs" 8 | 9 | [tool.hatch.build.targets.wheel] 10 | only-include = ["src"] 11 | sources = ["src"] 12 | 13 | # https://peps.python.org/pep-0621/ 14 | [project] 15 | name = "fieldz" 16 | dynamic = ["version"] 17 | description = "Utilities for providing compatibility with many dataclass-like libraries" 18 | readme = "README.md" 19 | requires-python = ">=3.8" 20 | license = { text = "BSD-3-Clause" } 21 | authors = [{ name = "Talley Lambert", email = "talley.lambert@gmail.com" }] 22 | classifiers = [ 23 | "Development Status :: 3 - Alpha", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3.8", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "License :: OSI Approved :: BSD License", 31 | "Typing :: Typed", 32 | ] 33 | dependencies = ["typing_extensions"] 34 | 35 | [project.optional-dependencies] 36 | test = [ 37 | "pytest", 38 | "pytest-cov", 39 | "attrs", 40 | "dataclassy", 41 | "msgspec", 42 | "pydantic", 43 | "annotated_types", 44 | ] 45 | dev = ["ipython", "mypy", "pdbpp", "rich", "ruff", "hatch", "pre-commit-uv"] 46 | 47 | [project.urls] 48 | homepage = "https://github.com/pyapp-kit/fieldz" 49 | repository = "https://github.com/pyapp-kit/fieldz" 50 | 51 | 52 | # these let you run tests across all backends easily with: 53 | # hatch run test:test 54 | [tool.hatch.envs.test] 55 | 56 | [tool.hatch.envs.test.scripts] 57 | test = "pytest" 58 | 59 | [[tool.hatch.envs.test.matrix]] 60 | pydantic = ["v1", "v2"] 61 | python = ["3.8", "3.11"] 62 | 63 | [tool.hatch.envs.test.overrides] 64 | matrix.pydantic.extra-dependencies = [ 65 | { value = "pydantic<2", if = [ 66 | "v1", 67 | ] }, 68 | { value = "pydantic>=2", if = [ 69 | "v2", 70 | ] }, 71 | ] 72 | 73 | [tool.ruff] 74 | line-length = 88 75 | target-version = "py38" 76 | 77 | [tool.ruff.lint] 78 | pydocstyle = { convention = "numpy" } 79 | select = [ 80 | "E", # style errors 81 | "W", # style warnings 82 | "F", # flakes 83 | "D", # pydocstyle 84 | "D417", # Missing argument descriptions in the docstring 85 | "I", # isort 86 | "UP", # pyupgrade 87 | "B", # flake8-bugbear 88 | "C4", # flake8-comprehensions 89 | "A001", # flake8-builtins 90 | "RUF", # ruff-specific rules 91 | "TC", # typecheck 92 | "TID", # tidy imports 93 | ] 94 | ignore = [ 95 | "D100", # Missing docstring in public module 96 | "D401", # First line should be in imperative mood 97 | ] 98 | 99 | [tool.ruff.lint.per-file-ignores] 100 | "tests/*.py" = ["D", "S", "RUF009"] 101 | "setup.py" = ["D"] 102 | 103 | # https://mypy.readthedocs.io/en/stable/config_file.html 104 | [tool.mypy] 105 | files = "src/**/" 106 | strict = true 107 | disallow_any_generics = false 108 | disallow_subclassing_any = false 109 | show_error_codes = true 110 | pretty = true 111 | 112 | # https://docs.pytest.org/en/6.2.x/customize.html 113 | [tool.pytest.ini_options] 114 | minversion = "6.0" 115 | testpaths = ["tests"] 116 | filterwarnings = ["error"] 117 | 118 | # https://coverage.readthedocs.io/en/6.4/config.html 119 | [tool.coverage.report] 120 | exclude_lines = [ 121 | "pragma: no cover", 122 | "if TYPE_CHECKING:", 123 | "@overload", 124 | "except ImportError", 125 | "\\.\\.\\.", 126 | "raise NotImplementedError()", 127 | ] 128 | show_missing = true 129 | 130 | [tool.coverage.run] 131 | source = ["fieldz"] 132 | 133 | # https://github.com/mgedmin/check-manifest#configuration 134 | [tool.check-manifest] 135 | ignore = [".pre-commit-config.yaml", "tests/**/*"] 136 | -------------------------------------------------------------------------------- /src/fieldz/__init__.py: -------------------------------------------------------------------------------- 1 | """Utilities for providing compatibility with many dataclass-like libraries.""" 2 | 3 | from importlib.metadata import PackageNotFoundError, version 4 | 5 | try: 6 | __version__ = version("fieldz") 7 | except PackageNotFoundError: # pragma: no cover 8 | __version__ = "uninstalled" 9 | 10 | __all__ = [ 11 | "Adapter", 12 | "Constraints", 13 | "DataclassParams", 14 | "Field", 15 | "asdict", 16 | "astuple", 17 | "display_as_type", 18 | "fields", 19 | "get_adapter", 20 | "params", 21 | "replace", 22 | ] 23 | 24 | from ._functions import asdict, astuple, fields, get_adapter, params, replace 25 | from ._repr import display_as_type 26 | from ._types import Constraints, DataclassParams, Field 27 | from .adapters import Adapter 28 | -------------------------------------------------------------------------------- /src/fieldz/_functions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from . import adapters 6 | 7 | if TYPE_CHECKING: 8 | from ._types import DataclassParams, Field 9 | 10 | 11 | def asdict(obj: Any) -> dict[str, Any]: 12 | """Return a dict representation of obj.""" 13 | return get_adapter(obj).asdict(obj) 14 | 15 | 16 | def astuple(obj: Any) -> tuple[Any, ...]: 17 | """Return a tuple representation of obj.""" 18 | return get_adapter(obj).astuple(obj) 19 | 20 | 21 | def replace(obj: Any, /, **changes: Any) -> Any: 22 | """Return a copy of obj with the specified changes.""" 23 | return get_adapter(obj).replace(obj, **changes) 24 | 25 | 26 | def fields(obj: Any | type[Any], *, parse_annotated: bool = True) -> tuple[Field, ...]: 27 | """Return a tuple of fields for the class or instance.""" 28 | fields = get_adapter(obj).fields(obj) 29 | if parse_annotated: 30 | fields = tuple(field.parse_annotated() for field in fields) 31 | return fields 32 | 33 | 34 | def params(obj: Any) -> DataclassParams: 35 | """Return parameters used to define the dataclass.""" 36 | return get_adapter(obj).params(obj) 37 | 38 | 39 | # Order matters here. The first adapter to return True for is_instance will be used. 40 | ADAPTERS: tuple[adapters.Adapter, ...] = ( 41 | adapters._pydantic, 42 | adapters._attrs, 43 | adapters._msgspec, 44 | adapters._dataclassy, 45 | adapters._dataclasses, 46 | adapters._named_tuple, 47 | adapters._typed_dict, 48 | ) 49 | 50 | 51 | def get_adapter(obj: Any) -> adapters.Adapter: 52 | """Return the module of the given object.""" 53 | for mod in ADAPTERS: 54 | if mod.is_instance(obj): 55 | return mod 56 | raise TypeError(f"Unsupported dataclass type: {type(obj)}") # pragma: no cover 57 | -------------------------------------------------------------------------------- /src/fieldz/_repr.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import types 5 | import typing 6 | from typing import Any 7 | 8 | import typing_extensions 9 | 10 | try: 11 | from typing import _TypingBase # type: ignore[attr-defined] 12 | except ImportError: 13 | from typing import _Final as _TypingBase 14 | 15 | typing_base = _TypingBase 16 | 17 | if sys.version_info < (3, 9): 18 | # python < 3.9 does not have GenericAlias (list[int], tuple[str, ...] and so on) 19 | TypingGenericAlias = () 20 | else: 21 | from typing import GenericAlias as TypingGenericAlias # type: ignore 22 | 23 | if sys.version_info < (3, 12): 24 | # python < 3.12 does not have TypeAliasType 25 | TypeAliasType = () 26 | else: 27 | from typing import TypeAliasType 28 | 29 | if sys.version_info < (3, 10): 30 | 31 | def origin_is_union(tp: type[Any] | None) -> bool: 32 | return tp is typing.Union 33 | 34 | WithArgsTypes = (TypingGenericAlias,) 35 | 36 | else: 37 | 38 | def origin_is_union(tp: type[Any] | None) -> bool: 39 | return tp is typing.Union or tp is types.UnionType # type: ignore 40 | 41 | WithArgsTypes = (typing._GenericAlias, types.GenericAlias, types.UnionType) # type: ignore[attr-defined] 42 | 43 | 44 | def origin_is_literal(tp: type[Any] | None) -> bool: 45 | return tp is typing_extensions.Literal # type: ignore 46 | 47 | 48 | class PlainRepr(str): 49 | """String class where repr doesn't include quotes. 50 | 51 | Useful with Representation when you want to return a string 52 | representation of something that is valid (or pseudo-valid) python. 53 | """ 54 | 55 | def __repr__(self) -> str: 56 | return str(self) 57 | 58 | @classmethod 59 | def for_type(cls, tp: Any, *, modern_union: bool = False) -> PlainRepr: 60 | """Return a PlainRepr for a type.""" 61 | return PlainRepr(display_as_type(tp, modern_union=modern_union)) 62 | 63 | 64 | def display_as_type(obj: Any, *, modern_union: bool = False) -> str: 65 | """Pretty representation of a type. 66 | 67 | Should be as close as possible to the original type definition string. 68 | Takes some logic from `typing._type_repr`. 69 | """ 70 | if isinstance(obj, types.FunctionType): 71 | # In python < 3.10, NewType was a function with __supertype__ set to the 72 | # wrapped type, so NewTypes pass through here 73 | return obj.__name__ 74 | elif obj is ...: 75 | return "..." 76 | elif obj in (None, type(None)): 77 | return "None" 78 | 79 | if sys.version_info >= (3, 10): 80 | if not isinstance( 81 | obj, 82 | ( 83 | typing_base, 84 | WithArgsTypes, 85 | type, 86 | TypeAliasType, 87 | typing.TypeVar, 88 | typing.NewType, 89 | ), 90 | ): 91 | obj = obj.__class__ 92 | 93 | if isinstance(obj, typing.NewType): 94 | # NewType repr includes the module name prepended, so we use __name__ 95 | # to get a clean name 96 | # NOTE: ignoring attr-defined because NewType has __name__ but mypy 97 | # can't see it for some reason; ignoring no-any-return because we 98 | # know __name__ must return a string 99 | return obj.__name__ # type: ignore[attr-defined, no-any-return] 100 | else: 101 | # We remove the NewType check because it doesn't work in isinstance prior to 102 | # python 3.10 103 | if not isinstance( 104 | obj, 105 | ( 106 | typing_base, 107 | WithArgsTypes, 108 | type, 109 | TypeAliasType, 110 | typing.TypeVar, 111 | ), 112 | ): 113 | obj = obj.__class__ 114 | 115 | if isinstance(obj, typing.TypeVar): 116 | # TypeVar repr includes a prepended ~, so we use __name__ to get a clean name 117 | return obj.__name__ 118 | 119 | origin = typing_extensions.get_origin(obj) 120 | if origin_is_literal(origin): 121 | # For Literal types, represent the actual values, not their types 122 | arg_reprs = [repr(arg) for arg in typing_extensions.get_args(obj)] 123 | return f"Literal[{', '.join(arg_reprs)}]" 124 | elif origin_is_union(origin): 125 | args = [display_as_type(x) for x in typing_extensions.get_args(obj)] 126 | if modern_union: 127 | return " | ".join(args) 128 | if len(args) == 2 and "None" in args: 129 | args.remove("None") 130 | return f"Optional[{args[0]}]" 131 | return f"Union[{', '.join(args)}]" 132 | elif isinstance(obj, WithArgsTypes): 133 | argstr = ", ".join(map(display_as_type, typing_extensions.get_args(obj))) 134 | return f"{obj.__qualname__}[{argstr}]" 135 | elif isinstance(obj, type): 136 | return obj.__qualname__ 137 | else: # pragma: no cover 138 | return repr(obj).replace("typing.", "").replace("typing_extensions.", "") 139 | -------------------------------------------------------------------------------- /src/fieldz/_types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | import enum 5 | import sys 6 | import warnings 7 | from typing import ( 8 | TYPE_CHECKING, 9 | Any, 10 | Callable, 11 | ClassVar, 12 | Generic, 13 | Iterable, 14 | Literal, 15 | Mapping, 16 | TypeVar, 17 | ) 18 | 19 | # python 3.8's `get_origin` is not Annotated-aware 20 | from typing_extensions import Annotated, get_args, get_origin 21 | 22 | from fieldz._repr import PlainRepr 23 | 24 | if TYPE_CHECKING: 25 | import builtins 26 | 27 | from typing_extensions import Self 28 | 29 | _T = TypeVar("_T") 30 | 31 | DC_KWARGS = {"frozen": True} 32 | if sys.version_info >= (3, 10): 33 | DC_KWARGS["slots"] = True 34 | 35 | 36 | class _MISSING_TYPE(enum.Enum): 37 | MISSING = "MISSING" 38 | 39 | def __repr__(self) -> str: 40 | return self.value 41 | 42 | 43 | @dataclasses.dataclass(**DC_KWARGS) 44 | class DataclassParams: 45 | init: bool = True 46 | repr: bool = True 47 | eq: bool = True 48 | order: bool = False 49 | unsafe_hash: bool = False 50 | frozen: bool = False 51 | 52 | 53 | @dataclasses.dataclass(**DC_KWARGS) 54 | class Constraints: 55 | gt: int | float | None = None 56 | ge: int | float | None = None 57 | lt: int | float | None = None 58 | le: int | float | None = None 59 | multiple_of: int | float | None = None 60 | min_length: int | None = None # for str 61 | max_length: int | None = None # for str 62 | max_digits: int | None = None # for decimal 63 | decimal_places: int | None = None # for decimal 64 | pattern: str | None = None 65 | deprecated: bool | None = None 66 | tz: bool | None = None 67 | predicate: Callable[[Any], bool] | None = None 68 | # enum: list[Any] | None = None 69 | # const: Any | None = None 70 | 71 | def __rich_repr__(self) -> Iterable[tuple[str, Any]]: 72 | for name, val in dataclasses.asdict(self).items(): 73 | if val is not None: 74 | yield name, val 75 | 76 | 77 | @dataclasses.dataclass(**DC_KWARGS) 78 | class Field(Generic[_T]): 79 | MISSING: ClassVar[Literal[_MISSING_TYPE.MISSING]] = _MISSING_TYPE.MISSING 80 | 81 | name: str 82 | type: type[_T] | str | Any = None 83 | description: str | None = None 84 | title: str | None = None 85 | default: _T | Literal[_MISSING_TYPE.MISSING] = MISSING 86 | # both attrs and pydantic allow a default factory that takes the 87 | # partially constructed object (attrs) or the init kwargs (pydantic) 88 | # as an argument. 89 | default_factory: ( 90 | Callable[[], _T] | Callable[[Any], _T] | Literal[_MISSING_TYPE.MISSING] 91 | ) = MISSING 92 | repr: bool = True 93 | hash: bool | None = None 94 | init: bool = True 95 | compare: bool = True 96 | metadata: Mapping[Any, Any] = dataclasses.field(default_factory=dict) 97 | kw_only: bool = False 98 | # extra 99 | frozen: bool = False 100 | native_field: Any | None = dataclasses.field( 101 | default=None, compare=False, repr=False 102 | ) 103 | constraints: Constraints | None = None 104 | 105 | # populated during parse_annotated 106 | annotated_type: builtins.type[_T] | None = dataclasses.field( 107 | default=None, repr=False, compare=False 108 | ) 109 | 110 | def __rich_repr__(self) -> Iterable[tuple[str, Any]]: 111 | for f in dataclasses.fields(self): 112 | if not f.repr: 113 | continue 114 | val = getattr(self, f.name) 115 | if f.name == "type": 116 | val = PlainRepr.for_type(val) 117 | elif f.name in {"default_factory", "default"} and val is Field.MISSING: 118 | continue 119 | yield f.name, val 120 | 121 | def parse_annotated(self) -> Self: 122 | """Extract info from Annotated type if present, and return new field. 123 | 124 | If `self.type` is not a `typing.Annotated` type, return self unchanged. 125 | """ 126 | if not _is_annotated_type(self.type): 127 | return self 128 | 129 | kwargs, constraints = _parse_annotated_hint(self.type) 130 | 131 | for key in ("default", "name"): 132 | if (val := getattr(self, key)) not in (Field.MISSING, None) and kwargs.get( 133 | key 134 | ) not in (Field.MISSING, None): 135 | warnings.warn( # pragma: no cover 136 | f"Cannot set {key!r} in both type annotation and field. Overriding " 137 | f"{key!r}={kwargs[key]!r} with {val!r}.", 138 | stacklevel=2, 139 | ) 140 | 141 | if self.constraints is not None: 142 | kwargs["constraints"] = dataclasses.replace(self.constraints, **constraints) 143 | elif constraints: 144 | kwargs["constraints"] = Constraints(**constraints) 145 | 146 | return dataclasses.replace(self, **kwargs) 147 | 148 | 149 | def _parse_annotated_hint(hint: Any) -> tuple[dict, dict]: 150 | """Convert an Annotated type to a dict of Field kwargs.""" 151 | # hint should have been checked to be an Annotated[...] type 152 | origin, *metadata = get_args(hint) 153 | kwargs: dict[str, Any] = {"type": origin, "annotated_type": hint} 154 | constraints: dict[str, Any] = {} 155 | 156 | # deal with annotated_types 157 | constraints.update(_parse_annotatedtypes_meta(metadata)) 158 | 159 | # deal with msgspec 160 | m_kwargs, m_constraints = _parse_msgspec_meta(metadata) 161 | kwargs.update(m_kwargs) 162 | constraints.update(m_constraints) 163 | 164 | # TODO: support pydantic.fields.FieldInfo? 165 | # TODO: support re.Pattern? 166 | # TODO: support msgspec 167 | return kwargs, constraints 168 | 169 | 170 | # At the moment, all of our constraint names match msgspec.Meta attributes 171 | # (we are a superset of msgspec.Meta) 172 | CONSTRAINT_NAMES = {f.name for f in dataclasses.fields(Constraints)} 173 | FIELD_NAMES = {f.name for f in dataclasses.fields(Field)} 174 | 175 | 176 | def _parse_annotatedtypes_meta(metadata: list[Any]) -> dict[str, Any]: 177 | """Extract constraints from annotated_types metadata.""" 178 | if TYPE_CHECKING: 179 | import annotated_types as at 180 | else: 181 | at = sys.modules.get("annotated_types") 182 | if at is None: 183 | return {} # pragma: no cover 184 | 185 | a_kwargs = {} 186 | for item in metadata: 187 | # annotated_types >= 0.3.0 is supported 188 | if isinstance(item, (at.BaseMetadata, at.GroupedMetadata)): 189 | values = {k: getattr(item, k) for k in CONSTRAINT_NAMES if hasattr(item, k)} 190 | a_kwargs.update(values) 191 | # annotated types calls the value of a Predicate "func" 192 | if hasattr(item, "func"): 193 | a_kwargs["predicate"] = item.func 194 | 195 | # these were changed in v0.4.0 196 | if hasattr(item, "min_inclusive"): # pragma: no cover 197 | a_kwargs["min_length"] = item.min_inclusive 198 | if hasattr(item, "max_exclusive"): # pragma: no cover 199 | a_kwargs["max_length"] = item.max_exclusive - 1 200 | return a_kwargs 201 | 202 | 203 | def _parse_msgspec_meta(metadata: list[Any]) -> tuple[dict, dict]: 204 | """Extract constraints from msgspec.Meta metadata.""" 205 | if TYPE_CHECKING: 206 | import msgspec 207 | else: 208 | msgspec = sys.modules.get("msgspec") 209 | if msgspec is None: 210 | return {}, {} 211 | 212 | field_kwargs = {} 213 | constraints = {} 214 | for item in metadata: 215 | if isinstance(item, msgspec.Meta): 216 | constraints.update( 217 | { 218 | k: val 219 | for k in CONSTRAINT_NAMES 220 | if (val := getattr(item, k, None)) is not None 221 | } 222 | ) 223 | field_kwargs.update( 224 | { 225 | k: val 226 | for k in FIELD_NAMES 227 | if (val := getattr(item, k, None)) is not None 228 | } 229 | ) 230 | 231 | return field_kwargs, constraints 232 | 233 | 234 | def _is_annotated_type(hint: Any) -> bool: 235 | """Whether the field is an Annotated type.""" 236 | return get_origin(hint) is Annotated 237 | 238 | 239 | def _is_classvar(a_type: Any) -> bool: 240 | return a_type is ClassVar or get_origin(a_type) is ClassVar 241 | 242 | 243 | def _is_initvar(a_type: Any) -> bool: 244 | return a_type is dataclasses.InitVar or type(a_type) is dataclasses.InitVar 245 | -------------------------------------------------------------------------------- /src/fieldz/adapters/__init__.py: -------------------------------------------------------------------------------- 1 | """Adapter modules for fieldz.""" 2 | 3 | from . import ( 4 | _attrs, 5 | _dataclasses, 6 | _dataclassy, 7 | _msgspec, 8 | _named_tuple, 9 | _pydantic, 10 | _typed_dict, 11 | ) 12 | from .protocol import Adapter 13 | 14 | __all__ = [ 15 | "Adapter", 16 | "_attrs", 17 | "_dataclasses", 18 | "_dataclassy", 19 | "_msgspec", 20 | "_named_tuple", 21 | "_pydantic", 22 | "_typed_dict", 23 | ] 24 | -------------------------------------------------------------------------------- /src/fieldz/adapters/_attrs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast, overload 5 | 6 | from fieldz._types import _MISSING_TYPE, DataclassParams, Field 7 | 8 | if TYPE_CHECKING: 9 | from typing import Callable 10 | 11 | import attrs 12 | from typing_extensions import Literal, TypeGuard 13 | 14 | class AttrsInstance(Protocol): 15 | __attrs_attrs__: ClassVar[tuple[attrs.Attribute, ...]] 16 | 17 | 18 | @overload 19 | def is_attrs_class(obj: type) -> TypeGuard[type[AttrsInstance]]: ... 20 | 21 | 22 | @overload 23 | def is_attrs_class(obj: object) -> TypeGuard[AttrsInstance]: ... 24 | 25 | 26 | def is_attrs_class(obj: object) -> bool: 27 | """Return True if the class is an attrs class.""" 28 | attr = sys.modules.get("attr", None) 29 | cls = obj if isinstance(obj, type) else type(obj) 30 | return attr.has(cls) if attr is not None else False 31 | 32 | 33 | is_instance = is_attrs_class 34 | 35 | 36 | def asdict(obj: AttrsInstance) -> dict[str, Any]: 37 | import attrs 38 | 39 | return attrs.asdict(obj) 40 | 41 | 42 | def astuple(obj: AttrsInstance) -> tuple[Any, ...]: 43 | import attrs 44 | 45 | return attrs.astuple(obj) 46 | 47 | 48 | def replace(obj: AttrsInstance, /, **changes: Any) -> AttrsInstance: 49 | """Return a copy of obj with the specified changes.""" 50 | import attrs 51 | 52 | return attrs.evolve(obj, **changes) # type: ignore [misc] 53 | 54 | 55 | def fields(class_or_instance: Any | type) -> tuple[Field, ...]: 56 | import attrs 57 | 58 | cls = ( 59 | class_or_instance 60 | if isinstance(class_or_instance, type) 61 | else type(class_or_instance) 62 | ) 63 | fields: list[Field] = [] 64 | for f in attrs.fields(cls): 65 | f = cast("attrs.Attribute", f) 66 | default = Field.MISSING if f.default is attrs.NOTHING else f.default 67 | default_factory: ( 68 | Callable[[], Any] | Callable[[Any], Any] | Literal[_MISSING_TYPE.MISSING] 69 | ) = Field.MISSING 70 | # attrs typing lies about the type of attrs.Factory 71 | if isinstance(default, attrs.Factory): # type: ignore [arg-type] 72 | default_factory, default = default.factory, Field.MISSING # type: ignore [union-attr] 73 | fields.append( 74 | Field( 75 | name=f.name, 76 | type=f.type, 77 | default=default, 78 | default_factory=default_factory, 79 | repr=f.repr, # type: ignore [arg-type] 80 | init=f.init, 81 | compare=f.eq, # type: ignore [arg-type] 82 | kw_only=f.kw_only, 83 | hash=f.hash, 84 | native_field=f, 85 | metadata=f.metadata, 86 | ) 87 | ) 88 | 89 | return tuple(fields) 90 | 91 | 92 | def params(obj: AttrsInstance) -> DataclassParams: 93 | """Return parameters used to define the dataclass.""" 94 | cls = obj if isinstance(obj, type) else type(obj) 95 | 96 | return DataclassParams( 97 | frozen=getattr(cls.__setattr__, "__name__", None) == "_frozen_setattrs" 98 | ) 99 | -------------------------------------------------------------------------------- /src/fieldz/adapters/_dataclasses.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | from typing import TYPE_CHECKING, Any, ClassVar, Protocol 5 | 6 | from fieldz._types import DataclassParams, Field 7 | 8 | if TYPE_CHECKING: 9 | 10 | class _DataclassParams(Protocol): 11 | init: bool 12 | repr: bool 13 | eq: bool 14 | order: bool 15 | unsafe_hash: bool 16 | frozen: bool 17 | 18 | class DataclassInstance(Protocol): 19 | __dataclass_fields__: ClassVar[dict[str, dataclasses.Field[Any]]] 20 | __dataclass_params__: _DataclassParams 21 | 22 | 23 | def is_instance(obj: Any) -> bool: 24 | return bool(dataclasses.is_dataclass(obj)) 25 | 26 | 27 | def asdict(obj: Any) -> dict[str, Any]: 28 | return dataclasses.asdict(obj) 29 | 30 | 31 | def astuple(obj: Any) -> tuple[Any, ...]: 32 | return dataclasses.astuple(obj) 33 | 34 | 35 | def replace(obj: Any, /, **changes: Any) -> Any: 36 | """Return a copy of obj with the specified changes.""" 37 | return dataclasses.replace(obj, **changes) 38 | 39 | 40 | def fields(obj: Any | type[Any]) -> tuple[Field, ...]: 41 | """Return a tuple of fields for the class or instance.""" 42 | return tuple( 43 | Field( 44 | name=f.name, 45 | type=f.type, 46 | default=( 47 | f.default if f.default is not dataclasses.MISSING else Field.MISSING 48 | ), 49 | default_factory=( 50 | f.default_factory if callable(f.default_factory) else Field.MISSING 51 | ), 52 | init=f.init, 53 | repr=f.repr, 54 | hash=f.hash, 55 | compare=f.compare, 56 | metadata=f.metadata, 57 | native_field=f, 58 | ) 59 | for f in dataclasses.fields(obj) 60 | ) 61 | 62 | 63 | def params(obj: Any) -> DataclassParams: 64 | """Return parameters used to define the dataclass.""" 65 | params: _DataclassParams | None = getattr(obj, "__dataclass_params__", None) 66 | if params is not None: 67 | return DataclassParams( 68 | init=params.init, 69 | repr=params.repr, 70 | eq=params.eq, 71 | order=params.order, 72 | unsafe_hash=params.unsafe_hash, 73 | frozen=params.frozen, 74 | ) 75 | return DataclassParams() # pragma: no cover 76 | -------------------------------------------------------------------------------- /src/fieldz/adapters/_dataclassy.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import TYPE_CHECKING, Any, ClassVar, Protocol 5 | 6 | from fieldz._types import DataclassParams, Field 7 | 8 | if TYPE_CHECKING: 9 | from typing_extensions import TypedDict, TypeGuard 10 | 11 | class DataclassyParams(TypedDict): 12 | init: bool 13 | repr: bool 14 | eq: bool 15 | order: bool 16 | unsafe_hash: bool 17 | frozen: bool 18 | kw_only: bool 19 | match_args: bool 20 | hide_internals: bool 21 | iter: bool 22 | kwargs: bool 23 | slots: bool 24 | 25 | class DataclassyInstance(Protocol): 26 | __dataclass__: ClassVar[DataclassyParams] 27 | 28 | 29 | def is_instance(obj: Any) -> TypeGuard[DataclassyInstance | type[DataclassyInstance]]: 30 | if TYPE_CHECKING: 31 | from dataclassy import functions 32 | else: 33 | functions = sys.modules.get("dataclassy.functions", None) 34 | 35 | return False if functions is None else functions.is_dataclass(obj) 36 | 37 | 38 | def asdict(obj: Any) -> dict[str, Any]: 39 | import dataclassy 40 | 41 | return dataclassy.as_dict(obj) 42 | 43 | 44 | def astuple(obj: Any) -> tuple[Any, ...]: 45 | import dataclassy 46 | 47 | return dataclassy.as_tuple(obj) 48 | 49 | 50 | def replace(obj: Any, /, **changes: Any) -> Any: 51 | """Return a copy of obj with the specified changes.""" 52 | import dataclassy 53 | 54 | return dataclassy.replace(obj, **changes) 55 | 56 | 57 | def fields(obj: Any | type) -> tuple: 58 | import dataclassy 59 | 60 | defaults = getattr(obj, "__defaults__", None) or getattr(obj, "__dict__", {}) 61 | fields: list[Field] = [] 62 | for name, type_ in dataclassy.fields(obj).items(): 63 | default = defaults.get(name, Field.MISSING) 64 | default_factory: Any = Field.MISSING 65 | # this is just how dataclassy does it... (you can assign a mutable default) 66 | # but, to match the other adapters, we perform a little checking to 67 | # normalize common cases to a "typical" default_factory 68 | if hasattr(default, "copy") and callable(default.copy): 69 | if default == []: 70 | default_factory = list 71 | elif default == {}: 72 | default_factory = dict 73 | elif default == (): 74 | default_factory = tuple 75 | elif default == set(): 76 | default_factory = set 77 | else: 78 | # the general fallback case 79 | default_factory = default.copy 80 | default = Field.MISSING 81 | 82 | fields.append( 83 | Field( 84 | name=name, 85 | type=type_, 86 | default=default, 87 | default_factory=default_factory, 88 | ) 89 | ) 90 | return tuple(fields) 91 | 92 | 93 | def params(obj: Any) -> DataclassParams: 94 | """Return parameters used to define the dataclass.""" 95 | params: DataclassyParams | None = getattr(obj, "__dataclass__", None) 96 | 97 | if params is not None: 98 | return DataclassParams( 99 | init=params["init"], 100 | repr=params["repr"], 101 | eq=params["eq"], 102 | order=params["order"], 103 | unsafe_hash=params["unsafe_hash"], 104 | frozen=params["frozen"], 105 | ) 106 | return DataclassParams() # pragma: no cover 107 | -------------------------------------------------------------------------------- /src/fieldz/adapters/_msgspec.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import TYPE_CHECKING, Any, overload 5 | 6 | from fieldz._types import DataclassParams, Field 7 | 8 | if TYPE_CHECKING: 9 | import msgspec 10 | from typing_extensions import TypeGuard 11 | 12 | 13 | @overload 14 | def is_msgspec_struct(obj: type) -> TypeGuard[type[msgspec.Struct]]: ... 15 | 16 | 17 | @overload 18 | def is_msgspec_struct(obj: object) -> TypeGuard[msgspec.Struct]: ... 19 | 20 | 21 | def is_msgspec_struct(obj: Any) -> bool: 22 | """Return True if the class is a `msgspec.Struct`.""" 23 | msgspec = sys.modules.get("msgspec", None) 24 | cls = obj if isinstance(obj, type) else type(obj) 25 | return msgspec is not None and issubclass(cls, msgspec.Struct) 26 | 27 | 28 | is_instance = is_msgspec_struct 29 | 30 | 31 | def asdict(obj: msgspec.Struct) -> dict[str, Any]: 32 | import msgspec.structs 33 | 34 | return msgspec.structs.asdict(obj) 35 | 36 | 37 | def astuple(obj: msgspec.Struct) -> tuple[Any, ...]: 38 | import msgspec.structs 39 | 40 | return msgspec.structs.astuple(obj) 41 | 42 | 43 | def replace(obj: msgspec.Struct, /, **changes: Any) -> Any: 44 | """Return a copy of obj with the specified changes.""" 45 | import msgspec.structs 46 | 47 | return msgspec.structs.replace(obj, **changes) 48 | 49 | 50 | def fields(obj: msgspec.Struct | type[msgspec.Struct]) -> tuple: 51 | import msgspec 52 | 53 | return tuple( 54 | Field( 55 | name=f.name, 56 | type=f.type, 57 | default=(Field.MISSING if f.default is msgspec.NODEFAULT else f.default), 58 | default_factory=( 59 | Field.MISSING 60 | if f.default_factory is msgspec.NODEFAULT 61 | else f.default_factory 62 | ), 63 | native_field=f, 64 | ) 65 | for f in msgspec.structs.fields(obj) 66 | ) 67 | 68 | 69 | def params(obj: msgspec.Struct) -> DataclassParams: 70 | """Return parameters used to define the dataclass.""" 71 | cfg: msgspec.structs.StructConfig | None = getattr(obj, "__struct_config__", None) 72 | if cfg is None: 73 | return DataclassParams() # pragma: no cover 74 | 75 | # this will be covered in msgspec > 0.13.1 76 | return DataclassParams( 77 | frozen=cfg.frozen, eq=cfg.eq, order=cfg.order, init=True, repr=True 78 | ) 79 | -------------------------------------------------------------------------------- /src/fieldz/adapters/_named_tuple.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, ClassVar 4 | 5 | from fieldz._types import DataclassParams, Field 6 | 7 | if TYPE_CHECKING: 8 | from typing import Iterable 9 | 10 | from typing_extensions import Self, TypeGuard 11 | 12 | class NamedTupleInstance(tuple[Any, ...]): 13 | _field_defaults: ClassVar[dict[str, Any]] 14 | _fields: ClassVar[tuple[str, ...]] 15 | 16 | @classmethod 17 | def _make(cls, iterable: Iterable[Any]) -> Self: ... 18 | 19 | def _asdict(self) -> dict[str, Any]: ... 20 | 21 | def _replace(self, **kwargs: Any) -> Self: ... 22 | 23 | 24 | def is_named_tuple(obj: Any) -> TypeGuard[NamedTupleInstance]: 25 | cls = obj if isinstance(obj, type) else type(obj) 26 | return ( 27 | hasattr(cls, "_fields") 28 | and hasattr(cls, "_field_defaults") 29 | and hasattr(cls, "_make") 30 | ) 31 | 32 | 33 | is_instance = is_named_tuple 34 | 35 | 36 | def asdict(obj: NamedTupleInstance) -> dict[str, Any]: 37 | return obj._asdict() 38 | 39 | 40 | def astuple(obj: NamedTupleInstance) -> tuple[Any, ...]: 41 | return obj 42 | 43 | 44 | def replace(obj: NamedTupleInstance, /, **changes: Any) -> NamedTupleInstance: 45 | """Return a copy of obj with the specified changes.""" 46 | return obj._replace(**changes) 47 | 48 | 49 | def fields(obj: NamedTupleInstance | type[NamedTupleInstance]) -> tuple[Field, ...]: 50 | """Return a tuple of fields for the class or instance.""" 51 | annotations = getattr(obj, "__annotations__", {}) 52 | defaults = getattr(obj, "_field_defaults", {}) 53 | return tuple( 54 | Field(name=name, type=annotations.get(name, Any), default=defaults.get(name)) 55 | for name in obj._fields 56 | ) 57 | 58 | 59 | def params(obj: Any) -> DataclassParams: 60 | """Return parameters used to define the dataclass.""" 61 | return DataclassParams() 62 | -------------------------------------------------------------------------------- /src/fieldz/adapters/_pydantic.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | import re 5 | import sys 6 | from typing import TYPE_CHECKING, Any, Iterator, cast, overload 7 | 8 | from fieldz._types import ( 9 | Constraints, 10 | DataclassParams, 11 | Field, 12 | _is_annotated_type, 13 | _parse_annotatedtypes_meta, 14 | ) 15 | 16 | if TYPE_CHECKING: 17 | import pydantic 18 | import pydantic.fields 19 | from pydantic.v1 import BaseModel as PydanticV1BaseModel 20 | from typing_extensions import TypeGuard 21 | 22 | 23 | @overload 24 | def is_pydantic_model(obj: type) -> TypeGuard[type[pydantic.BaseModel]]: ... 25 | 26 | 27 | @overload 28 | def is_pydantic_model(obj: object) -> TypeGuard[pydantic.BaseModel]: ... 29 | 30 | 31 | def is_pydantic_model(obj: Any) -> bool: 32 | """Return True if obj is a pydantic.BaseModel subclass or instance.""" 33 | pydantic = sys.modules.get("pydantic", None) 34 | pydantic_v1 = sys.modules.get("pydantic.v1", None) 35 | cls = obj if isinstance(obj, type) else type(obj) 36 | if pydantic is not None and issubclass(cls, pydantic.BaseModel): 37 | return True 38 | elif pydantic_v1 is not None and issubclass(cls, pydantic_v1.BaseModel): 39 | return True 40 | elif hasattr(cls, "__pydantic_model__") or hasattr(cls, "__pydantic_fields__"): 41 | return True 42 | return False 43 | 44 | 45 | is_instance = is_pydantic_model 46 | 47 | 48 | def asdict(obj: pydantic.BaseModel) -> dict[str, Any]: 49 | # sourcery skip: reintroduce-else 50 | 51 | if hasattr(obj, "__dataclass_params__"): 52 | return dataclasses.asdict(obj) 53 | 54 | if hasattr(obj, "model_dump"): 55 | return obj.model_dump() 56 | return obj.dict() 57 | 58 | 59 | def astuple(obj: pydantic.BaseModel) -> tuple[Any, ...]: 60 | return tuple(asdict(obj).values()) 61 | 62 | 63 | def replace(obj: pydantic.BaseModel, /, **changes: Any) -> Any: 64 | """Return a copy of obj with the specified changes.""" 65 | if hasattr(obj, "__dataclass_params__"): 66 | return dataclasses.replace(obj, **changes) 67 | 68 | if hasattr(obj, "model_copy"): 69 | return obj.model_copy(update=changes) 70 | return obj.copy(update=changes) 71 | 72 | 73 | def _fields_v1(obj: PydanticV1BaseModel | type[PydanticV1BaseModel]) -> Iterator[Field]: 74 | try: 75 | from pydantic.v1.fields import Undefined 76 | except ImportError: 77 | from pydantic.fields import Undefined # type: ignore 78 | 79 | annotations = {key: field.annotation for key, field in obj.__fields__.items()} 80 | for name, modelfield in obj.__fields__.items(): 81 | factory = ( 82 | modelfield.default_factory 83 | if callable(modelfield.default_factory) 84 | else Field.MISSING 85 | ) 86 | default = ( 87 | Field.MISSING 88 | if factory is not Field.MISSING 89 | or modelfield.default in (Undefined, Ellipsis) 90 | else modelfield.default 91 | ) 92 | # backport from pydantic2 93 | _extra_dict = modelfield.field_info.extra.copy() 94 | if "json_schema_extra" in _extra_dict: 95 | _extra_dict.update(_extra_dict.pop("json_schema_extra")) 96 | 97 | yield Field( 98 | name=name, 99 | type=annotations.get(name), # rather than outer_type_ 100 | default=default, 101 | default_factory=(factory if callable(factory) else Field.MISSING), 102 | native_field=modelfield, 103 | description=modelfield.field_info.description, 104 | metadata=_extra_dict, 105 | constraints=_constraints_v1(modelfield), 106 | ) 107 | 108 | 109 | def _constraints_v1(modelfield: Any) -> Constraints | None: 110 | kwargs = {} 111 | if not hasattr(modelfield.type_, "__mro__"): 112 | return None 113 | # check if the type is a pydantic constrained type 114 | for subt in modelfield.type_.__mro__: 115 | if (subt.__module__ or "").startswith("pydantic.types"): 116 | keys = ( 117 | "gt", 118 | "ge", 119 | "lt", 120 | "le", 121 | "multiple_of", 122 | "max_digits", 123 | "decimal_places", 124 | "min_length", 125 | "max_length", 126 | ) 127 | kwargs.update({key: getattr(modelfield.type_, key, None) for key in keys}) 128 | if regex := getattr(modelfield.type_, "regex", None): 129 | if isinstance(regex, re.Pattern): 130 | regex = regex.pattern 131 | kwargs["pattern"] = regex 132 | return Constraints(**kwargs) if kwargs else None 133 | 134 | 135 | def _fields_v2(obj: pydantic.BaseModel | type[pydantic.BaseModel]) -> Iterator[Field]: 136 | from pydantic_core import PydanticUndefined 137 | 138 | if hasattr(obj, "__pydantic_fields__"): # v2 dataclass 139 | _fields = obj.__pydantic_fields__.items() 140 | else: 141 | if not isinstance(obj, type): 142 | obj = type(obj) 143 | _fields = obj.model_fields.items() 144 | 145 | annotations = getattr(obj, "__annotations__", {}) 146 | for name, finfo in _fields: 147 | factory = ( 148 | finfo.default_factory if callable(finfo.default_factory) else Field.MISSING 149 | ) 150 | default = ( 151 | Field.MISSING 152 | if finfo.default in (PydanticUndefined, Ellipsis) 153 | else finfo.default 154 | ) 155 | extra = finfo.json_schema_extra 156 | 157 | annotated_type = annotations.get(name) 158 | if not _is_annotated_type(annotated_type): 159 | annotated_type = None 160 | 161 | c = _parse_annotatedtypes_meta(finfo.metadata) 162 | constraints = Constraints(**c) if c else None 163 | 164 | yield Field( 165 | name=name, 166 | type=finfo.annotation, 167 | default=default, 168 | default_factory=factory, 169 | native_field=finfo, 170 | description=finfo.description, 171 | metadata=extra if isinstance(extra, dict) else {}, 172 | annotated_type=annotated_type, 173 | constraints=constraints, 174 | ) 175 | 176 | 177 | def fields( 178 | obj: pydantic.BaseModel 179 | | PydanticV1BaseModel 180 | | type[pydantic.BaseModel] 181 | | type[PydanticV1BaseModel], 182 | ) -> tuple[Field, ...]: 183 | cls = obj if isinstance(obj, type) else type(obj) 184 | if hasattr(cls, "model_fields") or hasattr(obj, "__pydantic_fields__"): 185 | obj = cast("pydantic.BaseModel | type[pydantic.BaseModel]", obj) 186 | return tuple(_fields_v2(obj)) 187 | if hasattr(obj, "__pydantic_model__"): 188 | obj = obj.__pydantic_model__ # v1 dataclass 189 | obj = cast("PydanticV1BaseModel | type[PydanticV1BaseModel]", obj) 190 | return tuple(_fields_v1(obj)) 191 | 192 | 193 | def params(obj: pydantic.BaseModel) -> DataclassParams: 194 | """Return parameters used to define the dataclass.""" 195 | if hasattr(obj, "__dataclass_params__"): 196 | p = obj.__dataclass_params__ 197 | return DataclassParams( 198 | frozen=p.frozen, 199 | init=p.init, 200 | repr=p.repr, 201 | eq=p.eq, 202 | order=p.order, 203 | unsafe_hash=p.unsafe_hash, 204 | ) 205 | if hasattr(obj, "model_config"): 206 | cfg_dict: pydantic.ConfigDict = obj.model_config 207 | return DataclassParams( 208 | # unsafe_hash=not cfg.get("frozen", False), 209 | frozen=cfg_dict.get("frozen", False) 210 | ) 211 | else: 212 | cfg = obj.__config__ # type: ignore 213 | return DataclassParams(frozen=cfg.allow_mutation is False) 214 | return DataclassParams() # pragma: no cover 215 | -------------------------------------------------------------------------------- /src/fieldz/adapters/_typed_dict.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from fieldz._types import DataclassParams, Field, _is_classvar 6 | 7 | if TYPE_CHECKING: 8 | from typing_extensions import TypeGuard 9 | 10 | 11 | def is_typed_dict(obj: Any) -> TypeGuard[dict]: 12 | # NOTE: this will ONLY work on TypedDict subclasses, not instances 13 | # because at runtime, TypedDict instances are simply dicts, and there's no way 14 | # to tell if a dict is an instance of a TypedDict subclass 15 | 16 | # in python 3.8 the only difference between a TypedDict subclass and a dict is: 17 | # - __dict__ 18 | # - __weakref__ 19 | # - __module__ 20 | # - __total__ 21 | # - __annotations_ 22 | # ... here we choose to check for __total__ and __annotations__ 23 | # (since most things will have __dict__, __module__, __weakref__) 24 | # later versions also have __required_keys__ and __optional_keys__ 25 | return ( 26 | isinstance(obj, type) 27 | and issubclass(obj, dict) 28 | and hasattr(obj, "__annotations__") 29 | and hasattr(obj, "__total__") 30 | ) 31 | 32 | 33 | is_instance = is_typed_dict 34 | 35 | 36 | def asdict(obj: dict) -> dict[str, Any]: 37 | return obj 38 | 39 | 40 | def astuple(obj: dict) -> tuple[Any, ...]: 41 | return tuple(obj.values()) 42 | 43 | 44 | def replace(obj: dict, /, **changes: Any) -> dict: 45 | """Return a copy of obj with the specified changes.""" 46 | return {**obj, **changes} 47 | 48 | 49 | def fields(obj: dict | type[dict]) -> tuple[Field, ...]: 50 | """Return a tuple of fields for the class or instance.""" 51 | return tuple( 52 | Field(name=name, type=hint) 53 | for name, hint in getattr(obj, "__annotations__", {}).items() 54 | if not _is_classvar(hint) 55 | ) 56 | 57 | 58 | def params(obj: dict) -> DataclassParams: 59 | """Return parameters used to define the dataclass.""" 60 | return DataclassParams() 61 | -------------------------------------------------------------------------------- /src/fieldz/adapters/protocol.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar 4 | 5 | if TYPE_CHECKING: 6 | from typing_extensions import TypeAlias 7 | 8 | from fieldz._types import DataclassParams, Field 9 | 10 | 11 | # MISSING, replace 12 | _T = TypeVar("_T") 13 | 14 | 15 | DataclassLike: TypeAlias = Any 16 | _DataclassType = TypeVar("_DataclassType", bound=DataclassLike) 17 | 18 | 19 | class Adapter(Protocol, Generic[_DataclassType]): 20 | """Protocol that adapter modules need to implement.""" 21 | 22 | # @overload 23 | # def is_instance(self, obj: _DataclassType) -> Literal[True]: 24 | # ... 25 | 26 | # @overload 27 | # def is_instance(self, obj: type) -> TypeGuard[type[_DataclassType]]: 28 | # ... 29 | 30 | # @overload 31 | # def is_instance( 32 | # self, obj: object 33 | # ) -> TypeGuard[_DataclassType | type[_DataclassType]]: 34 | # ... 35 | 36 | def is_instance(self, obj: DataclassLike) -> bool: 37 | """Return true if obj is a a recognized instance for this adapter.""" 38 | 39 | def asdict(self, obj: DataclassLike) -> dict[str, Any]: 40 | """Return a dict representation of obj.""" 41 | 42 | def astuple(self, obj: DataclassLike) -> tuple[Any, ...]: 43 | """Return a tuple representation of obj.""" 44 | 45 | def replace(self, obj: _DataclassType, /, **changes: Any) -> _DataclassType: 46 | """Return a copy of obj with the specified changes.""" 47 | 48 | def fields(self, obj: DataclassLike | type[DataclassLike]) -> tuple[Field, ...]: 49 | """Return a tuple of fields for the class or instance.""" 50 | 51 | def params(self, obj: DataclassLike) -> DataclassParams: 52 | """Return parameters used to define the dataclass.""" 53 | -------------------------------------------------------------------------------- /src/fieldz/py.typed: -------------------------------------------------------------------------------- 1 | You may remove this file if you don't intend to add types to your package 2 | 3 | Details at: 4 | 5 | https://mypy.readthedocs.io/en/stable/installed_packages.html#creating-pep-561-compatible-packages 6 | -------------------------------------------------------------------------------- /tests/test_annotations.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | import annotated_types as at 5 | from typing_extensions import Annotated 6 | 7 | from fieldz import fields 8 | 9 | 10 | def test_annotated_types() -> None: 11 | @dataclass 12 | class MyClass: 13 | age: Annotated[int, at.Gt(18)] 14 | span: Annotated[float, at.Interval(ge=0, le=10)] 15 | even: Annotated[int, at.MultipleOf(2)] 16 | # note that annotated types will NOT iterate min_length=0 17 | my_list: Annotated[List[int], at.Len(1, 10)] 18 | lower_name: Annotated[str, at.Predicate(str.islower)] 19 | # TODO: determine how to handle nested Annotated types 20 | # factors: List[Annotated[str, at.Predicate(str.islower)]] 21 | with_tz: Annotated[str, at.Timezone(...)] 22 | unannotated: int = 0 23 | 24 | fields_ = {f.name: f for f in fields(MyClass)} 25 | assert fields_["age"].constraints.gt == 18 26 | assert fields_["span"].constraints.ge == 0 27 | assert fields_["span"].constraints.le == 10 28 | assert fields_["even"].constraints.multiple_of == 2 29 | assert fields_["my_list"].constraints.min_length == 1 30 | assert fields_["my_list"].constraints.max_length == 10 31 | assert fields_["lower_name"].constraints.predicate == str.islower 32 | assert fields_["lower_name"].constraints.predicate == str.islower 33 | 34 | 35 | def test_msgspec_constraints() -> None: 36 | from msgspec import Meta, Struct 37 | 38 | # TODO: determine how to handle nested Annotated types 39 | # UnixName = Annotated[ 40 | # str, Meta(min_length=1, max_length=32, pattern="^[a-z_][a-z0-9_-]*$") 41 | # ] 42 | # groups: Annotated[set[UnixName], Meta(max_length=16)] = set() # type: ignore 43 | 44 | class MyClass(Struct): 45 | age: Annotated[int, Meta(gt=18)] 46 | span: Annotated[float, Meta(ge=0, le=10)] 47 | even: Annotated[int, Meta(multiple_of=2)] 48 | my_list: Annotated[List[int], Meta(min_length=1, max_length=10)] 49 | # msgspec puts title and description in the Meta object 50 | with_title: Annotated[str, Meta(title="Title", description="Description")] 51 | with_tz: Annotated[int, Meta(tz=True)] 52 | 53 | fields_ = {f.name: f for f in fields(MyClass)} 54 | assert fields_["age"].constraints.gt == 18 55 | assert fields_["span"].constraints.ge == 0 56 | assert fields_["span"].constraints.le == 10 57 | assert fields_["even"].constraints.multiple_of == 2 58 | assert fields_["my_list"].constraints.min_length == 1 59 | assert fields_["my_list"].constraints.max_length == 10 60 | assert fields_["with_title"].title == "Title" 61 | assert fields_["with_title"].description == "Description" 62 | assert fields_["with_tz"].constraints.tz is True 63 | -------------------------------------------------------------------------------- /tests/test_fieldz.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from typing import Any, Callable, List, NamedTuple, Optional, TypedDict 3 | 4 | import pytest 5 | 6 | from fieldz import Field, asdict, astuple, fields, params, replace 7 | from fieldz.adapters._named_tuple import is_named_tuple 8 | 9 | 10 | def _dataclass_model() -> type: 11 | @dataclasses.dataclass 12 | class Model: 13 | a: int = 0 14 | b: Optional[str] = None 15 | c: float = 0.0 16 | d: bool = False 17 | e: List[int] = dataclasses.field(default_factory=list) 18 | f: Any = () 19 | 20 | return Model 21 | 22 | 23 | def _named_tuple() -> type: 24 | class Model(NamedTuple): 25 | a: int = 0 26 | b: Optional[str] = None 27 | c: float = 0.0 28 | d: bool = False 29 | e: List[int] = [] # noqa 30 | f: Any = () 31 | 32 | return Model 33 | 34 | 35 | def _pydantic_v1_model_str() -> type: 36 | from pydantic.v1 import BaseModel, Field 37 | 38 | class Model(BaseModel): 39 | a: "int" = 0 40 | b: "Optional[str]" = None 41 | c: "float" = 0.0 42 | d: "bool" = False 43 | e: "List[int]" = Field(default_factory=list) 44 | f: "Any" = () 45 | 46 | return Model 47 | 48 | 49 | def _pydantic_v1_model() -> type: 50 | from pydantic.v1 import BaseModel, Field 51 | 52 | class Model(BaseModel): 53 | a: int = 0 54 | b: Optional[str] = None 55 | c: float = 0.0 56 | d: bool = False 57 | e: List[int] = Field(default_factory=list) 58 | f: Any = () 59 | 60 | return Model 61 | 62 | 63 | def _pydantic_model() -> type: 64 | from pydantic import BaseModel, Field 65 | 66 | class Model(BaseModel): 67 | a: int = 0 68 | b: Optional[str] = None 69 | c: float = 0.0 70 | d: bool = False 71 | e: List[int] = Field(default_factory=list) 72 | f: Any = () 73 | 74 | return Model 75 | 76 | 77 | def _pydantic_dataclass() -> type: 78 | from pydantic.dataclasses import dataclass 79 | 80 | @dataclass 81 | class Model: 82 | a: int = 0 83 | b: Optional[str] = None 84 | c: float = 0.0 85 | d: bool = False 86 | e: List[int] = dataclasses.field(default_factory=list) 87 | f: Any = () 88 | 89 | return Model 90 | 91 | 92 | def _sqlmodel() -> type: 93 | pytest.importorskip("sqlmodel") 94 | from sqlmodel import Field, SQLModel 95 | 96 | class Model(SQLModel): 97 | a: int = 0 98 | b: Optional[str] = None 99 | c: float = 0.0 100 | d: bool = False 101 | e: List[int] = Field(default_factory=list) 102 | f: Any = () 103 | 104 | return Model 105 | 106 | 107 | def _attrs_model() -> type: 108 | import attr 109 | 110 | @attr.define 111 | class Model: 112 | a: int = 0 113 | b: Optional[str] = None 114 | c: float = 0.0 115 | d: bool = False 116 | e: List[int] = attr.field(default=attr.Factory(list)) 117 | f: Any = () 118 | 119 | return Model 120 | 121 | 122 | def _msgspec_model() -> type: 123 | import msgspec 124 | 125 | class Model(msgspec.Struct): 126 | a: int = 0 127 | b: Optional[str] = None 128 | c: float = 0.0 129 | d: bool = False 130 | e: List[int] = msgspec.field(default_factory=list) 131 | f: Any = () 132 | 133 | return Model 134 | 135 | 136 | def _dataclassy_model() -> type: 137 | import dataclassy 138 | 139 | @dataclassy.dataclass 140 | class Model: 141 | a: int = 0 142 | b: Optional[str] = None 143 | c: float = 0.0 144 | d: bool = False 145 | e: List[int] = [] # noqa 146 | f: Any = () 147 | 148 | return Model 149 | 150 | 151 | def _django_model() -> type: 152 | from django.db import models 153 | 154 | class Model(models.Model): 155 | a: int = models.IntegerField(default=0) 156 | b: str = models.CharField(default="b", max_length=255) 157 | c: float = models.FloatField(default=0.0) 158 | d: bool = models.BooleanField(default=False) 159 | e: List[int] = models.JSONField(default=list) 160 | f: Any = () 161 | 162 | return Model 163 | 164 | 165 | @pytest.mark.parametrize( 166 | "builder", 167 | [ 168 | _dataclass_model, 169 | _named_tuple, 170 | _dataclassy_model, 171 | _pydantic_model, 172 | _pydantic_v1_model, 173 | _pydantic_v1_model_str, 174 | _attrs_model, 175 | _msgspec_model, 176 | _sqlmodel, 177 | _pydantic_dataclass, 178 | # _django_model, 179 | ], 180 | ) 181 | def test_adapters(builder: Callable) -> None: 182 | model = builder() 183 | obj = model() 184 | assert asdict(obj) == {"a": 0, "b": None, "c": 0.0, "d": False, "e": [], "f": ()} 185 | assert astuple(obj) == (0, None, 0.0, False, [], ()) 186 | fields_ = fields(obj) 187 | assert [f.name for f in fields_] == ["a", "b", "c", "d", "e", "f"] 188 | assert [f.type for f in fields_] == [ 189 | int, 190 | Optional[str], 191 | float, 192 | bool, 193 | List[int], 194 | Any, 195 | ] 196 | assert [f.frozen for f in fields_] == [False] * 6 197 | if is_named_tuple(obj): 198 | assert [f.default for f in fields_] == [0, None, 0.0, False, [], ()] 199 | else: 200 | # namedtuples don't have default_factory 201 | assert [f.default for f in fields_] == [0, None, 0.0, False, Field.MISSING, ()] 202 | assert [f.default_factory for f in fields_] == [ 203 | *[Field.MISSING] * 4, 204 | list, 205 | Field.MISSING, 206 | ] 207 | 208 | obj2 = replace(obj, a=1, b="b2", c=1.0, d=True, e=[1, 2, 3], f={}) 209 | assert asdict(obj2) == { 210 | "a": 1, 211 | "b": "b2", 212 | "c": 1.0, 213 | "d": True, 214 | "e": [1, 2, 3], 215 | "f": {}, 216 | } 217 | 218 | p = params(obj) 219 | assert p.eq is True 220 | assert p.order is False 221 | assert p.repr is True 222 | assert p.init is True 223 | assert p.unsafe_hash is False 224 | assert p.frozen is False 225 | 226 | 227 | def test_typed_dict() -> None: 228 | class Model(TypedDict): 229 | a: int 230 | b: Optional[str] 231 | c: float 232 | d: bool 233 | e: List[int] 234 | 235 | assert fields(Model) == ( 236 | Field(name="a", type=int), 237 | Field(name="b", type=Optional[str]), 238 | Field(name="c", type=float), 239 | Field(name="d", type=bool), 240 | Field(name="e", type=List[int]), 241 | ) 242 | -------------------------------------------------------------------------------- /tests/test_pydantic.py: -------------------------------------------------------------------------------- 1 | import annotated_types as at 2 | from pydantic import BaseModel, Field, conint, constr 3 | from typing_extensions import Annotated 4 | 5 | from fieldz import fields 6 | 7 | PATTERN = r"^[a-z]+$" 8 | try: 9 | e_constr = constr(regex=r"^[a-z]+$") 10 | f_field = Field(default="abc", regex=PATTERN) 11 | except TypeError: # pydantic v2 12 | e_constr = constr(pattern=r"^[a-z]+$") # type: ignore 13 | f_field = Field(default="abc", pattern=PATTERN) 14 | 15 | 16 | def test_pydantic_constraints() -> None: 17 | class M(BaseModel): 18 | a: int = Field(default=50, ge=42, le=100) 19 | b: Annotated[int, Field(ge=42, le=100)] = 50 20 | c: Annotated[int, at.Ge(42), at.Le(100)] = 50 21 | d: conint(ge=42, le=100) = 50 # type: ignore 22 | e: e_constr = "abc" # type: ignore 23 | f: str = f_field 24 | 25 | for obj in (M, M()): 26 | for f in fields(obj): 27 | assert f.constraints 28 | if f.name in {"e", "f"}: 29 | assert f.constraints.pattern == PATTERN 30 | else: 31 | assert f.constraints.ge == 42 32 | assert f.constraints.le == 100 33 | assert f.default == 50 34 | assert f.default == 50 35 | -------------------------------------------------------------------------------- /tests/test_repr.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Any, Generic, Literal, NewType, Optional, TypeVar, Union 3 | 4 | import pytest 5 | from typing_extensions import Annotated 6 | 7 | import fieldz 8 | from fieldz._repr import PlainRepr 9 | 10 | T = TypeVar("T") 11 | 12 | NewInt = NewType("NewInt", int) 13 | 14 | 15 | def func() -> None: 16 | pass 17 | 18 | 19 | class Foo: 20 | pass 21 | 22 | 23 | class ParamFoo(Generic[T]): 24 | pass 25 | 26 | 27 | def test_PlainRepr() -> None: 28 | assert PlainRepr("str") == "str" 29 | assert PlainRepr.for_type(int) == "int" 30 | assert PlainRepr.for_type(...) == "..." 31 | assert PlainRepr.for_type(None) == "None" 32 | assert PlainRepr.for_type(Optional[int]) == "Optional[int]" 33 | assert PlainRepr.for_type(Union[int, str]) == "Union[int, str]" 34 | assert PlainRepr.for_type(Optional[int], modern_union=True) == "int | None" 35 | assert PlainRepr.for_type(Literal[1, "2", (1, 2)]) == "Literal[1, '2', (1, 2)]" 36 | 37 | assert PlainRepr.for_type(func) == "func" 38 | assert PlainRepr.for_type(Foo()) == "Foo" 39 | # in python <=3.9 the module appears to be in the qualname for Parametrized generics 40 | assert "ParamFoo[int]" in PlainRepr.for_type(ParamFoo[int]) 41 | assert PlainRepr.for_type(Any) == "Any" 42 | assert PlainRepr.for_type(Annotated[int, None]) == "Annotated[int, None]" 43 | 44 | # test TypeVar and NewInt cases directly as their representation must be handled 45 | # separately 46 | assert PlainRepr.for_type(T) == "T" 47 | assert PlainRepr.for_type(NewInt) == "NewInt" 48 | 49 | 50 | @pytest.mark.skipif( 51 | sys.version_info < (3, 12), 52 | reason="requires Python 3.12 or newer to support typing syntactic sugar", 53 | ) 54 | def test_PlainRepr_with_syntactic_sugar() -> None: 55 | # Test cases using the typing syntactic sugar of python >= 3.12 56 | 57 | # Create a namespace dictionary to capture the exec'd variables 58 | namespace = {} 59 | exec("type ExampleAlias = str | int", namespace) 60 | ExampleAlias = namespace["ExampleAlias"] 61 | assert PlainRepr.for_type(ExampleAlias) == "ExampleAlias" 62 | assert ( 63 | PlainRepr.for_type(ExampleAlias | tuple[ExampleAlias, ...]) 64 | == "Union[ExampleAlias, tuple[ExampleAlias, ...]]" 65 | ) 66 | assert ( 67 | PlainRepr.for_type(ExampleAlias | tuple[ExampleAlias, ...], modern_union=True) 68 | == "ExampleAlias | tuple[ExampleAlias, ...]" 69 | ) 70 | assert ( 71 | PlainRepr.for_type(Annotated[ExampleAlias, None]) 72 | == "Annotated[ExampleAlias, None]" 73 | ) 74 | assert PlainRepr.for_type(dict[str, ExampleAlias]) == "dict[str, ExampleAlias]" 75 | 76 | 77 | def test_rich_reprs() -> None: 78 | assert not list(fieldz.Constraints().__rich_repr__()) 79 | assert list(fieldz.Constraints(ge=1).__rich_repr__()) == [("ge", 1)] 80 | 81 | assert ("name", "hi") in fieldz.Field(name="hi").__rich_repr__() 82 | assert "native_field" not in dict( 83 | fieldz.Field(name="hi", repr=False).__rich_repr__() 84 | ) 85 | --------------------------------------------------------------------------------