├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── test.yaml ├── .gitignore ├── LICENSE ├── README.md ├── deploy └── nginx.conf ├── docs ├── assets │ ├── favicon.ico │ ├── logo.png │ ├── qq-group.jpg │ ├── utype-black.png │ └── utype-white.png ├── en │ ├── README.md │ ├── guide │ │ ├── cls.md │ │ ├── func.md │ │ └── type.md │ └── references │ │ ├── field.md │ │ ├── options.md │ │ └── rule.md ├── examples │ ├── async_fetch.py │ ├── async_generator.py │ ├── cls_example.py │ ├── field.py │ ├── field_mode.py │ ├── func_basic.py │ ├── nested_cls.py │ └── setter.py ├── mkdocs.en.yml ├── mkdocs.zh.yml └── zh │ ├── README.md │ ├── guide │ ├── cls.md │ ├── func.md │ └── type.md │ └── references │ ├── field.md │ ├── options.md │ └── rule.md ├── examples └── __init__.py ├── setup.py ├── tests ├── __init__.py ├── test_cls.py ├── test_field.py ├── test_func.py ├── test_future.py ├── test_options.py ├── test_rule.py ├── test_spec.py └── test_type.py └── utype ├── __init__.py ├── decorator.py ├── parser ├── __init__.py ├── base.py ├── cls.py ├── field.py ├── func.py ├── options.py └── rule.py ├── schema.py ├── settings.py ├── specs ├── __init__.py ├── json_schema │ ├── __init__.py │ ├── constant.py │ ├── generator.py │ └── parser.py └── python │ ├── __init__.py │ └── generator.py ├── types.py └── utils ├── __init__.py ├── base.py ├── compat.py ├── datastructures.py ├── encode.py ├── example.py ├── exceptions.py ├── functional.py ├── style.py └── transform.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | paste the code context and variables where the bug occurs 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Version Info** 20 | use `python -c "import utype; print(utype.version_info())"` and paste the version info here 21 | 22 | **Additional context** 23 | Add any other context about the problem here. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: utype CI Workflow 2 | # run before every pull request and every push 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - 'docs/**' 7 | - 'examples/**' 8 | - '**/README.md' 9 | push: 10 | paths-ignore: 11 | - 'docs/**' 12 | - 'examples/**' 13 | - '**/README.md' 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-22.04 18 | # https://github.com/onnx/tensorflow-onnx/pull/2376 19 | strategy: 20 | fail-fast: false 21 | max-parallel: 3 22 | matrix: 23 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Set up Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v4 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install flake8 pytest pytest-cov pytest-asyncio 34 | pip install typing-extensions>=4.1.0 35 | - name: Run lint 36 | run: | 37 | flake8 utype --count --select=E9,F63,F7,F82 --show-source --statistics 38 | - name: Run tests 39 | run: | 40 | pytest --cov=utype 41 | - name: Upload coverage reports to Codecov 42 | uses: codecov/codecov-action@v3 43 | # run: | 44 | # curl -Os https://uploader.codecov.io/latest/linux/codecov 45 | # chmod +x codecov 46 | # ./codecov -t ${CODECOV_TOKEN} 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /build 3 | /deploy 4 | /dist 5 | /utype.egg-info 6 | */__pycache__ 7 | __pycache__/ 8 | *.py[cod] 9 | .coverage 10 | .obsidian 11 | */.coverage 12 | */.obsidian 13 | 14 | /docs/build 15 | *.sqlite3 16 | */restats 17 | /.ext 18 | # .ext/ is used to store per-developer files that helps development 19 | /.pytest_cache 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019-present Xulin Zhou (周煦林) 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # uType 2 | [![Version](https://img.shields.io/pypi/v/utype)](https://pypi.org/project/utype/) 3 | [![Python Requires](https://img.shields.io/pypi/pyversions/utype)](https://pypi.org/project/utype/) 4 | [![License](https://img.shields.io/badge/license-Apache%202.0-blue)](https://github.com/utilmeta/utype/blob/main/LICENSE) 5 | [![CI](https://img.shields.io/github/actions/workflow/status/utilmeta/utype/test.yaml?branch=main&label=CI)](https://github.com/utilmeta/utype/actions?query=branch%3Amain+) 6 | [![Test Coverage](https://img.shields.io/codecov/c/github/utilmeta/utype?color=green)](https://app.codecov.io/github/utilmeta/utype) 7 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 8 | [![Downloads](https://pepy.tech/badge/utype/month)](https://pepy.tech/project/utype) 9 | 10 | utype is a data types declaration & parsing library based on Python type annotations, 11 | enforce types and constraints for classes and functions at runtime 12 | 13 | * Documentation: [https://utype.io](https://utype.io) 14 | * 中文文档: [https://utype.io/zh](https://utype.io/zh) 15 | * Source Code: [https://github.com/utilmeta/utype](https://github.com/utilmeta/utype) 16 | * Author: [@voidZXL](https://github.com/voidZXL) 17 | * License: Apache 2.0 18 | 19 | ### Core Features 20 | 21 | * Enforce types, data classes, function params and result parsing at runtime based on Python type annotation 22 | * Support a variety of constraints, logical operators and flexible parsing options 23 | * Highly extensible, all type transformer can be register, extend and override 24 | 25 | ### Installation 26 | 27 | ```shell 28 | pip install -U utype 29 | ``` 30 | 31 | > utype requires Python >= 3.7 32 | 33 | ### Usage Example 34 | 35 | ### Types and constraints 36 | The utype support to add constraints on types, such as 37 | ```Python 38 | from utype import Rule, exc 39 | 40 | class PositiveInt(int, Rule): 41 | gt = 0 42 | 43 | assert PositiveInt(b'3') == 3 44 | 45 | try: 46 | PositiveInt(-0.5) 47 | except exc.ParseError as e: 48 | print(e) 49 | """ 50 | Constraint: 0 violated 51 | """ 52 | ``` 53 | 54 | 55 | Data that conforms to the type and constraints will complete the conversion, otherwise will throw a parse error indicating what went wrong 56 | 57 | ### Parsing dataclasses 58 | 59 | utype supports the "dataclass" usage that convert a dict or JSON to a class instance, similar to `pydantic` and `attrs` 60 | ```python 61 | from utype import Schema, Field, exc 62 | from datetime import datetime 63 | 64 | class UserSchema(Schema): 65 | username: str = Field(regex='[0-9a-zA-Z]{3,20}') 66 | signup_time: datetime 67 | 68 | # 1. Valid input 69 | data = {'username': 'bob', 'signup_time': '2022-10-11 10:11:12'} 70 | print(UserSchema(**data)) 71 | #> UserSchema(username='bob', signup_time=datetime.datetime(2022, 10, 11, 10, 11, 12)) 72 | 73 | # 2. Invalid input 74 | try: 75 | UserSchema(username='@invalid', signup_time='2022-10-11 10:11:12') 76 | except exc.ParseError as e: 77 | print(e) 78 | """ 79 | parse item: ['username'] failed: 80 | Constraint: : '[0-9a-zA-Z]{3,20}' violated 81 | """ 82 | ``` 83 | 84 | After a simple declaration, you can get 85 | 86 | * Automatic `__init__` to take input data, perform validation and attribute assignment 87 | * Providing `__repr__` and `__str__` to get the clearly print output of the instance 88 | * parse and protect attribute assignment and deletion to avoid dirty data 89 | 90 | ### Parsing functions 91 | 92 | utype can also parse function params and result 93 | ```python 94 | import utype 95 | from typing import Optional 96 | 97 | class PositiveInt(int, utype.Rule): 98 | gt = 0 99 | 100 | class ArticleSchema(utype.Schema): 101 | id: Optional[PositiveInt] 102 | title: str = utype.Field(max_length=100) 103 | slug: str = utype.Field(regex=r"[a-z0-9]+(?:-[a-z0-9]+)*") 104 | 105 | @utype.parse 106 | def get_article(id: PositiveInt = None, title: str = '') -> ArticleSchema: 107 | return { 108 | 'id': id, 109 | 'title': title, 110 | 'slug': '-'.join([''.join( 111 | filter(str.isalnum, v)) for v in title.split()]).lower() 112 | } 113 | 114 | print(get_article('3', title=b'My Awesome Article!')) 115 | #> ArticleSchema(id=3, title='My Awesome Article!', slug='my-awesome-article') 116 | 117 | try: 118 | get_article('-1') 119 | except utype.exc.ParseError as e: 120 | print(e) 121 | """ 122 | parse item: ['id'] failed: Constraint: : 0 violated 123 | """ 124 | 125 | try: 126 | get_article(title='*' * 101) 127 | except utype.exc.ParseError as e: 128 | print(e) 129 | """ 130 | parse item: [''] failed: 131 | parse item: ['title'] failed: 132 | Constraint: : 100 violated 133 | """ 134 | ``` 135 | 136 | > You can easily get type checking and code completion of IDEs (such as Pycharm, VS Code) during development 137 | 138 | utype supports not only normal functions, but also generator functions, asynchronous functions, and asynchronous generator functions with the same usage 139 | ```python 140 | import utype 141 | import asyncio 142 | from typing import AsyncGenerator 143 | 144 | @utype.parse 145 | async def waiter(rounds: int = utype.Param(gt=0)) -> AsyncGenerator[int, float]: 146 | assert isinstance(rounds, int) 147 | i = rounds 148 | while i: 149 | wait = yield str(i) 150 | if wait: 151 | assert isinstance(wait, float) 152 | print(f'sleep for: {wait} seconds') 153 | await asyncio.sleep(wait) 154 | i -= 1 155 | 156 | async def wait(): 157 | wait_gen = waiter('2') 158 | async for index in wait_gen: 159 | assert isinstance(index, int) 160 | try: 161 | await wait_gen.asend(b'0.5') 162 | # sleep for: 0.5 seconds 163 | except StopAsyncIteration: 164 | return 165 | 166 | if __name__ == '__main__': 167 | asyncio.run(wait()) 168 | ``` 169 | 170 | > The `AsyncGenerator` type is used to annotate the return value of the asynchronous generator, which has two parameters: the type of the value output by `yield`, type of the value sent by `asend` 171 | 172 | As you can see, the parameters passed to the function and the value received from `yield` were all converted to the expected type as declared 173 | 174 | 175 | ### Logical operation of type 176 | utype supports logical operations on types and data structures using Python-native logical operators 177 | ```python 178 | from utype import Schema, Field 179 | from typing import Tuple 180 | 181 | class User(Schema): 182 | name: str = Field(max_length=10) 183 | age: int 184 | 185 | one_of_user = User ^ Tuple[str, int] 186 | 187 | print(one_of_user({'name': 'test', 'age': '1'})) 188 | # > User(name='test', age=1) 189 | 190 | print(one_of_user([b'test', '1'])) 191 | # > ('test', 1) 192 | ``` 193 | 194 | The example uses the `^` exclusive or symbol to logically combine `User` and `Tuple[str, int]`, and the new logical type gains the ability to convert data to one of those 195 | 196 | ### Register transformer for type 197 | Type transformation and validation strictness required by each project may be different, so in utype, all types support registraton, extension and override, such as 198 | ```python 199 | from utype import Rule, Schema, register_transformer 200 | from typing import Type 201 | 202 | class Slug(str, Rule): 203 | regex = r"[a-z0-9]+(?:-[a-z0-9]+)*" 204 | 205 | @register_transformer(Slug) 206 | def to_slug(transformer, value, t: Type[Slug]): 207 | str_value = transformer(value, str) 208 | return t('-'.join([''.join( 209 | filter(str.isalnum, v)) for v in str_value.split()]).lower()) 210 | 211 | 212 | class ArticleSchema(Schema): 213 | slug: Slug 214 | 215 | print(dict(ArticleSchema(slug=b'My Awesome Article!'))) 216 | # > {'slug': 'my-awesome-article'} 217 | ``` 218 | 219 | You can register transformers not only for custom types, but also for basic types (such as `str`, `int`, etc.) Or types in the standard library (such as `datetime`, `Enum`, etc.) To customize the conversion behavior 220 | 221 | ## RoadMap and Contribution 222 | utype is still growing, and the following features are planned for implementation in the new version 223 | 224 | * Improve the handling mechanism of parsing errors, including error handling hook functions, etc. 225 | * Support the declaration and parse command line parameters 226 | * Support for Python generics, type variables, and more type annotation syntax 227 | * Develop Pycharm/VS Code plugin that supports IDE detection and hints for constraints, logical types, and nested types 228 | 229 | You are also welcome to contribute features or submit issues. 230 | 231 | ## Applications 232 | 233 | ### UtilMeta Python Framework 234 | UtilMeta Python Framework is a progressive meta-framework for backend applications, which efficiently builds declarative APIs based on the Python type annotation standard, and supports the integration of mainstream Python frameworks as runtime backend 235 | * Homepage: [https://utilmeta.com/py](https://utilmeta.com/py) 236 | * Source Code: [https://github.com/utilmeta/utilmeta-py](https://github.com/utilmeta/utilmeta-py) 237 | 238 | ## Community 239 | 240 | utype is a project of [UtilMeta](https://utilmeta.com), so you can join the community in 241 | 242 | * [Discord](https://discord.gg/JdmEkFS6dS) 243 | * [X(Twitter)](https://twitter.com/utilmeta) 244 | * [Reddit](https://www.reddit.com/r/utilmeta) 245 | * [中文讨论区](https://lnzhou.com/channels/utilmeta/community) 246 | -------------------------------------------------------------------------------- /deploy/nginx.conf: -------------------------------------------------------------------------------- 1 | server{ 2 | listen 80; 3 | server_name www.utype.io utype.io www.uty.pe uty.pe; 4 | return 301 https://utype.io$request_uri; 5 | } 6 | server{ 7 | listen 443; 8 | server_name www.utype.io www.uty.pe uty.pe; 9 | return 301 https://utype.io$uri; 10 | 11 | ssl_certificate /etc/letsencrypt/live/utype.io/fullchain.pem; # managed by Certbot 12 | ssl_certificate_key /etc/letsencrypt/live/utype.io/privkey.pem; # managed by Certbot 13 | } 14 | server{ 15 | listen 443 ssl http2; 16 | server_name utype.io; 17 | charset utf-8; 18 | 19 | ssl_certificate /etc/letsencrypt/live/utype.io/fullchain.pem; # managed by Certbot 20 | ssl_certificate_key /etc/letsencrypt/live/utype.io/privkey.pem; # managed by Certbot 21 | 22 | location /{ 23 | root /srv/utype/dist; 24 | index index.html; 25 | default_type text/html; 26 | try_files $uri $uri.html $uri/ /404.html; 27 | } 28 | 29 | error_page 404 403 500 502 503 504 /404.html; 30 | 31 | location = /404.html { 32 | root /srv/utype/dist; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /docs/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utilmeta/utype/62539bb522812e5b1919e4a99418a70c417fe0f8/docs/assets/favicon.ico -------------------------------------------------------------------------------- /docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utilmeta/utype/62539bb522812e5b1919e4a99418a70c417fe0f8/docs/assets/logo.png -------------------------------------------------------------------------------- /docs/assets/qq-group.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utilmeta/utype/62539bb522812e5b1919e4a99418a70c417fe0f8/docs/assets/qq-group.jpg -------------------------------------------------------------------------------- /docs/assets/utype-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utilmeta/utype/62539bb522812e5b1919e4a99418a70c417fe0f8/docs/assets/utype-black.png -------------------------------------------------------------------------------- /docs/assets/utype-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utilmeta/utype/62539bb522812e5b1919e4a99418a70c417fe0f8/docs/assets/utype-white.png -------------------------------------------------------------------------------- /docs/en/README.md: -------------------------------------------------------------------------------- 1 | # uType - Introduction 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | utype is a data type declaration and parsing library based on Python type annotations, enforce types and constraints for classes and functions at runtime 20 | 21 | * Code Repository: https://github.com/utilmeta/utype 22 | * Author: @voidZXL 23 | 24 | ## Motivation 25 | 26 | Currently, Python does not have the mechanism to guarantee types at runtime, so when we write a function, we often need to perform type assertion and constraint checking on parameters before we can start writing the actual logic. such as 27 | ```python 28 | def signup(username, password): 29 | import re 30 | if not isinstance(username, str) \ 31 | or not re.match('[0-9a-zA-Z]{3,20}', username): 32 | raise ValueError('Bad username') 33 | if not isinstance(password, str) \ 34 | or len(password) < 6: 35 | raise ValueError('Bad password') 36 | # below is your actual logic 37 | ``` 38 | 39 | However, if we can declare all types and constraints in the parameters, enforce validation at calling, and throw an error directly for invalid input, such as 40 | === "Using Annotated" 41 | ```python 42 | import utype 43 | from utype.types import Annotated # compat 3.7+ 44 | 45 | @utype.parse 46 | def signup( 47 | username: Annotated[str, utype.Param(regex='[0-9a-zA-Z]{3,20}')], 48 | password: Annotated[str, utype.Param(min_length=6)] 49 | ): 50 | # # you can directly start coding 51 | return username, password 52 | 53 | print(signup('alice', 123456)) 54 | ('alice', '123456') 55 | 56 | try: 57 | signup('@invalid', 123456) 58 | except utype.exc.ParseError as e: 59 | print(e) 60 | """ 61 | parse item: ['username'] failed: 62 | Constraint: : '[0-9a-zA-Z]{3,20}' violated 63 | """ 64 | ``` 65 | 66 | === "Using default" 67 | ```python 68 | import utype 69 | 70 | @utype.parse 71 | def signup( 72 | username: str = utype.Param(regex='[0-9a-zA-Z]{3,20}'), 73 | password: str = utype.Param(min_length=6) 74 | ): 75 | # # you can directly start coding 76 | return username, password 77 | 78 | print(signup('alice', 123456)) 79 | ('alice', '123456') 80 | 81 | try: 82 | signup('@invalid', 123456) 83 | except utype.exc.ParseError as e: 84 | print(e) 85 | """ 86 | parse item: ['username'] failed: 87 | Constraint: : '[0-9a-zA-Z]{3,20}' violated 88 | """ 89 | ``` 90 | 91 | we can get 92 | 93 | * Type checking, code completion, etc. from IDE to improve efficiency and reduce bugs. 94 | * Eliminate all the type conversion and verification code, and get the standard high-readability error message to locate the problem. 95 | * The types and constraints of parameters are clearly to callers, which improves the efficiency of collaboration 96 | 97 | ## Core features 98 | 99 | * Enforce types, data classes, function params and result parsing at runtime based on Python type annotation 100 | * Support a variety of constraints, logical operators and flexible parsing options 101 | * Highly extensible, all type transformer can be register, extend and override 102 | 103 | ## Installation 104 | 105 | ```shell 106 | pip install -U utype 107 | ``` 108 | 109 | !!! note 110 | utype requires Python >= 3.7 111 | 112 | ## Usage examples 113 | 114 | ### Types and constraints 115 | 116 | The utype support to add constraints on types, such as 117 | ```Python 118 | from utype import Rule, exc 119 | 120 | class PositiveInt(int, Rule): 121 | gt = 0 122 | 123 | assert PositiveInt(b'3') == 3 124 | 125 | try: 126 | PositiveInt(-0.5) 127 | except exc.ParseError as e: 128 | print(e) 129 | """ 130 | Constraint: 0 violated 131 | """ 132 | ``` 133 | 134 | 135 | Data that conforms to the type and constraints will complete the conversion, otherwise will throw a parse error indicating what went wrong 136 | 137 | ### Parsing dataclasses 138 | 139 | utype supports the "dataclass" usage that convert a dict or JSON to a class instance, similar to `pydantic` and `attrs` 140 | ```python 141 | from utype import Schema, Field, exc 142 | from datetime import datetime 143 | 144 | class UserSchema(Schema): 145 | username: str = Field(regex='[0-9a-zA-Z]{3,20}') 146 | signup_time: datetime 147 | 148 | # 1. Valid input 149 | data = {'username': 'bob', 'signup_time': '2022-10-11 10:11:12'} 150 | print(UserSchema(**data)) 151 | #> UserSchema(username='bob', signup_time=datetime.datetime(2022, 10, 11, 10, 11, 12)) 152 | 153 | # 2. Invalid input 154 | try: 155 | UserSchema(username='@invalid', signup_time='2022-10-11 10:11:12') 156 | except exc.ParseError as e: 157 | print(e) 158 | """ 159 | parse item: ['username'] failed: 160 | Constraint: : '[0-9a-zA-Z]{3,20}' violated 161 | """ 162 | ``` 163 | 164 | After a simple declaration, you can get 165 | 166 | * Automatic `__init__` to take input data, perform validation and attribute assignment 167 | * Providing `__repr__` and `__str__` to get the clearly print output of the instance 168 | * parse and protect attribute assignment and deletion to avoid dirty data 169 | 170 | ### Parsing functions 171 | 172 | utype can also parse function params and result 173 | ```python 174 | import utype 175 | from typing import Optional 176 | 177 | class PositiveInt(int, utype.Rule): 178 | gt = 0 179 | 180 | class ArticleSchema(utype.Schema): 181 | id: Optional[PositiveInt] 182 | title: str = utype.Field(max_length=100) 183 | slug: str = utype.Field(regex=r"[a-z0-9]+(?:-[a-z0-9]+)*") 184 | 185 | @utype.parse 186 | def get_article(id: PositiveInt = None, title: str = '') -> ArticleSchema: 187 | return { 188 | 'id': id, 189 | 'title': title, 190 | 'slug': '-'.join([''.join( 191 | filter(str.isalnum, v)) for v in title.split()]).lower() 192 | } 193 | 194 | print(get_article('3', title=b'My Awesome Article!')) 195 | #> ArticleSchema(id=3, title='My Awesome Article!', slug='my-awesome-article') 196 | 197 | try: 198 | get_article('-1') 199 | except utype.exc.ParseError as e: 200 | print(e) 201 | """ 202 | parse item: ['id'] failed: Constraint: : 0 violated 203 | """ 204 | 205 | try: 206 | get_article(title='*' * 101) 207 | except utype.exc.ParseError as e: 208 | print(e) 209 | """ 210 | parse item: [''] failed: 211 | parse item: ['title'] failed: 212 | Constraint: : 100 violated 213 | """ 214 | ``` 215 | 216 | !!! success 217 | You can easily get type checking and code completion of IDEs (such as Pycharm, VS Code) during development 218 | 219 | utype supports not only normal functions, but also generator functions, asynchronous functions, and asynchronous generator functions with the same usage 220 | ```python 221 | import utype 222 | import asyncio 223 | from typing import AsyncGenerator 224 | 225 | @utype.parse 226 | async def waiter(rounds: int = utype.Param(gt=0)) -> AsyncGenerator[int, float]: 227 | assert isinstance(rounds, int) 228 | i = rounds 229 | while i: 230 | wait = yield str(i) 231 | if wait: 232 | assert isinstance(wait, float) 233 | print(f'sleep for: {wait} seconds') 234 | await asyncio.sleep(wait) 235 | i -= 1 236 | 237 | async def wait(): 238 | wait_gen = waiter('2') 239 | async for index in wait_gen: 240 | assert isinstance(index, int) 241 | try: 242 | await wait_gen.asend(b'0.5') 243 | # sleep for: 0.5 seconds 244 | except StopAsyncIteration: 245 | return 246 | 247 | if __name__ == '__main__': 248 | asyncio.run(wait()) 249 | ``` 250 | 251 | !!! note 252 | The `AsyncGenerator` type is used to annotate the return value of the asynchronous generator, which has two parameters: the type of the value output by `yield`, type of the value sent by `asend` 253 | 254 | As you can see, the parameters passed to the function and the value received from `yield` were all converted to the expected type as declared 255 | 256 | 257 | ### Logical operation of type 258 | utype supports logical operations on types and data structures using Python-native logical operators 259 | ```python 260 | from utype import Schema, Field 261 | from typing import Tuple 262 | 263 | class User(Schema): 264 | name: str = Field(max_length=10) 265 | age: int 266 | 267 | one_of_user = User ^ Tuple[str, int] 268 | 269 | print(one_of_user({'name': 'test', 'age': '1'})) 270 | # > User(name='test', age=1) 271 | 272 | print(one_of_user([b'test', '1'])) 273 | # > ('test', 1) 274 | ``` 275 | 276 | The example uses the `^` exclusive or symbol to logically combine `User` and `Tuple[str, int]`, and the new logical type gains the ability to convert data to one of those 277 | 278 | ### Register transformer for type 279 | Type transformation and validation strictness required by each project may be different, so in utype, all types support registraton, extension and override, such as 280 | ```python 281 | from utype import Rule, Schema, register_transformer 282 | from typing import Type 283 | 284 | class Slug(str, Rule): 285 | regex = r"[a-z0-9]+(?:-[a-z0-9]+)*" 286 | 287 | @register_transformer(Slug) 288 | def to_slug(transformer, value, t: Type[Slug]): 289 | str_value = transformer(value, str) 290 | return t('-'.join([''.join( 291 | filter(str.isalnum, v)) for v in str_value.split()]).lower()) 292 | 293 | 294 | class ArticleSchema(Schema): 295 | slug: Slug 296 | 297 | print(dict(ArticleSchema(slug=b'My Awesome Article!'))) 298 | # > {'slug': 'my-awesome-article'} 299 | ``` 300 | 301 | You can register transformers not only for custom types, but also for basic types (such as `str`, `int`, etc.) Or types in the standard library (such as `datetime`, `Enum`, etc.) To customize the conversion behavior 302 | 303 | 304 | ## RoadMap and Contribution 305 | utype project is still growing, and the following features are planned for implementation in the new version 306 | 307 | * Improve the handling mechanism of parsing errors, including error handling hook functions, etc. 308 | * Support for Python generics, type variables, and more type annotation syntax 309 | * Develop Pycharm/VS Code plugin that supports IDE detection and hints for constraints, logical types, and nested types 310 | 311 | You are also welcome to contribute features or submit issues. 312 | 313 | ## Applications 314 | 315 | ### UtilMeta Python Framework 316 | UtilMeta Python Framework is a progressive meta-framework for backend applications, which efficiently builds declarative APIs based on the Python type annotation standard, and supports the integration of mainstream Python frameworks as runtime backend 317 | 318 | * Homepage: [https://utilmeta.com/py](https://utilmeta.com/py) 319 | * Source Code: [https://github.com/utilmeta/utilmeta-py](https://github.com/utilmeta/utilmeta-py) 320 | 321 | ## Community 322 | 323 | utype is a project of [UtilMeta](https://utilmeta.com), so you can join our community in 324 | 325 | * [Discord](https://discord.gg/JdmEkFS6dS) 326 | * [X(Twitter)](https://twitter.com/utilmeta) 327 | * [Reddit](https://www.reddit.com/r/utilmeta) 328 | * [中文讨论区](https://lnzhou.com/channels/utilmeta/community) 329 | 330 | -------------------------------------------------------------------------------- /docs/en/references/rule.md: -------------------------------------------------------------------------------- 1 | # Rule - constrained type 2 | In utype, the role of Rule is to impose constraints on types, and we will explain its use in detail in this document. 3 | 4 | ## Built-in constraints 5 | The Rule class has a series of built-in constraints. When you inherit from Rule, you only need to declare the constraint name as an attribute to get the ability of the constraint. Rule currently supports the following built-in constraints 6 | 7 | ### Range constraints 8 | Range constraints are used to limit the range of data, such as maximum, minimum, and so on. They include 9 | 10 | * `gt`: The input value must be greater than `gt` (>) 11 | * `ge`: The input value must be greater than or equal to `ge` (>=) 12 | * `lt`: The input value must be less than `lt` (<) 13 | * `le`: The input value must be less than or equal to `le` (<=) 14 | ```python 15 | from utype import Rule, exc 16 | 17 | class WeekDay(int, Rule): 18 | ge = 1 19 | le = 7 20 | 21 | assert WeekDay('3.0') == 3 22 | 23 | # Input that violate constrants 24 | try: 25 | WeekDay(8) 26 | except exc.ConstraintError as e: 27 | print(e) 28 | """ 29 | Constraint: : 7 violated 30 | """ 31 | ``` 32 | 33 | !!! warning 34 | If you specified maximum ( `lt` / `le` ) and minimum ( `gt` / `ge` ) at the same time, the maxinum value should greater or equal than the minimum, and have the same type as the minimum 35 | 36 | Range constraints are not restricted to types, and can be supported as long as the type have corresponding comparison method ( `__gt__`, `__ge__`, `__lt__`, `__le__`), such as 37 | 38 | ```python 39 | from utype import Rule, exc 40 | from datetime import datetime 41 | 42 | class Year2020(Rule, datetime): 43 | ge = datetime(2020, 1, 1) 44 | lt = datetime(2021, 1, 1) 45 | 46 | assert Year2020('2020-03-04') == datetime(2020, 3, 4) 47 | 48 | try: 49 | Year2020('2021-01-01') 50 | except exc.ConstraintError as e: 51 | print(e) 52 | """ 53 | Constraint: : datetime.datetime(2021, 1, 1, 0, 0) violated 54 | """ 55 | ``` 56 | 57 | !!! note 58 | Every constraint violation will throw an `utype.exc.ConstraintError`, which has the information of the violated contraint name, value and the input value 59 | But if are not sure about the cause of the parsing error, you shoul use the base Exception class `utype.exc.ParseError` to capture 60 | 61 | ### Length constraints 62 | Length constraints are used to limit the length of data and are typically used to validate data such as strings or lists, the constraints including 63 | 64 | * `length`: length of input data must be equal to `length` value 65 | * `max_length`: length of the input data must be less than or equal to `max_length` 66 | * `min_length`: length of the input data must be greater than or equal to `min_length` 67 | ```python 68 | from utype import Rule, exc 69 | 70 | class LengthRule(Rule): 71 | max_length = 3 72 | min_length = 1 73 | 74 | assert LengthRule([1, 2, 3]) == [1, 2, 3] 75 | 76 | try: 77 | LengthRule('abcde') 78 | except exc.ConstraintError as e: 79 | print(e) 80 | """ 81 | Constraint: : 3 violated 82 | """ 83 | ``` 84 | 85 | All length constraints must be positive integers. If is `length` set, you cannot set `max_length` or `min_length` 86 | 87 | !!! note 88 | If the input type does not defining the `__len__` methods (such as `int`), utype will validate the length of the value converted to string (like `len(str(value))`), so if you are validating the digits of the numbers, use `max_digits` instead 89 | 90 | ### Regex constraints 91 | Regular expressions are often able to declare more complex string validation rules for many purposes. 92 | 93 | * `regex`: specifies a regular expression that the data must exactly match 94 | ```python 95 | from utype import Rule, exc 96 | 97 | class Email(str, Rule): 98 | regex = r"([A-Za-z0-9]+[.-_])*[A-Za-z0-9]+@[A-Za-z0-9-]+(\.[A-Z|a-z]{2,})+" 99 | 100 | assert Email('dev@utype.io') == 'dev@utype.io' 101 | 102 | try: 103 | Email('invalid#email.com') 104 | except exc.ConstraintError as e: 105 | print(e) 106 | """ 107 | Constraint: : 108 | '([A-Za-z0-9]+[.-_])*[A-Za-z0-9]+@[A-Za-z0-9-]+(\\.[A-Z|a-z]{2,})+' violated 109 | """ 110 | ``` 111 | 112 | In the example, we declare a constraint type `Email` that is used to validate email addresses. 113 | 114 | ### Const and enum 115 | 116 | * `const`: input data must exactly equa to the `const` constant. 117 | ```python 118 | from utype import Rule, exc 119 | 120 | class Const1(Rule): 121 | const = 1 122 | 123 | class ConstKey(str, Rule): 124 | const = 'SECRET_KEY' 125 | 126 | assert ConstKey(b'SECRET_KEY') == 'SECRET_KEY' 127 | 128 | try: 129 | Const1(True) 130 | except exc.ConstraintError as e: 131 | print(e) 132 | """ 133 | Constraint: : 1 violated 134 | """ 135 | ``` 136 | 137 | If you specify a source type for a constant constraint, the Rule performs the type conversion first and then checks whether the constant is equal, otherwise it makes a direct comparison 138 | 139 | !!! note 140 | `const` not only verifies that a value is “equal” to a constant using Python’s equal symbol ( `==` ) , but also check that their types are equal, because a type can be made equal to any value by overriding the `__eq__` method. For example `True == 1` is true, and True is `bool` of type while 1 is of `int` type, So `True` can’t pass the `const=1` verification. 141 | 142 | * `enum`: pass in a `list`, `set`, or an `Enum` class. The data must be within the value range specified by `enum`. 143 | ```python 144 | from utype import Rule, exc 145 | 146 | class Infinity(float, Rule): 147 | enum = [float("inf"), float("-inf")] 148 | 149 | assert Infinity('-infinity') == float("-inf") 150 | 151 | try: 152 | Infinity(10.5) 153 | except exc.ConstraintError as e: 154 | print(e) 155 | """ 156 | Constraint: : [inf, -inf] violated 157 | """ 158 | ``` 159 | 160 | !!! warning 161 | `enum` accept `Enum` subclass does not means it will convert the input to an instance of that `Enum` subclass, but rather using the range specified by `Enum` subclass. if you like to convert data to an instance of `Enum` subclass, use that class directly as the type annotation 162 | 163 | !!! note 164 | In type annotation, you can use `Literal[...]` to declare constant or enums with the same effect 165 | 166 | ### Numeric constraints 167 | Numeric constraints are used to place restrictions on numeric types (int, float, Decimal), including 168 | 169 | * `max_digits`: limit the maximum digits in a number (excluding the sign bit or decimal point) 170 | * `multiple_of`: the input number must be multiple of the `multiple_of` 171 | ```python 172 | from utype import Rule, exc 173 | 174 | class Hundreds(int, Rule): 175 | max_digits = 3 176 | multiple_of = 100 177 | 178 | assert Hundreds('200') == 200 179 | 180 | try: 181 | Hundreds(1000) 182 | except exc.ConstraintError as e: 183 | print(e) 184 | """ 185 | Constraint: : 3 violated 186 | """ 187 | 188 | try: 189 | Hundreds(120) 190 | except exc.ConstraintError as e: 191 | print(e) 192 | """ 193 | Constraint: : 100 violated 194 | """ 195 | ``` 196 | 197 | !!! note 198 | If the number is between 0 and 1 (like `0.0123`), the 0 on the integer side does not count as a digit, we only calculate 4 digits in the decimal places, so `max_digits` can be understand as "maximum significant digits" 199 | 200 | * `decimal_places` limit the maximum number of digits in the decimal part of a number to this value 201 | ```python 202 | from utype import Rule, exc 203 | import decimal 204 | 205 | class ConDecimal(decimal.Decimal, Rule): 206 | decimal_places = 2 207 | max_digits = 4 208 | 209 | assert ConDecimal(1.5) == decimal.Decimal('1.50') 210 | 211 | try: 212 | ConDecimal(123.4) 213 | except exc.ConstraintError as e: 214 | print(e) 215 | """ 216 | Constraint: : 4 violated 217 | """ 218 | 219 | try: 220 | ConDecimal('1.500') 221 | except exc.ConstraintError as e: 222 | print(e) 223 | """ 224 | Constraint: : 2 violated 225 | """ 226 | ``` 227 | 228 | If the source type of the constraint is `decimal.Decimal`, when the decimal digits of the input data are insufficient, it will be completed first, so the data `123.4` will be completed first `Decimal('123.40')`, and then the verification `max_digits` will not pass. 229 | 230 | And if the incoming data contains a decimal place, it will be calculated whether the end is 0 or not, for example `Decimal('1.500')`, 3 digits will be calculated according to the decimal place of the fixed-point number. 231 | 232 | ### Array constraints 233 | Array constraints are used to constrain lists, tuples, sets, and other data that can traverse a single element, the constrants including 234 | 235 | * `contains`: specifies a type, which can be a normal type or a constrainted type. The data must contain (at least 1) matching elements. 236 | * `max_contains`: the maximum number of elements that matching `contains` type 237 | * `min_contains`: the minimum number of elements that matching `contains` type 238 | ```python 239 | from utype import Rule, exc 240 | 241 | class Const1(int, Rule): 242 | const = 1 243 | 244 | class ConTuple(tuple, Rule): 245 | contains = Const1 246 | max_contains = 3 247 | 248 | assert ConTuple([1, True]) == (1, True) 249 | 250 | try: 251 | ConTuple([0, 2]) 252 | except exc.ConstraintError as e: 253 | print(e) 254 | """ 255 | Constraint: : Const1(int, const=1) violated: 256 | Const1(int, const=1) not contained in value 257 | """ 258 | 259 | try: 260 | ConTuple([1, True, b'1', '1.0']) 261 | except exc.ConstraintError as e: 262 | print(e) 263 | """ 264 | Constraint: : 3 violated: 265 | value contains 4 of Const1(int, const=1), 266 | which is bigger than max_contains 267 | """ 268 | ``` 269 | 270 | `contains` of ConTuple in the example specifies the input must equal to 1 after convert to integer, and the maximum number `max_contains` of matches is 3, so if no element in the data matches the type of Const1, or if the number of matched elements exceeds 3, an error will be thrown 271 | 272 | !!! note 273 | `contains` only validate the match of the elements, which does not affect the output data, if you want to convert the elements, you should declare a nested type or use `__args__` attribute to specify the element type 274 | 275 | * `unique_items`: whether the element needs to be unique 276 | ```python 277 | from utype import Rule, exc, types 278 | 279 | class UniqueList(types.Array): 280 | unique_items = True 281 | 282 | assert UniqueList[int]([1, '2', 3.5]) == [1, 2, 3] 283 | 284 | try: 285 | UniqueList[int]([1, '1', True]) 286 | except exc.ConstraintError as e: 287 | print(e) 288 | """ 289 | Constraint: : True violated: value is not unique 290 | """ 291 | ``` 292 | 293 | In the example, we use `UniqueList[int]` to convert the input, so we will convert the element type first, and then check the constraint. 294 | 295 | !!! note 296 | If your source type is `set`, the data after conversion is de-duplicated already, so you don't need to specify `unique_items` in that case 297 | 298 | ## Lax constraints 299 | 300 | Lax constraints (aka loose constraints, transformational constraints) is a kind of constraints that is not strict, and can transform the input data to satisfying the constraint in its best effort 301 | 302 | Declaring a lax constraint simply requires importing `Lax` from the utype and wrapping the constraint value in `Lax`, as shown in 303 | ```python 304 | from utype import Rule, Lax 305 | 306 | class LaxLength(Rule): 307 | max_length = Lax(3) 308 | 309 | print(LaxLength('ab')) 310 | # > ab 311 | 312 | print(LaxLength('abcd')) 313 | # > abc 314 | ``` 315 | 316 | In the example, when the input `'abcd'` does not meet the lax constraint `max_length`, it will be truncated directly according to `max_length` the length of the data and output `'abc'`, instead of throwing an error directly like the strict constraint. 317 | 318 | Different constraints behave differently in the lax mode. The constraints supported by the lax mode and their respective behaviors are 319 | 320 | * `max_length`: defines a lax maximum length, truncating to the given maximum length if the value is longer than it. 321 | * `length`: If the data is greater than this length, it is truncated to `length` the corresponding length. If the data is less than this length, an error is thrown. 322 | * `ge` If the value is less than `ge`, output `ge` the value of directly, otherwise use the input value 323 | * `le` If the value is greater than `le`, the value of is output `le` directly, otherwise the input value is used 324 | * `decimal_places`: Instead of checking the decimal place, the decimal place is reserved directly to `decimals_places`, which is consistent with the effect of Python’s built-in method `round()`. 325 | * `max_digits` If the number of digits exceeds the maximum number of digits, round off the decimal places from the smallest to the largest, and throw an error if it still cannot be satisfied. 326 | * `multiple_of`: If the data is not `multiple_of` an integer multiple of, the value of an integer multiple of the nearest input data that is smaller than the input data is taken. 327 | * `const`: direct;y output the constant value 328 | * `enum`: If the data is not within the range, the first value of the range will be output directly 329 | * `unique_items`: If the data is duplicated, the de-duplicated data will be returned 330 | 331 | !!! note 332 | `decimal_places` in the Lax mode is commonly used, so `Field` provided a `round` param as a shortcut, you could use `Field(round=3)` as a shortcut for `Field(decimal_places=Lax(3))` 333 | 334 | Strict constraints are only used for validation, but lax constraints may convert the input data. Although **information loss** may occur during the conversion, lax constraints will also ensure that the conversion is **Idempotent**, that is, the value obtained from a data after multiple conversions is the same as that obtained from a single conversion 335 | 336 | !!! note 337 | As the lax constraints only been abled to compress data, not adding information, so it cannot applied to `min_length`, `gt` and `lt` -------------------------------------------------------------------------------- /docs/examples/async_fetch.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import asyncio 3 | from typing import Dict 4 | import utype 5 | 6 | 7 | @utype.parse 8 | async def fetch(url: str) -> str: 9 | async with aiohttp.ClientSession() as session: 10 | async with session.get(url) as response: 11 | print('URL:', url) 12 | return await response.text() 13 | 14 | 15 | @utype.parse 16 | async def fetch_urls(*urls: str) -> Dict[str, dict]: 17 | result = {} 18 | tasks = [] 19 | 20 | async def task(loc): 21 | result[loc] = await fetch(loc) 22 | 23 | for url in urls: 24 | tasks.append(asyncio.create_task(task(url))) 25 | 26 | await asyncio.gather(*tasks) 27 | return result 28 | 29 | # def awaitable_fetch_urls(urls: List[str]) -> Awaitable[Dict[str, dict]]: 30 | # return fetch_urls(urls) 31 | 32 | 33 | async def main(): 34 | urls = [ 35 | b'https://httpbin.org/get?k1=v1', 36 | b'https://httpbin.org/get?k1=v1&k2=v2', 37 | b'https://httpbin.org/get', 38 | ] 39 | result_map = await fetch_urls(*urls) 40 | for url, res in result_map.items(): 41 | print(url, ': query =', res) 42 | 43 | 44 | if __name__ == "__main__": 45 | loop = asyncio.get_event_loop() 46 | loop.run_until_complete(main()) 47 | # asyncio.run(main()) 48 | -------------------------------------------------------------------------------- /docs/examples/async_generator.py: -------------------------------------------------------------------------------- 1 | import utype 2 | import asyncio 3 | from typing import AsyncGenerator 4 | 5 | 6 | @utype.parse 7 | async def waiter(rounds: int = utype.Field(gt=0)) -> AsyncGenerator[int, float]: 8 | assert isinstance(rounds, int) 9 | i = rounds 10 | while i: 11 | wait = yield str(i) 12 | if wait: 13 | assert isinstance(wait, float) 14 | print(f'sleep for: {wait} seconds') 15 | await asyncio.sleep(wait) 16 | i -= 1 17 | 18 | 19 | async def wait(): 20 | wait_gen = waiter("2") 21 | async for index in wait_gen: 22 | assert isinstance(index, int) 23 | try: 24 | await wait_gen.asend(b"0.5") 25 | # wait for 0.5 seconds 26 | except StopAsyncIteration: 27 | return 28 | 29 | 30 | if __name__ == "__main__": 31 | asyncio.run(wait()) 32 | -------------------------------------------------------------------------------- /docs/examples/cls_example.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | class Article: 5 | slug: str 6 | content: str 7 | views: int = 0 8 | 9 | def __init__(self, slug: str, content: str, views: int = 0): 10 | if not isinstance(slug, str) \ 11 | or not re.findall(slug, r"[a-z0-9]+(?:-[a-z0-9]+)*") \ 12 | or len(slug) > 30: 13 | raise ValueError(f'Bad slug: {slug}') 14 | if not isinstance(content, str): 15 | raise ValueError(f'Bad content: {content}') 16 | if not isinstance(views, int) or views < 0: 17 | raise ValueError(f'Bad views: {views}') 18 | self.slug = slug 19 | self.content = content 20 | self.views = views 21 | 22 | 23 | from utype import Schema, Rule, Field 24 | 25 | 26 | class Slug(str, Rule): 27 | regex = r"[a-z0-9]+(?:-[a-z0-9]+)*" 28 | 29 | 30 | class ArticleSchema(Schema): 31 | slug: Slug = Field(max_length=30) 32 | content: str = Field(alias_from=['body', 'text']) 33 | views: int = Field(ge=0, default=0) 34 | 35 | 36 | print(ArticleSchema(slug='my-article', text=b'my article body')) 37 | -------------------------------------------------------------------------------- /docs/examples/field.py: -------------------------------------------------------------------------------- 1 | from utype import Schema, Field 2 | from datetime import datetime 3 | from typing import List 4 | 5 | 6 | class ArticleSchema(Schema): 7 | slug: str = Field( 8 | regex=r"[a-z0-9]+(?:-[a-z0-9]+)*", 9 | immutable=True, 10 | example='my-article', 11 | description='the url part of an article' 12 | ) 13 | content: str = Field(alias_from=['text', 'body']) 14 | # body: str = Field(required=False, deprecated='content') 15 | views: int = Field(ge=0, default=0, readonly=True) 16 | created_at: datetime = Field( 17 | alias='createdAt', 18 | readonly=True, 19 | required=False, 20 | ) 21 | tags: List[str] = Field(default_factory=list, no_output=lambda v: not v) 22 | 23 | 24 | article = ArticleSchema( 25 | slug=b'test-article', 26 | body='article body', 27 | tags=[] 28 | ) 29 | assert 'createdAt' not in article 30 | 31 | print(article) 32 | # > ArticleSchema(slug='test-article', content='article body', views=0) 33 | 34 | try: 35 | article.slug = 'other-slug' 36 | except AttributeError as e: 37 | print(e) 38 | """ 39 | AttributeError: ArticleSchema: Attempt to set immutable attribute: ['slug'] 40 | """ 41 | 42 | from utype import exc 43 | try: 44 | article.views = -3 45 | except exc.ParseError as e: 46 | print(e) 47 | """ 48 | ParseError: parse item: ['views'] failed: Constraint: : 0 violated 49 | """ 50 | 51 | article.created_at = '2022-02-02 10:11:12' 52 | print(dict(article)) 53 | # > {'slug': 'test-article', 'content': 'article body', 54 | # > 'views': 0, 'createdAt': datetime.datetime(2022, 2, 2, 10, 11, 12)} 55 | -------------------------------------------------------------------------------- /docs/examples/field_mode.py: -------------------------------------------------------------------------------- 1 | from utype import Schema, Field 2 | from datetime import datetime 3 | 4 | 5 | class ArticleSchema(Schema): 6 | title: str 7 | content: str 8 | updated_at: datetime = Field(default_factory=datetime.now, no_input=True) 9 | 10 | 11 | class KeyInfo(Schema): 12 | access_key: str = Field(no_output=True) 13 | last_activity: datetime = Field(default_factory=datetime.now, no_input=True) 14 | 15 | @property 16 | def key_sketch(self) -> str: 17 | return self.access_key[:5] + '*' * (len(self.access_key) - 5) 18 | -------------------------------------------------------------------------------- /docs/examples/func_basic.py: -------------------------------------------------------------------------------- 1 | import utype 2 | from typing import Optional 3 | from datetime import datetime 4 | 5 | 6 | class PositiveInt(int, utype.Rule): 7 | gt = 0 8 | 9 | 10 | class ArticleSchema(utype.Schema): 11 | id: Optional[PositiveInt] 12 | title: str = utype.Field(max_length=100) 13 | slug: str = utype.Field(regex=r"[a-z0-9]+(?:-[a-z0-9]+)*") 14 | 15 | 16 | @utype.parse 17 | def get_article(id: PositiveInt = None, title: str = '') -> ArticleSchema: 18 | return { 19 | 'id': id, 20 | 'title': title, 21 | 'slug': '-'.join([''.join( 22 | filter(str.isalnum, v)) for v in title.split()]).lower() 23 | } 24 | 25 | 26 | print(get_article('3', title=b'My Awesome Article!')) 27 | # > ArticleSchema(id=3, title='My Awesome Article!', slug='my-awesome-article') 28 | 29 | try: 30 | get_article('-1') 31 | except utype.exc.ParseError as e: 32 | print(e) 33 | 34 | try: 35 | get_article(title='*' * 101) 36 | except utype.exc.ParseError as e: 37 | print(e) 38 | 39 | 40 | @utype.parse 41 | def create_user( 42 | username: str = utype.Field(regex='[0-9a-zA-Z_-]{3,20}', example='alice-01'), 43 | password: str = utype.Field(min_length=6, max_length=50), 44 | avatar: Optional[str] = utype.Field( 45 | description='the avatar url of user', 46 | alias_from=['picture', 'headImg'], 47 | default=None, 48 | ), 49 | signup_time: datetime = utype.Field( 50 | no_input=True, 51 | default_factory=datetime.now 52 | ) 53 | ) -> dict: 54 | return { 55 | 'username': username, 56 | 'password': password, 57 | 'avatar': avatar, 58 | 'signup_time': signup_time, 59 | } 60 | -------------------------------------------------------------------------------- /docs/examples/nested_cls.py: -------------------------------------------------------------------------------- 1 | from utype import Schema, Field 2 | from typing import List 3 | 4 | 5 | class Member(Schema): 6 | name: str 7 | level: int = 0 8 | 9 | 10 | class Group(Schema): 11 | name: str 12 | creator: Member 13 | members: List[Member] = Field(default_factory=list) 14 | 15 | 16 | Group( 17 | name='group 1', 18 | creator=b'{"name": "Alice", "level": 3}', 19 | members=[{'name': 'bob'}, {'name': 'tom', 'level': '2'}] 20 | ) 21 | -------------------------------------------------------------------------------- /docs/examples/setter.py: -------------------------------------------------------------------------------- 1 | from utype import Schema, Field, exc 2 | 3 | 4 | class ArticleSchema(Schema): 5 | _slug: str 6 | _title: str 7 | 8 | @property 9 | def slug(self) -> str: 10 | return self._slug 11 | 12 | @property 13 | def title(self) -> str: 14 | return self._title 15 | 16 | @title.setter 17 | def title(self, val: str = Field(max_length=50)): 18 | self._title = val 19 | self._slug = '-'.join([''.join(filter(str.isalnum, v)) 20 | for v in val.split()]).lower() 21 | 22 | 23 | article = ArticleSchema(title=b'My Awesome article!') 24 | print(article.slug) 25 | # > 'my-awesome-article' 26 | 27 | try: 28 | article.slug = 'other value' 29 | except AttributeError: 30 | pass 31 | 32 | article.title = b'Our Awesome article!' 33 | print(dict(article)) 34 | # > {'slug': 'our-awesome-article', 'title': 'Our Awesome article!'} 35 | 36 | try: 37 | article.title = '*' * 100 38 | except exc.ParseError as e: 39 | print(e) 40 | -------------------------------------------------------------------------------- /docs/mkdocs.en.yml: -------------------------------------------------------------------------------- 1 | site_name: uType 2 | site_description: Declare & parse your type based on python type annotation 3 | site_url: https://utype.io 4 | docs_dir: en 5 | site_dir: build 6 | # ref: https://github.com/squidfunk/mkdocs-material/discussions/2346 7 | 8 | theme: 9 | name: material 10 | favicon: https://utype.io/favicon.ico 11 | language: en 12 | logo: https://utype.io/assets/utype-white.png 13 | palette: 14 | - media: '(prefers-color-scheme: light)' 15 | scheme: default 16 | primary: indigo 17 | toggle: 18 | icon: material/lightbulb 19 | name: Switch to light mode 20 | - media: '(prefers-color-scheme: dark)' 21 | scheme: slate 22 | primary: indigo 23 | toggle: 24 | icon: material/lightbulb-outline 25 | name: Switch to dark mode 26 | features: 27 | - navigation.sections 28 | - toc.follow 29 | - navigation.tracking 30 | - navigation.top 31 | - content.code.copy 32 | 33 | repo_name: utilmeta/utype 34 | repo_url: https://github.com/utilmeta/utype 35 | edit_uri: edit/main/docs/en 36 | plugins: 37 | - search 38 | - open-in-new-tab 39 | 40 | nav: 41 | - README.md 42 | - Usage: 43 | - guide/type.md 44 | - guide/cls.md 45 | - guide/func.md 46 | - API References: 47 | - references/rule.md 48 | - references/field.md 49 | - references/options.md 50 | - Languages: 51 | - English: / 52 | - 中文: /zh 53 | 54 | extra: 55 | social: 56 | - icon: fontawesome/brands/github 57 | link: https://github.com/utilmeta/utype 58 | - icon: fontawesome/brands/twitter 59 | link: https://twitter.com/utype_io 60 | - icon: fontawesome/brands/reddit 61 | link: https://www.reddit.com/r/utilmeta 62 | alternate: 63 | - name: English 64 | link: / 65 | lang: en 66 | - name: 中文 67 | link: /zh/ 68 | lang: zh 69 | analytics: 70 | provider: google 71 | property: G-T7PKK2EXMW 72 | feedback: 73 | title: Was this page helpful? 74 | ratings: 75 | - icon: material/emoticon-happy-outline 76 | name: This page was helpful 77 | data: 1 78 | note: >- 79 | Thank you for your feedback~ 80 | - icon: material/emoticon-sad-outline 81 | name: This page could be improved 82 | data: 0 83 | note: >- 84 | Thank you for your feedback~ 85 | 86 | markdown_extensions: 87 | - toc: 88 | permalink: true 89 | - markdown.extensions.codehilite: 90 | guess_lang: false 91 | - admonition 92 | - pymdownx.highlight: 93 | use_pygments: true 94 | - pymdownx.tabbed: 95 | alternate_style: true 96 | - pymdownx.superfences 97 | 98 | copyright: Copyright © 2022 - 2024 Xulin Zhou -------------------------------------------------------------------------------- /docs/mkdocs.zh.yml: -------------------------------------------------------------------------------- 1 | site_name: uType 2 | site_description: 基于 Python 类型注解的类型声明与解析库 3 | site_url: https://utype.io/zh 4 | docs_dir: zh 5 | site_dir: build/zh 6 | 7 | theme: 8 | name: material 9 | favicon: https://utype.io/favicon.ico 10 | language: zh 11 | logo: https://utype.io/assets/utype-white.png 12 | palette: 13 | - media: '(prefers-color-scheme: light)' 14 | scheme: default 15 | primary: indigo 16 | toggle: 17 | icon: material/lightbulb 18 | name: Switch to light mode 19 | - media: '(prefers-color-scheme: dark)' 20 | scheme: slate 21 | primary: indigo 22 | toggle: 23 | icon: material/lightbulb-outline 24 | name: Switch to dark mode 25 | features: 26 | - navigation.sections 27 | - toc.follow 28 | - navigation.tracking 29 | - navigation.top 30 | - content.code.copy 31 | 32 | repo_name: utilmeta/utype 33 | repo_url: https://github.com/utilmeta/utype 34 | edit_uri: edit/main/docs/zh 35 | plugins: 36 | - search 37 | - open-in-new-tab 38 | 39 | nav: 40 | - README.md 41 | - 核心用法: 42 | - guide/type.md 43 | - guide/cls.md 44 | - guide/func.md 45 | - API 参考: 46 | - references/rule.md 47 | - references/field.md 48 | - references/options.md 49 | - 语言: 50 | - English: / 51 | - 中文: /zh 52 | # - 功能扩展: 53 | # - extension/env.md 54 | # - extension/cmd.md 55 | 56 | extra: 57 | social: 58 | - icon: fontawesome/brands/github 59 | link: https://github.com/utilmeta/utype 60 | - icon: fontawesome/brands/twitter 61 | link: https://twitter.com/utype_io 62 | alternate: 63 | - name: English 64 | link: / 65 | lang: en 66 | - name: 中文 67 | link: /zh/ 68 | lang: zh 69 | analytics: 70 | provider: google 71 | property: G-T7PKK2EXMW 72 | feedback: 73 | title: 本篇文档是否能帮助到你? 74 | ratings: 75 | - icon: material/emoticon-happy-outline 76 | name: This page was helpful 77 | data: 1 78 | note: >- 79 | 感谢反馈~ 80 | - icon: material/emoticon-sad-outline 81 | name: This page could be improved 82 | data: 0 83 | note: >- 84 | 感谢反馈~ 85 | 86 | markdown_extensions: 87 | - toc: 88 | permalink: true 89 | - markdown.extensions.codehilite: 90 | guess_lang: false 91 | - admonition 92 | - pymdownx.highlight: 93 | use_pygments: true 94 | - pymdownx.tabbed: 95 | alternate_style: true 96 | - pymdownx.superfences 97 | 98 | copyright: Copyright © 2022 - 2024 周煦林 -------------------------------------------------------------------------------- /docs/zh/README.md: -------------------------------------------------------------------------------- 1 | # uType - 介绍 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | utype 是一个基于 Python 类型注解的数据类型声明与解析库,能够在运行时根据你的声明对类与函数的参数进行解析转化 21 | 22 | * 代码:https://github.com/utilmeta/utype 23 | * 作者:@voidZXL 24 | 25 | ## 需求动机 26 | 27 | 目前 Python 没有在运行时解析类型与校验约束的机制,所以当我们编写一个函数时,往往需要先对参数进行类型断言和约束校验等操作,然后才能开始编写真正的逻辑,否则很可能会在运行时发生异常错误,如 28 | ```python 29 | def signup(username, password): 30 | import re 31 | if not isinstance(username, str) \ 32 | or not re.match('[0-9a-zA-Z]{3,20}', username): 33 | raise ValueError('Bad username') 34 | if not isinstance(password, str) \ 35 | or len(password) < 6: 36 | raise ValueError('Bad password') 37 | # 下面才是你真正的处理逻辑 38 | ``` 39 | 40 | 但如果我们能够把类型和约束都在参数中声明出来,在调用时就进行校验,对非法参数直接抛出错误,如 41 | === "使用 Annotated" 42 | ```python 43 | import utype 44 | from utype.types import Annotated 45 | 46 | @utype.parse 47 | def signup( 48 | username: Annotated[str, utype.Param(regex='[0-9a-zA-Z]{3,20}')], 49 | password: Annotated[str, utype.Param(min_length=6)] 50 | ): 51 | # 你可以直接开始编写逻辑了 52 | return username, password 53 | 54 | print(signup('alice', 123456)) 55 | ('alice', '123456') 56 | 57 | try: 58 | signup('@invalid', 123456) 59 | except utype.exc.ParseError as e: 60 | print(e) 61 | """ 62 | parse item: ['username'] failed: 63 | Constraint: : '[0-9a-zA-Z]{3,20}' violated 64 | """ 65 | ``` 66 | 67 | === "使用默认值" 68 | ```python 69 | import utype 70 | 71 | @utype.parse 72 | def signup( 73 | username: str = utype.Param(regex='[0-9a-zA-Z]{3,20}'), 74 | password: str = utype.Param(min_length=6) 75 | ): 76 | # 你可以直接开始编写逻辑了 77 | return username, password 78 | 79 | print(signup('alice', 123456)) 80 | ('alice', '123456') 81 | 82 | try: 83 | signup('@invalid', 123456) 84 | except utype.exc.ParseError as e: 85 | print(e) 86 | """ 87 | parse item: ['username'] failed: 88 | Constraint: : '[0-9a-zA-Z]{3,20}' violated 89 | """ 90 | ``` 91 | 92 | 这样我们就可以获得 93 | 94 | * 来自 IDE 的类型检查,代码补全等,提高了开发效率,还减少了产生 bug 的机会 95 | * 省去所有的的类型转化与校验工作,并且获得标准的高可读性的报错信息来定位问题 96 | * 对调用者清晰可见参数的类型和约束,提高了协作开发的效率 97 | 98 | ## 核心特性 99 | 100 | * 基于 Python 类型注解在运行时对类型,数据结构,函数参数与结果等进行解析转化 101 | * 支持类型约束,类型的逻辑运算等,以声明更复杂的解析条件 102 | * 高度可扩展,所有类型的转化函数都可以注册,覆盖与扩展,并提供高度灵活的解析选项 103 | 104 | ## 安装 105 | 106 | ```shell 107 | pip install -U utype 108 | ``` 109 | 110 | !!! note 111 | utype 需要 Python >= 3.7 112 | 113 | ## 用法示例 114 | 115 | ### 类型与约束 116 | 117 | utype 支持方便地为类型施加约束,你可以使用常用的约束条件(比如大小,长度,正则等)构造约束类型 118 | ```Python 119 | from utype import Rule, exc 120 | 121 | class PositiveInt(int, Rule): 122 | gt = 0 123 | 124 | assert PositiveInt(b'3') == 3 125 | 126 | try: 127 | PositiveInt(-0.5) 128 | except exc.ParseError as e: 129 | print(e) 130 | """ 131 | Constraint: : 0 violated 132 | """ 133 | ``` 134 | 135 | 在调用约束类型时,符合类型和约束声明的数据会成功完成转化,不符合的数据会抛出一个解析错误,用于指示哪里出了问题 136 | 137 | ### 解析 JSON 数据 138 | 139 | utype 支持将字典或 JSON 数据转化为类实例,类似于 `pydantic` 和 `attrs` ,如 140 | ```python 141 | from utype import Schema, Field, exc 142 | from datetime import datetime 143 | 144 | class UserSchema(Schema): 145 | username: str = Field(regex='[0-9a-zA-Z]{3,20}') 146 | signup_time: datetime 147 | 148 | # 1. 正常输入 149 | data = {'username': 'bob', 'signup_time': '2022-10-11 10:11:12'} 150 | print(UserSchema(**data)) 151 | #> UserSchema(username='bob', signup_time=datetime.datetime(2022, 10, 11, 10, 11, 12)) 152 | 153 | # 2. 异常输入 154 | try: 155 | UserSchema(username='@invalid', signup_time='2022-10-11 10:11:12') 156 | except exc.ParseError as e: 157 | print(e) 158 | """ 159 | parse item: ['username'] failed: 160 | Constraint: : '[0-9a-zA-Z]{3,20}' violated 161 | """ 162 | ``` 163 | 164 | 在简单的声明后,你就可以获得 165 | 166 | * 无需声明 `__init__` 便能够接收对应的参数,并且完成类型转化和约束校验 167 | * 提供清晰可读的 `__repr__` 与 `__str__` 函数使得在输出和调试时方便直接获得内部的数据值 168 | * 在属性赋值或删除时根据字段的类型与配置进行解析与保护,避免出现脏数据 169 | 170 | ### 解析函数参数与结果 171 | 172 | utype 提供了函数解析的机制,你只需要把函数参数的类型与配置声明出来,就可以在函数中拿到类型安全,约束保障的参数值,函数的调用者也能够获得满足返回类型声明的结果 173 | ```python 174 | import utype 175 | from typing import Optional 176 | 177 | class PositiveInt(int, utype.Rule): 178 | gt = 0 179 | 180 | class ArticleSchema(utype.Schema): 181 | id: Optional[PositiveInt] 182 | title: str = utype.Field(max_length=100) 183 | slug: str = utype.Field(regex=r"[a-z0-9]+(?:-[a-z0-9]+)*") 184 | 185 | @utype.parse 186 | def get_article(id: PositiveInt = None, title: str = '') -> ArticleSchema: 187 | return { 188 | 'id': id, 189 | 'title': title, 190 | 'slug': '-'.join([''.join( 191 | filter(str.isalnum, v)) for v in title.split()]).lower() 192 | } 193 | 194 | print(get_article('3', title=b'My Awesome Article!')) 195 | #> ArticleSchema(id=3, title='My Awesome Article!', slug='my-awesome-article') 196 | 197 | try: 198 | get_article('-1') 199 | except utype.exc.ParseError as e: 200 | print(e) 201 | """ 202 | parse item: ['id'] failed: Constraint: : 0 violated 203 | """ 204 | 205 | try: 206 | get_article(title='*' * 101) 207 | except utype.exc.ParseError as e: 208 | print(e) 209 | """ 210 | parse item: [''] failed: 211 | parse item: ['title'] failed: 212 | Constraint: : 100 violated 213 | """ 214 | ``` 215 | 216 | !!! success 217 | 使用这样的用法你可以在开发中轻松获得 IDE (如 Pycharm, VS Code)的类型检查与代码补全 218 | 219 | utype 不仅支持解析普通函数,还支持解析生成器函数,异步函数和异步生成器函数,它们的用法都是一致的,只需要正确地进行类型注解 220 | ```python 221 | import utype 222 | import asyncio 223 | from typing import AsyncGenerator 224 | 225 | @utype.parse 226 | async def waiter(rounds: int = utype.Param(gt=0)) -> AsyncGenerator[int, float]: 227 | assert isinstance(rounds, int) 228 | i = rounds 229 | while i: 230 | wait = yield str(i) 231 | if wait: 232 | assert isinstance(wait, float) 233 | print(f'sleep for: {wait} seconds') 234 | await asyncio.sleep(wait) 235 | i -= 1 236 | 237 | async def wait(): 238 | wait_gen = waiter('2') 239 | async for index in wait_gen: 240 | assert isinstance(index, int) 241 | try: 242 | await wait_gen.asend(b'0.5') 243 | # sleep for: 0.5 seconds 244 | except StopAsyncIteration: 245 | return 246 | 247 | if __name__ == '__main__': 248 | asyncio.run(wait()) 249 | ``` 250 | 251 | !!! note 252 | `AsyncGenerator` 类型用于注解异步生成器的返回值,其中有两个参数,第一个表示 `yield` 出的值的类型,第二个表示 `asend` 发送的值的类型 253 | 254 | 可以看到,虽然我们在传参和 `yield` 中使用了字符等类型,它们全部都按照声明转化为了期望的数字类型(当然在无法完成转化时会抛出错误) 255 | 256 | 257 | ### 类型的逻辑运算 258 | utype 支持使用 Python 原生的逻辑运算符对类型与数据结构进行逻辑运算 259 | ```python 260 | from utype import Schema, Field 261 | from typing import Tuple 262 | 263 | class User(Schema): 264 | name: str = Field(max_length=10) 265 | age: int 266 | 267 | one_of_user = User ^ Tuple[str, int] 268 | 269 | print(one_of_user({'name': 'test', 'age': '1'})) 270 | # > User(name='test', age=1) 271 | 272 | print(one_of_user([b'test', '1'])) 273 | # > ('test', 1) 274 | ``` 275 | 276 | 例子中使用了 `^` 异或符号对 utype 数据类 `User` 和嵌套类型 `Tuple[str, int]` 进行逻辑组合,组合得到的逻辑类型就可以转把数据转化为 `User` 或 `Tuple[str, int]` 实例 277 | 278 | ### 类型的注册扩展 279 | 由于每个项目需要的类型转化方式和校验严格程度可能不同,在 utype 中,所有的类型都是支持自行注册和扩展转化函数,如 280 | ```python 281 | from utype import Rule, Schema, register_transformer 282 | from typing import Type 283 | 284 | class Slug(str, Rule): 285 | regex = r"[a-z0-9]+(?:-[a-z0-9]+)*" 286 | 287 | @register_transformer(Slug) 288 | def to_slug(transformer, value, t: Type[Slug]): 289 | str_value = transformer(value, str) 290 | return t('-'.join([''.join( 291 | filter(str.isalnum, v)) for v in str_value.split()]).lower()) 292 | 293 | 294 | class ArticleSchema(Schema): 295 | slug: Slug 296 | 297 | print(dict(ArticleSchema(slug=b'My Awesome Article!'))) 298 | # > {'slug': 'my-awesome-article'} 299 | ``` 300 | 301 | !!! note 302 | 注册转换器并没有影响类的 `__init__` 方法的行为,所以直接调用 `Slug(value)` 并不会生效 303 | 304 | 你不仅可以为自定义类型注册转化器,还可以为基本类型(如 `str`, `int` 等)或标准库中的类型(如 `datetime`, `Enum` 等)注册转化器函数,来自定义其中的转化行为 305 | 306 | !!! note 307 | `utype` 提供的是 **运行时** 提供的类型解析能力,也就是说它不能(也没有必要)让 Python 像静态语言一样在程序启动时就能够分析所有的类型与调用是否正确 308 | 309 | 310 | ## RoadMap 与贡献 311 | utype 还在成长中,目前规划了以下将在新版本中实现的特性 312 | 313 | * 完善解析错误的处理机制,包括错误处理钩子函数等 314 | * 支持 Python 泛型,类型变量等更多类型注解语法 315 | * 开发 Pycharm / VS Code 插件,支持对约束,逻辑类型和嵌套类型的 IDE 检测与提示 316 | 317 | 也欢迎你来贡献 feature 或者提交 issue ~ 318 | 319 | ## 应用案例 320 | 321 | ### UtilMeta Python 框架 322 | UtilMeta 是一个面向服务端应用的渐进式元框架,基于 Python 类型注解标准高效构建声明式接口,支持使用主流 Python 框架作为运行时实现或渐进式迁移 323 | 324 | * 主页: [https://utilmeta.com/py](https://utilmeta.com/py) 325 | * 源码: [https://github.com/utilmeta/utilmeta-py](https://github.com/utilmeta/utilmeta-py) 326 | 327 | ## 社区 328 | 329 | utype 是一个 [UtilMeta](https://utilmeta.com) 项目,你可以加入下面的社区参与交流 330 | 331 | * [Discord](https://discord.gg/JdmEkFS6dS) 332 | * [X(Twitter)](https://twitter.com/utilmeta) 333 | * [Reddit](https://www.reddit.com/r/utilmeta) 334 | * [中文讨论区](https://lnzhou.com/channels/utilmeta/community) 335 | 336 | 337 | ## 对比 338 | ### utype | Pydantic 339 | Pydantic 是一个流行的 Python 数据解析验证库,utype 提供的功能与 Pydantic 大体上是相近的,但相比之下,utype 在以下方面有更多的关注 340 | 341 | * **函数的解析**:utype 能很好的处理各种函数参数与返回值的解析(包括同步函数,异步函数,生成器与异步生成器函数),pydantic 对函数返回值只进行验证,并不尝试进行类型转化,且并不支持生成器函数 342 | * **约束类型**:对于 utype 来说所有的 **约束** (比如大小,长度,正则等)都会体现在类型中,从而可以直接用来进行类型转化与判断,pydantic 定义的类型往往需要作为字段的注解才能发挥作用 343 | ```python 344 | >>> from pydantic import PositiveInt 345 | >>> PositiveInt(-1) 346 | -1 347 | >>> from utype.types import PositiveInt 348 | >>> PositiveInt(-1) 349 | utype.utils.exceptions.ConstraintError: Constraint: : 0 violated 350 | ``` 351 | * **类型注册机制**:utype 中所有类型的解析与转化方式都是可以进行注册与覆盖的,也就是说开发者可以方便地自定义基本类型的解析方式,或者注册自定义类型的解析函数;pydantic 支持的解析的内置类型是固定的。由于 utype 的类型解析是注册机制的,所以 utype 也可以兼容解析 **pydantic**, **dataclasses**, **attrs** 等数据类 (参考 [兼容 Pydantic](/zh/guide/type/#pydantic)) 352 | ```python 353 | from utype import register_transformer 354 | from collections.abc import Mapping 355 | from pydantic import BaseModel 356 | 357 | @register_transformer(BaseModel) 358 | def transform_pydantic(transformer, data, cls): 359 | if not transformer.no_explicit_cast and not isinstance(data, Mapping): 360 | data = transformer(data, dict) 361 | return cls(**data) 362 | ``` 363 | * **逻辑类型**:utype 的类型支持任意嵌套组合的逻辑运算,可以兼容基本类型与 typing 用法,以及支持运算出的类型对数据进行处理(pydantic 没有相应用法) 364 | ```python 365 | from utype import Rule, exc 366 | from typing import Literal 367 | 368 | class IntWeekDay(int, Rule): 369 | gt = 0 370 | le = 7 371 | 372 | weekday = IntWeekDay ^ Literal['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] 373 | 374 | >>> weekday('6') 375 | 6 376 | >>> weekday(b'tue') 377 | 'tue' 378 | >>> weekday(8) 379 | Constraint: : 7 violated; 380 | Constraint: : ('mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun') violated 381 | ``` 382 | * **字段模式**:utype 的字段提供了 模式 (`mode`) 机制,包括 `no_input` 与 `no_output` 等,可以在一个数据类中定义字段的多种用法,对于在 web 场景中定义负责 **增改查** 等多种目的的数据模型更加方便 383 | * **原生字典模型**:pydantic 的 BaseModel 产出的数据实例虽然有 JSON 序列化方法,但并不能被 `json.dumps` 处理,utype 提供继承原生字典的 `Schema` 类,整合到数据工作流中更方便 384 | ```python 385 | from pydantic import BaseModel 386 | from utype import Schema 387 | import json 388 | 389 | class md(BaseModel): 390 | value: int 391 | 392 | class schema(Schema): 393 | value: int 394 | 395 | >>> json.dumps(md(value=1)) 396 | TypeError: Object of type md is not JSON serializable 397 | >>> json.dumps(schema(value=1)) 398 | '{"value": 1}' 399 | ``` 400 | 401 | 整体上而言,utype 提供的配置参数更加简洁一些,提供的功更加灵活一些,可以看作一个更加灵活与轻量级的 Pydantic -------------------------------------------------------------------------------- /docs/zh/references/options.md: -------------------------------------------------------------------------------- 1 | # Options 解析选项 2 | 在 utype 中,Options 可以用来调控数据类与函数的解析行为,本篇文档我们来详细说明它的用法 3 | 4 | ## 类型转化选项 5 | 6 | 在数据解析中,类型转化是其中最关键的部分,Options 中提供了一些选项来调控类型转化的行为 7 | 8 | ### 转化偏好 9 | 10 | * `no_explicit_cast`:无显式类型转化,默认为 False 11 | 12 | 无显式类型转化的含义是尽量不发生预期之外的类型转化,实现上会将类型按照基本类型分组 13 | 14 | 1. `null`:None 15 | 2. `boolean`:0, 1, True, False 16 | 3. `number`:int/float/decimal 等数字 17 | 4. `string`:str/bytes/bytearray 等字符串与二进制字节 18 | 5. `array`:list/tuple/set 19 | 6. `object`:dict/mapping 20 | 21 | 开启 `no_explicit_cast` 后,同组的类型之间可以互相转化,不同组的类型之间不能相互转化,但存在一定的特例,比如 Decimal(定点数) 允许从 str 转化,因为从浮点数转化会出现失真;datetime 等类型也支持从日期字符串与时间戳转化,因为没有更原生的类型表达方式 22 | 23 | 我们举例来说明以下,默认情况下,utype 允许字符串到列表/字典的转化,前提是满足某些模式,比如 24 | ```python 25 | from utype import type_transform 26 | 27 | print(type_transform('[1,2,3]', list)) 28 | # > [1, 2, 3] 29 | print(type_transform('{"value": true}', dict)) 30 | # > {'value': True} 31 | ``` 32 | 33 | 但是在开启 `no_explicit_cast` 参数后,不会允许这样的转化 34 | ```python 35 | from utype import type_transform, Options 36 | 37 | try: 38 | type_transform('[1,2,3]', list, options=Options(no_explicit_cast=True)) 39 | except TypeError: 40 | pass 41 | 42 | try: 43 | type_transform('{"value": true}', dict, options=Options(no_explicit_cast=True)) 44 | except TypeError: 45 | pass 46 | 47 | print(type_transform((1, 2), list, options=Options(no_explicit_cast=True))) 48 | # > [1, 2] 49 | ``` 50 | 51 | * `no_data_loss`:不允许转化中发生信息损耗,默认为 False 52 | 53 | 默认情况下我们允许在类型转化中存在信息的损耗,比如 54 | 55 | ```python 56 | from utype import type_transform 57 | 58 | print(type_transform("Some Value", bool)) 59 | # > True 60 | 61 | print(type_transform(3.1415, int)) 62 | # > 3 63 | 64 | from datetime import date 65 | print(type_transform('2022-03-04 10:11:12', date)) 66 | # 2022-03-04 67 | ``` 68 | 69 | 这些例子中,输入数据的信息都在进行类型转化时发生了不可逆的压缩或损耗,如果开启 `no_data_loss`,则这些发生了信息损耗的转化都会报错 70 | 71 | ```python 72 | from utype import type_transform, Options 73 | 74 | try: 75 | type_transform(3.1415, int, options=Options(no_data_loss=True)) 76 | except TypeError: 77 | pass 78 | ``` 79 | 80 | 只接受没有信息损失的转化,比如 81 | 82 | 1. `bool`:只接受 `True`, `False`, `0`, `1` 和一些明显表示布尔值的字符串,如 `'true'`,`'f'`,`'no'` 等 83 | 2. `int`:不接受有有效小数位的 `float` 和 `Decimal`,比如 `3.14` 84 | 3. `date`:不接受从 `datetime` 或包含时分秒部分的字符串进行转化 85 | 86 | !!! note 87 | 需要注意的是,这些偏好只是给转换器函数的 “**提示**” 或者 flag,Python 本身没有什么机制能够强制保障这些条件,它们会在具体的类型转化函数中实施,如果你自己定义了类型转化函数,也需要自行判断这些 flag 并实施对应的转化策略 88 | 89 | ### 未知类型的处理 90 | 91 | 如果一个类型无法在 utype 中找到匹配的转换器(包括由开发者自行注册的转换器)就会被称为未知类型,对于未知类型的转化处理(与输入数据不匹配),utype 在解析选项 Options 中提供的配置参数为 92 | 93 | * `unresolved_types`:指定处理未知类型的行为,它有几个取值 94 | 95 | 1. `'ignore'`:忽略,不再转化,而是直接使用输入值作为结果 96 | 2. `'init'`:尝试使用 `t(data)` 对未知类型进行初始化 97 | 3. `'throw'`:直接抛出错误,不再转化,这个选项是默认值 98 | 99 | ```python 100 | from utype import Schema, Options 101 | 102 | class MyClass: 103 | def __init__(self, value): 104 | self.value = value 105 | 106 | class MySchema(Schema): 107 | __options__ = Options( 108 | unresolved_types='init', 109 | ) 110 | 111 | inst: MyClass = None 112 | 113 | data = MySchema(inst=3) 114 | 115 | print(data.inst.value) 116 | # > 3 117 | ``` 118 | 119 | 120 | ## 数据处理选项 121 | 122 | Options 提供了一些选项用于对函数的参数以及数据类的输入数据进行整体调控或限制,包括 123 | 124 | * `addition`:调控超出声明范围之外的参数,有几个选项可以指定 125 | 126 | 1. `None`:默认选项,直接忽略,不进行接收和处理 127 | 2. `True`:接受额外的参数作为数据的一部分 128 | 3. `False`:禁止额外参数,如果输入中包含额外参数,则直接抛出错误 129 | 4. ``:指定一个类型,表示额外参数的值都需要转化到这个类型 130 | 131 | 下面来示例一下 `addition` 的用法 132 | ```python 133 | from utype import Schema, Options, exc 134 | 135 | class User(Schema): 136 | name: str 137 | level: int = 0 138 | 139 | data = {'name': 'Test', 'code': 'XYZ'} 140 | print(dict(User.__from__(data))) # default: addition=None 141 | # > {'name': 'Test', 'level': 0} 142 | 143 | user = User.__from__(data, options=Options(addition=True)) 144 | print(dict(user)) 145 | # > {'name': 'Test', 'level': 0, 'code': 'XYZ'} 146 | 147 | try: 148 | User.__from__(data, options=Options(addition=False)) 149 | except exc.ParseError as e: 150 | print(e) 151 | """ 152 | parse item: ['code'] exceeded 153 | """ 154 | ``` 155 | 156 | !!! note 157 | 对于函数而言,可以通过声明 `**kwargs` 参数来表示调控额外参数的接受和类型,所以一般不需要声明 `addition` 参数,除非需要禁止额外参数,则声明 `addition=False` 即可 158 | 159 | 160 | * `max_depth`:限制数据嵌套的最大深度。这个参数主要用于限制自引用或循环引用的数据结构,避免出现递归栈溢出 161 | 162 | ```python 163 | from utype import Schema, Options, exc 164 | 165 | class Comment(Schema): 166 | __options__ = Options(max_depth=3) 167 | content: str 168 | comment: 'Comment' = None 169 | 170 | comment = {'content': 'stuck'} 171 | comment['comment'] = comment 172 | 173 | try: 174 | Comment(**comment) 175 | except exc.ParseError as e: 176 | print(e) 177 | """ 178 | parse item: ['comment'] failed: 179 | parse item: ['comment'] failed: 180 | parse item: ['comment'] failed: max_depth: 3 exceed: 4 181 | """ 182 | ``` 183 | 184 | 在例子中我们构造了一个自引用的字典,如果一直按照数据类声明进行解析,会一直解析到 Python 抛出递归错误,通过限制 `max_depth` 就可以控制解析的最大深度 185 | 186 | 187 | 另外 Options 还提供了控制传入参数数量的限制调节 188 | 189 | * `max_params`:设置传入的参数的最大数量 190 | * `min_params`:设置传入的参数的最小数量 191 | 192 | 这两个选项往往在开启了 `addition=True` 时使用,用于在解析前控制输入参数的数量,避免输入数据过大而耗费解析资源 193 | 194 | ```python 195 | from utype import Schema, Options, exc 196 | 197 | class Info(Schema): 198 | __options__ = Options( 199 | min_params=2, 200 | max_params=5, 201 | addition=True 202 | ) 203 | version: str 204 | 205 | data = { 206 | 'version': 'v1', 207 | 'k1': 1, 208 | 'k2': 2, 209 | 'k3': 3 210 | } 211 | print(len(Info(**data))) 212 | # > 4 213 | 214 | try: 215 | Info(version='v1') 216 | except exc.ParamsLackError as e: 217 | print(e) 218 | """ 219 | min params num: 2 lacked: 1 220 | """ 221 | 222 | try: 223 | Info(**data, k4=4, k5=5) 224 | except exc.ParamsExceedError as e: 225 | print(e) 226 | """ 227 | max params num: 5 exceed: 6 228 | """ 229 | ``` 230 | 231 | 可以看到,当输入参数数量少于 `min_params` 时,会抛出 `exc.ParamsLackError`,当输入参数数量大于 `max_params` 时,会抛出 `exc.ParamsExceedError` 232 | 233 | **与长度约束的区别** 234 | 235 | 虽然使用 Rule 约束参数的 `max_length` 和 `min_length` 也能够约束字典的长度,但是它们与`max_params` / `min_params` 在作用上是有区别的 236 | 237 | `max_params` / `min_params` 是在所有的字段解析开始之前对输入数据进行的校验,其中 `max_params` 是为了避免输入数据过大而耗费解析资源。而 `max_length` / `min_length` 在作用于数据类中,是用于在所有字段解析结束后,用于限制 **输出** 的数据的长度 238 | 239 | 并且 `max_params` / `min_params` 可以用于限制函数参数的输入,`max_length` / `min_length` 只能限制普通类型和数据类 240 | 241 | 242 | ## 错误处理 243 | 244 | Options 提供了一系列错误处理选项,用于控制解析错误的行为,包括 245 | 246 | * `collect_errors`:是否收集所有的错误,默认为 False 247 | 248 | utype 对于数据类和函数的参数在解析时,如果发现出错的数据(无法完成类型转化或者无法满足约束),当 `collect_errors=False` 时,会直接将错误作为 `exc.ParseError` 进行抛出,也就是 ”快速失败“ 策略 249 | 250 | 但当 `collect_errors=True` 时,utype 会继续解析,并继续收集遇到的错误,当输入数据解析完毕后再将这些错误合并位一个 `exc.CollectedParseError` 进行抛出,从这个合并错误中能够获取到所有的输入数据错误信息 251 | 252 | ```python 253 | from utype import Schema, Options, Field, exc 254 | 255 | class LoginForm(Schema): 256 | __options__ = Options( 257 | addition=False, 258 | collect_errors=True 259 | ) 260 | 261 | username: str = Field(regex='[0-9a-zA-Z]{3,20}') 262 | password: str = Field(min_length=6, max_length=20) 263 | 264 | form = { 265 | 'username': '@attacker', 266 | 'password': '12345', 267 | 'token': 'XXX' 268 | } 269 | 270 | try: 271 | LoginForm(**form) 272 | except exc.CollectedParseError as e: 273 | print(e) 274 | """ 275 | parse item: ['username'] failed: Constraint: : '[0-9a-zA-Z]{3,20}' violated; 276 | parse item: ['password'] failed: Constraint: : 6 violated; 277 | parse item: ['token'] exceeded 278 | """ 279 | print(len(e.errors)) 280 | # > 3 281 | ``` 282 | 283 | !!! note 284 | 当然,在 `collect_errors=True` 时,应对非法输入的性能会有适当下降,这样的配置更适合在调试期间使用,方便定位输入错误 285 | 286 | 287 | * `max_errors`:在收集错误 `collect_errors=True` 模式下,设置一个错误数量阈值,如果错误数量达到这个阈值,则不再继续收集,而是直接将当前收集到的错误合并抛出 288 | 289 | ```python 290 | from utype import Schema, Options, Field, exc 291 | 292 | class LoginForm(Schema): 293 | __options__ = Options( 294 | addition=False, 295 | collect_errors=True, 296 | max_errors=2 297 | ) 298 | 299 | username: str = Field(regex='[0-9a-zA-Z]{3,20}') 300 | password: str = Field(min_length=6, max_length=20) 301 | 302 | form = { 303 | 'username': '@attacker', 304 | 'password': '12345', 305 | 'token': 'XXX' 306 | } 307 | 308 | try: 309 | LoginForm(**form) 310 | except exc.CollectedParseError as e: 311 | print(e) 312 | """ 313 | parse item: ['username'] failed: Constraint: : '[0-9a-zA-Z]{3,20}' violated; 314 | parse item: ['password'] failed: Constraint: : 6 violated; 315 | """ 316 | print(len(e.errors)) 317 | # > 2 318 | ``` 319 | 320 | ### 非法数据处理 321 | 322 | 除了整体性的错误错误策略外,Options 还提供了针对特定种类元素的错误处理策略 323 | 324 | * `invalid_items`:如何处置列表/集合/元组中的非法元素 325 | * `invalid_keys`:如何处置字典/映射中非法的键 326 | * `invalid_values`:如何处置字典/映射中非法的值 327 | 328 | 这些配置都有着一样的可选项 329 | 330 | 1. `'throw'`:默认值,直接抛出错误 331 | 2. `'exclude'`:将非法元素从数据中剔除,只进行警告但不抛出错误 332 | 3. `'preserve'`:将非法元素保留,只进行警告但不抛出错误 333 | 334 | 我们来具体看一个例子 335 | ```python 336 | from utype import Schema, Options, exc 337 | from typing import List, Dict, Tuple 338 | 339 | class IndexSchema(Schema): 340 | __options__ = Options( 341 | invalid_items='exclude', 342 | invalid_keys='preserve', 343 | ) 344 | 345 | indexes: List[int] 346 | info: Dict[Tuple[int, int], int] 347 | 348 | data = { 349 | 'indexes': ['1', '-2', '*', 3], 350 | 'info': { 351 | '2,3': 6, 352 | '3,4': 12, 353 | 'a,b': '10' 354 | } 355 | } 356 | 357 | index = IndexSchema(**data) 358 | # UserWarning: parse item: [2] failed: could not convert string to float: '*' 359 | # UserWarning: parse item: ['a,b'] failed: could not convert string to float: 'a' 360 | 361 | print(index) 362 | # > IndexSchema(indexes=[1, -2, 3], info={(2, 3): 6, (3, 4): 12, 'a,b': 10}) 363 | ``` 364 | 365 | 我们为数据类 IndexSchema 声明的解析选项中指定了 `invalid_items='exclude'`,所以在列表元素中非法的元素将会被剔除,比如输入的 `['1', '-2', '*', 3]` 被转化到了 `[1, -2, 3]` 366 | 367 | 我们还指定了 `invalid_keys='preserve'`,表示无法完成转化的字典键会得到保留,所以在我们输入的 `'info'` 字段的数据中,能够完成转化的键值进行了转化,无法完成转化的键值也得到了保留 368 | 369 | !!! warning 370 | 除非你知道自己在做什么,否则尽量不要使用 `'preserve'` 作为非法处理选项,这样会破坏类型安全的保障 371 | 372 | ## 字段行为调节 373 | 374 | Options 提供了一些用于调节字段的行为的选项,包括 375 | 376 | * `ignore_required`:忽略必传参数,也就是将所有的参数都变为可选参数 377 | * `no_default`:忽略默认值,没有提供的参数不会出现在数据中 378 | * `force_default`:强制指定一个默认值 379 | * `defer_default`:强制推迟默认值计算,对应着 Field 配置中的 `defer_default` 380 | * `ignore_constraints`:忽略约束校验,只进行类型转化 381 | * `immutable`:让数据类的全部属性都变得不可变更,即不能赋值与删除 382 | 383 | !!! warning 384 | `no_default`, `defer_default` 与 `immutable` 选项只能用于数据类,不能用于函数 385 | 386 | * `ignore_delete_nonexistent`:在数据类中,你可以使用 `del data.attr` 这样的方式去删除数据实例的 `attr` 属性,如果这个属性并不存在(对应的数据键不存在), 会抛出 `DeleteError`,但你可以通过开启 `ignore_delete_nonexistent=True` 来忽略这种情况,不抛出错误 387 | 388 | > 版本 0.6.2 及以上支持 389 | 390 | 这些选项默认都没有开启,开启这些选项相当于强制给字段的配置值,所以相关的用法可以参考 [Field 字段配置](/zh/references/field) 391 | 392 | ## 字段别名选项 393 | 394 | Options 还提供了一些用于控制字段名称和别名的选项 395 | 396 | * `case_insensitive`:是否大小写不敏感地接收参数,默认为 False 397 | * `alias_generator`:指定一个用于为没有指定 `alias` 的字段生成输出别名的函数 398 | * `alias_from_generator`:指定一个用于为没有指定 `alias_from` 的字段生成输入别名的函数 399 | * `ignore_alias_conflicts`:是否忽略输入数据中的别名冲突,默认为 False 400 | 401 | 402 | ### 命名风格转化 403 | 404 | 不同的编程语言或开发者都可能有着不同的习惯命名风格,所以你提供的 API 函数很可能需要从不同的命名风格中转化 405 | 406 | 比如在 Python 中一般使用小写和下划线方式命名字段,而如果你的客户端需要接收 camelCase 的数据的话,一般你需要这样声明 407 | 408 | ```python 409 | from utype import Schema, Field 410 | 411 | class ArticleSchema(Schema): 412 | slug: str 413 | liked_num: int = Field(alias='likedNum') 414 | created_at: str = Field(alias='createdAt') 415 | ``` 416 | 417 | 418 | 但由于 Options 提供了 `alias_generator` 选项,所以你可以为整个数据类指定一个输出别名的转化函数,如 419 | 420 | ```python 421 | from utype import Schema 422 | from utype.utils.style import AliasGenerator 423 | from datetime import datetime 424 | 425 | class ArticleSchema(Schema): 426 | __options__ = Schema.Options( 427 | alias_from_generator=[ 428 | AliasGenerator.kebab, 429 | AliasGenerator.pascal, 430 | ], 431 | alias_generator=AliasGenerator.camel 432 | ) 433 | 434 | slug: str 435 | liked_num: int 436 | created_at: datetime 437 | 438 | data = { 439 | 'Slug': 'my-article', # pascal case 440 | 'LikedNum': '3', # pascal case 441 | 'created-at': '2022-03-04 10:11:12' # kebab case 442 | } 443 | article = ArticleSchema(**data) 444 | print(article) 445 | 446 | print(dict(article)) 447 | # { 448 | # 'slug': 'my-article', 449 | # 'likedNum': 3, 450 | # 'createdAt': datetime.datetime(2022, 3, 4, 10, 11, 12) 451 | # } 452 | ``` 453 | 454 | 455 | utype 为了使得命名风格的转化更加方便,在 `utype.utils.style.AliasGenerator` 中已经提供了一些常用的能够生成各种命名风格字段的别名生成函数 456 | 457 | * `camel`:驼峰命名风格,如 `camelCase` 458 | * `pascal`:帕斯卡命名风格,或称首字母大写的驼峰命名,如 `PascalCase` 459 | * `snake`:小写下划线命名风格,Python 等语言的推荐变量命名风格,如 `snake_case` 460 | * `kebab`:小写短横线命名风格,如 `kebab-case` 461 | * `cap_snake`:大写下划线命名风格,常用于常量的命名,如 `CAP_SNAKE_CASE` 462 | * `cap_kebab`:大写短横线命名风格,如 `CAP-KEBAB-CASE` 463 | 464 | 你只需要使用这些函数指定 `alias_generator` 或 `alias_from_generator` 即可获得对应的命名风格转化能力,如在例子中的解析选项指定的 `alias_from_generator` 为 `[AliasGenerator.kebab, AliasGenerator.pascal]`,表示能够从小写短横线命名风格和首字母大写的驼峰命名风格的输入数据中进行转化,而 `alias_generator=AliasGenerator.camel` 表示会将输出数据转化为驼峰命名风格 465 | 466 | 所以我们看到例子中的输入数据使用的命名风格都能被正确地识别和接受,完成了对应的类型转化,并输出到了目标的别名 467 | -------------------------------------------------------------------------------- /docs/zh/references/rule.md: -------------------------------------------------------------------------------- 1 | # Rule 类型约束 2 | 在 utype 中,Rule 的作用是为类型施加约束,本篇文档我们来详细说明它的用法 3 | 4 | ## 内置约束 5 | Rule 类已经内置了一系列的约束,当你继承 Rule 的时候,只需要把约束名称作为属性声明出来即可获得该约束的能力。Rule 目前支持的内置约束如下 6 | 7 | ### 范围约束 8 | 范围约束用于限制数据的范围,如最大,最小值等,它们包括 9 | 10 | * `gt` :输入值必须大于 `gt` 的值(>) 11 | * `ge` :输入值必须大于等于 `ge` 的值(>=) 12 | * `lt` :输入值必须小于 `lt` 的值(<) 13 | * `le` :输入值必须小于等于 `le` 的值(<=) 14 | ```python 15 | from utype import Rule, exc 16 | 17 | class WeekDay(int, Rule): 18 | ge = 1 19 | le = 7 20 | 21 | assert WeekDay('3.0') == 3 22 | 23 | # 违背约束的输入 24 | try: 25 | WeekDay(8) 26 | except exc.ConstraintError as e: 27 | print(e) 28 | """ 29 | Constraint: : 7 violated 30 | """ 31 | ``` 32 | 33 | !!! warning 34 | 如果同时设置了最小值和最大值,则最大值不得小于最小值,且两者的类型需一致 35 | 36 | 范围约束并没有类型的限制,只要类型声明了对应的比较方法(`__gt__`,`__ge__`,`__lt__`,`__le__`),就可以支持范围约束,比如 37 | 38 | ```python 39 | from utype import Rule, exc 40 | from datetime import datetime 41 | 42 | class Year2020(Rule, datetime): 43 | ge = datetime(2020, 1, 1) 44 | lt = datetime(2021, 1, 1) 45 | 46 | assert Year2020('2020-03-04') == datetime(2020, 3, 4) 47 | 48 | try: 49 | Year2020('2021-01-01') 50 | except exc.ConstraintError as e: 51 | print(e) 52 | """ 53 | Constraint: : datetime.datetime(2021, 1, 1, 0, 0) violated 54 | """ 55 | ``` 56 | 57 | !!! note 58 | 所有的约束在违背时都会抛出一个 `utype.exc.ConstraintError`,其中记录的错误信息,包括约束的名称,约束的值,输入的值等 59 | 不过如果你不确定是由什么因素造成的解析错误,可以使用所有 utype 解析错误的基类 `utype.exc.ParseError` 来捕获 60 | 61 | 62 | ### 长度约束 63 | 长度约束用于限制数据的长度,一般用于校验字符串或列表等数据,约束项包括 64 | 65 | * `length`: 输入数据的长度必须等于 `length` 值 66 | * `max_length`:输入数据的长度必须小于等于 `max_length` 的值 67 | * `min_length`:输入数据的长度必须大于等于 `min_length` 的值 68 | ```python 69 | from utype import Rule, exc 70 | 71 | class LengthRule(Rule): 72 | max_length = 3 73 | min_length = 1 74 | 75 | assert LengthRule([1, 2, 3]) == [1, 2, 3] 76 | 77 | try: 78 | LengthRule('abcde') 79 | except exc.ConstraintError as e: 80 | print(e) 81 | """ 82 | Constraint: : 3 violated 83 | """ 84 | ``` 85 | 86 | 所有的长度约束必须都是正整数,如果设置了 `length` ,就不能再设置 `max_length` 或 `min_length` 87 | 88 | !!! note 89 | 如果输入数据的类型没有定义 `__len__` 方法(如整数),将会校验转化为字符串后的长度,也就是说取 `len(str(value))`,所以如果需要校验数字的最大位数,建议使用 `max_digits` 约束 90 | 91 | 92 | ### 正则约束 93 | 正则表达式往往能够声明更加丰富的字符串校验规则,用途很多,正则约束如下 94 | 95 | * `regex`:指定一个正则表达式,数据必须完全匹配这个正则表达式 96 | ```python 97 | from utype import Rule, exc 98 | 99 | class Email(str, Rule): 100 | regex = r"([A-Za-z0-9]+[.-_])*[A-Za-z0-9]+@[A-Za-z0-9-]+(\.[A-Z|a-z]{2,})+" 101 | 102 | assert Email('dev@utype.io') == 'dev@utype.io' 103 | 104 | try: 105 | Email('invalid#email.com') 106 | except exc.ConstraintError as e: 107 | print(e) 108 | """ 109 | Constraint: : 110 | '([A-Za-z0-9]+[.-_])*[A-Za-z0-9]+@[A-Za-z0-9-]+(\\.[A-Z|a-z]{2,})+' violated 111 | """ 112 | ``` 113 | 114 | 例子中我们声明了一个用于校验邮箱地址的约束类型 Email 115 | 116 | ### 常量与枚举 117 | 118 | * `const`:输入数据必须全等于 `const` 所指定的常量 119 | ```python 120 | from utype import Rule, exc 121 | 122 | class Const1(Rule): 123 | const = 1 124 | 125 | class ConstKey(str, Rule): 126 | const = 'SECRET_KEY' 127 | 128 | assert ConstKey(b'SECRET_KEY') == 'SECRET_KEY' 129 | 130 | try: 131 | Const1(True) 132 | except exc.ConstraintError as e: 133 | print(e) 134 | """ 135 | Constraint: : 1 violated 136 | """ 137 | ``` 138 | 139 | 如果你为常量约束指定了源类型,则 Rule 会先完成类型转化,再校验常量是否相等,否则会直接进行比较 140 | 141 | !!! note 142 | 值得注意的是,`const` 不仅使用 Python 的全等符号(`==`)校验值与常量是否 “相等”,还会判断它们的类型是否相等,因为在 Python 中,通过 `__eq__` 方法能够使得一种类型与任意值相等,比如 `True == 1` 就是为真的,而 True 是 `bool` 类型,1 是 `int` 类型(在 Python 中 `bool` 是 `int` 的子类),所以无法通过 `const` 校验 143 | 144 | * `enum`:可以传入一个列表,集合或者一个 Enum 类,数据必须是在 `enum` 规定的取值范围之内 145 | ```python 146 | from utype import Rule, exc 147 | 148 | class Infinity(float, Rule): 149 | enum = [float("inf"), float("-inf")] 150 | 151 | assert Infinity('-infinity') == float("-inf") 152 | 153 | try: 154 | Infinity(10.5) 155 | except exc.ConstraintError as e: 156 | print(e) 157 | """ 158 | Constraint: : [inf, -inf] violated 159 | """ 160 | ``` 161 | 162 | !!! warning 163 | 即使 `enum` 参数传入了一个 Enum 类,也并不代表会将结果转化为 Enum 类的实例,而是仅使用 Enum 类所声明的数据范围,如果你需要让数据转化为 Enum 类的实例,请直接使用对应的 Enum 类型作为参数的类型注解 164 | 165 | !!! note 166 | 在类型声明中,你也可以直接使用 `Literal[...]` 来声明常量或者枚举值 167 | 168 | ### 数字约束 169 | 数字约束用于对数字类型(int, float, Decimal)施加限制,包括 170 | 171 | * `max_digits`:限制数字的的最大位数(不包括符号位或小数点) 172 | * `multiple_of`:数字必须是 `multiple_of` 指定的数值的倍数 173 | ```python 174 | from utype import Rule, exc 175 | 176 | class Hundreds(int, Rule): 177 | max_digits = 3 178 | multiple_of = 100 179 | 180 | assert Hundreds('200') == 200 181 | 182 | try: 183 | Hundreds(1000) 184 | except exc.ConstraintError as e: 185 | print(e) 186 | """ 187 | Constraint: : 3 violated 188 | """ 189 | 190 | try: 191 | Hundreds(120) 192 | except exc.ConstraintError as e: 193 | print(e) 194 | """ 195 | Constraint: : 100 violated 196 | """ 197 | ``` 198 | 199 | !!! note 200 | 如果数据在 0 到 1 之间(比如 `0.0123`),那么整数位的 0 并不算作一位,只计算小数位 4 位,所以 `max_digits` 也可以理解为最大有效位数 201 | 202 | * `decimal_places`:限制数字的小数部分的最大位数不能超过这个值 203 | ```python 204 | from utype import Rule, exc 205 | import decimal 206 | 207 | class ConDecimal(decimal.Decimal, Rule): 208 | decimal_places = 2 209 | max_digits = 4 210 | 211 | assert ConDecimal(1.5) == decimal.Decimal('1.50') 212 | 213 | try: 214 | ConDecimal(123.4) 215 | except exc.ConstraintError as e: 216 | print(e) 217 | """ 218 | Constraint: : 4 violated 219 | """ 220 | 221 | try: 222 | ConDecimal('1.500') 223 | except exc.ConstraintError as e: 224 | print(e) 225 | """ 226 | Constraint: : 2 violated 227 | """ 228 | ``` 229 | 230 | 如果约束的源类型是是定点数 `decimal.Decimal`,当输入数据的小数位数不足时会先进行补齐,所以数据 `123.4` 会被先补齐到 `Decimal('123.40')` ,再校验 `max_digits` 就无法通过 231 | 232 | 并且如果传入的数据包含小数位,那么无论末尾是否是 0,都会进行计算,比如 `Decimal('1.500')`,那么就会按照定点数的小数位计算 3 位 233 | 234 | ### 数组约束 235 | 数组约束用于对约束列表(list),元组(tuple),集合(set)等可以遍历单个元素的数据,包括 236 | 237 | * `contains`:指定一个类型(可以是普通类型或约束类型),数据中必须包含(至少一个)匹配的元素 238 | * `max_contains`:最多匹配 `contains` 类型的元素数量 239 | * `min_contains`:最少匹配 `contains` 类型的元素数量 240 | ```python 241 | from utype import Rule, exc 242 | 243 | class Const1(int, Rule): 244 | const = 1 245 | 246 | class ConTuple(tuple, Rule): 247 | contains = Const1 248 | max_contains = 3 249 | 250 | assert ConTuple([1, True]) == (1, True) 251 | 252 | try: 253 | ConTuple([0, 2]) 254 | except exc.ConstraintError as e: 255 | print(e) 256 | """ 257 | Constraint: : Const1(int, const=1) violated: 258 | Const1(int, const=1) not contained in value 259 | """ 260 | 261 | try: 262 | ConTuple([1, True, b'1', '1.0']) 263 | except exc.ConstraintError as e: 264 | print(e) 265 | """ 266 | Constraint: : 3 violated: 267 | value contains 4 of Const1(int, const=1), 268 | which is bigger than max_contains 269 | """ 270 | ``` 271 | 272 | 例子中类型 ConTuple 的 `contains` 约束指定的是转换为整数后为 1 的类型,最大匹配数 `max_contains` 为 3,所以如果数据中没有元素能够与 Const1 类型匹配,或者匹配元素的数量超过 3 个,都会抛出错误 273 | 274 | !!! note 275 | `contains` 约束仅校验元素是否匹配,并不会输出元素的类型转化结果,对元素进行类型转化需要使用嵌套类型的声明方式,或者在类中声明 `__args__` 属性来指定元素类型 276 | 277 | * `unique_items`:元素是否需要唯一 278 | ```python 279 | from utype import Rule, exc, types 280 | 281 | class UniqueList(types.Array): 282 | unique_items = True 283 | 284 | assert UniqueList[int]([1, '2', 3.5]) == [1, 2, 3] 285 | 286 | try: 287 | UniqueList[int]([1, '1', True]) 288 | except exc.ConstraintError as e: 289 | print(e) 290 | """ 291 | Constraint: : True violated: value is not unique 292 | """ 293 | ``` 294 | 295 | 例子中我们使用的是 `UniqueList[int]` 进行转化,所以会先转化元素类型,再进行约束校验 296 | 297 | !!! note 298 | 如果你约束的源类型是集合(set),那么它在转化后就是去重的,所以无需指定 `unique_items` 了 299 | 300 | 301 | ## Lax 宽松约束 302 | 303 | Lax 约束(aka 宽松约束,转化式约束)指的是一种不严格的约束,它实现的效果是:尽最大努力把输入数据向满足约束的目标进行转化 304 | 305 | 声明宽松约束只需要从 utype 中引入 `Lax` 类,并将约束值使用 Lax 包裹起来,如 306 | ```python 307 | from utype import Rule, Lax 308 | 309 | class LaxLength(Rule): 310 | max_length = Lax(3) 311 | 312 | print(LaxLength('ab')) 313 | # > ab 314 | 315 | print(LaxLength('abcd')) 316 | # > abc 317 | ``` 318 | 319 | 例子中当输入 `'abcd'` 不满足宽松约束 `max_length` 时,会直接按照 `max_length` 对数据长度进行截断,输出 `'abc'`,而不是像严格约束那样直接抛出错误 320 | 321 | 不同的约束在宽松模式下的表现不同,宽松模式支持的约束和各自的表现分别为 322 | 323 | * `max_length`:定义一个松散的最大长度,如果值的长度大于它,则截断到给定的最大长度 324 | * `length`:如果数据大于这个长度,则截断到 `length` 对应的长度,如果数据小于这个长度,则抛出错误 325 | * `ge`:如果值小于 `ge`,则直接输出 `ge` 的值,否则使用输入值 326 | * `le`:如果值大于 `le`,则直接输出 `le` 的值,否则使用输入值 327 | * `decimal_places`:不再对小数位数进行校验,而是直接按照 `decimals_places` 对小数位进行保留,与 Python 内置的 `round()` 方法的效果一致 328 | * `max_digits`:如果数字位数超过最大位数,则从小到大舍去小数位,如果仍然无法满足,则抛出错误 329 | * `multiple_of`:如果数据不是 `multiple_of` 的整数倍,则取比输入数据小的最近输入数据的整数倍的值 330 | * `const`:直接输出常量 331 | * `enum`:如果数据不在范围内,则直接输出取值范围的首个值 332 | * `unique_items`:如果数据存在重复,则返回去重后的数据 333 | 334 | !!! note 335 | 由于 Lax 模式下的 `decimals_places` 较为常用,所以 Field 提供了一个 `round` 参数进行简化,如使用 `Field(round=3)` 作为 `Field(decimal_places=Lax(3))` 的简写 336 | 337 | 默认的严格约束只是用于进行校验,但宽松约束却可能会对输入数据进行转化,在转化中虽然可能会出现 **信息损失**,但宽松约束也会保障转化是 **幂等性** 的,也就是一个数据经过多次转化得到的是与单次转化相同的值 338 | 339 | !!! note 340 | 由于约束转化只能从给定数据中压缩信息,不能添加数据信息,所以无法为 min_length, gt, lt 等约束采取宽松模式 341 | 342 | 343 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utilmeta/utype/62539bb522812e5b1919e4a99418a70c417fe0f8/examples/__init__.py -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from utype import __version__ 3 | 4 | with open("README.md", "r", encoding="UTF-8") as fh: 5 | long_description = fh.read() 6 | 7 | setup( 8 | name="utype", 9 | version=__version__, 10 | description="Declare & parse types / dataclasses / functions based on Python type annotations", 11 | long_description=long_description, 12 | long_description_content_type="text/markdown", 13 | author="XuLin Zhou", 14 | author_email="zxl@utilmeta.com", 15 | keywords="utype type schema meta-type validation data-model type-transform parser json-schema", 16 | classifiers=[ 17 | 'Development Status :: 3 - Alpha', 18 | 'Programming Language :: Python', 19 | 'Programming Language :: Python :: 3', 20 | 'Programming Language :: Python :: 3 :: Only', 21 | 'Programming Language :: Python :: 3.7', 22 | 'Programming Language :: Python :: 3.8', 23 | 'Programming Language :: Python :: 3.9', 24 | 'Programming Language :: Python :: 3.10', 25 | 'Programming Language :: Python :: 3.11', 26 | 'Intended Audience :: Developers', 27 | 'Intended Audience :: Information Technology', 28 | 'Intended Audience :: System Administrators', 29 | 'License :: OSI Approved :: Apache Software License', 30 | 'Operating System :: OS Independent', 31 | 'Topic :: Software Development :: Libraries :: Python Modules', 32 | 'Topic :: Internet', 33 | ], 34 | install_requires=[ 35 | 'typing-extensions>=4.1.0', 36 | ], 37 | python_requires=">=3.7", 38 | license="Apache 2.0", 39 | url="https://utype.io", 40 | project_urls={ 41 | "Project Home": "https://utype.io", 42 | "Documentation": "https://utype.io", 43 | "Source Code": "https://github.com/utilmeta/utype", 44 | }, 45 | packages=find_packages(exclude=["tests.*", "tests", "docs.*"]), 46 | ) 47 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utilmeta/utype/62539bb522812e5b1919e4a99418a70c417fe0f8/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_future.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest # noqa 4 | from utype import Schema, Field 5 | 6 | 7 | class TestSchema(Schema): 8 | a: int 9 | base: TestSchema | None 10 | 11 | 12 | class MyField(Field): 13 | my_prop = 'my_value' 14 | 15 | 16 | class MySchema(Schema): 17 | __field__ = MyField 18 | 19 | 20 | class TestGeneratedSchema(Schema): 21 | key: MySchema 22 | 23 | 24 | class TestFuture: 25 | def test_recursive(self): 26 | t = TestSchema(a=1, base={'a': 2, 'base': None}) 27 | assert t.base.a == 2 28 | assert t.base.base is None 29 | 30 | def test_generate(self): 31 | assert isinstance(TestGeneratedSchema.__parser__.fields['key'].field, MyField) 32 | # class MySchema2(Schema): 33 | # __field__ = MyField 34 | # 35 | # class TestGeneratedSchema2(Schema): 36 | # key: MySchema2 37 | -------------------------------------------------------------------------------- /tests/test_spec.py: -------------------------------------------------------------------------------- 1 | import utype 2 | from utype.types import * 3 | from utype.parser.rule import Rule 4 | 5 | 6 | class TargetSchema(utype.Schema): 7 | name: str 8 | ref: Optional['RefSchema'] = None 9 | ref_values: List['RefSchema'] = utype.Field(default_factory=list) 10 | 11 | 12 | class RefSchema(utype.Schema): 13 | value: int = None 14 | 15 | 16 | class InfiniteSchema(utype.Schema): 17 | name: str 18 | self: List['InfiniteSchema'] = utype.Field(default_factory=list) 19 | 20 | 21 | class TestSpec: 22 | def test_json_schema_parser(self): 23 | from utype.specs.json_schema.parser import JsonSchemaParser 24 | assert JsonSchemaParser({})() == Any 25 | assert JsonSchemaParser({'anyOf': [{}, {'type': 'null'}]})() in (Rule, Any) 26 | assert JsonSchemaParser({'type': 'object'})() == dict 27 | assert JsonSchemaParser({'type': 'array'})() == list 28 | assert JsonSchemaParser({'type': 'string'})() == str 29 | assert JsonSchemaParser({'type': 'string', 'format': 'date'})() == date 30 | 31 | def test_schema_generator(self): 32 | class TestSchema(utype.Schema): 33 | int_val: int 34 | str_val: str 35 | bytes_val: bytes 36 | float_val: float 37 | bool_val: bool 38 | uuid_val: UUID 39 | # test nest types 40 | list_val: List[str] = utype.Field(default_factory=list) # test callable default 41 | union_val: Union[int, List[int]] = utype.Field(default_factory=list) 42 | 43 | from utype.specs.json_schema.generator import JsonSchemaGenerator 44 | output = JsonSchemaGenerator(TestSchema)() 45 | assert output == {'type': 'object', 46 | 'properties': {'int_val': {'type': 'integer'}, 47 | 'str_val': {'type': 'string'}, 48 | 'bytes_val': {'type': 'string', 'format': 'binary'}, 49 | 'float_val': {'type': 'number', 'format': 'float'}, 50 | 'bool_val': {'type': 'boolean'}, 51 | 'uuid_val': {'type': 'string', 'format': 'uuid'}, 52 | 'list_val': {'type': 'array', 'items': {'type': 'string'}}, 53 | 'union_val': {'anyOf': [{'type': 'integer'}, 54 | {'type': 'array', 'items': {'type': 'integer'}}]}}, 55 | 'required': ['int_val', 56 | 'str_val', 57 | 'bytes_val', 58 | 'float_val', 59 | 'bool_val', 60 | 'uuid_val']} 61 | 62 | def test_forward_ref_generator(self): 63 | from utype.specs.json_schema.generator import JsonSchemaGenerator 64 | output = JsonSchemaGenerator(TargetSchema)() 65 | assert output == {'type': 'object', 66 | 'properties': {'name': {'type': 'string'}, 67 | 'ref': {'anyOf': [{'type': 'object', 68 | 'properties': {'value': {'type': 'integer'}}}, 69 | {'type': 'null'}]}, 70 | 'ref_values': {'type': 'array', 71 | 'items': {'type': 'object', 72 | 'properties': {'value': {'type': 'integer'}}}}}, 73 | 'required': ['name']} 74 | 75 | def test_recursive_generator(self): 76 | from utype.specs.json_schema.generator import JsonSchemaGenerator 77 | refs = {} 78 | ref_output = JsonSchemaGenerator(InfiniteSchema, defs=refs)() 79 | assert ref_output == {'$ref': '#/$defs/InfiniteSchema'} 80 | assert refs == {InfiniteSchema: {'type': 'object', 81 | 'properties': {'name': {'type': 'string'}, 82 | 'self': {'type': 'array', 83 | 'items': {'$ref': '#/$defs/InfiniteSchema'}}}, 84 | 'required': ['name']}} 85 | 86 | output = JsonSchemaGenerator(InfiniteSchema)() 87 | assert output == {'type': 'object', 88 | 'properties': {'name': {'type': 'string'}, 89 | 'self': {'type': 'array', 'items': {'$ref': '#/$defs/InfiniteSchema'}}}, 90 | 'required': ['name']} 91 | -------------------------------------------------------------------------------- /utype/__init__.py: -------------------------------------------------------------------------------- 1 | from .decorator import apply, dataclass, handle, parse, raw 2 | from .parser.field import Field, Param 3 | from .parser.options import Options 4 | from .parser.rule import Lax, Rule 5 | from .schema import DataClass, LogicalMeta, Schema 6 | from .utils import exceptions as exc 7 | from .utils.encode import register_encoder, JSONEncoder 8 | from .utils.transform import (TypeTransformer, type_transform) 9 | from .utils.datastructures import unprovided 10 | from .specs.json_schema import JsonSchemaGenerator 11 | 12 | register_transformer = TypeTransformer.registry.register 13 | 14 | 15 | VERSION = (0, 6, 6) 16 | 17 | 18 | def _get_version(): 19 | pre_release = VERSION[3] if len(VERSION) > 3 else "" 20 | version = ".".join([str(v) for v in VERSION[:3]]) 21 | if pre_release: 22 | version += f"-{pre_release}" 23 | return version 24 | 25 | 26 | __version__ = _get_version() 27 | 28 | 29 | def version_info() -> str: 30 | import platform 31 | import sys 32 | from pathlib import Path 33 | 34 | info = { 35 | 'utype version': __version__, 36 | 'installed path': Path(__file__).resolve().parent, 37 | 'python version': sys.version, 38 | 'platform': platform.platform(), 39 | } 40 | return '\n'.join('{:>30} {}'.format(k + ':', str(v).replace('\n', ' ')) for k, v in info.items()) 41 | -------------------------------------------------------------------------------- /utype/decorator.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import warnings 3 | from typing import Any, Callable, Iterable, Type, TypeVar, Union 4 | 5 | from .parser.cls import ClassParser 6 | from .parser.func import FunctionParser 7 | from .parser.options import Options 8 | from .parser.rule import Lax, Rule 9 | from .utils import exceptions as exc 10 | from .utils.datastructures import Unprovided, unprovided 11 | from .settings import warning_settings 12 | 13 | T = TypeVar("T") 14 | FUNC = TypeVar("FUNC") 15 | CLS = TypeVar("CLS") 16 | 17 | 18 | def raw(f: FUNC): 19 | parser = getattr(f, "__parser__", None) 20 | if isinstance(parser, FunctionParser): 21 | return parser.obj 22 | return f 23 | 24 | 25 | def parse( 26 | f: T = None, 27 | # /, compat 3.7 28 | *, 29 | parser_cls: Type[FunctionParser] = FunctionParser, 30 | options: Union[Options, Type[Options]] = None, 31 | no_cache: bool = False, 32 | # static: bool = False, 33 | ignore_params: bool = False, 34 | ignore_result: bool = False, 35 | eager: bool = False, 36 | ) -> Union[T, Callable[[T], T]]: 37 | if ignore_params and ignore_result: 38 | warning_settings.warn( 39 | f"@utype.parse: you turn off both params and result parse in @parse decorator," 40 | f" which is basically meaningless...", warning_settings.parse_ignore_both_params_and_result 41 | ) 42 | 43 | def decorator(func: T) -> T: 44 | if inspect.isclass(func): 45 | # class 46 | parser_cls.apply_class( 47 | func, 48 | options=options, 49 | no_cache=no_cache, 50 | ignore_result=ignore_result, 51 | ignore_params=ignore_params, 52 | eager=eager, 53 | ) 54 | return func 55 | else: 56 | parser = parser_cls.apply_for(func, options=options, no_cache=no_cache) 57 | return parser.wrap( 58 | parse_params=not ignore_params, 59 | parse_result=not ignore_result, 60 | eager_parse=eager 61 | # first_reserve=not static 62 | ) 63 | 64 | if f: 65 | return decorator(f) 66 | return decorator 67 | 68 | 69 | def dataclass( 70 | obj: CLS = None, 71 | # /, compat 3.7 72 | *, 73 | parser_cls: Type[ClassParser] = ClassParser, 74 | options: Union[Options, Type[Options]] = None, 75 | no_cache: bool = False, 76 | no_parse: bool = False, 77 | set_class_properties: bool = False, 78 | post_init: Callable = None, 79 | post_setattr: Callable = None, 80 | post_delattr: Callable = None, 81 | contains: bool = False, 82 | repr: bool = True, # noqa 83 | eq: bool = False, 84 | ) -> Union[CLS, Callable[[CLS], CLS]]: 85 | def decorator(cls: CLS) -> CLS: 86 | parser = parser_cls.apply_for(cls, options=options, no_cache=no_cache) 87 | 88 | parser.make_init( 89 | # init_super=init_super, 90 | # allow_runtime=allow_runtime, 91 | # set_attributes=init_attributes, 92 | # coerce_property=init_properties, 93 | no_parse=no_parse, 94 | post_init=post_init, 95 | ) 96 | if repr: 97 | parser.make_repr() 98 | if eq: 99 | parser.make_eq() 100 | if contains: 101 | parser.make_contains() 102 | if set_class_properties: 103 | parser.assign_properties( 104 | post_setattr=post_setattr, post_delattr=post_delattr 105 | ) 106 | elif post_setattr or post_delattr: 107 | warning_settings.warn( 108 | f"@utype.dataclass received post_delattr / post_setattr " 109 | f'without "set_properties=True", these params won\'t take effect', 110 | warning_settings.dataclass_setattr_delattr_not_effect 111 | ) 112 | 113 | cls.__parser__ = parser 114 | # set the flag so that class parser can be quickly resolve 115 | # and transformer can be register 116 | 117 | return parser.obj 118 | 119 | if obj: 120 | return decorator(obj) 121 | return decorator 122 | 123 | 124 | def apply( 125 | rule_cls: Type[Rule] = Rule, 126 | *, 127 | # init: bool = False, # whether to override init 128 | # -- constraints: 129 | const: Any = unprovided, 130 | enum: Iterable = None, 131 | gt=None, 132 | ge=None, 133 | lt=None, 134 | le=None, 135 | regex: str = None, 136 | length: int = None, 137 | max_length: int = None, 138 | min_length: int = None, 139 | # number 140 | max_digits: int = None, 141 | decimal_places: int = None, 142 | round: int = None, 143 | multiple_of: int = None, 144 | # array 145 | contains: type = None, 146 | max_contains: int = None, 147 | min_contains: int = None, 148 | unique_items: bool = None, 149 | ): 150 | # if rule_cls: 151 | # pass 152 | 153 | if round: 154 | if decimal_places and decimal_places not in (round, Lax(round)): 155 | raise exc.ConfigError( 156 | f"@apply round: {round} is a shortcut for decimal_places=Lax({round}), " 157 | f"but you specified a different decimal_places: {repr(decimal_places)}" 158 | ) 159 | 160 | decimal_places = Lax(round) 161 | 162 | constraints = { 163 | k: v 164 | for k, v in dict( 165 | enum=enum, 166 | gt=gt, 167 | ge=ge, 168 | lt=lt, 169 | le=le, 170 | min_length=min_length, 171 | max_length=max_length, 172 | length=length, 173 | regex=regex, 174 | max_digits=max_digits, 175 | decimal_places=decimal_places, 176 | multiple_of=multiple_of, 177 | contains=contains, 178 | max_contains=max_contains, 179 | min_contains=min_contains, 180 | unique_items=unique_items, 181 | ).items() 182 | if v is not None 183 | } 184 | 185 | if not isinstance(const, Unprovided): 186 | constraints.update(const=const) 187 | 188 | def decorator(_type): 189 | cls = rule_cls.annotate(_type, constraints=constraints) 190 | cls.__name__ = getattr(_type, "__name__", cls.__name__) 191 | cls.__repr__ = getattr(_type, "__repr__", cls.__repr__) 192 | cls.__str__ = getattr(_type, "__str__", cls.__str__) 193 | cls.__applied__ = True 194 | # applied Rule only checks constraints if value is not the instance of the __origin__ type 195 | return cls 196 | # 197 | # if init: 198 | # pass 199 | # else: 200 | # @register_transformer(_type) 201 | # def _transform_type(trans, value, t): 202 | # return type_cls(value) 203 | # 204 | # _transform_type.__name__ = f'_to_{getattr(_type, "__name__", str(_type))}' 205 | # return _type 206 | 207 | return decorator 208 | 209 | 210 | def handle(*func_and_errors): 211 | # implement in next version 212 | pass 213 | -------------------------------------------------------------------------------- /utype/parser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utilmeta/utype/62539bb522812e5b1919e4a99418a70c417fe0f8/utype/parser/__init__.py -------------------------------------------------------------------------------- /utype/settings.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | 4 | class WarningSettings: 5 | disabled: bool = False 6 | parse_ignore_both_params_and_result: bool = True 7 | dataclass_setattr_delattr_not_effect: bool = True 8 | globals_name_conflict: bool = True 9 | field_unresolved_types_with_throw_options: bool = True 10 | field_alias_on_positional_args: bool = True 11 | field_case_sensitive_on_positional_args: bool = True 12 | field_invalid_params_in_function: bool = True 13 | 14 | function_kwargs_With_no_addition: bool = True 15 | function_invalid_options: bool = True 16 | function_invalid_return_annotation: bool = True 17 | function_invalid_params_annotation: bool = True 18 | function_non_default_follows_default_args: bool = True 19 | 20 | options_max_errors_with_no_collect_errors: bool = True 21 | 22 | rule_length_constraints_on_unsupported_types: bool = True 23 | rule_no_origin_transformer: bool = True 24 | rule_no_arg_transformer: bool = True 25 | rule_arg_parser_unresolved: bool = True 26 | rule_none_arg_in_unsupported_origin: bool = True 27 | rule_args_in_any: bool = True 28 | 29 | def warn(self, message: str, type: str = None): 30 | if self.disabled: 31 | return 32 | if type is False: 33 | return 34 | if isinstance(type, str): 35 | if not getattr(self, type, None): 36 | return 37 | warnings.warn(message) 38 | 39 | 40 | warning_settings = WarningSettings() 41 | -------------------------------------------------------------------------------- /utype/specs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utilmeta/utype/62539bb522812e5b1919e4a99418a70c417fe0f8/utype/specs/__init__.py -------------------------------------------------------------------------------- /utype/specs/json_schema/__init__.py: -------------------------------------------------------------------------------- 1 | from .parser import JsonSchemaParser, JsonSchemaGroupParser 2 | from .generator import JsonSchemaGenerator 3 | -------------------------------------------------------------------------------- /utype/specs/json_schema/constant.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from datetime import datetime, date, time, timedelta 3 | from uuid import UUID 4 | from ipaddress import IPv4Address, IPv6Address 5 | from utype.parser.rule import SEQ_TYPES, MAP_TYPES 6 | 7 | PRIMITIVES = ("null", "boolean", "object", "array", "integer", "number", "string") 8 | PRIMITIVE_MAP = { 9 | type(None): "null", 10 | bool: "boolean", 11 | MAP_TYPES: "object", 12 | SEQ_TYPES: "array", 13 | int: "integer", 14 | (float, Decimal): "number", 15 | } 16 | TYPE_MAP = { 17 | 'null': type(None), 18 | 'string': str, 19 | 'boolean': bool, 20 | 'bool': bool, 21 | 'object': dict, 22 | 'array': list, 23 | 'integer': int, 24 | 'int': int, 25 | 'bigint': int, 26 | 'number': float, 27 | 'float': float, 28 | 'decimal': Decimal, 29 | 'binary': bytes, 30 | 'ipv4': IPv4Address, 31 | 'ipv6': IPv6Address, 32 | 'date-time': datetime, 33 | 'date': date, 34 | 'time': time, 35 | 'duration': timedelta, 36 | 'uuid': UUID, 37 | } 38 | OPERATOR_NAMES = { 39 | "&": "allOf", 40 | "|": "anyOf", 41 | "^": "oneOf", 42 | "~": "not", 43 | } 44 | FORMAT_MAP = { 45 | (bytes, bytearray, memoryview): 'binary', 46 | float: 'float', 47 | IPv4Address: 'ipv4', 48 | IPv6Address: 'ipv6', 49 | datetime: 'date-time', 50 | date: 'date', 51 | time: 'time', 52 | timedelta: 'duration', 53 | UUID: 'uuid' 54 | } 55 | DEFAULT_CONSTRAINTS_MAP = { 56 | 'enum': 'enum', 57 | 'const': 'const', 58 | } 59 | CONSTRAINTS_MAP = { 60 | 'multipleOf': 'multiple_of', 61 | 'maximum': 'le', 62 | 'minimum': 'ge', 63 | 'exclusiveMaximum': 'lt', 64 | 'exclusiveMinimum': 'gt', 65 | 'decimalPlaces': 'decimal_places', 66 | 'maxDigits': 'max_digits', 67 | 'enum': 'enum', 68 | 'const': 'const', 69 | 'maxItems': 'max_length', 70 | 'minItems': 'min_length', 71 | 'uniqueItems': 'unique_items', 72 | 'maxContains': 'max_contains', 73 | 'minContains': 'min_contains', 74 | 'contains': 'contains', 75 | 'maxProperties': 'max_length', 76 | 'minProperties': 'min_length', 77 | 'minLength': 'min_length', 78 | 'maxLength': 'max_length', 79 | 'pattern': 'regex', 80 | } 81 | 82 | TYPE_CONSTRAINTS_MAP = { 83 | ("integer", "number"): { 84 | 'multiple_of': 'multipleOf', 85 | 'le': 'maximum', 86 | 'lt': 'exclusiveMaximum', 87 | 'ge': 'minimum', 88 | 'gt': 'exclusiveMinimum', 89 | 'decimal_places': 'decimalPlaces', 90 | 'max_digits': 'maxDigits', 91 | **DEFAULT_CONSTRAINTS_MAP, 92 | }, 93 | ("array",): { 94 | 'max_length': 'maxItems', 95 | 'min_length': 'minItems', 96 | 'unique_items': 'uniqueItems', 97 | 'max_contains': 'maxContains', 98 | 'min_contains': 'minContains', 99 | 'contains': 'contains', 100 | **DEFAULT_CONSTRAINTS_MAP, 101 | }, 102 | ("object",): { 103 | 'max_length': 'maxProperties', 104 | 'min_length': 'minProperties', 105 | **DEFAULT_CONSTRAINTS_MAP, 106 | }, 107 | ("string",): { 108 | 'regex': 'pattern', 109 | 'max_length': 'maxLength', 110 | 'min_length': 'minLength', 111 | **DEFAULT_CONSTRAINTS_MAP, 112 | }, 113 | ("boolean", "null"): DEFAULT_CONSTRAINTS_MAP 114 | } 115 | 116 | FORMAT_PATTERNS = { 117 | 'integer': r'[-]?\d+', 118 | 'number': r'[-]?\d+(\.\d+)?', 119 | 'date': r'\d{4}-\d{2}-\d{2}', 120 | } 121 | -------------------------------------------------------------------------------- /utype/specs/json_schema/generator.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from utype.parser.rule import Rule, LogicalType, SEQ_TYPES, MAP_TYPES 3 | from utype.parser.field import ParserField 4 | from utype.parser.cls import ClassParser 5 | from utype.parser.func import FunctionParser 6 | from utype.parser.base import Options 7 | 8 | from typing import Optional, Type, Union, Dict 9 | from utype.utils.datastructures import unprovided 10 | from utype.utils.compat import JSON_TYPES, ForwardRef, evaluate_forward_ref 11 | from enum import EnumMeta 12 | from . import constant 13 | 14 | 15 | class JsonSchemaGenerator: 16 | # pass in a defs dict to generate re-use '$defs' 17 | 18 | DEFAULT_PRIMITIVE = "string" 19 | DEFAULT_REF_PREFIX = "#/$defs/" 20 | 21 | def __init__(self, t, 22 | defs: Dict[type, dict] = None, 23 | names: Dict[str, type] = None, 24 | ref_prefix: str = None, 25 | mode: str = None, 26 | output: bool = False 27 | ): 28 | self.t = t 29 | self.defs = defs 30 | # self.recursive_types = [] 31 | # handle infinite recursive forward ref if defs is empty 32 | 33 | self.names = names 34 | if isinstance(defs, dict) and self.names is None: 35 | self.names = {} 36 | 37 | self.ref_prefix = ref_prefix or self.DEFAULT_REF_PREFIX 38 | self.mode = mode 39 | self.output = output 40 | self.options = Options(mode=mode) 41 | # can generate based on mode and input/output 42 | 43 | def generate_for_type(self, t: type, recursive_types: set = None): 44 | if t is None: 45 | return {} 46 | if not isinstance(t, type): 47 | return {} 48 | if issubclass(t, Rule): 49 | return self.generate_for_rule(t, recursive_types=recursive_types) 50 | elif isinstance(getattr(t, "__parser__", None), ClassParser): 51 | return self.generate_for_dataclass(t, recursive_types=recursive_types) 52 | elif isinstance(t, LogicalType) and t.combinator: 53 | return self.generate_for_logical(t, recursive_types=recursive_types) 54 | elif isinstance(t, ForwardRef): 55 | # for more robust 56 | if t.__forward_evaluated__: 57 | return self.generate_for_type(t.__forward_value__, recursive_types=recursive_types) 58 | else: 59 | try: 60 | annotation = evaluate_forward_ref(t, globals(), None) 61 | except NameError: 62 | # ignore for now 63 | return {} 64 | return self.generate_for_type(annotation, recursive_types=recursive_types) 65 | elif isinstance(t, EnumMeta): 66 | base = t.__base__ 67 | enum_type = None 68 | enum_values = [] 69 | enum_map = {} 70 | for key, val in t.__members__.items(): 71 | enum_values.append(val.value) 72 | enum_map[key] = val.value 73 | enum_type = type(val.value) 74 | if not isinstance(base, EnumMeta): 75 | enum_type = base 76 | prim = self._get_primitive(enum_type) 77 | fmt = self._get_format(enum_type) 78 | data = { 79 | "type": prim, 80 | "enum": enum_values, 81 | "x-annotation": { 82 | "enums": enum_map 83 | } 84 | } 85 | if fmt: 86 | data.update(format=fmt) 87 | return data 88 | 89 | # default common type 90 | prim = self._get_primitive(t) 91 | fmt = self._get_format(t) 92 | data = {"type": prim} 93 | if fmt: 94 | data.update(format=fmt) 95 | return data 96 | 97 | def generate_for_logical(self, t: LogicalType, recursive_types: set = None): 98 | operator_name = constant.OPERATOR_NAMES.get(t.combinator) 99 | if not operator_name: 100 | return {} 101 | conditions = [self.generate_for_type(cond, recursive_types=recursive_types) for cond in t.args] 102 | if operator_name == 'not': 103 | return {operator_name: conditions[0]} 104 | return {operator_name: conditions} 105 | 106 | def _get_format(self, origin: type) -> Optional[str]: 107 | if not origin: 108 | return None 109 | format = getattr(origin, 'format', None) 110 | if format and isinstance(format, str): 111 | return format 112 | for types, f in constant.FORMAT_MAP.items(): 113 | if issubclass(origin, types): 114 | return f 115 | return None 116 | 117 | def _get_primitive(self, origin: type) -> str: 118 | if not origin: 119 | return self.DEFAULT_PRIMITIVE 120 | for types, pri in constant.PRIMITIVE_MAP.items(): 121 | if issubclass(origin, types): 122 | return pri 123 | return self.DEFAULT_PRIMITIVE 124 | 125 | def _get_args(self, r: Type[Rule], recursive_types: set = None) -> dict: 126 | origin = r.__origin__ 127 | args = r.__args__ 128 | if not args: 129 | return {} 130 | if not origin: 131 | return {} 132 | args_res = [self.generate_for_type(arg, recursive_types=recursive_types) for arg in args] 133 | if issubclass(origin, tuple): 134 | if r.__ellipsis_args__: 135 | name = 'items' 136 | return {name: args_res[0]} 137 | else: 138 | name = 'prefixItems' 139 | return {name: args_res} 140 | elif issubclass(origin, SEQ_TYPES): 141 | name = 'items' 142 | return {name: args_res[0]} 143 | elif issubclass(origin, MAP_TYPES): 144 | name = 'patternProperties' 145 | key_arg: dict = args_res[0] 146 | val_arg: dict = args_res[1] 147 | pattern = dict(key_arg).get('pattern', None) 148 | if not pattern: 149 | fmt = key_arg.get('format') or key_arg.get('type') 150 | if fmt: 151 | pattern = constant.FORMAT_PATTERNS.get(fmt) 152 | pattern = pattern or '.*' 153 | return {name: {pattern: val_arg}} 154 | else: 155 | return {} 156 | 157 | def __call__(self) -> dict: 158 | if inspect.isfunction(self.t): 159 | return self.generate_for_function(self.t) 160 | return self.generate_for_type(self.t) 161 | 162 | def get_defs(self) -> Dict[str, dict]: 163 | defs = {} 164 | for t, values in self.defs.items(): 165 | name = self.get_def_name(t) 166 | defs[name] = values 167 | return defs 168 | 169 | def generate_for_rule(self, t: Type[Rule], recursive_types: set = None): 170 | name = t.__qualname__ 171 | if isinstance(self.defs, dict): 172 | def_name = self.get_def_name(t) 173 | if def_name: 174 | return {"$ref": f"{self.ref_prefix}{def_name}"} 175 | 176 | # type 177 | origin = t.__origin__ 178 | data = dict(self.generate_for_type(origin, recursive_types=recursive_types)) 179 | primitive = getattr(t, 'primitive', None) 180 | if primitive in constant.PRIMITIVES: 181 | data.update(type=primitive) 182 | else: 183 | primitive = data.get('type', self.DEFAULT_PRIMITIVE) 184 | 185 | # format 186 | fmt = getattr(t, 'format', None) 187 | if fmt and isinstance(fmt, str): 188 | data.update(format=fmt) 189 | else: 190 | fmt = self._get_format(origin) 191 | if fmt and isinstance(fmt, str): 192 | data.update(format=fmt) 193 | 194 | # constraints 195 | constrains_map = constant.DEFAULT_CONSTRAINTS_MAP 196 | for types, mp in constant.TYPE_CONSTRAINTS_MAP.items(): 197 | if primitive in types: 198 | constrains_map = mp 199 | break 200 | for constraint, value, validator in t.__validators__: 201 | constraint_name = constrains_map.get(constraint, constraint) 202 | data[constraint_name] = value 203 | 204 | extra = getattr(t, 'extra', None) 205 | if extra and isinstance(extra, dict): 206 | data.update(extra) 207 | if t.__args__: 208 | data.update(self._get_args(t, recursive_types=recursive_types)) 209 | if isinstance(self.defs, dict) and name != 'Rule': 210 | if '' not in name: 211 | # not a auto created rule 212 | return {"$ref": f"{self.ref_prefix}{self.set_def(name, t, data)}"} 213 | return data 214 | 215 | def get_def_name(self, t: type): 216 | if t in self.defs: 217 | for k, v in self.names.items(): 218 | if v == t: 219 | return k 220 | return None 221 | 222 | def set_def(self, name: str, t: type, data: dict = None): 223 | if t in self.defs: 224 | if data is not None: 225 | # name already set 226 | self.defs[t] = data 227 | return name 228 | n = 0 229 | while True: 230 | _name = name + (f'_{n}' if n else '') 231 | if _name in self.names: 232 | n += 1 233 | continue 234 | name = _name 235 | break 236 | # de-duplicate name 237 | self.defs[t] = data 238 | self.names[name] = t 239 | return name 240 | 241 | def generate_for_field(self, f: ParserField, 242 | options: Options = None, 243 | recursive_types: set = None 244 | ) -> Optional[dict]: 245 | if self.output: 246 | if f.always_no_output(options or self.options): 247 | return None 248 | else: 249 | if f.always_no_input(options or self.options): 250 | return None 251 | 252 | t = f.output_type if self.output else f.type 253 | if not isinstance(t, type): 254 | if self.output: 255 | t = t or f.type 256 | else: 257 | t = t or f.output_type 258 | 259 | data = dict(self.generate_for_type( 260 | t, recursive_types=recursive_types 261 | )) 262 | 263 | if f.field.title: 264 | data.update(title=f.field.title) 265 | if f.field.description: 266 | data.update(description=f.field.description) 267 | if f.field.deprecated: 268 | data.update(deprecated=f.field.deprecated) 269 | if f.field.mode: 270 | if f.field.mode == 'r': 271 | data.update(readOnly=True) 272 | elif f.field.mode == 'w': 273 | data.update(writeOnly=True) 274 | if not unprovided(f.field.example) and f.field.example is not None: 275 | example = f.field.example 276 | if type(f.field.example) not in JSON_TYPES: 277 | example = str(f.field.example) 278 | data.update(examples=[example]) 279 | if f.aliases: 280 | aliases = list(f.aliases) 281 | if aliases: 282 | # sort to stay identical 283 | aliases.sort() 284 | data.update({ 285 | 'x-var-name': f.attname, 286 | 'x-aliases': aliases, 287 | 'aliases': aliases, # compat old version, will be deprecated 288 | }) 289 | 290 | annotations = f.schema_annotations 291 | if annotations: 292 | data.update({ 293 | 'x-annotation': annotations 294 | }) 295 | return data 296 | 297 | def generate_for_dataclass(self, t, recursive_types: set = None): 298 | # name = t.__qualname__ 299 | parser: ClassParser = getattr(t, '__parser__') 300 | if not isinstance(parser, ClassParser): 301 | raise TypeError(f'Invalid dataclass: {t}') 302 | parser.resolve_forward_refs() # resolve before generate 303 | cls_name = parser.name 304 | mode = parser.options.mode 305 | if mode: 306 | cls_name += '_' + mode 307 | if self.output and not parser.in_out_identical: 308 | cls_name += '_O' 309 | 310 | if isinstance(self.defs, dict): 311 | def_name = self.get_def_name(t) 312 | if def_name: 313 | return {"$ref": f"{self.ref_prefix}{def_name}"} 314 | cls_name = self.set_def(cls_name, t, data=None) 315 | # set data to None: 316 | # avoid cascade references 317 | elif isinstance(recursive_types, set): 318 | # handle cascade forward ref 319 | if t in recursive_types: 320 | return {"$ref": f"{self.ref_prefix}{cls_name}"} 321 | recursive_types.add(t) 322 | 323 | data = {"type": "object"} 324 | required = [] 325 | properties = {} 326 | dependent_required = {} 327 | options = parser.options 328 | 329 | if self.output: 330 | # handle output 331 | if parser.output_options: 332 | options = parser.output_options 333 | 334 | for name, field in parser.fields.items(): 335 | value = self.generate_for_field(field, options=options, recursive_types=recursive_types or {t}) 336 | if value is None: 337 | continue 338 | properties[name] = value 339 | if field.dependencies: 340 | dependent_required[name] = field.dependencies 341 | if field.is_required(options or self.options): 342 | # will count options.ignore_required in 343 | required.append(name) 344 | elif self.output: 345 | if not field.no_default: 346 | # if field has default, the value is required in the output data 347 | required.append(name) 348 | 349 | data.update(properties=properties) 350 | if required: 351 | data.update(required=required) 352 | if dependent_required: 353 | data.update(dependentRequired=dependent_required) 354 | addition = options.addition 355 | if addition is not None: 356 | if isinstance(addition, type): 357 | data.update(additionalProperties=self.generate_for_type(addition)) 358 | else: 359 | data.update(additionalProperties=addition) 360 | 361 | annotations = parser.schema_annotations 362 | if annotations: 363 | data.update({ 364 | 'x-annotation': annotations 365 | }) 366 | 367 | if isinstance(self.defs, dict): 368 | return {"$ref": f"{self.ref_prefix}{self.set_def(cls_name, t, data)}"} 369 | return data 370 | 371 | def generate_for_function(self, f): 372 | if not inspect.isfunction(f): 373 | raise TypeError(f'Invalid function: {f}') 374 | parser = getattr(f, '__parser__', None) 375 | if not isinstance(parser, FunctionParser): 376 | parser = FunctionParser.apply_for(f) 377 | data = {"type": "function"} 378 | pos_params = [] 379 | required = [] 380 | params = {} 381 | for name, field in parser.fields.items(): 382 | value = self.generate_for_field(field, options=parser.options) 383 | if value is None: 384 | continue 385 | params[name] = value 386 | if field.positional_only: 387 | pos_params.append(name) 388 | if field.is_required(parser.options or self.options): 389 | required.append(name) 390 | data.update(parameters=params) 391 | if required: 392 | data.update(required=required) 393 | if pos_params: 394 | data.update(positionalOnly=pos_params) 395 | addition = parser.options.addition 396 | if addition is not None: 397 | if isinstance(addition, type): 398 | data.update(additionalParameters=self.generate_for_type(addition)) 399 | else: 400 | data.update(additionalParameters=addition) 401 | return data 402 | -------------------------------------------------------------------------------- /utype/specs/json_schema/parser.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Union, Tuple, Any, List, Type, Optional 2 | from utype.utils.compat import ForwardRef 3 | from utype.parser.rule import LogicalType, Rule 4 | from utype.parser.field import Field 5 | from utype.schema import LogicalMeta, Schema, DataClass 6 | from utype.parser.options import Options 7 | from utype.utils.datastructures import unprovided 8 | from utype.utils.functional import valid_attr 9 | from . import constant 10 | import re 11 | import keyword 12 | 13 | _type = type 14 | 15 | 16 | class JsonSchemaParser: 17 | object_base_cls = Schema 18 | object_meta_cls = LogicalMeta 19 | object_options_cls = Options 20 | field_cls = Field 21 | default_type = Any 22 | 23 | NON_NAME_REG = '[^A-Za-z0-9]+' 24 | 25 | def __init__(self, json_schema: dict, 26 | refs: Dict[str, dict] = None, 27 | name: str = None, 28 | description: str = None, 29 | # '#/components/...': SchemaClass 30 | # names: Dict[str, type] = None, 31 | ref_prefix: str = None, # '#/components/schemas' 32 | def_prefix: str = None, # 'schemas' 33 | type_map: dict = None, 34 | force_forward_ref: bool = False, 35 | ): 36 | 37 | if not isinstance(json_schema, dict): 38 | raise TypeError(f'Invalid json schema: {json_schema}') 39 | if force_forward_ref: 40 | if refs is None: 41 | raise ValueError('JsonSchemaParser force forward ref, but refs is None') 42 | self.json_schema = json_schema 43 | self.refs = refs 44 | self.name = self.get_attname(name) if name else None 45 | self.description = description 46 | self.ref_prefix = (ref_prefix.rstrip('/') + '/') if ref_prefix else '' 47 | self.def_prefix = (def_prefix.rstrip('.') + '.') if def_prefix else '' 48 | self.force_forward_ref = force_forward_ref 49 | _type_map = dict(constant.TYPE_MAP) 50 | if type_map: 51 | _type_map.update(type_map) 52 | self.type_map = _type_map 53 | 54 | def get_ref_object(self, ref: str) -> Optional[dict]: 55 | if not self.refs: 56 | return None 57 | if ref.startswith(self.ref_prefix): 58 | ref = ref[len(self.ref_prefix):] 59 | ref_routes = ref.strip('/').split('/') 60 | obj = self.refs 61 | for route in ref_routes: 62 | if not obj: 63 | return None 64 | obj = obj.get(route) 65 | return None 66 | 67 | def get_def_name(self, ref: str) -> str: 68 | if ref.startswith(self.ref_prefix): 69 | ref = ref[len(self.ref_prefix):] 70 | ref_name = self.get_attname(ref) 71 | return self.def_prefix + ref_name 72 | 73 | # def get_ref_name(self, name: str) -> str: 74 | # return f'{self.ref_prefix.rstrip("/")}/{name.lstrip("/")}' 75 | 76 | # def parse_type(self, schema: dict) -> type: 77 | # return self.__class__( 78 | # json_schema=schema, 79 | # refs=self.refs, 80 | # ref_prefix=self.ref_prefix, 81 | # def_prefix=self.def_prefix, 82 | # ).parse(type_only=True) 83 | 84 | @classmethod 85 | def get_constraints(cls, schema: dict): 86 | constraints = {} 87 | for key, val in schema.items(): 88 | if key in constant.CONSTRAINTS_MAP: 89 | constraints[constant.CONSTRAINTS_MAP[key]] = val 90 | return constraints 91 | 92 | def parse_field(self, schema: dict, 93 | name: str = None, 94 | field_cls: Type[Field] = None, 95 | required: bool = None, 96 | description: str = None, 97 | dependencies: List[str] = None, 98 | alias: str = None, 99 | **kwargs, 100 | ) -> Tuple[type, Field]: 101 | type = self.parse_type(schema, name=name, with_constraints=False) 102 | # annotations 103 | default = schema.get('default', unprovided) 104 | deprecated = schema.get('deprecated', False) 105 | title = schema.get('title') 106 | description = schema.get('description') or description 107 | readonly = schema.get('readOnly') 108 | writeonly = schema.get('writeOnly') 109 | aliases = schema.get('x-aliases') 110 | kwargs.update(self.get_constraints(schema)) 111 | kwargs.update( 112 | alias=alias, 113 | default=default, 114 | deprecated=deprecated, 115 | title=title, 116 | description=description, 117 | readonly=readonly, 118 | writeonly=writeonly, 119 | required=required, 120 | dependencies=dependencies, 121 | alias_from=aliases 122 | ) 123 | field_cls = field_cls or self.field_cls 124 | return type, field_cls(**kwargs) 125 | 126 | def __call__(self, *args, **kwargs): 127 | return self.parse_type( 128 | self.json_schema, 129 | name=self.name, 130 | description=self.description, 131 | with_constraints=True 132 | ) 133 | 134 | def parse_type(self, schema: dict, 135 | name: str = None, 136 | description: str = None, 137 | with_constraints: bool = True): 138 | ref = schema.get('$ref') 139 | type = schema.get('type') 140 | any_of = schema.get('anyOf') 141 | one_of = schema.get('oneOf') 142 | all_of = schema.get('allOf') 143 | not_of = schema.get('not') 144 | const = schema.get('const', unprovided) 145 | enum = schema.get('enum') 146 | conditions = any_of or one_of or all_of or ([not_of] if not_of else []) 147 | value = const if not unprovided(const) else enum[0] if enum else unprovided 148 | 149 | if ref: 150 | return ForwardRef(self.get_def_name(ref)) 151 | 152 | constraints = {} 153 | if with_constraints: 154 | constraints = self.get_constraints(schema) 155 | 156 | t = self.default_type 157 | if type: 158 | if type == 'array': 159 | return self.parse_array( 160 | schema, 161 | name=name, 162 | description=description, 163 | constraints=constraints 164 | ) 165 | elif type == 'object': 166 | return self.parse_object( 167 | schema, 168 | name=name, 169 | description=description, 170 | constraints=constraints 171 | ) 172 | else: 173 | format = schema.get('format') 174 | t = None 175 | if format: 176 | t = self.type_map.get(format) 177 | t = t or self.type_map.get(type) or self.default_type 178 | 179 | elif not unprovided(value): 180 | t = type(value) 181 | elif conditions: 182 | condition_types = [self.parse_type(cond) for cond in conditions] 183 | if any_of: 184 | t = LogicalType.any_of(*condition_types) 185 | elif all_of: 186 | t = LogicalType.all_of(*condition_types) 187 | elif one_of: 188 | t = LogicalType.one_of(*condition_types) 189 | elif not_of: 190 | t = LogicalType.not_of(*condition_types) 191 | 192 | if constraints: 193 | return Rule.annotate( 194 | t, 195 | name=name, 196 | description=description, 197 | constraints=constraints 198 | ) 199 | return t 200 | 201 | @classmethod 202 | def get_attname(cls, name: str, excludes: list = None): 203 | name = re.sub(cls.NON_NAME_REG, '_', name).strip('_') 204 | if keyword.iskeyword(name): 205 | name += '_value' 206 | if excludes: 207 | i = 1 208 | origin = name 209 | while name in excludes: 210 | name = f'{origin}_{i}' 211 | i += 1 212 | return name 213 | 214 | def parse_object(self, 215 | schema: dict, 216 | name: str = None, 217 | description: str = None, 218 | constraints: dict = None 219 | ): 220 | if list(schema) == ['type'] and not constraints: 221 | return dict 222 | name = name or 'ObjectSchema' 223 | properties = schema.get('properties') or {} 224 | required = schema.get('required') or [] 225 | additional_properties = schema.get("additionalProperties", unprovided) 226 | min_properties = schema.get("minProperties", unprovided) 227 | max_properties = schema.get("maxProperties", unprovided) 228 | property_names = schema.get("propertyNames") 229 | dependent_required = schema.get('dependentRequired') 230 | pattern_properties = schema.get("patternProperties") # not supported now 231 | 232 | if not properties: 233 | if property_names: 234 | key_obj = {'type': 'string'} 235 | key_obj.update(property_names) 236 | key_type = self.parse_type(key_obj) 237 | else: 238 | key_type = str 239 | constraints = dict(constraints or {}) 240 | if min_properties: 241 | constraints.update(min_length=min_properties) 242 | if max_properties: 243 | constraints.update(max_length=max_properties) 244 | return Rule.annotate(dict, key_type, Any, constraints=constraints) 245 | 246 | attrs = {} 247 | annotations = {} 248 | options = self.object_options_cls( 249 | max_params=max_properties, 250 | min_params=min_properties, 251 | addition=self.parse_type(additional_properties) if isinstance(additional_properties, dict) 252 | else additional_properties, 253 | ) 254 | 255 | for key, prop in properties.items(): 256 | prop = prop or {} 257 | field_required = key in required if required else False 258 | field_dependencies = dependent_required.get(key) if dependent_required else None 259 | ref = prop.get('$ref') 260 | if ref: 261 | prop_schema = self.get_ref_object(ref) or {} 262 | else: 263 | prop_schema = prop 264 | attname = prop_schema.get('x-var-name') or key 265 | if not valid_attr(attname) or attname in attrs or hasattr(dict, attname): 266 | attname = self.get_attname(attname, excludes=list(attrs)) 267 | alias = None 268 | if attname != key: 269 | alias = key 270 | field_type, field = self.parse_field( 271 | prop, 272 | required=field_required, 273 | dependencies=field_dependencies, 274 | alias=alias 275 | ) 276 | annotations[attname] = field_type 277 | attrs[attname] = field 278 | 279 | if self.force_forward_ref: 280 | # return after parse all fields 281 | # cause even if it's in force_forward_ref 282 | # there may be schemas inside the schema field types 283 | def_name = self.register_ref(name=name, schema=schema) 284 | return ForwardRef(def_name) 285 | 286 | attrs.update( 287 | __annotations__=annotations, 288 | __options__=options 289 | ) 290 | if description: 291 | attrs.update(__doc__=description) 292 | new_cls = self.object_meta_cls(name, (self.object_base_cls,), attrs) 293 | return new_cls 294 | 295 | def register_ref(self, name: str, schema: dict) -> str: 296 | i = 1 297 | cls_name = name 298 | while name in self.refs: 299 | name = f'{cls_name}_{i}' 300 | i += 1 301 | self.refs[name] = schema 302 | return self.get_def_name(name) 303 | 304 | def parse_array(self, 305 | schema: dict, 306 | name: str = None, 307 | description: str = None, 308 | constraints: dict = None 309 | ): 310 | if list(schema) == ['type'] and not constraints: 311 | return list 312 | items = schema.get('items') 313 | prefix_items = schema.get('prefixItems') 314 | args = [] 315 | origin = list 316 | addition = None 317 | 318 | if prefix_items: 319 | origin = tuple 320 | args = [self.parse_type(item) for item in prefix_items] 321 | 322 | if items is False: 323 | addition = False 324 | elif items: 325 | addition = self.parse_type(items, with_constraints=True) 326 | 327 | elif items: 328 | items_type = self.parse_type(items, with_constraints=True) 329 | args = [items_type] 330 | 331 | options = Options(addition=addition) if addition is not None else None 332 | return Rule.annotate( 333 | origin, *args, 334 | name=name, 335 | description=description, 336 | constraints=constraints, 337 | options=options 338 | ) 339 | 340 | 341 | class JsonSchemaGroupParser: 342 | schema_parser_cls = JsonSchemaParser 343 | 344 | # '#/components/schemas/...' 345 | def __init__(self, schemas: Dict[str, dict], 346 | # '#/components/...': SchemaClass 347 | # names: Dict[str, type] = None, 348 | ref_prefix: str = None, # '#/components/schemas' 349 | def_prefix: str = None, # 'schemas' 350 | ): 351 | self.schemas = schemas 352 | self.ref_prefix = (ref_prefix.rstrip('/') + '/') if ref_prefix else '' 353 | self.def_prefix = (def_prefix.rstrip('.') + '.') if def_prefix else '' 354 | self.refs = {} 355 | 356 | def __call__(self, *args, **kwargs): 357 | pass 358 | 359 | def parse(self): 360 | for name, schema in self.schemas.items(): 361 | cls = self.schema_parser_cls( 362 | json_schema=schema, 363 | name=name, 364 | refs=self.refs, 365 | ref_prefix=self.ref_prefix, 366 | def_prefix=self.def_prefix 367 | )() 368 | ref_name = self.ref_prefix + name 369 | self.refs[ref_name] = cls 370 | return self.refs 371 | 372 | # class schemas: 373 | # class Int: 374 | # pass 375 | # 376 | # class A: 377 | # a: 'schemas.Int' 378 | -------------------------------------------------------------------------------- /utype/specs/python/__init__.py: -------------------------------------------------------------------------------- 1 | from .generator import PythonCodeGenerator -------------------------------------------------------------------------------- /utype/specs/python/generator.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import keyword 3 | import re 4 | 5 | from utype.parser.rule import Rule, LogicalType 6 | from utype.parser.field import Field 7 | from utype.parser.cls import ClassParser 8 | from utype.parser.func import FunctionParser 9 | from utype import unprovided, Options 10 | from typing import Type, Dict, Any, ForwardRef 11 | from utype.utils.functional import represent, valid_attr 12 | from collections import deque 13 | 14 | ORIGIN_MAP: dict = { 15 | list: 'List', 16 | dict: 'Dict', 17 | tuple: 'Tuple', 18 | set: 'Set', 19 | deque: 'Deque', 20 | frozenset: 'FrozenSet', 21 | } 22 | 23 | 24 | class PythonCodeGenerator: 25 | # pass in a defs dict to generate re-use '$defs' 26 | object_base_cls = 'utype.Schema' 27 | object_field_cls = 'utype.Field' 28 | 29 | def __init__(self, t, 30 | defs: Dict[type, str] = None, 31 | force_forward_ref: bool = False 32 | # refs: Dict[str, dict] = None, 33 | # with_schemas: bool = False, 34 | # names: Dict[str, type] = None, 35 | # ref_prefix: str = None, 36 | # mode: str = None, 37 | # output: bool = False 38 | ): 39 | self.t = t 40 | self.defs = defs or {} 41 | self.force_forward_ref = force_forward_ref 42 | # self.refs = refs 43 | # self.schemas = {} 44 | # self.with_schemas = with_schemas 45 | 46 | def __call__(self) -> str: 47 | if inspect.isfunction(self.t): 48 | return self.generate_for_function(self.t) 49 | return self.generate_for_type(self.t, with_constraints=True, annotation=False) 50 | 51 | def generate_for_function(self, f, force_forward_ref: bool = None) -> str: 52 | if force_forward_ref is None: 53 | force_forward_ref = self.force_forward_ref 54 | 55 | parser: FunctionParser = getattr(f, '__parser__', None) or FunctionParser.apply_for(f) 56 | 57 | params = [] 58 | if parser.first_reserve: 59 | if parser.instancemethod: 60 | params.append('self') 61 | elif parser.classmethod: 62 | params.append('cls') 63 | 64 | kinds = [] 65 | for i, (k, v) in enumerate(parser.parameters): 66 | if kinds and all(kind == v.POSITIONAL_ONLY for kind in kinds) and v.kind == v.POSITIONAL_OR_KEYWORD: 67 | params.append('/') 68 | if not any(kind in (v.KEYWORD_ONLY, v.VAR_KEYWORD) for kind in kinds) and v.kind == v.KEYWORD_ONLY: 69 | params.append('*') 70 | 71 | kinds.append(v.kind) 72 | field = parser.get_field(k) 73 | if field: 74 | param_type = field.type 75 | param_default = field.field 76 | else: 77 | param_type = parser.parse_annotation(v.annotation) if v.annotation != v.empty else unprovided 78 | param_default = v.default if v.default != v.empty else unprovided 79 | 80 | arg_name = k 81 | if v.kind == v.VAR_POSITIONAL: 82 | arg_name = f'*{k}' 83 | elif v.kind == v.VAR_KEYWORD: 84 | arg_name = f'**{k}' 85 | 86 | args = [arg_name] 87 | if not unprovided(param_type) and param_type: 88 | annotation = self.generate_for_type(param_type, with_constraints=False, annotation=True) 89 | if not isinstance(field.type, ForwardRef): 90 | if force_forward_ref: 91 | annotation = repr(annotation) 92 | args.append(f': {annotation}') 93 | 94 | if not unprovided(param_default): 95 | if isinstance(param_default, Field): 96 | default = self.generate_for_field(param_default) 97 | else: 98 | default = represent(param_default) 99 | if default: 100 | if len(args) == 1: 101 | args.append(f'={default}') 102 | else: 103 | args.append(f' = {default}') 104 | params.append(''.join(args)) 105 | 106 | return_annotation = None 107 | if parser.return_type: 108 | return_annotation = self.generate_for_type(parser.return_type, with_constraints=False, annotation=True) 109 | 110 | func_content = f'def {f.__name__}(%s)' % ', '.join(params) 111 | if return_annotation: 112 | func_content += f' -> {return_annotation}: pass' 113 | else: 114 | func_content += ': pass' 115 | return func_content 116 | 117 | def generate_for_type(self, t, with_constraints: bool = True, annotation: bool = True) -> str: 118 | if t is None: 119 | return 'Any' 120 | if isinstance(t, str): 121 | return t 122 | if isinstance(t, ForwardRef): 123 | return repr(t.__forward_arg__) 124 | if not isinstance(t, type) or t in (Any, Rule): 125 | return 'Any' 126 | if isinstance(t, LogicalType): 127 | if t.combinator: 128 | arg_list = [self.generate_for_type(arg, with_constraints=with_constraints, annotation=True) 129 | for arg in t.args] 130 | if not arg_list: 131 | return 'Any' 132 | if t.combinator == '|': 133 | if len(t.args) == 2 and type(None) in t.args: 134 | index = 0 if t.args[1] is type(None) else 1 135 | return f'Optional[{arg_list[index]}]' 136 | return f'Union[%s]' % ', '.join(arg_list) 137 | elif t.combinator == '~': 138 | return '~' + arg_list[0] 139 | return str(f' {t.combinator} ').join(arg_list) 140 | elif issubclass(t, Rule): 141 | return self.generate_for_rule(t, with_constraints=with_constraints, annotation=annotation) 142 | elif isinstance(getattr(t, "__parser__", None), ClassParser) and not annotation: 143 | return self.generate_for_dataclass(t) 144 | # if not annotation or (self.with_schemas and t != self.t): 145 | # content = self.generate_for_dataclass(t) 146 | # if not annotation: 147 | # return content 148 | return represent(t) if t else 'Any' 149 | 150 | def generate_for_rule(self, t: Type[Rule], with_constraints: bool = True, annotation: bool = True) -> str: 151 | constraints = {} 152 | if with_constraints: 153 | for name, val, func in t.__validators__: 154 | constraints[name] = getattr(t, name, val) 155 | origin = t.__origin__ 156 | args = [] 157 | if t.__args__: 158 | origin_str = ORIGIN_MAP.get(origin) or self.generate_for_type(origin, with_constraints=False) 159 | args = [self.generate_for_type(arg, with_constraints=True) for arg in t.__args__] 160 | type_str = f'{origin_str}[%s]' % (', '.join(args)) 161 | else: 162 | type_str = self.generate_for_type(origin, with_constraints=False) 163 | 164 | if annotation: 165 | if constraints: 166 | constraints_str = ('utype.Field(%s)' % 167 | (', '.join([f'{k}={represent(v)}' for k, v in constraints.items()]))) 168 | return f'Annotated[{type_str}, {constraints_str}]' 169 | return type_str 170 | else: 171 | lines = [f'class {t.__name__}({type_str}, Rule):'] 172 | if t.__doc__: 173 | lines.append(f'\t"""{t.__doc__}"""') 174 | if args: 175 | lines.append('\t__args__ = [%s]' % ', '.join(args)) 176 | if constraints: 177 | for name, val in constraints.items(): 178 | lines.append(f'\t{name} = {represent(val)}') 179 | if len(lines) == 1: 180 | lines.append('\tpass') 181 | return '\n'.join(lines) 182 | 183 | @classmethod 184 | def generate_for_field(cls, field: Field, addition: dict = None) -> str: 185 | if not field.__spec_kwargs__ and not addition and field.__class__ == Field: 186 | return '' 187 | name = None 188 | if field.__class__ == Field: 189 | name = cls.object_field_cls 190 | return field._repr(name=name, addition=addition) 191 | 192 | @classmethod 193 | def get_constraints(cls, t) -> dict: 194 | if isinstance(t, LogicalType) and issubclass(t, Rule): 195 | constraints = cls.get_constraints(t.__origin__) 196 | for name, val, func in t.__validators__: 197 | constraints[name] = getattr(t, name, val) 198 | return constraints 199 | return {} 200 | 201 | @classmethod 202 | def get_attname(cls, name: str, excludes: list = None) -> str: 203 | if name.isidentifier(): 204 | name = re.sub('[^A-Za-z0-9]+', '_', name) 205 | if not name.isidentifier(): 206 | name = 'key_' + name 207 | elif keyword.iskeyword(name): 208 | name = name + '_' 209 | if excludes: 210 | while name in excludes: 211 | name = name + '_1' 212 | return name 213 | 214 | def generate_for_dataclass(self, t, force_forward_ref: bool = None) -> str: 215 | if force_forward_ref is None: 216 | force_forward_ref = self.force_forward_ref 217 | 218 | parser: ClassParser = getattr(t, '__parser__') 219 | cls_name = parser.name.split('.')[-1] 220 | name_line = f'class {cls_name}({self.object_base_cls}):' 221 | options_line = None 222 | if not parser.options.vacuum: 223 | if parser.options.__class__ == Options: 224 | options_line = f'\t__options__ = utype.{repr(parser.options)}' 225 | else: 226 | options_line = f'\t__options__ = {repr(parser.options)}' 227 | 228 | lines = [name_line] 229 | if t.__doc__: 230 | lines.append(f'\t"""{t.__doc__}"""') 231 | if options_line: 232 | lines.append(options_line) 233 | attrs = [] 234 | attr_names = [] 235 | for name, field in parser.fields.items(): 236 | attname = field.attname or name 237 | type_str = self.generate_for_type(field.type, with_constraints=False, annotation=True) 238 | if not isinstance(field.type, ForwardRef): 239 | if force_forward_ref: 240 | type_str = repr(type_str) 241 | addition = dict(self.get_constraints(field.type)) 242 | if not valid_attr(attname): 243 | attname = self.get_attname(attname, excludes=attr_names) 244 | addition.update(alias=name) 245 | field_str = self.generate_for_field(field.field, addition=addition) or None 246 | attr_names.append(attname) 247 | parts = [attname] 248 | if type_str: 249 | parts.extend([f': {type_str}']) 250 | if field_str: 251 | parts.extend([f' = {field_str}']) 252 | attrs.append('\t' + ''.join(parts)) 253 | lines.extend(attrs) 254 | if len(lines) == 1: 255 | lines.append('\tpass') 256 | content = '\n'.join(lines) 257 | return content 258 | -------------------------------------------------------------------------------- /utype/types.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from enum import Enum 3 | from datetime import datetime, timedelta, date, time 4 | from uuid import UUID 5 | from typing import Union, Optional, Tuple, List, Set, Mapping, \ 6 | Dict, Type, Callable, Any, TYPE_CHECKING, Iterator, ClassVar 7 | from .utils.compat import Literal, Annotated, Final, ForwardRef, Self 8 | from .parser.rule import Lax, Rule 9 | 10 | # from typing import TypeVar 11 | # T = TypeVar('T') 12 | 13 | 14 | class Number(Rule): 15 | primitive = "number" 16 | 17 | @classmethod 18 | def check_type(cls, t): 19 | # must be an iterable 20 | assert issubclass(t, (int, float, Decimal)) 21 | 22 | 23 | class Array(Rule): 24 | __origin__ = list # use list instead of abc.Iterable 25 | primitive = "array" 26 | 27 | # def __class_getitem__(cls, item) -> Type["Array"]: 28 | # if not isinstance(item, tuple): 29 | # item = (item,) 30 | # return cls.annotate(cls.__origin__, *item) 31 | 32 | @classmethod 33 | def check_type(cls, t): 34 | # must be an iterable 35 | assert hasattr(t, "__iter__") 36 | 37 | 38 | class Object(Rule): 39 | __origin__ = dict 40 | primitive = "object" 41 | 42 | def __class_getitem__(cls, params): 43 | if len(params) != 2: 44 | raise TypeError( 45 | f"{cls} should use {cls}[KeyType, ValType] with 2 params, got {params}" 46 | ) 47 | return cls.annotate(cls.__origin__, *params) 48 | 49 | @classmethod 50 | def check_type(cls, t): 51 | # must be an iterable 52 | assert hasattr(t, "__iter__") and hasattr(t, "items") 53 | 54 | 55 | class Float(float, Number): 56 | pass 57 | 58 | 59 | class Int(int, Number): 60 | pass 61 | 62 | 63 | class Str(str, Rule): 64 | pass 65 | 66 | 67 | class Bool(Rule): 68 | __origin__ = bool 69 | 70 | 71 | class Null(Rule): 72 | __origin__ = type(None) 73 | primitive = "null" 74 | # const = None 75 | 76 | 77 | class PositiveInt(Int): 78 | gt = 0 79 | 80 | 81 | class NaturalInt(Int): 82 | ge = 0 83 | 84 | 85 | class PositiveFloat(Float): 86 | gt = 0 87 | 88 | 89 | class NegativeFloat(Float): 90 | lt = 0 91 | 92 | 93 | class NanFloat(Float): 94 | @classmethod 95 | def post_validate(cls, value, options=None): 96 | import math 97 | 98 | if not math.isnan(value): 99 | # do not use const = float('nan') 100 | # cause NaN can not use equal operator 101 | from .utils import exceptions as exc 102 | raise exc.ConstraintError(constraint="const", constraint_value=float("nan")) 103 | return value 104 | 105 | 106 | class InfinityFloat(Float): 107 | enum = [float("inf"), float("-inf")] 108 | 109 | 110 | AbnormalFloat = NanFloat ^ InfinityFloat 111 | 112 | NormalFloat = Float & ~AbnormalFloat 113 | 114 | 115 | class Zero(Rule): 116 | const = 0 117 | 118 | 119 | Divisor = Float & ~Zero 120 | 121 | 122 | class NegativeInt(Int): 123 | lt = 0 124 | 125 | 126 | class Timestamp(Float): 127 | """ 128 | timestamps to represent datetime and date 129 | """ 130 | 131 | ge = 0 132 | format = "timestamp" 133 | 134 | @classmethod 135 | def pre_validate(cls, value, options=None): 136 | import datetime 137 | 138 | if isinstance(value, datetime.datetime): 139 | value = value.timestamp() 140 | elif isinstance(value, datetime.timedelta): 141 | value = value.total_seconds() 142 | return value 143 | 144 | 145 | def round_number(precision: int = 0, num_type: type = float): 146 | class RoundNumber(num_type, Rule): 147 | decimal_places = Lax(precision) 148 | 149 | return RoundNumber 150 | 151 | 152 | def enum_array( 153 | item_enum: Union[Type[Enum], list, tuple, set], 154 | item_type=None, 155 | array_type=list, 156 | unique: bool = False, 157 | ) -> Type[Array]: 158 | """ 159 | Make an array type, which item is one of the enum value 160 | """ 161 | if isinstance(item_enum, Enum): 162 | EnumItem = item_enum 163 | else: 164 | 165 | class EnumItem(Rule): 166 | __origin__ = item_type 167 | enum = item_enum 168 | 169 | class EnumArray(Array): 170 | __origin__ = array_type 171 | __args__ = (EnumItem,) 172 | __ellipsis_args__ = issubclass(array_type, tuple) 173 | unique_items = unique 174 | 175 | return EnumArray 176 | 177 | 178 | class SlugStr(Str): 179 | """ 180 | Slug str or URI 181 | """ 182 | 183 | format = "slug" 184 | regex = r"[a-z0-9]+(?:-[a-z0-9]+)*" 185 | 186 | 187 | # class Secret(Str): 188 | # """ 189 | # Slug str or URI 190 | # """ 191 | # def __repr__(self): 192 | # return f'{self.__class__.__name__}("%s")' % ('*' * 6) 193 | # 194 | # def __str__(self): 195 | # return f'{self.__class__.__name__}("%s")' % ('*' * 6) 196 | 197 | 198 | class Year(Int): 199 | ge = 1 200 | le = 9999 201 | 202 | 203 | class Month(Int): 204 | ge = 1 205 | le = 12 206 | 207 | 208 | class Day(Int): 209 | ge = 1 210 | le = 31 211 | 212 | 213 | class Week(Int): 214 | ge = 1 215 | le = 53 216 | 217 | 218 | class WeekDay(Int): 219 | ge = 1 220 | le = 7 221 | 222 | 223 | class Quarter(Int): 224 | ge = 1 225 | le = 4 226 | 227 | 228 | class Hour(Int): 229 | ge = 0 230 | le = 23 231 | 232 | 233 | class Minute(Int): 234 | ge = 0 235 | le = 59 236 | 237 | 238 | class Second(Int): 239 | ge = 0 240 | le = 59 241 | 242 | 243 | class Datetime(datetime, Rule): 244 | primitive = "string" 245 | format = "datetime" 246 | 247 | 248 | class Date(date, Rule): 249 | primitive = "string" 250 | format = "date" 251 | 252 | 253 | class Timedelta(timedelta, Rule): 254 | primitive = "string" 255 | format = "duration" 256 | 257 | 258 | class EmailStr(Str): 259 | format = "email" 260 | regex = r"([A-Za-z0-9]+[.-_])*[A-Za-z0-9]+@[A-Za-z0-9-]+(\.[A-Z|a-z]{2,})+" 261 | 262 | 263 | # from pathlib import Path 264 | # 265 | # 266 | # class FilePath(Path, Rule): 267 | # format = 'file-path' 268 | # is_dir: bool = False 269 | # is_abs: bool = None 270 | # is_link: bool = None 271 | # is_exists: bool = None 272 | # max_size: int 273 | # min_size: int 274 | # 275 | # 276 | # class Directory(Path, Rule): 277 | # format = 'directory' 278 | # is_dir: bool = True 279 | # is_abs: bool = None 280 | # is_link: bool = None 281 | # is_exists: bool = None 282 | # max_files: int 283 | # min_files: int 284 | -------------------------------------------------------------------------------- /utype/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utilmeta/utype/62539bb522812e5b1919e4a99418a70c417fe0f8/utype/utils/__init__.py -------------------------------------------------------------------------------- /utype/utils/base.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Callable, Optional, Dict, Any, TypeVar, List 3 | from .datastructures import ImmutableDict 4 | from .functional import represent, multi, distinct_add, pop 5 | 6 | T = TypeVar('T') 7 | SEG = '__' 8 | 9 | 10 | class TypeRegistry: 11 | def __init__(self, 12 | name: str = 'default', 13 | base: 'TypeRegistry' = None, 14 | validator: Callable = callable, 15 | cache: bool = False, 16 | default=None, 17 | shortcut: str = None 18 | ): 19 | self._registry = [] 20 | self._cache = {} 21 | 22 | self.name = name 23 | self.cache = cache 24 | self.default = default 25 | self.shortcut = shortcut 26 | self.base = base 27 | self.validator = validator 28 | 29 | def register( 30 | self, 31 | *classes, 32 | attr=None, 33 | detector=None, 34 | metaclass=None, 35 | # to=None, # register to a specific type transformer class 36 | allow_subclasses: bool = True, 37 | priority: int = 0, 38 | volatile: bool = False, 39 | ): 40 | # detect class by issubclass or hasattr 41 | # this method can override 42 | # the latest function will have the final effect 43 | # signature = (*classes, attr, detector) 44 | 45 | if not detector: 46 | if not classes and not attr and not metaclass: 47 | raise ValueError( 48 | f"register_transformer must provide any of classes, metaclass, attr, detector" 49 | ) 50 | 51 | for c in classes: 52 | assert inspect.isclass( 53 | c 54 | ), f"register_transformer classes must be class, got {c}" 55 | pop(self._cache, c) 56 | # delete the type cache if exists 57 | 58 | if metaclass: 59 | # clear all related caches 60 | for t in list(self._cache): 61 | if isinstance(t, metaclass): 62 | pop(self._cache, t) 63 | 64 | if attr: 65 | assert isinstance( 66 | attr, str 67 | ), f"register_transformer classes must be str, got {attr}" 68 | 69 | def detector(_cls): 70 | if classes: 71 | if allow_subclasses: 72 | if not issubclass(_cls, classes): 73 | return False 74 | else: 75 | if _cls not in classes: 76 | return False 77 | if metaclass: 78 | if not isinstance(_cls, metaclass): 79 | return False 80 | if attr and not hasattr(_cls, attr): 81 | return False 82 | return True 83 | 84 | def decorator(f): 85 | if not self.validator(f): 86 | raise TypeError(f'Invalid register target: {f}, must pass <{self.validator}> validate') 87 | self._registry.insert(0, (detector, f, priority)) 88 | if priority: 89 | self._registry.sort(key=lambda v: -v[2]) 90 | if volatile: 91 | f.__volatile__ = True 92 | return f 93 | 94 | # before runtime, type will be compiled and applied 95 | # if transformer is defined after the validator compiled 96 | # it will not take effect 97 | return decorator 98 | 99 | def resolve(self, t: type) -> Optional[Callable]: 100 | # resolve to it's subclass if both subclass and baseclass is provided 101 | # like Schema type will not resolve to dict 102 | if self.shortcut and hasattr(t, self.shortcut) and self.validator(getattr(t, self.shortcut)): 103 | # this type already got a callable transformer, do not resolve then 104 | return getattr(t, self.shortcut) 105 | if self.cache and t in self._cache: 106 | return self._cache[t] 107 | 108 | for detector, trans, priority in self._registry: 109 | try: 110 | if detector(t): 111 | if self.cache and not getattr(trans, '__volatile__', False): 112 | self._cache[t] = trans 113 | return trans 114 | except (TypeError, ValueError): 115 | continue 116 | if self.base: 117 | # default to base 118 | return self.base.resolve(t) 119 | return self.default 120 | 121 | 122 | class ParamsCollectorMeta(type): 123 | def __init__(cls, name, bases: tuple, attrs: dict, **kwargs): 124 | super().__init__(name, bases, attrs) 125 | 126 | __init = attrs.get('__init__') # only track current init 127 | 128 | cls._kwargs = kwargs 129 | cls._pos_var = None 130 | cls._key_var = None 131 | cls._pos_keys = [] 132 | cls._kw_keys = [] 133 | cls._defaults = {} 134 | cls._requires = set() 135 | 136 | prefix = getattr(cls, '__name_prefix__', None) 137 | if prefix: 138 | name = prefix.rstrip('.') + '.' + name 139 | cls.__name__ = name 140 | 141 | if not bases: 142 | return 143 | 144 | defaults = {} 145 | requires = set() 146 | for base in bases: 147 | if isinstance(base, ParamsCollectorMeta): 148 | defaults.update(base._defaults) 149 | requires.update(base._requires) 150 | distinct_add(cls._pos_keys, base._pos_keys) 151 | distinct_add(cls._kw_keys, base._kw_keys) 152 | if base._key_var: 153 | cls._key_var = base._key_var 154 | if base._pos_var: 155 | cls._pos_var = base._pos_var 156 | 157 | if __init: 158 | _self, *parameters = inspect.signature(__init).parameters.items() 159 | for k, v in parameters: 160 | v: inspect.Parameter 161 | if k.startswith(SEG) and k.endswith(SEG): 162 | continue 163 | if v.default is not v.empty: 164 | defaults[k] = v.default 165 | if k in requires: 166 | # if base is required but subclass not 167 | requires.remove(k) 168 | elif v.kind not in (v.VAR_KEYWORD, v.VAR_POSITIONAL): 169 | requires.add(k) 170 | 171 | if v.kind == v.VAR_POSITIONAL: 172 | cls._pos_var = k 173 | elif v.kind == v.POSITIONAL_ONLY: 174 | if k not in cls._pos_keys: 175 | cls._pos_keys.append(k) 176 | elif v.kind == v.VAR_KEYWORD: 177 | cls._key_var = k 178 | else: 179 | if k not in cls._kw_keys: 180 | cls._kw_keys.append(k) 181 | 182 | cls._defaults = ImmutableDict(defaults) 183 | cls._requires = requires 184 | cls._attr_names = [a for a in attrs if not a.startswith('_')] 185 | 186 | @property 187 | def cls_path(cls): 188 | return f'{cls.__module__}.{cls.__name__}' 189 | 190 | @property 191 | def kw_keys(cls): 192 | return cls._kw_keys 193 | 194 | @property 195 | def pos_slice(cls) -> slice: 196 | if cls._pos_var: 197 | return slice(0, None) 198 | return slice(0, len(cls._pos_keys)) 199 | 200 | @property 201 | def cls_name(cls): 202 | try: 203 | return cls.__qualname__ 204 | except AttributeError: 205 | return cls.__name__ 206 | 207 | # def __repr__(cls): 208 | # name = cls.__name__ 209 | # prefix = getattr(cls, '__repr_prefix__', None) 210 | # if prefix: 211 | # name = prefix.rstrip('.') + '.' + name 212 | # else: 213 | # return super().__repr__() 214 | # return name 215 | # 216 | # def __str__(self): 217 | # return self.__repr__() 218 | 219 | 220 | class ParamsCollector(metaclass=ParamsCollectorMeta): 221 | __name_prefix__ = None 222 | 223 | def __init__(self, __params__: Dict[str, Any]): 224 | args = [] 225 | kwargs = {} 226 | spec = {} 227 | 228 | for key, val in __params__.items(): 229 | if key.startswith(SEG) and key.endswith(SEG): 230 | continue 231 | if val is self: 232 | continue 233 | if key == self._pos_var: 234 | args += list(val) 235 | continue 236 | elif key == self._key_var: 237 | if isinstance(val, dict): 238 | _kwargs = {k: v for k, v in val.items() if not k.startswith(SEG)} 239 | kwargs.update(_kwargs) 240 | spec.update(_kwargs) # also update spec 241 | continue 242 | elif key in self._pos_keys: 243 | args.append(key) 244 | elif key in self._kw_keys: 245 | kwargs[key] = val 246 | else: 247 | continue 248 | if val != self._defaults.get(key): # for key_var or pos_var the default is None 249 | spec[key] = val 250 | 251 | self.__args__ = tuple(args) 252 | self.__kwargs__ = kwargs 253 | self.__spec_kwargs__ = ImmutableDict(spec) 254 | self.__name__ = self._get_cls_name() 255 | 256 | def __hash__(self): 257 | return hash(repr(self)) 258 | 259 | def __eq__(self, other: 'ParamsCollector'): 260 | if inspect.isclass(self): 261 | return super().__eq__(other) 262 | if not isinstance(other, self.__class__): 263 | return False 264 | return self.__spec_kwargs__ == other.__spec_kwargs__ and self.__args__ == other.__args__ 265 | 266 | def __bool__(self): 267 | # !! return not self.vacuum 268 | # prevent use as bool (causing lots of recessive errors) 269 | # let sub utils define there own way of bool 270 | return True 271 | 272 | def __str__(self): 273 | return self._repr() 274 | 275 | def __repr__(self): 276 | return self._repr() 277 | 278 | @classmethod 279 | def __copy(cls, data, copy_class: bool = False): 280 | if multi(data): 281 | return type(data)([cls.__copy(d) for d in data]) 282 | if isinstance(data, dict): 283 | return {key: cls.__copy(val) for key, val in data.items()} 284 | if inspect.isclass(data) and not copy_class: 285 | # prevent class util that carry other utils cause RecursiveError 286 | return data 287 | if isinstance(data, ParamsCollector): 288 | return data.__copy__() 289 | return data 290 | 291 | def _update_spec(self, **kwargs): 292 | # this is a rather ugly patch, we will figure something more elegantly in future 293 | spec = dict(self.__spec_kwargs__) 294 | spec.update(kwargs) 295 | self.__spec_kwargs__ = ImmutableDict(spec) 296 | 297 | def __deepcopy__(self, memo): 298 | return self.__copy__() 299 | 300 | def __copy__(self): 301 | # use copied version of sub utils 302 | # return self.__class__(*self._args, **self._kwargs) 303 | if inspect.isclass(self): 304 | bases = getattr(self, '__bases__', ()) 305 | attrs = dict(self.__dict__) 306 | # pop(attrs, Attr.LOCK) # pop __lock__ 307 | return self.__class__(self.__name__, bases, self.__copy(attrs)) 308 | return self.__class__(*self.__copy(self.__args__), **self.__copy(self.__spec_kwargs__)) 309 | 310 | def _get_cls_name(self): 311 | if inspect.isclass(self): 312 | cls = self 313 | else: 314 | cls = self.__class__ 315 | try: 316 | name = cls.__qualname__ 317 | except AttributeError: 318 | name = cls.__name__ 319 | prefix = self.__name_prefix__ 320 | if prefix: 321 | name = prefix.rstrip('.') + '.' + name 322 | return name 323 | 324 | def _repr(self, name: str = None, 325 | prefix: str = None, 326 | includes: List[str] = None, 327 | excludes: List[str] = None, 328 | addition: dict = None 329 | ): 330 | name = name or self.__name__ 331 | prefix = prefix 332 | if prefix: 333 | name = prefix.rstrip('.') + '.' + name 334 | if inspect.isclass(self): 335 | return f'<{name} class "{self.__module__}.{name}">' 336 | attrs = [] 337 | 338 | for k, v in self.__spec_kwargs__.items(): 339 | # if not isinstance(v, bool) and any([s in str(k).lower() for s in self._secret_names]) and v: 340 | # v = SECRET 341 | if k.startswith('_'): 342 | continue 343 | if includes is not None and k not in includes: 344 | continue 345 | if excludes is not None and k in excludes: 346 | continue 347 | attrs.append(k + '=' + represent(v)) # str(self.display(v))) 348 | if addition: 349 | for k, v in addition.items(): 350 | if k not in self.__spec_kwargs__: 351 | attrs.append(k + '=' + represent(v)) 352 | s = ', '.join([represent(v) for v in self.__args__] + attrs) 353 | return f'{name}({s})' 354 | -------------------------------------------------------------------------------- /utype/utils/compat.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python type hint standard may vary from versions 3 | """ 4 | import sys 5 | import typing 6 | from typing import (Any, ClassVar, ForwardRef, Optional, Tuple, # type: ignore 7 | Type, Union) 8 | 9 | try: 10 | from typing import Literal 11 | except ImportError: 12 | from typing_extensions import Literal 13 | 14 | try: 15 | from types import UnionType 16 | except ImportError: 17 | UnionType = Union 18 | 19 | try: 20 | from typing import Final 21 | except ImportError: 22 | from typing_extensions import Final 23 | 24 | try: 25 | from typing import Annotated 26 | except ImportError: 27 | from typing_extensions import Annotated 28 | 29 | try: 30 | from typing import Self 31 | except ImportError: 32 | from typing_extensions import Self 33 | 34 | try: 35 | from typing import Required 36 | except ImportError: 37 | from typing_extensions import Required 38 | 39 | 40 | __all__ = [ 41 | "get_origin", 42 | "get_args", 43 | 'Literal', 44 | 'Final', 45 | 'Self', 46 | 'Required', 47 | 'UnionType', 48 | "ForwardRef", 49 | "Annotated", 50 | "is_final", 51 | "is_union", 52 | "is_classvar", 53 | "is_annotated", 54 | "evaluate_forward_ref", 55 | 'JSON_TYPES' 56 | ] 57 | 58 | if sys.version_info < (3, 8): 59 | 60 | def get_origin(t) -> Optional[Type[Any]]: 61 | return getattr(t, "__origin__", None) 62 | 63 | def get_args(t) -> Tuple[Type[Any], ...]: 64 | return getattr(t, "__args__", ()) 65 | 66 | else: 67 | from typing import get_args as _typing_get_args 68 | from typing import get_origin as _typing_get_origin 69 | 70 | def get_origin(t) -> Optional[Type[Any]]: 71 | return _typing_get_origin(t) or getattr(t, "__origin__", None) 72 | 73 | def get_args(t) -> Tuple[Type[Any], ...]: 74 | args = getattr(t, "__args__", ()) 75 | if args == (): 76 | # typing.get_args(typing.Callable) will throw an error 77 | return args 78 | return _typing_get_args(t) or args 79 | 80 | 81 | try: 82 | from typing import ForwardRef # type: ignore 83 | 84 | def evaluate_forward_ref(ref: ForwardRef, globalns: Any, localns: Any): 85 | return typing._eval_type(ref, globalns, localns) # noqa 86 | 87 | except ImportError: 88 | # python 3.6 89 | from typing import _ForwardRef as ForwardRef # noqa 90 | 91 | def evaluate_forward_ref(ref: ForwardRef, globalns: Any, localns: Any): 92 | return ref._eval_type(globalns, localns) # noqa 93 | 94 | 95 | if sys.version_info < (3, 10): 96 | 97 | def is_union(tp: Optional[Type[Any]]) -> bool: 98 | return tp is Union 99 | 100 | else: 101 | import types 102 | 103 | def is_union(tp: Optional[Type[Any]]) -> bool: 104 | return tp is Union or tp is types.UnionType # noqa 105 | 106 | 107 | def _check_classvar(v) -> bool: 108 | return type(v) == type(ClassVar) and ( 109 | sys.version_info < (3, 7) or getattr(v, "_name", None) == "ClassVar" 110 | ) 111 | 112 | 113 | def _check_final(v) -> bool: 114 | return type(v) == type(Final) and ( 115 | sys.version_info < (3, 7) or getattr(v, "_name", None) == "Final" 116 | ) 117 | 118 | 119 | def is_classvar(ann_type) -> bool: 120 | return _check_classvar(ann_type) or _check_classvar( 121 | getattr(ann_type, "__origin__", None) 122 | ) 123 | 124 | 125 | _AnnotatedTypeNames = {'AnnotatedMeta', '_AnnotatedAlias'} 126 | 127 | 128 | def is_annotated(ann_type) -> bool: 129 | # duck type check for Annotated, name in AnnotatedTypeNames and have __origin__ attribute 130 | return type(ann_type).__name__ in _AnnotatedTypeNames and getattr(ann_type, '__origin__', None) 131 | 132 | 133 | def is_final(ann_type) -> bool: 134 | return _check_final(ann_type) or _check_final(getattr(ann_type, "__origin__", None)) 135 | 136 | 137 | ATOM_TYPES = (str, int, bool, float, type(None)) 138 | JSON_TYPES = (*ATOM_TYPES, list, dict) 139 | # types thar can directly dump to json 140 | # COMMON_TYPES = (*JSON_TYPES, set, tuple, bytes, *VENDOR_TYPES) 141 | -------------------------------------------------------------------------------- /utype/utils/datastructures.py: -------------------------------------------------------------------------------- 1 | class Unprovided: 2 | def __bool__(self): 3 | return False 4 | 5 | def __eq__(self, other): 6 | return self(other) 7 | 8 | def __call__(self, v): 9 | return isinstance(v, Unprovided) 10 | 11 | def __repr__(self): 12 | return "" 13 | 14 | 15 | unprovided = Unprovided() 16 | 17 | 18 | try: 19 | from functools import cached_property 20 | except ImportError: 21 | class cached_property: 22 | """ 23 | Decorator that converts a method with a single self argument into a 24 | property cached on the instance. 25 | 26 | A cached property can be made out of an existing method: 27 | (e.g. ``url = cached_property(get_absolute_url)``). 28 | The optional ``name`` argument is obsolete as of Python 3.6 and will be 29 | deprecated in Django 4.0 (#30127). 30 | """ 31 | name = None 32 | 33 | @staticmethod 34 | def func(instance): 35 | raise TypeError( 36 | 'Cannot use cached_property instance without calling ' 37 | '__set_name__() on it.' 38 | ) 39 | 40 | def __init__(self, func, name=None): 41 | self.real_func = func 42 | self.__doc__ = getattr(func, '__doc__') 43 | 44 | def __set_name__(self, owner, name): 45 | if self.name is None: 46 | self.name = name 47 | self.func = self.real_func 48 | elif name != self.name: 49 | raise TypeError( 50 | "Cannot assign the same cached_property to two different names " 51 | "(%r and %r)." % (self.name, name) 52 | ) 53 | 54 | def __get__(self, instance, cls=None): 55 | """ 56 | Call the function and put the return value in instance.__dict__ so that 57 | subsequent attribute access on the instance returns the cached value 58 | instead of calling cached_property.__get__(). 59 | """ 60 | if instance is None: 61 | return self 62 | res = instance.__dict__[self.name] = self.func(instance) 63 | return res 64 | 65 | 66 | class ImmutableDict(dict): 67 | def __error__(self, *args, **kwargs): 68 | raise AttributeError("ImmutableDict can not modify value") 69 | 70 | __delitem__ = __error__ 71 | __setitem__ = __error__ 72 | 73 | def __str__(self): 74 | return f'{self.__class__.__name__}({super().__repr__()})' 75 | 76 | def __repr__(self): 77 | return f'{self.__class__.__name__}({super().__repr__()})' 78 | 79 | setdefault = __error__ 80 | pop = __error__ 81 | popitem = __error__ 82 | clear = __error__ 83 | update = __error__ 84 | 85 | 86 | class ImmutableList(list): 87 | def error(self, *args, **kwargs): 88 | raise AttributeError("ImmutableList can not modify value") 89 | 90 | def __str__(self): 91 | return f'{self.__class__.__name__}({super().__repr__()})' 92 | 93 | def __repr__(self): 94 | return f'{self.__class__.__name__}({super().__repr__()})' 95 | 96 | append = error 97 | clear = error 98 | extend = error 99 | insert = error 100 | pop = error 101 | remove = error 102 | reverse = error 103 | sort = error 104 | __iadd__ = error 105 | __imul__ = error 106 | __setitem__ = error 107 | __delitem__ = error 108 | -------------------------------------------------------------------------------- /utype/utils/encode.py: -------------------------------------------------------------------------------- 1 | import decimal 2 | import io 3 | import uuid 4 | from collections.abc import Mapping 5 | from datetime import date, datetime, time, timedelta 6 | from enum import Enum 7 | from typing import Union 8 | from .base import TypeRegistry 9 | import json 10 | from .datastructures import unprovided 11 | from ipaddress import IPv4Address, IPv6Address, IPv4Network, IPv6Network 12 | from pathlib import PurePath 13 | 14 | 15 | encoder_registry = TypeRegistry('encoder', cache=True, shortcut='__encoder__') 16 | register_encoder = encoder_registry.register 17 | 18 | 19 | class JSONEncoder(json.JSONEncoder): 20 | def default(self, o): 21 | encoder = encoder_registry.resolve(type(o)) 22 | if encoder: 23 | return encoder(o) 24 | return super().default(o) 25 | 26 | 27 | class JSONSerializer: 28 | """ 29 | Simple wrapper around json to be used in signing.dumps and 30 | signing.loads. 31 | """ 32 | encoder_cls = JSONEncoder 33 | charset = 'utf-8' 34 | separators = (',', ':') 35 | ensure_ascii = False 36 | skipkeys = True 37 | 38 | def dumps(self, obj): 39 | return json.dumps( 40 | obj, 41 | separators=self.separators, 42 | cls=self.encoder_cls, 43 | ensure_ascii=self.ensure_ascii, 44 | skipkeys=self.skipkeys 45 | ).encode(self.charset) 46 | 47 | def loads(self, data: bytes): 48 | return json.loads(data.decode(self.charset)) 49 | 50 | 51 | def duration_iso_string(duration: timedelta): 52 | if duration < timedelta(0): 53 | sign = "-" 54 | duration *= -1 55 | else: 56 | sign = "" 57 | 58 | days = duration.days 59 | seconds = duration.seconds 60 | microseconds = duration.microseconds 61 | 62 | minutes = seconds // 60 63 | seconds = seconds % 60 64 | 65 | hours = minutes // 60 66 | minutes = minutes % 60 67 | 68 | ms = ".{:06d}".format(microseconds) if microseconds else "" 69 | return "{}P{}DT{:02d}H{:02d}M{:02d}{}S".format( 70 | sign, days, hours, minutes, seconds, ms 71 | ) 72 | 73 | 74 | @register_encoder(Mapping) 75 | def from_mapping(data): 76 | return dict(data) 77 | 78 | 79 | @register_encoder(set) 80 | def from_set(data): 81 | return list(data) 82 | 83 | 84 | @register_encoder(tuple) 85 | def from_tuple(data): 86 | return list(data) 87 | 88 | 89 | @register_encoder(unprovided.__class__) 90 | def from_unprovided(data): 91 | return None 92 | 93 | 94 | @register_encoder(bytes, memoryview, bytearray) 95 | def from_bytes(data: bytes): 96 | if not isinstance(data, bytes): 97 | data = bytes(data) 98 | return data.decode("utf-8", errors="replace") 99 | 100 | 101 | @register_encoder(PurePath, allow_subclasses=True) 102 | def from_path(data: PurePath): 103 | return str(data) 104 | 105 | 106 | @register_encoder(io.BytesIO) 107 | def from_bytes_io(data: io.BytesIO): 108 | return data.read().decode("utf-8", errors="replace") 109 | 110 | 111 | @register_encoder(date) 112 | def from_datetime(data: Union[datetime, date]): 113 | return data.isoformat() 114 | 115 | 116 | @register_encoder(IPv4Network, IPv4Address, IPv6Network, IPv6Address) 117 | def from_ip(data): 118 | return str(data) 119 | 120 | 121 | @register_encoder(timedelta) 122 | def from_duration(data: timedelta): 123 | return duration_iso_string(data) 124 | 125 | 126 | @register_encoder(time) 127 | def from_time(data: time): 128 | r = data.isoformat() 129 | if data.microsecond: 130 | r = r[:12] 131 | return r 132 | 133 | 134 | @register_encoder(uuid.UUID) 135 | def from_uuid(data: uuid.UUID): 136 | return str(data) 137 | 138 | 139 | @register_encoder(decimal.Decimal) 140 | def from_decimal(data: decimal.Decimal): 141 | if data.is_finite(): 142 | if js_unsafe(data): 143 | return str(data) 144 | if not data.as_tuple().exponent: 145 | # integer 146 | return int(data) 147 | return float(data) 148 | # infinity / NaN 149 | return str(data) 150 | 151 | 152 | # TODO? 153 | # @register_encoder(float) 154 | # def from_float(data: float): 155 | # if js_unsafe(data): 156 | # return str(data) 157 | # return data 158 | # 159 | # 160 | # @register_encoder(int) 161 | # def from_int(data: int): 162 | # if js_unsafe(data): 163 | # return str(data) 164 | # return data 165 | 166 | 167 | @register_encoder(Enum) 168 | def from_enum(en: Enum): 169 | return en.value 170 | 171 | 172 | MAX_SAFE_NUMBER = 9007199254740991 173 | MIN_SAFE_NUMBER = -9007199254740991 174 | 175 | 176 | def js_unsafe(num: Union[int, float, decimal.Decimal]): 177 | return num > MAX_SAFE_NUMBER or num < MIN_SAFE_NUMBER 178 | 179 | 180 | # @register_encoder(attr="__iter__") 181 | # def from_iterable(encoder, data): 182 | # return list(data) 183 | -------------------------------------------------------------------------------- /utype/utils/example.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import math 3 | import warnings 4 | import random 5 | from decimal import Decimal 6 | from enum import Enum, EnumMeta 7 | from ..utils.datastructures import unprovided 8 | from ..parser.field import ParserField 9 | from ..parser.base import BaseParser 10 | from ..parser.rule import LogicalType, Rule, SEQ_TYPES, MAP_TYPES 11 | from uuid import UUID 12 | from datetime import date, datetime, timedelta, time 13 | from typing import Type 14 | 15 | VALUE_TYPES = (str, int, float, Decimal, datetime, date, time, timedelta) 16 | 17 | 18 | def get_example_from_json_schema(schema): 19 | pass 20 | 21 | 22 | def get_example_from(t: type): 23 | if t == type(None): 24 | return None 25 | 26 | if t == bool: 27 | return random.choice([True, False]) 28 | 29 | if t == UUID: 30 | import uuid 31 | return uuid.uuid4() 32 | 33 | if isinstance(t, EnumMeta): 34 | val = random.choice(t.__members__.values()) # noqa 35 | return t(val.value) 36 | 37 | parser = getattr(t, '__parser__', None) 38 | if isinstance(parser, BaseParser): 39 | return t(**get_example_from_parser(parser)) 40 | 41 | if inspect.isclass(t) and issubclass(t, Rule): 42 | return get_example_from_rule(t) 43 | 44 | if isinstance(t, LogicalType): 45 | return t.get_example() 46 | 47 | return t() 48 | 49 | 50 | def get_example_from_field(field: ParserField): 51 | if not unprovided(field.field.example): 52 | return field.field.example 53 | return get_example_from(field.type) 54 | 55 | 56 | def get_example_from_parser(self): 57 | data = {} 58 | for name, field in self.fields.items(): 59 | try: 60 | example = get_example_from_field(field) 61 | except Exception as e: 62 | warnings.warn(f'{self.obj}: generate example for field: [{repr(name)}] failed with error: {e}') 63 | continue 64 | data[name] = example 65 | return data 66 | 67 | 68 | def get_example_from_rule(cls: Type[Rule]): 69 | """ 70 | If example is forced and there is unsolvable rules (validator / converter) and no example provided 71 | will prompt error to ask provide example 72 | """ 73 | if hasattr(cls, 'const'): 74 | return cls.const 75 | 76 | if hasattr(cls, 'enum'): 77 | return random.choice(cls.enum) 78 | 79 | if hasattr(cls, 'regex'): 80 | try: 81 | import exrex # noqa 82 | return exrex.getone(cls.regex) 83 | except (ModuleNotFoundError, AttributeError): 84 | pass 85 | 86 | t = cls.__origin__ 87 | 88 | if t in SEQ_TYPES: 89 | if cls.__args__: 90 | values = [] 91 | if cls.__ellipsis_args__: 92 | # tuple 93 | for arg in cls.__args__: 94 | values.append( 95 | get_example_from(arg) 96 | ) 97 | else: 98 | values.append( 99 | get_example_from(cls.__args__[0]) 100 | ) 101 | return t(values) 102 | 103 | if t in MAP_TYPES: 104 | if cls.__args__: 105 | values = {} 106 | key_type = cls.__args__[0] 107 | val_type = None 108 | if len(cls.__args__) > 1: 109 | val_type = cls.__args__[1] 110 | key = get_example_from(key_type) 111 | val = get_example_from(val_type) if val_type else None 112 | values[key] = val 113 | return t(values) 114 | 115 | if t not in VALUE_TYPES: 116 | return get_example_from(t) 117 | 118 | multi_of = getattr(cls, 'multiple_of', None) 119 | 120 | length = getattr(cls, 'length', None) 121 | min_length = getattr(cls, 'min_length', None) 122 | max_length = getattr(cls, 'max_length', None) 123 | 124 | round_v = getattr(cls, 'decimal_places', None) 125 | 126 | ge = getattr(cls, 'ge', None) 127 | gt = getattr(cls, 'gt', None) 128 | le = getattr(cls, 'le', None) 129 | lt = getattr(cls, 'lt', None) 130 | min_value = getattr(cls, 'ge', getattr(cls, 'gt', None)) 131 | max_value = getattr(cls, 'le', getattr(cls, 'lt', None)) 132 | 133 | if min_value is None: 134 | if max_value is None: 135 | if t == datetime: 136 | return datetime.now() 137 | elif t == date: 138 | return datetime.now().date() 139 | elif t == time: 140 | return datetime.now().time() 141 | elif t == timedelta: 142 | v = datetime.now().time() 143 | return timedelta(hours=v.hour, minutes=v.minute, seconds=v.second, microseconds=v.microsecond) 144 | else: 145 | if isinstance(multi_of, (int, float)): 146 | return multi_of * random.randint(1, 10) 147 | 148 | val_length = length 149 | if val_length is None: 150 | min_len = min_length or 0 151 | max_len = max_length or min_len + 10 152 | val_length = int(min_len + (max_len - min_len) * random.random()) 153 | 154 | if not val_length: 155 | return t() 156 | 157 | if t == str: 158 | import string 159 | return ''.join(random.sample(string.digits + string.ascii_letters, val_length)) 160 | 161 | elif t in (float, Decimal): 162 | if round_v and round_v > 0: 163 | val_length = max(val_length - round_v - 1, 1) 164 | val = random.randint(10 ** (val_length - 1), 10 ** val_length - 1) + random.random() 165 | if round_v is not None: 166 | val = round(val, round_v) 167 | while len(str(val)) < val_length: 168 | val = float(str(val) + '1') 169 | return t(val) 170 | 171 | elif t == int: 172 | return random.randint(10 ** (val_length - 1), 10 ** val_length - 1) 173 | else: 174 | if t in (int, float, Decimal): 175 | if isinstance(multi_of, (int, float)) and multi_of: 176 | times = int(max_value / multi_of) 177 | return multi_of * random.randint(max(1, times - 3), times) 178 | 179 | min_value = (max_value / 2) if max_value > 0 else max_value * 2 180 | elif t in (datetime, date): 181 | min_value = max_value - timedelta(days=1) # noqa 182 | elif t == timedelta: 183 | min_value = min(timedelta(), max_value - timedelta(days=1)) # noqa 184 | elif t == time: 185 | min_value = time() 186 | 187 | elif max_value is None: 188 | if t in (int, float, Decimal): 189 | if isinstance(multi_of, (int, float)) and multi_of: 190 | times = math.ceil(min_value / multi_of) 191 | return multi_of * random.randint(times, times + 5) 192 | 193 | max_value = (min_value * 2) if min_value > 0 else min_value / 2 194 | elif t in (datetime, date, timedelta): 195 | max_value = min_value + timedelta(days=1) # noqa 196 | elif t == time: 197 | max_value = time(23, 59, 59) 198 | 199 | if max_value is not None and min_value is not None: 200 | try: 201 | delta = max_value - min_value 202 | if isinstance(delta, int): 203 | if isinstance(multi_of, (int, float)) and multi_of: 204 | max_times = int(max_value / multi_of) 205 | min_times = math.ceil(min_value / multi_of) 206 | return multi_of * random.randint(min_times, max_times) 207 | 208 | v = min_value + random.randint(0, delta) 209 | if v == gt: 210 | v = v + 1 211 | elif v == lt: 212 | v = v - 1 213 | else: 214 | v = min_value + delta * random.random() 215 | 216 | v = t(v) 217 | if round_v is not None: 218 | v = round(v, round_v) 219 | return v 220 | except TypeError: 221 | # like str 222 | seq = [] 223 | if le: 224 | seq.append(le) 225 | if ge: 226 | seq.append(ge) 227 | if seq: 228 | return random.choice(seq) 229 | if isinstance(min_value, str): 230 | return min_value + '_' + str(random.randint(0, 9)) 231 | return t() 232 | -------------------------------------------------------------------------------- /utype/utils/exceptions.py: -------------------------------------------------------------------------------- 1 | # ERROR for handling parse 2 | from typing import Any, List, Set, Union, TYPE_CHECKING 3 | if TYPE_CHECKING: 4 | from ..parser.field import ParserField 5 | 6 | 7 | class ConfigError(SyntaxError): 8 | def __init__(self, msg="", obj=None, params: dict = None, field: str = None): 9 | super().__init__(msg) 10 | self.obj = obj 11 | self.params = params 12 | self.field = field 13 | 14 | 15 | class FieldError(AttributeError, KeyError): 16 | def __init__( 17 | self, 18 | msg: str = None, 19 | *, 20 | field=None, 21 | origin_exc: Exception = None, 22 | ): 23 | self.msg = msg 24 | self.field = field 25 | self.origin_exc = origin_exc 26 | 27 | super().__init__(msg) 28 | 29 | if isinstance(self.origin_exc, Exception): 30 | self.__traceback__ = self.origin_exc.__traceback__ 31 | 32 | 33 | class UpdateError(FieldError): 34 | pass 35 | 36 | 37 | class DeleteError(FieldError): 38 | pass 39 | 40 | 41 | class ParseError(TypeError, ValueError): 42 | def __init__( 43 | self, 44 | msg: str = None, 45 | *, 46 | value: Any = None, 47 | type: type = None, 48 | item: Union[ 49 | int, str 50 | ] = None, # like key in the object or index in seq to indentify value 51 | field: 'ParserField' = None, # no field can means it's additional field 52 | routes: list = None, 53 | origin_exc: Exception = None, 54 | ): 55 | if not msg and origin_exc: 56 | msg = str(origin_exc) 57 | self.msg = msg 58 | self.origin_exc = origin_exc 59 | self.value = value 60 | self.type = type 61 | self.item = item 62 | self.field = field 63 | self.routes = routes 64 | super().__init__(self.formatted_message) 65 | 66 | if isinstance(self.origin_exc, Exception): 67 | self.__traceback__ = self.origin_exc.__traceback__ 68 | 69 | @property 70 | def formatted_message(self): 71 | msg = self.msg 72 | if self.item: 73 | msg = f"parse item: [{repr(self.item)}] failed: {msg}" 74 | if isinstance(self.origin_exc, Exception) and not isinstance(self.origin_exc, ParseError): 75 | msg = f'{self.origin_exc.__class__.__name__}: {msg}' 76 | return msg 77 | 78 | def get_detail(self) -> dict: 79 | from ..specs.json_schema import JsonSchemaGenerator 80 | origin = None 81 | if self.origin_exc: 82 | if isinstance(self.origin_exc, ParseError) and self.origin_exc.field: 83 | origin = self.origin_exc.get_detail() 84 | 85 | if origin: 86 | return { 87 | 'name': self.field.name if self.field else None, 88 | 'field': self.field.field.__class__.__name__ if self.field else None, 89 | 'origin': origin 90 | } 91 | return { 92 | 'name': self.field.name if self.field else None, 93 | 'value': self.value, 94 | 'field': self.field.field.__class__.__name__ if self.field else None, 95 | 'schema': JsonSchemaGenerator(self.type)() if self.type else None, 96 | 'msg': self.msg, 97 | } 98 | 99 | 100 | class TypeMismatchError(ParseError): 101 | @property 102 | def formatted_message(self): 103 | msg = f"type: {self.type} is unrecognized and forbid to auto-init" 104 | if self.msg: 105 | msg += f": {self.msg}" 106 | return msg 107 | 108 | 109 | class InvalidInstance(TypeMismatchError): 110 | @property 111 | def formatted_message(self): 112 | msg = f"invalid class instance: {self.value} for {self.type}" 113 | if self.msg: 114 | msg += f": {self.msg}" 115 | return msg 116 | 117 | 118 | class InvalidSubclass(TypeMismatchError): 119 | @property 120 | def formatted_message(self): 121 | msg = f"invalid subclass: {self.value} for {self.type}" 122 | if self.msg: 123 | msg += f": {self.msg}" 124 | return msg 125 | 126 | 127 | class DiscriminatorMismatchError(ParseError): 128 | def __init__(self, discriminator: str, discriminator_value=None, **kwargs): 129 | self.discriminator = discriminator 130 | self.discriminator_value = discriminator_value 131 | super().__init__(**kwargs) 132 | 133 | 134 | class ConstraintError(ParseError): 135 | def __init__( 136 | self, 137 | msg: str = None, 138 | *, 139 | value: Any = None, 140 | type: type = None, 141 | item: Union[ 142 | int, str 143 | ] = None, # like key in the object or index in seq to indentify value 144 | constraint: str = None, # failed constraint 145 | constraint_value: Any = None, # failed constraint value 146 | origin_exc: Exception = None, 147 | ): 148 | self.constraint = constraint 149 | self.constraint_value = constraint_value 150 | super().__init__(msg, value=value, type=type, item=item, origin_exc=origin_exc) 151 | 152 | @property 153 | def formatted_message(self): 154 | if self.constraint: 155 | msg = f"Constraint: <{self.constraint}>: {repr(self.constraint_value)} violated" 156 | if self.msg: 157 | msg += f": {self.msg}" 158 | return msg 159 | return self.msg 160 | 161 | 162 | class ExceedError(ParseError): 163 | # a key has excess the dict template and allow_excess=False in options 164 | # def __init__( 165 | # self, msg: str = None, excess_items: Union[list, set] = None, **kwargs 166 | # ): 167 | # self.excess_items = excess_items 168 | # super().__init__(msg, **kwargs) 169 | 170 | @property 171 | def formatted_message(self): 172 | msg = f"parse item: [{repr(self.item)}] exceeded" 173 | if self.msg: 174 | msg += f": {self.msg}" 175 | return msg 176 | 177 | 178 | class TupleExceedError(ExceedError): 179 | pass 180 | 181 | 182 | class AliasConflictError(ParseError): 183 | pass 184 | 185 | 186 | # class ItemsExceedError(ExceedError): 187 | # pass 188 | 189 | 190 | class DepthExceedError(ParseError): 191 | def __init__( 192 | self, 193 | msg: str = None, 194 | max_depth: int = None, 195 | depth: int = None, 196 | **kwargs, 197 | ): 198 | self.depth = depth 199 | self.max_depth = max_depth 200 | super().__init__(msg, **kwargs) 201 | 202 | @property 203 | def formatted_message(self): 204 | msg = f"max_depth: {self.max_depth} exceed: {self.depth}" 205 | if self.msg: 206 | msg += f": {self.msg}" 207 | return msg 208 | 209 | 210 | class ParamsExceedError(ParseError): 211 | def __init__( 212 | self, 213 | msg: str = None, 214 | max_params: int = None, 215 | params_num: int = None, 216 | **kwargs, 217 | ): 218 | self.params_num = params_num 219 | self.max_params = max_params 220 | super().__init__(msg, **kwargs) 221 | 222 | @property 223 | def formatted_message(self): 224 | msg = f"max params num: {self.max_params} exceed: {self.params_num}" 225 | if self.msg: 226 | msg += f": {self.msg}" 227 | return msg 228 | 229 | 230 | class ParamsLackError(ParseError): 231 | def __init__( 232 | self, 233 | msg: str = None, 234 | min_params: int = None, 235 | params_num: int = None, 236 | **kwargs, 237 | ): 238 | self.params_num = params_num 239 | self.min_params = min_params 240 | super().__init__(msg, **kwargs) 241 | 242 | @property 243 | def formatted_message(self): 244 | msg = f"min params num: {self.min_params} lacked: {self.params_num}" 245 | if self.msg: 246 | msg += f": {self.msg}" 247 | return msg 248 | 249 | 250 | class AbsenceError(ParseError): 251 | @property 252 | def formatted_message(self): 253 | msg = f"required item: {repr(self.item)} is absence" 254 | if self.msg: 255 | msg += f": {self.msg}" 256 | return msg 257 | 258 | 259 | class DependenciesAbsenceError(AbsenceError): 260 | def __init__( 261 | self, msg: str = None, absence_dependencies: Set[str] = None, **kwargs 262 | ): 263 | self.absence_dependencies = absence_dependencies 264 | super().__init__(msg, **kwargs) 265 | 266 | @property 267 | def formatted_message(self): 268 | msg = f"required dependencies: {self.absence_dependencies} is absence" 269 | if self.msg: 270 | msg += f": {self.msg}" 271 | return msg 272 | 273 | 274 | class RecursionExceeded(ParseError, RecursionError): 275 | def __init__(self, msg: str = None, depth: int = None, **kwargs): 276 | super().__init__(msg, **kwargs) 277 | self.depth = depth 278 | 279 | 280 | class TransformError(ParseError, TypeError): 281 | def __init__(self, msg: str = None, data_type: type = None, **kwargs): 282 | super().__init__(msg, **kwargs) 283 | self.data_type = data_type 284 | 285 | 286 | class CollectedParseError(ParseError): 287 | def __init__(self, errors: List[ParseError]): 288 | self.errors = errors 289 | super().__init__(";\n".join([str(error) for error in errors])) 290 | 291 | def get_detail(self) -> list: 292 | errors = [] 293 | for error in self.errors: 294 | if isinstance(error, ParseError): 295 | errors.append(error.get_detail()) 296 | else: 297 | errors.append({ 298 | 'msg': str(error), 299 | 'error': error.__class__.__name__ 300 | }) 301 | return errors 302 | 303 | 304 | class NegateViolatedError(ParseError): 305 | pass 306 | 307 | 308 | class OneOfViolatedError(ParseError): 309 | pass 310 | -------------------------------------------------------------------------------- /utype/utils/functional.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | import inspect 3 | 4 | LOCALS_NAME = "" 5 | 6 | 7 | def multi(f): 8 | return isinstance( 9 | f, (list, set, frozenset, tuple, type({}.values()), type({}.keys())) 10 | ) 11 | 12 | 13 | def pop(data, key, default=None): 14 | if isinstance(data, dict): 15 | return data.pop(key) if key in data else default 16 | elif isinstance(data, list): 17 | return data.pop(key) if key < len(data) else default 18 | return default 19 | 20 | 21 | def copy_value(data): 22 | """ 23 | return a new value identical to default , but different in memory, 24 | to avoid multiple initialize to modify the same default data 25 | """ 26 | if multi(data): 27 | return type(data)([copy_value(d) for d in data]) 28 | elif isinstance(data, dict): 29 | return {k: copy_value(v) for k, v in data.items()} 30 | return data 31 | 32 | 33 | def get_name(func) -> Optional[str]: 34 | if isinstance(func, str): 35 | return func 36 | if isinstance(func, property): 37 | func = func.fget 38 | from functools import partial 39 | 40 | if isinstance(func, partial): 41 | if hasattr(func, "__name__"): 42 | return func.__name__ 43 | func = func.func 44 | if hasattr(func, "__name__"): 45 | return func.__name__ 46 | return None 47 | 48 | 49 | def represent(val) -> str: 50 | if isinstance(val, type): 51 | if val is type(None): 52 | return 'type(None)' 53 | return val.__name__ 54 | if inspect.isfunction(val) or inspect.ismethod(val) or inspect.isclass(val) or inspect.isbuiltin(val): 55 | return val.__name__ 56 | return repr(val) 57 | 58 | 59 | def get_obj_name(obj) -> str: 60 | name = getattr( 61 | obj, "__qualname__", getattr(obj, "__name__", None) 62 | ) or str(obj) 63 | if LOCALS_NAME in name: 64 | name = str(name.split(LOCALS_NAME)[-1]).strip(".") 65 | return name 66 | 67 | 68 | def is_local_var(obj): 69 | name = getattr( 70 | obj, "__qualname__", getattr(obj, "__name__", None) 71 | ) or '' 72 | return not name or LOCALS_NAME in name 73 | 74 | 75 | def distinct_add(target: list, data): 76 | if not data: 77 | return target 78 | if not isinstance(target, list): 79 | raise TypeError(f'Invalid distinct_add target type: {type(target)}, must be lsit') 80 | # target = list(target) 81 | if not multi(data): 82 | if data not in target: 83 | target.append(data) 84 | return target 85 | for item in data: 86 | if item not in target: 87 | target.append(item) 88 | return target 89 | 90 | 91 | def valid_attr(name: str): 92 | from keyword import iskeyword 93 | return name.isidentifier() and not iskeyword(name) 94 | -------------------------------------------------------------------------------- /utype/utils/style.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from typing import Callable, List, Union 3 | 4 | from .functional import multi 5 | 6 | 7 | class CaseStyle: 8 | camelCase = "camel" 9 | PascalCase = "pascal" 10 | snake_case = "snake" 11 | kebab_case = "kebab" 12 | CAP_KEBAB_CASE = "cap_kebab" 13 | CAP_SNAKE_CASE = "cap_snake" 14 | 15 | 16 | CASE_STYLES = [ 17 | CaseStyle.camelCase, 18 | CaseStyle.PascalCase, 19 | CaseStyle.snake_case, 20 | CaseStyle.kebab_case, 21 | CaseStyle.CAP_KEBAB_CASE, 22 | CaseStyle.CAP_SNAKE_CASE, 23 | ] 24 | 25 | 26 | class AliasGenerator: 27 | @classmethod 28 | def guess_style(cls, style: Union[str, Callable]): 29 | if callable(style): 30 | return style 31 | if not isinstance(style, str) or not style: 32 | return None 33 | style = style.lower() 34 | if CaseStyle.camelCase in style: 35 | return CaseStyle.camelCase 36 | if CaseStyle.snake_case in style: 37 | if "cap" in style: 38 | return CaseStyle.CAP_SNAKE_CASE 39 | return CaseStyle.snake_case 40 | if CaseStyle.kebab_case in style: 41 | if "cap" in style: 42 | return CaseStyle.CAP_KEBAB_CASE 43 | return CaseStyle.kebab_case 44 | if CaseStyle.PascalCase in style: 45 | return CaseStyle.PascalCase 46 | # guess val 47 | ans = "".join(filter(str.isalnum, style)) 48 | if "_" in style: 49 | if ans.isupper(): 50 | return CaseStyle.CAP_SNAKE_CASE 51 | return CaseStyle.snake_case 52 | if "-" in style: 53 | if ans.isupper(): 54 | return CaseStyle.CAP_KEBAB_CASE 55 | return CaseStyle.kebab_case 56 | if ans.isupper(): 57 | return CaseStyle.CAP_SNAKE_CASE 58 | if ans.islower(): 59 | return CaseStyle.snake_case 60 | if ans[0].islower(): 61 | return CaseStyle.camelCase 62 | return CaseStyle.PascalCase 63 | 64 | def __init__(self, generator: Union[str, Callable], allow_conflict: bool = False): 65 | self.generator = self.guess_style(generator) 66 | if not self.generator: 67 | raise ValueError(f"Invalid case style: {generator}") 68 | self.allow_conflict = allow_conflict 69 | self.func = None 70 | if callable(self.generator): 71 | self.func = self.generator 72 | elif isinstance(self.generator, str): 73 | self.func = getattr(self.__class__, self.generator, None) 74 | if not self.func: 75 | raise ValueError(f"Invalid case style: {generator}") 76 | 77 | def __call__(self, data): 78 | if not self.generator or not data: 79 | return data 80 | if multi(data): 81 | return [self(d) for d in data] 82 | elif isinstance(data, dict): 83 | result = {} 84 | if data.get("@"): 85 | return data 86 | for key, val in data.items(): 87 | k = self(key) 88 | if not self.allow_conflict and k in result: 89 | raise ValueError(f"Duplicate data key: {k}") 90 | if multi(k) and k: 91 | k = list(k)[0] 92 | if not isinstance(k, str): 93 | # invalid key, ignore 94 | continue 95 | result[k] = val 96 | return result 97 | elif isinstance(data, str): 98 | try: 99 | return self.func(data) 100 | except Exception as e: 101 | warnings.warn( 102 | f"apply field transformer failed with error: {e}, ignoring..." 103 | ) 104 | return data 105 | return data 106 | 107 | @classmethod 108 | def pascal(cls, val: str): 109 | if not val: 110 | return "" 111 | split = None 112 | if "_" in val: 113 | # guess type: snake / cap_snake 114 | split = "_" 115 | elif "-" in val: 116 | split = "-" 117 | if split: 118 | val = "".join(word.capitalize() for word in val.split(split)) 119 | if not val.isalnum(): 120 | return val 121 | # val = ''.join(filter(str.isalnum, val)) 122 | if split: 123 | return val 124 | if val.islower(): 125 | return val.capitalize() 126 | if val.isupper(): 127 | return val.capitalize() 128 | if val[0].islower(): 129 | # guess is PascalCase 130 | return val[0].upper() + val[1:] 131 | return val 132 | 133 | @classmethod 134 | def snake(cls, val: str): 135 | if not val: 136 | return "" 137 | if "_" in val: 138 | # guess type: snake / cap_snake 139 | return val.lower() 140 | elif "-" in val: 141 | return val.replace("-", "_").lower() 142 | 143 | if not val.isalnum(): 144 | return val 145 | 146 | # val = ''.join(filter(str.isalnum, val)) 147 | 148 | if val.islower(): 149 | return val 150 | if val.isupper(): 151 | return val.lower() 152 | 153 | s = "" 154 | for i, c in enumerate(val): 155 | if c.isupper(): 156 | if i: 157 | # not for first upper case 158 | s += "_" 159 | c = c.lower() 160 | s += c 161 | 162 | return s 163 | 164 | @classmethod 165 | def camel(cls, val: str): 166 | val = cls.pascal(val) 167 | return val[0].lower() + val[1:] 168 | 169 | @classmethod 170 | def cap_snake(cls, val: str): 171 | return cls.snake(val).upper() 172 | 173 | @classmethod 174 | def kebab(cls, val: str): 175 | return cls.snake(val).replace("_", "-") 176 | 177 | @classmethod 178 | def cap_kebab(cls, val: str): 179 | return cls.cap_snake(val).replace("_", "-") 180 | 181 | @classmethod 182 | def generate_aliases( 183 | cls, val: str, generator: Union[str, Callable, List[str], bool] = "*" 184 | ): 185 | if not generator: 186 | return [] 187 | if generator == "*" or generator is True: 188 | generator = CASE_STYLES 189 | elif not multi(generator): 190 | generator = [generator] 191 | aliases = [] 192 | 193 | def _validator(v): 194 | return isinstance(v, str) and v and v != val 195 | 196 | for g in generator: 197 | res = cls(generator=g)(val) 198 | if multi(res): 199 | for r in res: 200 | if _validator(r): 201 | aliases.append(r) 202 | elif _validator(res): 203 | aliases.append(res) 204 | return aliases 205 | --------------------------------------------------------------------------------