├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── jsonschema_typed ├── __init__.py ├── optional_typed_dict.py ├── plugin.py ├── py.typed └── types.py ├── mypy.ini ├── publish.sh ├── schema ├── check_required.json ├── draft7.json ├── hard.json ├── nested.json ├── nonetype.json ├── outer_array.json ├── readme_example.json └── tuple.json ├── setup.py ├── tests ├── cases │ ├── alias.py │ ├── check_required.py │ ├── from_readme.py │ ├── hard.py │ ├── nested.py │ ├── nonetype.py │ ├── optional_typed_dict.py │ ├── optional_typed_dict_hard_mode.py │ ├── outer_array.py │ └── tuple.py └── test_run_mypy.py └── version.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | # Editor files & misc 104 | *~ 105 | *.idea 106 | *.vscode 107 | default.nix 108 | *.db 109 | *#* 110 | *.#* 111 | 112 | .DS_Store 113 | 114 | Pipfile* 115 | main.py 116 | parse.py 117 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: check-yaml 6 | - id: check-toml 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | - id: pretty-format-json 10 | args: [--autofix, --no-sort-keys] 11 | - id: check-builtin-literals 12 | - id: check-docstring-first 13 | - id: check-merge-conflict 14 | - id: mixed-line-ending 15 | args: [--fix=lf] 16 | - repo: https://github.com/ambv/black 17 | rev: stable 18 | hooks: 19 | - id: black 20 | language_version: python3.7 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Erick Peirson 4 | Copyright (c) 2019-2020 Inspera AS 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI version](https://img.shields.io/pypi/v/jsonschema-typed-v2.svg?logo=pypi&logoColor=FFE873)](https://pypi.org/project/jsonschema-typed-v2/) 2 | [![Python version](https://img.shields.io/pypi/pyversions/jsonschema-typed-v2)](https://pypi.org/project/jsonschema-typed-v2/) 3 | [![PyPI downloads](https://img.shields.io/pypi/dm/jsonschema-typed-v2)](https://pypistats.org/packages/jsonschema-typed-v2) 4 | [![License](https://img.shields.io/pypi/l/jsonschema-typed-v2)](LICENSE) 5 | [![Code style: Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 6 | 7 | # JSON Schema-powered type annotations 8 | 9 | This package provides a way to automatically produce type annotations based 10 | on [`jsonschema`-schemas](https://json-schema.org). 11 | 12 | Not all concepts covered by `jsonschema` are expressible within Python typing annotations. However, most things 13 | will work like you'd expect. Most types are translated trivially 14 | (`integer`, `number`, `string`, `array`, `boolean` and `null`). 15 | The interesting type is `object`, which is translated into a [``TypedDict``](https://www.python.org/dev/peps/pep-0589/). 16 | 17 | **Warning:** This is based on the [mypy plugin system](https://mypy.readthedocs.io/en/latest/extending_mypy.html), which 18 | is stated to have no backwards compatibility guarantee. New versions of mypy might not be supported immediately. 19 | 20 | **Note**: This is a maintained fork of [erickpeirson](https://github.com/erickpeirson/jsonschema-typed)'s original start 21 | on this project. The original repo seems to be abandoned and its current state is not functional. *Make sure to install 22 | the right package from PyPI, `jsonschema-typed-v2`* 23 | 24 | ## Example 25 | 26 | A JSON schema: 27 | 28 | ```json 29 | { 30 | "$schema": "http://json-schema.org/draft-07/schema#", 31 | "$id": "http://foo.qwerty/some/schema#", 32 | "title": "Foo Schema", 33 | "type": "object", 34 | "properties": { 35 | "title": { 36 | "type": "string" 37 | }, 38 | "awesome": { 39 | "type": "number" 40 | } 41 | }, 42 | "required": ["title"] 43 | } 44 | ``` 45 | 46 | A TypedDict: 47 | 48 | ```python 49 | 50 | from typing import TYPE_CHECKING 51 | from jsonschema_typed import JSONSchema 52 | 53 | data: JSONSchema["path/to/schema.json"] = {"title": "baz"} 54 | 55 | if TYPE_CHECKING: 56 | reveal_type(data) # Revealed type is 'TypedDict('FooSchema', {'title': builtins.str, 57 | # 'awesome'?: Union[builtins.int, builtins.float]})' 58 | data["description"] = "there is no description" # TypedDict "FooSchema" has no key 'description' 59 | data["awesome"] = 42 60 | data["awesome"] = None # Argument 2 has incompatible type "None"; expected "Union[int, float]" 61 | ``` 62 | 63 | You can also get types of parts of a schema, as well as types of elements in arrays. Take a look at the 64 | [test cases](tests/cases) for more examples of usage. 65 | 66 | ## Installation 67 | 68 | ```bash 69 | pip install jsonschema-typed-v2 70 | ``` 71 | 72 | You also need to enable the plugin(s) in your `mypy.ini` configuration file: 73 | 74 | ```toml 75 | # mypy.ini 76 | [mypy] 77 | plugins = jsonschema_typed.plugin, jsonschema_typed.optional_typed_dict 78 | 79 | # Due to a quirk of how these type hints are generated, mypy's caching breaks. 80 | # Disabling caching might be required. 81 | cache_dir = /dev/null 82 | ``` 83 | 84 | ## Requirements 85 | 86 | The above installations resolves the dependencies, which consist of `mypy` and `jsonschema` (naturally). 87 | Testing has been done with versions: 88 | 89 | - mypy==0.761 90 | - jsonschema==3.2.0 91 | 92 | Probably some older versions will also work. Report an [issue](https://github.com/inspera/jsonschema-typed/issues) 93 | if you need other versions. 94 | 95 | ## Limitations 96 | 97 | - `additionalProperties` doesn't really have an equivalent in TypedDict. 98 | - The ``default`` keyword is not supported; but see: https://github.com/python/mypy/issues/6131. 99 | - Self-references (e.g. ``"#"``) can't really work properly until nested 100 | forward-references are supported; see: https://github.com/python/mypy/issues/731. 101 | -------------------------------------------------------------------------------- /jsonschema_typed/__init__.py: -------------------------------------------------------------------------------- 1 | """This space intentionally left blank.""" 2 | 3 | 4 | class JSONSchema(dict): 5 | """Placeholder for JSON schema TypedDict.""" 6 | 7 | def __class_getitem__(cls, item): 8 | return dict 9 | 10 | 11 | class OptionalTypedDict(dict): 12 | """Placeholder for wrapper around JSON schema TypedDicts to make their attributes optional.""" 13 | 14 | def __class_getitem__(cls, item): 15 | return dict 16 | 17 | 18 | dummy_path = "schema/nested.json" 19 | 20 | 21 | class Awesome: 22 | key = "awesome" 23 | 24 | 25 | nested = "nested" 26 | -------------------------------------------------------------------------------- /jsonschema_typed/optional_typed_dict.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Union, Any 3 | 4 | from mypy.plugin import Plugin, AnalyzeTypeContext 5 | from mypy.types import TypedDictType 6 | import warnings 7 | 8 | # Raise issues here. 9 | ISSUE_URL = "https://github.com/inspera/jsonschema-typed" 10 | 11 | 12 | class OptionalTypedDictPlugin(Plugin): 13 | 14 | OptionalTypedDict = "jsonschema_typed.OptionalTypedDict" 15 | 16 | def get_type_analyze_hook(self, fullname: str): 17 | if fullname == self.OptionalTypedDict: 18 | 19 | def callback(ctx: AnalyzeTypeContext) -> Union[Any, TypedDictType]: 20 | """Generate annotations from a TypedDict with optional attributes.""" 21 | if not ctx.type.args: 22 | return ctx.type 23 | 24 | # First, get the TypedDict analyzed: 25 | typed_dict_type = ctx.api.analyze_type(ctx.type.args[0]) 26 | 27 | # Then remove its required keys. If this doesn't work, the type wasn't a typed dict. 28 | # If so, we just return back whatever the user gave us. 29 | try: 30 | try: 31 | typed_dict_type.required_keys = set() 32 | except AttributeError: 33 | typed_dict_type.alias.target.required_keys = set() 34 | except AttributeError: 35 | logging.error( 36 | "Failed making OptionalTypedDict: Argument in brackets is not a typed dict type.\n" 37 | f"Failed at line {ctx.context.line} column {ctx.context.column} (file not known).\n" 38 | "The argument in brackets will be used instead." 39 | ) 40 | 41 | return typed_dict_type 42 | 43 | return callback 44 | 45 | 46 | def plugin(version: str): 47 | """See `https://mypy.readthedocs.io/en/latest/extending_mypy.html`_.""" 48 | if float(version) < 0.7: 49 | warnings.warn( 50 | "This plugin not tested below mypy 0.710. But you can" 51 | f" test it and let us know at {ISSUE_URL}." 52 | ) 53 | return OptionalTypedDictPlugin 54 | -------------------------------------------------------------------------------- /jsonschema_typed/plugin.py: -------------------------------------------------------------------------------- 1 | """Provides :class:`.JSONSchemaPlugin`.""" 2 | 3 | import json 4 | import os 5 | import re 6 | import uuid 7 | import importlib 8 | import warnings 9 | from collections import OrderedDict 10 | from typing import Optional, Callable, Any, Union, List, Set, Dict 11 | from abc import abstractmethod 12 | 13 | from jsonschema import RefResolver # type: ignore 14 | from jsonschema.validators import _id_of as id_of # type: ignore 15 | from mypy.nodes import TypeInfo, ClassDef, Block, SymbolTable 16 | from mypy.plugin import Plugin, AnalyzeTypeContext, DynamicClassDefContext 17 | from mypy.types import ( 18 | TypedDictType, 19 | Instance, 20 | UnionType, 21 | LiteralType, 22 | AnyType, 23 | TypeOfAny, 24 | Type, 25 | NoneType, 26 | UnboundType, 27 | RawExpressionType, 28 | ) 29 | 30 | # Raise issues here. 31 | ISSUE_URL = "https://github.com/inspera/jsonschema-typed" 32 | 33 | 34 | # Monkey-patching `warnings.formatwarning`. 35 | def formatwarning(message, category, filepath, lineno, line=None): 36 | """Make the warnings a bit prettier.""" 37 | _, filename = os.path.split(filepath) 38 | return f"{filename}:{lineno} {category.__name__}: {message}\n" 39 | 40 | 41 | warnings.formatwarning = formatwarning 42 | 43 | 44 | class API: 45 | """Base class for JSON schema types API.""" 46 | 47 | # Custom None value, used to differentiate from other sources of None. 48 | NO_VALUE = "__jsonschema_typed_special_None__" + str(uuid.uuid4()) 49 | 50 | def __init__(self, resolver: RefResolver, outer_name: str) -> None: 51 | """Initialize with a resolver.""" 52 | self.resolver = resolver 53 | self.outer_name = outer_name 54 | 55 | def get_type_handler(self, schema_type: str) -> Callable: 56 | """Get a handler from this schema draft version.""" 57 | if schema_type.startswith("_"): 58 | raise AttributeError("No way friend") 59 | handler = getattr(self, schema_type, None) 60 | if handler is None: 61 | raise NotImplementedError( 62 | f"Type `{schema_type}` is not supported." 63 | " If you think that this is an error," 64 | f" say something at {ISSUE_URL}" 65 | ) 66 | return handler 67 | 68 | def get_type( 69 | self, ctx: AnalyzeTypeContext, schema: Dict[str, Any], outer=False 70 | ) -> Type: 71 | """Get a :class:`.Type` for a JSON schema.""" 72 | scope = id_of(schema) 73 | if scope: 74 | self.resolver.push_scope(scope) 75 | 76 | # 6.1.1. type 77 | # The value of this keyword MUST be either a string or an array. If it 78 | # is an array, elements of the array MUST be strings and MUST be 79 | # unique. 80 | # 81 | # String values MUST be one of the six primitive types ("null", 82 | # "boolean", "object", "array", "number", or "string"), or "integer" 83 | # which matches any number with a zero fractional part. 84 | # 85 | # An instance validates if and only if the instance is in any of the 86 | # sets listed for this keyword. 87 | schema_type = schema.get("type") 88 | if isinstance(schema_type, list): 89 | if outer: 90 | # Cases in which the root of the schema is anything other than 91 | # an object are not terribly interesting for this project, so 92 | # we'll ignore them for now. 93 | if "object" not in schema_type: 94 | raise NotImplementedError( 95 | "Schemas with a root type other than ``object`` are" 96 | " not currently supported." 97 | ) 98 | warnings.warn( 99 | f"Root type is an array, which is out of scope" 100 | " for this library. Falling back to `object`. If" 101 | " you believe this to be in error, say so at" 102 | f" {ISSUE_URL}" 103 | ) 104 | schema_type = "object" 105 | else: 106 | return UnionType( 107 | [ 108 | self._get_type(ctx, schema, primitive_type, outer=outer) 109 | for primitive_type in schema_type 110 | ] 111 | ) 112 | elif schema_type is None: 113 | if "$ref" in schema: 114 | return self.ref(ctx, schema["$ref"]) 115 | elif "allOf" in schema: 116 | return self.allOf(ctx, schema["allOf"]) 117 | elif "anyOf" in schema: 118 | return self.anyOf(ctx, schema["anyOf"]) 119 | elif "oneOf" in schema: 120 | return self.anyOf(ctx, schema["oneOf"]) 121 | elif "enum" in schema: 122 | return self.enum(ctx, schema["enum"]) 123 | elif "default" in schema: 124 | return self.default(ctx, schema["default"]) 125 | if scope: 126 | self.resolver.pop_scope() 127 | 128 | assert isinstance(schema_type, str), ( 129 | f"Expected to find a supported schema type, got {schema_type}" 130 | f"\nDuring parsing of {schema}" 131 | ) 132 | 133 | return self._get_type(ctx, schema, schema_type, outer=outer) 134 | 135 | def _get_type( 136 | self, 137 | ctx: AnalyzeTypeContext, 138 | schema: Dict[str, Any], 139 | schema_type: str, 140 | outer=False, 141 | ) -> Type: 142 | 143 | # Enums get special treatment, as they should be one of the literal values. 144 | # Note: If a "type" field indicates types that are incompatible with some of 145 | # the enumeration values (which is allowed by jsonschema), the "type" will _not_ 146 | # be respected. This should be considered a malformed schema anyway, so this 147 | # will not be fixed. 148 | if "enum" in schema: 149 | handler = self.get_type_handler("enum") 150 | return handler(ctx, schema["enum"]) 151 | 152 | handler = self.get_type_handler(schema_type) 153 | if handler is not None: 154 | return handler(ctx, schema, outer=outer) 155 | 156 | warnings.warn( 157 | f"No handler for `{schema_type}`; please raise an issue" 158 | f" at {ISSUE_URL} if you believe this to be in error" 159 | ) 160 | return AnyType(TypeOfAny.unannotated) 161 | 162 | @abstractmethod 163 | def ref(self, *args, **kwargs): 164 | pass 165 | 166 | @abstractmethod 167 | def allOf(self, ctx: AnalyzeTypeContext, subschema: List, **kwargs) -> UnionType: 168 | pass 169 | 170 | @abstractmethod 171 | def anyOf(self, ctx: AnalyzeTypeContext, subschema: List, **kwargs) -> UnionType: 172 | pass 173 | 174 | @abstractmethod 175 | def enum( 176 | self, ctx: AnalyzeTypeContext, values: List[Union[str, int]], *_, **kwargs 177 | ) -> UnionType: 178 | pass 179 | 180 | @abstractmethod 181 | def default(self, *args, **kwargs): 182 | pass 183 | 184 | 185 | class APIv4(API): 186 | """JSON Schema draft 4.""" 187 | 188 | def const( 189 | self, ctx: AnalyzeTypeContext, const: Union[int, str, bool], *_, **__ 190 | ) -> LiteralType: 191 | """Generate a ``Literal`` for a const value.""" 192 | name = type(const).__name__ 193 | return LiteralType(const, named_builtin_type(ctx, name, [])) 194 | 195 | def enum( 196 | self, ctx: AnalyzeTypeContext, values: List[Union[str, int]], *_, **kwargs 197 | ) -> UnionType: 198 | """Generate a ``Union`` of ``Literal``s for an enum.""" 199 | return UnionType([self.const(ctx, value) for value in values]) 200 | 201 | def boolean(self, ctx: AnalyzeTypeContext, schema, **kwargs): 202 | """Generate a ``bool`` annotation for a boolean object.""" 203 | if "properties" in schema: 204 | default = schema["properties"].get("default", self.NO_VALUE) 205 | if default != self.NO_VALUE: 206 | warnings.warn( 207 | "`default` keyword not supported; but see: " 208 | "https://github.com/python/mypy/issues/6131" 209 | ) 210 | const = schema["properties"].get("const", self.NO_VALUE) 211 | if const != self.NO_VALUE: 212 | return self.const(ctx, const) 213 | return named_builtin_type(ctx, "bool", []) 214 | 215 | def object( 216 | self, 217 | ctx: AnalyzeTypeContext, 218 | schema: Dict[str, Any], 219 | outer: bool = False, 220 | **kwargs, 221 | ) -> Type: 222 | """Generate an annotation for an object, usually a TypedDict.""" 223 | properties = schema.get("properties") 224 | 225 | if properties is None: 226 | return named_builtin_type(ctx, "dict") 227 | 228 | try: 229 | fallback = ctx.api.named_type("mypy_extensions._TypedDict", []) 230 | except AssertionError: 231 | fallback = named_builtin_type(ctx, "dict", []) 232 | items, types = zip( 233 | *filter( 234 | lambda o: o[1] is not None, 235 | [ 236 | (prop, self.get_type(ctx, subschema)) 237 | for prop, subschema in properties.items() 238 | if prop not in ["default", "const"] # These are reserved names, 239 | # not properties. 240 | ], 241 | ) 242 | ) 243 | required_keys = set(schema.get("required", [])) 244 | 245 | if outer: 246 | # We want to name the outer Type, so that we can support nested 247 | # references. Note that this may not be fully supported in mypy 248 | # at this time. 249 | info = self._build_typeddict_typeinfo( 250 | ctx, self.outer_name, list(items), list(types), required_keys 251 | ) 252 | instance = Instance(info, []) 253 | td = info.typeddict_type 254 | assert td is not None 255 | typing_type = td.copy_modified( 256 | item_types=list(td.items.values()), fallback=instance 257 | ) 258 | # # Resolve any forward (nested) references to this Type. 259 | # if self.forward_refs: 260 | # 261 | # for fw in self.forward_refs: 262 | # fw.resolve(typing_type) 263 | return typing_type 264 | 265 | struct = OrderedDict(zip(items, types)) 266 | return TypedDictType(struct, required_keys, fallback) 267 | 268 | def array( 269 | self, ctx: AnalyzeTypeContext, schema: Optional[Union[bool, dict]], **kwargs 270 | ) -> Type: 271 | """Generate a ``List[]`` annotation with the allowed types.""" 272 | items = schema.get("items") 273 | if items is True: 274 | inner_types = [AnyType(TypeOfAny.unannotated)] 275 | elif items is False: 276 | raise NotImplementedError('"items": false is not supported') 277 | elif isinstance(items, list): 278 | # https://json-schema.org/understanding-json-schema/reference/array.html#tuple-validation 279 | if {schema.get("minItems"), schema.get("maxItems")} - {None, len(items)}: 280 | raise NotImplementedError( 281 | '"items": If list, must have minItems == maxItems' 282 | ) 283 | inner_types = [self.get_type(ctx, item) for item in items] 284 | return ctx.api.tuple_type(inner_types) 285 | elif items is not None: 286 | inner_types = [self.get_type(ctx, items)] 287 | else: 288 | inner_types = [] 289 | return named_builtin_type(ctx, "list", inner_types) 290 | 291 | def allOf( 292 | self, ctx: AnalyzeTypeContext, subschema: List[dict], **kwargs 293 | ) -> UnionType: 294 | """ 295 | Generate a ``Union`` annotation with the allowed types. 296 | 297 | Unfortunately PEP 544 currently does not support an Intersection type; 298 | see `this issue `_ for 299 | some context. 300 | """ 301 | warnings.warn( 302 | "PEP 544 does not support an Intersection type, so " 303 | " `allOf` is interpreted as a `Union` for now; see" 304 | " https://github.com/python/typing/issues/213" 305 | ) 306 | return UnionType( 307 | list( 308 | filter( 309 | lambda o: o is not None, 310 | [self.get_type(ctx, subs) for subs in subschema], 311 | ) 312 | ) 313 | ) 314 | 315 | def anyOf(self, ctx: AnalyzeTypeContext, subschema: List, **kwargs) -> UnionType: 316 | """Generate a ``Union`` annotation with the allowed types.""" 317 | return UnionType( 318 | list( 319 | filter( 320 | lambda o: o is not None, 321 | [self.get_type(ctx, subs) for subs in subschema], 322 | ) 323 | ) 324 | ) 325 | 326 | def any(self, ctx: AnalyzeTypeContext, subschema, **kwargs): 327 | """Generate an ``Any`` annotation.""" 328 | return AnyType(TypeOfAny.unannotated) 329 | 330 | def ref(self, ctx: AnalyzeTypeContext, ref: str, **kwargs): 331 | """Handle a `$ref`.""" 332 | if ref == "#": # Self ref. 333 | # Per @ilevkivskyi: 334 | # 335 | # > You should never use ForwardRef manually 336 | # > Also it is deprecated and will be removed soon 337 | # > Support for recursive types is limited to proper classes 338 | # > currently 339 | # 340 | # forward_ref = ForwardRef(UnboundType(self.outer_name)) 341 | # self.forward_refs.append(forward_ref) 342 | # return forward_ref 343 | warnings.warn( 344 | "Forward references may not be supported; see" 345 | " https://github.com/python/mypy/issues/731" 346 | ) 347 | return self.object(ctx, {}) 348 | resolve = getattr(self.resolver, "resolve", None) 349 | if resolve is None: 350 | with self.resolver.resolving(ref) as resolved: 351 | return self.get_type(ctx, resolved) 352 | else: 353 | scope, resolved = self.resolver.resolve(ref) 354 | self.resolver.push_scope(scope) 355 | try: 356 | return self.get_type(ctx, resolved) 357 | finally: 358 | self.resolver.pop_scope() 359 | 360 | def string(self, ctx: AnalyzeTypeContext, *args, **kwargs): 361 | """Generate a ``str`` annotation.""" 362 | return named_builtin_type(ctx, "str") 363 | 364 | def number(self, ctx: AnalyzeTypeContext, *args, **kwargs): 365 | """Generate a ``Union[int, float]`` annotation.""" 366 | return UnionType( 367 | [named_builtin_type(ctx, "int"), named_builtin_type(ctx, "float")] 368 | ) 369 | 370 | def integer(self, ctx: AnalyzeTypeContext, *args, **kwargs): 371 | """Generate an ``int`` annotation.""" 372 | return named_builtin_type(ctx, "int") 373 | 374 | def null(self, ctx: AnalyzeTypeContext, *args, **kwargs): 375 | """Generate an ``int`` annotation.""" 376 | return NoneType() 377 | 378 | def default(self, ctx: AnalyzeTypeContext, *args, **kwargs) -> None: 379 | """ 380 | The ``default`` keyword is not supported. 381 | 382 | But see: `https://github.com/python/mypy/issues/6131`_. 383 | """ 384 | warnings.warn( 385 | "`default` keyword not supported; but see: " 386 | "https://github.com/python/mypy/issues/6131" 387 | ) 388 | return None 389 | 390 | def _basic_new_typeinfo( 391 | self, ctx: AnalyzeTypeContext, name: str, basetype_or_fallback: Instance 392 | ) -> TypeInfo: 393 | """ 394 | Build a basic :class:`.TypeInfo`. 395 | 396 | This was basically lifted from ``mypy.semanal``. 397 | """ 398 | class_def = ClassDef(name, Block([])) 399 | class_def.fullname = name 400 | 401 | info = TypeInfo(SymbolTable(), class_def, "") 402 | class_def.info = info 403 | mro = basetype_or_fallback.type.mro 404 | if not mro: 405 | mro = [basetype_or_fallback.type, named_builtin_type(ctx, "object").type] 406 | info.mro = [info] + mro 407 | info.bases = [basetype_or_fallback] 408 | return info 409 | 410 | def _build_typeddict_typeinfo( 411 | self, 412 | ctx: AnalyzeTypeContext, 413 | name: str, 414 | items: List[str], 415 | types: List[Type], 416 | required_keys: Set[str], 417 | ) -> TypeInfo: 418 | """ 419 | Build a :class:`.TypeInfo` for a TypedDict. 420 | 421 | This was basically lifted from ``mypy.semanal_typeddict``. 422 | """ 423 | try: 424 | fallback = ctx.api.named_type("mypy_extensions._TypedDict", []) 425 | except AssertionError: 426 | fallback = named_builtin_type(ctx, "dict") 427 | info = self._basic_new_typeinfo(ctx, name, fallback) 428 | info.typeddict_type = TypedDictType( 429 | OrderedDict(zip(items, types)), required_keys, fallback 430 | ) 431 | return info 432 | 433 | 434 | class APIv6(APIv4): 435 | """JSON Schema draft 6.""" 436 | 437 | def integer(self, ctx: AnalyzeTypeContext, *args, **kwargs) -> UnionType: 438 | """Generate a ``Union`` annotation for an integer.""" 439 | return UnionType( 440 | [named_builtin_type(ctx, "int"), named_builtin_type(ctx, "float")] 441 | ) 442 | 443 | 444 | class APIv7(APIv6): 445 | """JSON Schema draft 7.""" 446 | 447 | 448 | class JSONSchemaPlugin(Plugin): 449 | """Provides support for the JSON Schema as TypedDict.""" 450 | 451 | JSONSchema = "jsonschema_typed.JSONSchema" 452 | 453 | @staticmethod 454 | def make_subschema(schema: Dict[str, Any], key_path: List[str]): 455 | """ 456 | Extract a property from the schema (changing in place). 457 | 458 | If `obj` is a structure that validates against the given schema, the `key_path` 459 | is interpreted as `obj[key_path[0]][key_path[1]]...[key_path[-1]]`. 460 | 461 | This will effectively make a schema where the indexed object is 462 | the the type being described. 463 | """ 464 | 465 | # Only inherit the require things, all other info is dropped. 466 | # E.g. things such as `required` do not affect schemas for sub-objects. 467 | for k in list(schema.keys()): 468 | if k not in ("$schema", "$id", "title", "type", "properties", "items"): 469 | del schema[k] 470 | 471 | for key in key_path: 472 | 473 | if schema.get("type") == "array": 474 | while "items" in schema: 475 | items = schema["items"] 476 | del schema["items"] 477 | schema.update(items) 478 | 479 | if schema["type"] != "object": 480 | return schema 481 | 482 | assert schema.get("type") == "object", ( 483 | "Attempted to build a schema type from a non-object type." 484 | "The base type must be 'object' (aka. a dict)." 485 | ) 486 | assert ( 487 | "properties" in schema 488 | ), "Schema has no properties, cannot make a sub-schema." 489 | assert ( 490 | key == "#" or key in schema["properties"] 491 | ), "Invalid key path for sub-schema." 492 | 493 | if key != "#": 494 | schema.update(schema["properties"][key]) 495 | 496 | if "$id" in schema: 497 | schema["$id"] += f"/{key}" 498 | if "title" in schema: 499 | schema["title"] += f" {key}" 500 | 501 | if "properties" in schema and schema["type"] != "object": 502 | del schema["properties"] 503 | 504 | return schema 505 | 506 | @staticmethod 507 | def resolve_var(value: Union[RawExpressionType, UnboundType]): 508 | var: str 509 | if isinstance(value, RawExpressionType): 510 | var = value.literal_value 511 | else: 512 | var = value.original_str_expr 513 | 514 | if not var.startswith("var:"): 515 | return var 516 | 517 | _, path, variable = var.split(":") 518 | module = importlib.import_module(path) 519 | return eval(f"module.{variable}") 520 | 521 | def get_type_analyze_hook(self, fullname: str) -> Optional[Callable]: 522 | """Produce an analyzer callback if a JSONSchema annotation is found.""" 523 | if fullname == self.JSONSchema: 524 | 525 | def callback(ctx: AnalyzeTypeContext) -> TypedDictType: 526 | """Generate annotations from a JSON Schema.""" 527 | if not ctx.type.args: 528 | return ctx.type 529 | schema_path, *key_path = list(map(self.resolve_var, ctx.type.args)) 530 | 531 | schema_path = os.path.abspath(schema_path) 532 | schema = self._load_schema(schema_path) 533 | 534 | if key_path: 535 | schema = self.make_subschema(schema, key_path) 536 | 537 | draft_version = schema.get("$schema", "default") 538 | api_version = { 539 | "draft-04": APIv4, 540 | "draft-06": APIv6, 541 | "draft-07": APIv7, 542 | }.get(next(re.finditer(r"draft-\d+", draft_version)).group(), APIv7) 543 | 544 | make_type = TypeMaker(schema_path, schema, api_version=api_version) 545 | _type = make_type(ctx) 546 | return _type 547 | 548 | return callback 549 | 550 | def _load_schema(self, path: str) -> dict: 551 | with open(path) as f: 552 | return json.load(f) 553 | 554 | def get_additional_deps(self, file): 555 | """Add ``mypy_extensions`` as a dependency.""" 556 | return [(10, "mypy_extensions", -1)] 557 | 558 | 559 | class TypeMaker: 560 | r"""Makes :class:`.Type`\s from a JSON schema.""" 561 | 562 | def __init__( 563 | self, schema_path: str, schema: Dict[str, Any], api_version: type = APIv7 564 | ) -> None: 565 | """Set up a resolver and API instance.""" 566 | self.schema = schema 567 | self.outer_name = self._sanitize_name(self.schema.get("title", "JSONSchema")) 568 | self.schema_path = schema_path 569 | self.resolver = RefResolver.from_schema(schema, id_of=id_of) 570 | self.forward_refs = [] 571 | self.api = api_version(self.resolver, self.outer_name) 572 | 573 | def _sanitize_name(self, name: str) -> str: 574 | return name.replace("-", " ").title().replace(" ", "") 575 | 576 | def __call__(self, ctx: AnalyzeTypeContext) -> Type: 577 | """Generate the appropriate types for this schema.""" 578 | return self.api.get_type(ctx, self.schema, outer=True) 579 | 580 | 581 | def plugin(version: str): 582 | """See `https://mypy.readthedocs.io/en/latest/extending_mypy.html`_.""" 583 | if float(version) < 0.7: 584 | warnings.warn( 585 | "This plugin not tested below mypy 0.710. But you can" 586 | f" test it and let us know at {ISSUE_URL}." 587 | ) 588 | return JSONSchemaPlugin 589 | 590 | 591 | def named_builtin_type(ctx: AnalyzeTypeContext, name: str, *args, **kwargs) -> Instance: 592 | assert type(ctx) is AnalyzeTypeContext 593 | mod = "builtins" 594 | return ctx.api.named_type(f"{mod}.{name}", *args, **kwargs) 595 | -------------------------------------------------------------------------------- /jsonschema_typed/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inspera/jsonschema-typed/54d2f8c250fdba14422e44545c7cd1254218045e/jsonschema_typed/py.typed -------------------------------------------------------------------------------- /jsonschema_typed/types.py: -------------------------------------------------------------------------------- 1 | """ 2 | The jsonschema_typed library provides a single type definition, `JSONSchema`. 3 | 4 | Please note that at runtime, this type is only an alias to `typing.Type`, and 5 | does not hold any relevant properties or information. The type has special behaviour 6 | only to mypy after the plugin has been installed and enabled. This means that the type 7 | should not be used in any context other than for annotations. 8 | """ 9 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = jsonschema_typed.plugin, jsonschema_typed.optional_typed_dict 3 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | version="$(git describe --tags)" 3 | 4 | 5 | echo "You are about to publish version ${version} (based on most recent git tag). Continue?" 6 | select yn in "Yes" "No"; do 7 | case $yn in 8 | Yes ) break;; 9 | No ) exit;; 10 | esac 11 | done 12 | 13 | 14 | python setup.py bdist_wheel 15 | twine upload dist/* 16 | -------------------------------------------------------------------------------- /schema/check_required.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "$id": "http://foo.qwerty/some/schema#", 4 | "title": "Foo Schema", 5 | "type": "object", 6 | "properties": { 7 | "title": { 8 | "type": "string" 9 | }, 10 | "awesome": { 11 | "type": "integer" 12 | } 13 | }, 14 | "required": [ 15 | "awesome" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /schema/draft7.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "http://json-schema.org/draft-07/schema#", 4 | "title": "Core schema meta-schema", 5 | "definitions": { 6 | "schemaArray": { 7 | "type": "array", 8 | "minItems": 1, 9 | "items": { 10 | "$ref": "#" 11 | } 12 | }, 13 | "nonNegativeInteger": { 14 | "type": "integer", 15 | "minimum": 0 16 | }, 17 | "nonNegativeIntegerDefault0": { 18 | "allOf": [ 19 | { 20 | "$ref": "#/definitions/nonNegativeInteger" 21 | }, 22 | { 23 | "default": 0 24 | } 25 | ] 26 | }, 27 | "simpleTypes": { 28 | "enum": [ 29 | "array", 30 | "boolean", 31 | "integer", 32 | "null", 33 | "number", 34 | "object", 35 | "string" 36 | ] 37 | }, 38 | "stringArray": { 39 | "type": "array", 40 | "items": { 41 | "type": "string" 42 | }, 43 | "uniqueItems": true, 44 | "default": [] 45 | } 46 | }, 47 | "type": [ 48 | "object", 49 | "boolean" 50 | ], 51 | "properties": { 52 | "$id": { 53 | "type": "string", 54 | "format": "uri-reference" 55 | }, 56 | "$schema": { 57 | "type": "string", 58 | "format": "uri" 59 | }, 60 | "$ref": { 61 | "type": "string", 62 | "format": "uri-reference" 63 | }, 64 | "$comment": { 65 | "type": "string" 66 | }, 67 | "title": { 68 | "type": "string" 69 | }, 70 | "description": { 71 | "type": "string" 72 | }, 73 | "default": true, 74 | "readOnly": { 75 | "type": "boolean", 76 | "default": false 77 | }, 78 | "examples": { 79 | "type": "array", 80 | "items": true 81 | }, 82 | "multipleOf": { 83 | "type": "number", 84 | "exclusiveMinimum": 0 85 | }, 86 | "maximum": { 87 | "type": "number" 88 | }, 89 | "exclusiveMaximum": { 90 | "type": "number" 91 | }, 92 | "minimum": { 93 | "type": "number" 94 | }, 95 | "exclusiveMinimum": { 96 | "type": "number" 97 | }, 98 | "maxLength": { 99 | "$ref": "#/definitions/nonNegativeInteger" 100 | }, 101 | "minLength": { 102 | "$ref": "#/definitions/nonNegativeIntegerDefault0" 103 | }, 104 | "pattern": { 105 | "type": "string", 106 | "format": "regex" 107 | }, 108 | "additionalItems": { 109 | "$ref": "#" 110 | }, 111 | "items": { 112 | "anyOf": [ 113 | { 114 | "$ref": "#" 115 | }, 116 | { 117 | "$ref": "#/definitions/schemaArray" 118 | } 119 | ], 120 | "default": true 121 | }, 122 | "maxItems": { 123 | "$ref": "#/definitions/nonNegativeInteger" 124 | }, 125 | "minItems": { 126 | "$ref": "#/definitions/nonNegativeIntegerDefault0" 127 | }, 128 | "uniqueItems": { 129 | "type": "boolean", 130 | "default": false 131 | }, 132 | "contains": { 133 | "$ref": "#" 134 | }, 135 | "maxProperties": { 136 | "$ref": "#/definitions/nonNegativeInteger" 137 | }, 138 | "minProperties": { 139 | "$ref": "#/definitions/nonNegativeIntegerDefault0" 140 | }, 141 | "required": { 142 | "$ref": "#/definitions/stringArray" 143 | }, 144 | "additionalProperties": { 145 | "$ref": "#" 146 | }, 147 | "definitions": { 148 | "type": "object", 149 | "additionalProperties": { 150 | "$ref": "#" 151 | }, 152 | "default": {} 153 | }, 154 | "properties": { 155 | "type": "object", 156 | "additionalProperties": { 157 | "$ref": "#" 158 | }, 159 | "default": {} 160 | }, 161 | "patternProperties": { 162 | "type": "object", 163 | "additionalProperties": { 164 | "$ref": "#" 165 | }, 166 | "propertyNames": { 167 | "format": "regex" 168 | }, 169 | "default": {} 170 | }, 171 | "dependencies": { 172 | "type": "object", 173 | "additionalProperties": { 174 | "anyOf": [ 175 | { 176 | "$ref": "#" 177 | }, 178 | { 179 | "$ref": "#/definitions/stringArray" 180 | } 181 | ] 182 | } 183 | }, 184 | "propertyNames": { 185 | "$ref": "#" 186 | }, 187 | "const": true, 188 | "enum": { 189 | "type": "array", 190 | "items": true 191 | }, 192 | "type": { 193 | "anyOf": [ 194 | { 195 | "$ref": "#/definitions/simpleTypes" 196 | }, 197 | { 198 | "type": "array", 199 | "items": { 200 | "$ref": "#/definitions/simpleTypes" 201 | }, 202 | "minItems": 1, 203 | "uniqueItems": true 204 | } 205 | ] 206 | }, 207 | "format": { 208 | "type": "string" 209 | }, 210 | "contentMediaType": { 211 | "type": "string" 212 | }, 213 | "contentEncoding": { 214 | "type": "string" 215 | }, 216 | "if": { 217 | "$ref": "#" 218 | }, 219 | "then": { 220 | "$ref": "#" 221 | }, 222 | "else": { 223 | "$ref": "#" 224 | }, 225 | "allOf": { 226 | "$ref": "#/definitions/schemaArray" 227 | }, 228 | "anyOf": { 229 | "$ref": "#/definitions/schemaArray" 230 | }, 231 | "oneOf": { 232 | "$ref": "#/definitions/schemaArray" 233 | }, 234 | "not": { 235 | "$ref": "#" 236 | } 237 | }, 238 | "default": true 239 | } 240 | -------------------------------------------------------------------------------- /schema/hard.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "hard.json", 3 | "$schema": "http://json-schema.org/draft-04/schema#", 4 | "description": "Complicated JSON meant to test all kinds of things.", 5 | "title": "Complicated JSON", 6 | "type": "object", 7 | "properties": { 8 | "num": { 9 | "description": "a number", 10 | "type": "integer" 11 | }, 12 | "status": { 13 | "description": "List of return messages", 14 | "items": { 15 | "additionalProperties": false, 16 | "description": "Return messages metadata", 17 | "properties": { 18 | "code": { 19 | "description": "The return message code", 20 | "enum": [ 21 | "success", 22 | "failure" 23 | ], 24 | "type": "string" 25 | }, 26 | "diagnostics": { 27 | "description": "Detailed diagnostics for the status", 28 | "items": { 29 | "additionalProperties": false, 30 | "properties": { 31 | "field": { 32 | "description": "Offending field of the offending value", 33 | "type": "string" 34 | }, 35 | "illegal_value": { 36 | "description": "Offending value", 37 | "type": "string" 38 | }, 39 | "level": { 40 | "description": "Level of diagnostics (info, warn, error)", 41 | "enum": [ 42 | "info", 43 | "warn", 44 | "error" 45 | ], 46 | "type": "string" 47 | }, 48 | "mismatch_fields": { 49 | "description": "Offending incompatible fields (or field values)", 50 | "items": { 51 | "type": "string" 52 | }, 53 | "type": "array" 54 | }, 55 | "ids": { 56 | "description": "The IDs of the offending things", 57 | "items": { 58 | "additionalProperties": false, 59 | "properties": { 60 | "id": { 61 | "type": "integer" 62 | }, 63 | "thing_type": { 64 | "enum": [ 65 | "A", 66 | "B" 67 | ], 68 | "type": "string" 69 | } 70 | }, 71 | "required": [ 72 | "id" 73 | ], 74 | "type": "object" 75 | }, 76 | "type": "array" 77 | } 78 | }, 79 | "required": [ 80 | "level" 81 | ], 82 | "type": "object" 83 | }, 84 | "type": "array" 85 | }, 86 | "message": { 87 | "description": "The return message (human readable) associated with the return message code", 88 | "type": "string" 89 | }, 90 | "module": { 91 | "description": "Module where the status comes from", 92 | "enum": [ 93 | "m1", 94 | "m2" 95 | ], 96 | "type": "string" 97 | } 98 | }, 99 | "required": [ 100 | "module" 101 | ], 102 | "type": "object" 103 | }, 104 | "type": "array" 105 | } 106 | }, 107 | "required": [ 108 | "num", 109 | "status" 110 | ] 111 | } 112 | -------------------------------------------------------------------------------- /schema/nested.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "http://foo.qwerty/some/schema#", 3 | "$schema": "http://json-schema.org/draft-04/schema#", 4 | "title": "Nested Foo Schema", 5 | "type": "object", 6 | "properties": { 7 | "title": { 8 | "type": "string" 9 | }, 10 | "awesome": { 11 | "type": "object", 12 | "properties": { 13 | "nested": { 14 | "type": "object", 15 | "properties": { 16 | "thing": { 17 | "type": "string" 18 | } 19 | }, 20 | "required": [ 21 | "thing" 22 | ] 23 | }, 24 | "thing": { 25 | "type": "integer" 26 | } 27 | }, 28 | "required": [ 29 | "thing" 30 | ] 31 | } 32 | }, 33 | "required": [ 34 | "title" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /schema/nonetype.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "$id": "http://foo.qwerty/some/schema#", 4 | "title": "None Schema", 5 | "type": "object", 6 | "properties": { 7 | "title": { 8 | "type": "string" 9 | }, 10 | "awesome": { 11 | "type": [ 12 | "array", 13 | "null" 14 | ] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /schema/outer_array.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "ArrayOfObjects.json", 3 | "$schema": "http://json-schema.org/draft-04/schema#", 4 | "description": "An object inside an outer array", 5 | "items": { 6 | "properties": { 7 | "a_number": { 8 | "type": "integer" 9 | }, 10 | "a_string": { 11 | "type": "string" 12 | }, 13 | "nested_array_of_numbers": { 14 | "items": { 15 | "items": { 16 | "type": "number" 17 | }, 18 | "type": "array" 19 | }, 20 | "type": "array" 21 | } 22 | }, 23 | "required": [ 24 | "a_string" 25 | ], 26 | "type": "object" 27 | }, 28 | "title": "Array of Objects", 29 | "type": "array" 30 | } 31 | -------------------------------------------------------------------------------- /schema/readme_example.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "$id": "http://foo.qwerty/some/schema#", 4 | "title": "Foo Schema", 5 | "type": "object", 6 | "properties": { 7 | "title": { 8 | "type": "string" 9 | }, 10 | "awesome": { 11 | "type": "integer" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /schema/tuple.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "$id": "http://foo.qwerty/some/schema#", 4 | "title": "List of tuples", 5 | "type": "array", 6 | "items": { 7 | "type": [ 8 | "array", 9 | "null" 10 | ], 11 | "items": [ 12 | { 13 | "type": [ 14 | "string", 15 | "null" 16 | ] 17 | }, 18 | { 19 | "type": [ 20 | "string", 21 | "null" 22 | ] 23 | } 24 | ], 25 | "maxItems": 2, 26 | "minItems": 2 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Install jsonschema-typed.""" 2 | 3 | import os 4 | from setuptools import setup 5 | 6 | from version import get_version 7 | 8 | repo_base_dir = os.path.abspath(os.path.dirname(__file__)) 9 | 10 | with open(os.path.join(repo_base_dir, "README.md")) as f: 11 | description = f.read() 12 | 13 | setup( 14 | name="jsonschema-typed-v2", 15 | author="Bendik Samseth", 16 | author_email="b.samseth@gmail.com", 17 | url="https://github.com/bsamseth/jsonschema-typed", 18 | python_requires=">=3.7", # Really should have 3.8 for Final and Literal, but usable without. 19 | license="MIT", 20 | version=get_version(), 21 | packages=["jsonschema_typed"], 22 | package_data={"jsonschema_typed": ["py.typed"]}, 23 | zip_safe=False, 24 | install_requires=[ 25 | "jsonschema>=3.2.0", 26 | "mypy>=0.761", 27 | ], # Possibly not so strict, but don't want to check. 28 | description="Automatic type annotations from JSON schemas", 29 | long_description=description, 30 | long_description_content_type="text/markdown", 31 | classifiers=[ 32 | "Development Status :: 4 - Beta", 33 | "Environment :: Console", 34 | "Intended Audience :: Developers", 35 | "License :: OSI Approved :: MIT License", 36 | "Operating System :: OS Independent", 37 | "Programming Language :: Python", 38 | "Programming Language :: Python :: 3.7", 39 | "Programming Language :: Python :: 3.8", 40 | "Programming Language :: Python :: 3 :: Only", 41 | "Topic :: Software Development :: Quality Assurance", 42 | "Topic :: Software Development :: Code Generators", 43 | "Topic :: Software Development :: Testing", 44 | "Typing :: Typed", 45 | ], 46 | ) 47 | -------------------------------------------------------------------------------- /tests/cases/alias.py: -------------------------------------------------------------------------------- 1 | from jsonschema_typed import JSONSchema 2 | from typing import TYPE_CHECKING 3 | 4 | Schema = JSONSchema["schema/readme_example.json"] 5 | data: Schema = {"title": "baz"} 6 | if TYPE_CHECKING: 7 | reveal_type(data) 8 | data["description"] = "there is no description" 9 | data["awesome"] = 42 10 | data["awesome"] = None 11 | -------------------------------------------------------------------------------- /tests/cases/check_required.py: -------------------------------------------------------------------------------- 1 | from jsonschema_typed import JSONSchema 2 | from typing import TYPE_CHECKING 3 | 4 | data: JSONSchema["schema/check_required.json"] = {"title": "some title"} 5 | if TYPE_CHECKING: 6 | reveal_type(data) 7 | data["description"] = "there is no description" 8 | data["awesome"] = 42 9 | data["awesome"] = None 10 | -------------------------------------------------------------------------------- /tests/cases/from_readme.py: -------------------------------------------------------------------------------- 1 | from jsonschema_typed import JSONSchema 2 | from typing import TYPE_CHECKING 3 | 4 | data: JSONSchema["schema/readme_example.json"] = {"title": "baz"} 5 | if TYPE_CHECKING: 6 | reveal_type(data) 7 | data["description"] = "there is no description" 8 | data["awesome"] = 42 9 | data["awesome"] = None 10 | -------------------------------------------------------------------------------- /tests/cases/hard.py: -------------------------------------------------------------------------------- 1 | from jsonschema_typed import JSONSchema 2 | from typing import TYPE_CHECKING, Final, Literal, TypedDict, Type 3 | 4 | 5 | data: JSONSchema["schema/hard.json"] 6 | number: JSONSchema["schema/hard.json", "num"] 7 | status: JSONSchema["schema/hard.json", "status"] 8 | diagnostics_list: JSONSchema["schema/hard.json", "status", "diagnostics"] 9 | diagnostics: JSONSchema["schema/hard.json", "status", "diagnostics", "#"] 10 | 11 | diagnostics_list = [{"level": "warn"}] 12 | if TYPE_CHECKING: 13 | reveal_type(data) 14 | reveal_type(number) 15 | reveal_type(status) 16 | reveal_type(status[0]) 17 | reveal_type(diagnostics_list) 18 | reveal_type(diagnostics_list[0]) 19 | reveal_type(diagnostics) 20 | -------------------------------------------------------------------------------- /tests/cases/nested.py: -------------------------------------------------------------------------------- 1 | from jsonschema_typed import JSONSchema 2 | from typing import TYPE_CHECKING, Final, Literal, TypedDict, Type 3 | 4 | 5 | data: JSONSchema["var:jsonschema_typed:dummy_path"] = {"title": "baz"} 6 | awesome: JSONSchema[ 7 | "var:jsonschema_typed:dummy_path", "var:jsonschema_typed:Awesome.key" 8 | ] 9 | nested: JSONSchema[ 10 | "schema/nested.json", 11 | "var:jsonschema_typed:Awesome.key", 12 | "var:jsonschema_typed:nested", 13 | ] 14 | 15 | if TYPE_CHECKING: 16 | reveal_type(data) 17 | reveal_type(awesome) 18 | reveal_type(nested) 19 | -------------------------------------------------------------------------------- /tests/cases/nonetype.py: -------------------------------------------------------------------------------- 1 | from jsonschema_typed import JSONSchema 2 | from typing import TYPE_CHECKING 3 | 4 | data: JSONSchema["schema/nonetype.json"] = {"title": "baz"} 5 | if TYPE_CHECKING: 6 | reveal_type(data) 7 | data["awesome"] = [1, 2, 3] 8 | data["awesome"] = None 9 | data["awesome"] = 123 10 | -------------------------------------------------------------------------------- /tests/cases/optional_typed_dict.py: -------------------------------------------------------------------------------- 1 | from jsonschema_typed import JSONSchema, OptionalTypedDict 2 | from typing import TYPE_CHECKING 3 | 4 | # The key 'awesome' is required, so this wouldn't be ok without the Optional wrapper. 5 | data: OptionalTypedDict[JSONSchema["schema/check_required.json"]] = { 6 | "title": "some title" 7 | } 8 | if TYPE_CHECKING: 9 | reveal_type(data) 10 | 11 | data["description"] = "there is no description" 12 | data["awesome"] = 42 13 | data["awesome"] = None 14 | -------------------------------------------------------------------------------- /tests/cases/optional_typed_dict_hard_mode.py: -------------------------------------------------------------------------------- 1 | from jsonschema_typed import JSONSchema, OptionalTypedDict 2 | from typing import TYPE_CHECKING 3 | 4 | Hard = JSONSchema["schema/hard.json"] 5 | 6 | # The key 'num' is required (among others), so this wouldn't be ok without the Optional wrapper. 7 | data: OptionalTypedDict[Hard] = {} 8 | 9 | if TYPE_CHECKING: 10 | reveal_type(data) 11 | 12 | data["description"] = "there is no description" 13 | data["num"] = 42 14 | data["num"] = None 15 | -------------------------------------------------------------------------------- /tests/cases/outer_array.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from jsonschema_typed import JSONSchema 4 | 5 | array: JSONSchema["schema/outer_array.json"] 6 | inner_object: JSONSchema["schema/outer_array.json", "#"] = {"a_string": "string"} 7 | 8 | array = [inner_object] 9 | 10 | if TYPE_CHECKING: 11 | reveal_type(array) 12 | reveal_type(inner_object) 13 | -------------------------------------------------------------------------------- /tests/cases/tuple.py: -------------------------------------------------------------------------------- 1 | from jsonschema_typed import JSONSchema 2 | from typing import TYPE_CHECKING 3 | 4 | 5 | Ty = JSONSchema["schema/tuple.json"] 6 | 7 | data: Ty = [("key", "value")] 8 | 9 | error0: Ty = [["key", "value"]] 10 | error1: Ty = [("key", "value", "toomuch")] 11 | error2: Ty = [1] 12 | error3: Ty = [(1, 2)] 13 | 14 | if TYPE_CHECKING: 15 | reveal_type(data) 16 | -------------------------------------------------------------------------------- /tests/test_run_mypy.py: -------------------------------------------------------------------------------- 1 | """ 2 | This type of test attempts to run mypy on selected sources and asserts that 3 | the output is consistent. This does not (yet) test sufficient edge cases, 4 | and more testing should be done. 5 | 6 | # TODO: Add more test cases. 7 | """ 8 | 9 | import os 10 | import pytest 11 | from mypy import api 12 | from typing import List, Tuple, TypedDict 13 | 14 | 15 | class Expect(TypedDict): 16 | normal: str 17 | error: str 18 | exit_status: int 19 | 20 | 21 | case_directory = os.path.join(os.path.dirname(__file__), "cases") 22 | 23 | cases: List[Tuple[str, Expect]] = [ 24 | ( 25 | "from_readme.py", 26 | Expect( 27 | normal=""" 28 | note: Revealed type is 'TypedDict('FooSchema', {'title'?: builtins.str, 'awesome'?: builtins.int})' 29 | error: TypedDict "FooSchema" has no key 'description' 30 | error: Argument 2 has incompatible type "None"; expected "int" 31 | """, 32 | error="", 33 | exit_status=1, 34 | ), 35 | ), 36 | ( 37 | "check_required.py", 38 | Expect( 39 | normal=""" 40 | error: Key 'awesome' missing for TypedDict "FooSchema" 41 | note: Revealed type is 'TypedDict('FooSchema', {'title'?: builtins.str, 'awesome': builtins.int})' 42 | error: TypedDict "FooSchema" has no key 'description' 43 | error: Argument 2 has incompatible type "None"; expected "int" 44 | """, 45 | error="", 46 | exit_status=1, 47 | ), 48 | ), 49 | ( 50 | "alias.py", 51 | Expect( 52 | normal=""" 53 | note: Revealed type is 'TypedDict('FooSchema', {'title'?: builtins.str, 'awesome'?: builtins.int})' 54 | error: TypedDict "FooSchema" has no key 'description' 55 | error: Argument 2 has incompatible type "None"; expected "int" 56 | """, 57 | error="", 58 | exit_status=1, 59 | ), 60 | ), 61 | ( 62 | "nonetype.py", 63 | Expect( 64 | normal=""" 65 | note: Revealed type is 'TypedDict('NoneSchema', {'title'?: builtins.str, 'awesome'?: Union[builtins.list[Any], None]})' 66 | error: Argument 2 has incompatible type "int"; expected "Optional[List[Any]]" 67 | """, 68 | error="", 69 | exit_status=1, 70 | ), 71 | ), 72 | ( 73 | "nested.py", 74 | Expect( 75 | normal=""" 76 | note: Revealed type is 'TypedDict('NestedFooSchema', {'title': builtins.str, 'awesome'?: TypedDict({'nested'?: TypedDict({'thing': builtins.str}), 'thing': builtins.int})})' 77 | note: Revealed type is 'TypedDict('NestedFooSchemaAwesome', {'nested'?: TypedDict({'thing': builtins.str}), 'thing': builtins.int})' 78 | note: Revealed type is 'TypedDict('NestedFooSchemaAwesomeNested', {'thing': builtins.str})' 79 | """, 80 | error="", 81 | exit_status=1, 82 | ), 83 | ), 84 | ( 85 | "hard.py", 86 | Expect( 87 | normal=""" 88 | note: Revealed type is 'TypedDict('ComplicatedJson', {'num': builtins.int, 'status': builtins.list[TypedDict({'code'?: Union[Literal['success'], Literal['failure']], 'diagnostics'?: builtins.list[TypedDict({'field'?: builtins.str, 'illegal_value'?: builtins.str, 'level': Union[Literal['info'], Literal['warn'], Literal['error']], 'mismatch_fields'?: builtins.list[builtins.str], 'ids'?: builtins.list[TypedDict({'id': builtins.int, 'thing_type'?: Union[Literal['A'], Literal['B']]})]})], 'message'?: builtins.str, 'module': Union[Literal['m1'], Literal['m2']]})]})' 89 | note: Revealed type is 'builtins.int' 90 | note: Revealed type is 'builtins.list[TypedDict({'code'?: Union[Literal['success'], Literal['failure']], 'diagnostics'?: builtins.list[TypedDict({'field'?: builtins.str, 'illegal_value'?: builtins.str, 'level': Union[Literal['info'], Literal['warn'], Literal['error']], 'mismatch_fields'?: builtins.list[builtins.str], 'ids'?: builtins.list[TypedDict({'id': builtins.int, 'thing_type'?: Union[Literal['A'], Literal['B']]})]})], 'message'?: builtins.str, 'module': Union[Literal['m1'], Literal['m2']]})]' 91 | note: Revealed type is 'TypedDict({'code'?: Union[Literal['success'], Literal['failure']], 'diagnostics'?: builtins.list[TypedDict({'field'?: builtins.str, 'illegal_value'?: builtins.str, 'level': Union[Literal['info'], Literal['warn'], Literal['error']], 'mismatch_fields'?: builtins.list[builtins.str], 'ids'?: builtins.list[TypedDict({'id': builtins.int, 'thing_type'?: Union[Literal['A'], Literal['B']]})]})], 'message'?: builtins.str, 'module': Union[Literal['m1'], Literal['m2']]})' 92 | note: Revealed type is 'builtins.list[TypedDict({'field'?: builtins.str, 'illegal_value'?: builtins.str, 'level': Union[Literal['info'], Literal['warn'], Literal['error']], 'mismatch_fields'?: builtins.list[builtins.str], 'ids'?: builtins.list[TypedDict({'id': builtins.int, 'thing_type'?: Union[Literal['A'], Literal['B']]})]})]' 93 | note: Revealed type is 'TypedDict({'field'?: builtins.str, 'illegal_value'?: builtins.str, 'level': Union[Literal['info'], Literal['warn'], Literal['error']], 'mismatch_fields'?: builtins.list[builtins.str], 'ids'?: builtins.list[TypedDict({'id': builtins.int, 'thing_type'?: Union[Literal['A'], Literal['B']]})]})' 94 | note: Revealed type is 'TypedDict('ComplicatedJsonStatusDiagnostics', {'field'?: builtins.str, 'illegal_value'?: builtins.str, 'level': Union[Literal['info'], Literal['warn'], Literal['error']], 'mismatch_fields'?: builtins.list[builtins.str], 'ids'?: builtins.list[TypedDict({'id': builtins.int, 'thing_type'?: Union[Literal['A'], Literal['B']]})]})' 95 | """, 96 | error="", 97 | exit_status=1, 98 | ), 99 | ), 100 | ( 101 | "optional_typed_dict.py", 102 | Expect( 103 | normal=""" 104 | note: Revealed type is 'TypedDict('FooSchema', {'title'?: builtins.str, 'awesome'?: builtins.int})' 105 | error: TypedDict "FooSchema" has no key 'description' 106 | error: Argument 2 has incompatible type "None"; expected "int" 107 | """, 108 | error="", 109 | exit_status=1, 110 | ), 111 | ), 112 | ( 113 | "optional_typed_dict_hard_mode.py", 114 | Expect( 115 | normal=""" 116 | note: Revealed type is 'TypedDict('ComplicatedJson', {'num'?: builtins.int, 'status'?: builtins.list[TypedDict({'code'?: Union[Literal['success'], Literal['failure']], 'diagnostics'?: builtins.list[TypedDict({'field'?: builtins.str, 'illegal_value'?: builtins.str, 'level': Union[Literal['info'], Literal['warn'], Literal['error']], 'mismatch_fields'?: builtins.list[builtins.str], 'ids'?: builtins.list[TypedDict({'id': builtins.int, 'thing_type'?: Union[Literal['A'], Literal['B']]})]})], 'message'?: builtins.str, 'module': Union[Literal['m1'], Literal['m2']]})]})' 117 | error: TypedDict "ComplicatedJson" has no key 'description' 118 | error: Argument 2 has incompatible type "None"; expected "int" 119 | """, 120 | error="", 121 | exit_status=1, 122 | ), 123 | ), 124 | ( 125 | "outer_array.py", 126 | Expect( 127 | normal=""" 128 | note: Revealed type is 'builtins.list[TypedDict({'a_number'?: builtins.int, 'a_string': builtins.str, 'nested_array_of_numbers'?: builtins.list[builtins.list[builtins.float]]})]' 129 | note: Revealed type is 'TypedDict('ArrayOfObjects', {'a_number'?: builtins.int, 'a_string': builtins.str, 'nested_array_of_numbers'?: builtins.list[builtins.list[Union[builtins.int, builtins.float]]]})' 130 | """, 131 | error="", 132 | exit_status=1, 133 | ), 134 | ), 135 | ( 136 | "tuple.py", 137 | Expect( 138 | normal=""" 139 | tests/cases/tuple.py:9: error: List item 0 has incompatible type "List[str]"; expected "Optional[Tuple[Optional[str], Optional[str]]]" 140 | tests/cases/tuple.py:10: error: List item 0 has incompatible type "Tuple[str, str, str]"; expected "Optional[Tuple[Optional[str], Optional[str]]]" 141 | tests/cases/tuple.py:11: error: List item 0 has incompatible type "int"; expected "Optional[Tuple[Optional[str], Optional[str]]]" 142 | tests/cases/tuple.py:12: error: List item 0 has incompatible type "Tuple[int, int]"; expected "Optional[Tuple[Optional[str], Optional[str]]]" 143 | tests/cases/tuple.py:15: note: Revealed type is 'builtins.list[Union[Tuple[Union[builtins.str, None], Union[builtins.str, None]], None]]' 144 | Found 4 errors in 1 file (checked 1 source file) 145 | """, 146 | error="", 147 | exit_status=1, 148 | ), 149 | ), 150 | ] 151 | 152 | 153 | @pytest.mark.parametrize("case_file, expected", cases) 154 | def test_cases(case_file: str, expected: Expect): 155 | normal_report, error_report, exit_status = api.run( 156 | ["--show-traceback", os.path.join(case_directory, case_file)] 157 | ) 158 | 159 | for line in expected["normal"].strip().splitlines(): 160 | assert line.strip() in normal_report 161 | 162 | for line in expected["error"].strip().splitlines(): 163 | assert line.strip() in error_report 164 | 165 | assert exit_status == expected["exit_status"] 166 | -------------------------------------------------------------------------------- /version.py: -------------------------------------------------------------------------------- 1 | """ 2 | Gets the current version number from the most recent tag. 3 | 4 | This will simply get the most recent tag name that , assuming this to be 5 | in the proper version format 6 | 7 | Use as: 8 | 9 | from version import * 10 | setup( 11 | ... 12 | version=get_version(), 13 | ... 14 | ) 15 | """ 16 | 17 | __all__ = "get_version" 18 | 19 | import subprocess 20 | 21 | 22 | def get_version(): 23 | 24 | # Get the version using "git describe". 25 | cmd = "git describe --tags".split() 26 | try: 27 | return subprocess.check_output(cmd).decode().strip() 28 | except subprocess.CalledProcessError: 29 | print("Unable to get version number from git tags") 30 | exit(1) 31 | 32 | 33 | if __name__ == "__main__": 34 | print(get_version()) 35 | --------------------------------------------------------------------------------