undictify

Python library providing type-checked function calls at runtime

https://github.com/dobiasd/undictify

Science Score: 44.0%

This score indicates how likely this project is to be science-related based on various indicators:

  • CITATION.cff file
    Found CITATION.cff file
  • codemeta.json file
    Found codemeta.json file
  • .zenodo.json file
    Found .zenodo.json file
  • DOI references
  • Academic publication links
  • Committers with academic emails
  • Institutional organization owner
  • JOSS paper metadata
  • Scientific vocabulary similarity
    Low similarity (12.1%) to scientific vocabulary

Keywords

deserialization json python3 type-safety
Last synced: 4 months ago · JSON representation ·

Repository

Python library providing type-checked function calls at runtime

Basic Info
Statistics
  • Stars: 98
  • Watchers: 6
  • Forks: 9
  • Open Issues: 0
  • Releases: 35
Topics
deserialization json python3 type-safety
Created over 7 years ago · Last pushed over 1 year ago
Metadata Files
Readme Funding License Citation

README.md

logo

CI (License MIT 1.0)

undictify

Python library providing type-checked function calls at runtime

Table of contents

Introduction

Let's start with a toy example: ```python3 def times_two(value): return 2 * value

value = 3 result = times_two(value) print(f'{value} * 2 == {result}') ```

This is fine, it outputs output: 3 * 2 = 6. But what if value accidentally is '3' instead of 3? The output will become output: 3 * 2 = 33, which might not be desired.

So you add something like python3 if not isinstance(value, int): raise TypeError(...) to times_two. This will raise an TypeError instead, which is better. But you still only recognize the mistake when actually running the code. Catching it earlier in the development process might be better. Luckily Python allows to opt-in for static typing by offering type annotations. So you add them and mypy (or your IDE) will tell you about the problem early. ```python3 def times_two(value: int) -> int: return 2 * value

value = '3' result = timestwo(value) # error: Argument 1 to "timestwo" # has incompatible type "str"; expected "int" print(f'{value} * 2 == {result}') ```

But you may get into a situation in which there is no useful static type information, because of values: - coming from external non-typed functions (so actually they are of type Any) - were produced by a (rogue) function that returns different types depending on some internal decision (Union[T, V]) - being provided as a Dict[str, Any] - etc.

```python3 def times_two(value: int) -> int: return 2 * value

def get_value() -> Any: return '3'

value = getvalue() result = timestwo(value) print(f'{value} * 2 == {result}') ```

At least with the appropriate settings, mypy should dutifully complain, and now you're left with two options: - Drop type-checking (for example by adding # type: ignore to the end of the result = times_two(value) line): This however catapults you back into the insane world where 2 * 3 == 33. - You manually add type checks before the call (or inside of times_two) like if not isinstance(value, int):: This of course does not provide static type checking (because of the dynamic nature of value), but at least guarantees sane runtime behavior.

But the process of writing that boilerplate validation code can become quite cumbersome if you have multiple parameters/functions to check. Also it is not very DRY since you already have the needed type information in our function signature and you just duplicated it in the check condition.

This is where undictify comes into play. Simply decorate your times_two function with @type_checked_call(): ```python3 from undictify import typecheckedcall

@typecheckedcall() def times_two(value: int) -> int: return 2 * value ```

And the arguments of times_two will be type-checked with every call at runtime automatically. A TypeError will be raised if needed.

This concept of runtime type-checks of function calls derived from static type annotations is quite simple, however it is very powerful and brings some highly convenient consequences with it.

Use case: JSON deserialization

Imagine your application receives a JSON string representing an entity you need to handle:

```python3 tobiasjson = ''' { "id": 1, "name": "Tobias", "heart": { "weightinkg": 0.31, "pulseatrest": 52 }, "friendids": [2, 3, 4, 5] }'''

tobias = json.loads(tobias_json) ```

Now you start to work with it. Somewhere deep in your business logic you have: python3 name_length = len(tobias['name']) But that's only fine if the original JSON string was well-behaved. If it had "name": 4, in it, you would get: name_length = len(tobias['name']) TypeError: object of type 'int' has no len() at runtime, which is not nice. So you start to manually add type checking: python3 if isinstance(tobias['name'], str): name_length = len(tobias['name']) else: # todo: handle the situation somehow

You quickly realize that you need to separate concerns better, in that case the business logic and the input data validation.

So you start to do all checks directly after receiving the data: python3 tobias = json.loads(... if isinstance(tobias['id'], int): ... if isinstance(tobias['name'], str): ... if isinstance(... # *yawn*

and then transfer it into a type-safe class instance: ```python3 @dataclass class Heart: weightinkg: float pulseatrest: int

@dataclass class Human: id: int name: str nick: Optional[str] heart: Heart friend_ids: List[int] ```

Having the safety provided by the static type annotations (and probably checking your code with mypy) is a great because of all the: - bugs that don't make it into PROD - manual type checks (and matching unit tests) that you don't have to write - help your IDE can now offer - better understanding people get when reading your code - easier and more confident refactorings

But again, writing all that boilerplate code for data validation is tedious (and not DRY).

So you decide to use a library that does JSON schema validation for you. But now you have to manually adjust the schema every time your entity structure changes, which still is not DRY, and thus also brings with it all the typical possibilities to make mistakes.

Undictify can help here too! Annotate the classes @type_checked_constructor and their constructors will be wrapped in type-checked calls. python3 @type_checked_constructor() @dataclass class Heart: ... @type_checked_constructor() @dataclass class Human: ...

(They do not need to be dataclasses. Deriving from NamedTuple works too.)

Undictify will type-check the construction of objects of type Heart and Human automatically. (This works for normal classes with a manually written __init__ function too. You just need to provide the type annotations to its parameters.) So you can use the usual dictionary unpacking syntax, to safely convert your untyped dictionary (i.e., Dict[str, Any]) resulting from the JSON string into your statically typed class:

python3 tobias = Human(**json.loads(tobias_json))

(Btw this application is the origin of the name of this library.)

It throws exceptions with meaningful details in their associated values in case of errors like: - missing a field - a field having the wrong type - etc.

It also supports optional values being omitted instead of being None explicitly (as shown in the example with the nick field).

Details

Sometimes, e.g., in case of unpacking a dictionary resulting from a JSON string, you might want to just skip the fields in the dictionary that your function / constructor does not take as a parameter. For these cases undictify provides @type_checked_call(skip=True).

It also supports valid type conversions via @type_checked_call(convert=True), which might for example come in handy when processing the arguments of an HTTP request you receive for example in a get handler of a flask_restful.Resource class: ```python3 @typecheckedcall(convert=True) def targetfunction(someint: int, some_str: str)

class WebController(Resource): def get(self) -> Any: # request.args is something like {"someint": "4", "somestr": "hi"} result = target_function(**flask.request.args) ```

The values in the MultiDict request.args are all strings, but the logic behind @type_checked_call(convert=True) tries to convert them into the desired target types with reasonable exceptions in case the conversion is not possible.

This way a request to http://.../foo?some_int=4&some_str=hi would be handled normally, but http://.../foo?some_int=four&some_str=hi would raise an appropriate TypeError.

Additional flexibility is offered for cases in which you would like to not type-check all calls of a specific function / class constructor, but only some. You can use type_checked_call() at call site instead of adding the annotation for those:

```python3 from undictify import typecheckedcall

def times_two(value: int) -> int: return 2 * value

value: Any = '3' resutl = typecheckedcall()(times_two)(value) ```

And last but not least, custom converters for specified parameters are also supported:

```python3 import json from dataclasses import dataclass from datetime import datetime

from undictify import typecheckedconstructor, optional_converter

def parsetimestamp(datetimerepr: str) -> datetime: return datetime.strptime(datetime_repr, '%Y-%m-%dT%H:%M:%SZ')

@typecheckedconstructor(converters={'sometimestamp': optionalconverter(parsetimestamp)}) @dataclass class Foo: sometimestamp: datetime

jsonrepr = '{"sometimestamp": "2019-06-28T07:20:34Z"}' myfoo = Foo(**json.loads(jsonrepr)) ```

In case the converter should be applied even if the source type already matches the destination type, use mandatory_converter instead of optional_converter.

Requirements and Installation

You need Python 3.8 or higher.

bash python3 -m pip install undictify

Or, if you like to use latest version from this repository: bash git clone https://github.com/Dobiasd/undictify cd undictify python3 -m pip install .

License

Distributed under the MIT License. (See accompanying file LICENSE or at https://opensource.org/licenses/MIT)

Owner

  • Name: Tobias Hermann
  • Login: Dobiasd
  • Kind: user
  • Location: Germany

Loving functional programming, machine learning, and neat software architecture.

Citation (CITATION.cff)

cff-version: 1.2.0
title: "undictify"
url: "https://github.com/Dobiasd/undictify"
authors:
  - family-names: "Hermann"
    given-names: "Tobias"
    orcid: "https://orcid.org/0009-0007-4792-4904"

GitHub Events

Total
Last Year

Committers

Last synced: 7 months ago

All Time
  • Total Commits: 166
  • Total Committers: 4
  • Avg Commits per committer: 41.5
  • Development Distribution Score (DDS): 0.054
Past Year
  • Commits: 2
  • Committers: 1
  • Avg Commits per committer: 2.0
  • Development Distribution Score (DDS): 0.0
Top Committers
Name Email Commits
Dobiasd e****m@g****m 157
Sam Roeca s****a@g****m 7
Timothy Hopper t****r 1
Bosco Yung 1****y 1

Issues and Pull Requests

Last synced: 4 months ago

All Time
  • Total issues: 14
  • Total pull requests: 8
  • Average time to close issues: 18 days
  • Average time to close pull requests: about 18 hours
  • Total issue authors: 8
  • Total pull request authors: 4
  • Average comments per issue: 3.29
  • Average comments per pull request: 3.5
  • Merged pull requests: 7
  • Bot issues: 0
  • Bot pull requests: 0
Past Year
  • Issues: 0
  • Pull requests: 1
  • Average time to close issues: N/A
  • Average time to close pull requests: 1 minute
  • Issue authors: 0
  • Pull request authors: 1
  • Average comments per issue: 0
  • Average comments per pull request: 0.0
  • Merged pull requests: 1
  • Bot issues: 0
  • Bot pull requests: 0
Top Authors
Issue Authors
  • LukeMathWalker (6)
  • shawalli (2)
  • Dobiasd (1)
  • kheast (1)
  • richin13 (1)
  • pappasam (1)
  • JoshuaGutman (1)
  • gjedlicska (1)
Pull Request Authors
  • Dobiasd (7)
  • tdhopper (1)
  • pappasam (1)
  • bhky (1)
Top Labels
Issue Labels
Pull Request Labels

Packages

  • Total packages: 1
  • Total downloads:
    • pypi 8,159 last-month
  • Total dependent packages: 2
  • Total dependent repositories: 8
  • Total versions: 34
  • Total maintainers: 1
pypi.org: undictify

Type-checked function calls at runtime

  • Versions: 34
  • Dependent Packages: 2
  • Dependent Repositories: 8
  • Downloads: 8,159 Last month
Rankings
Dependent packages count: 3.1%
Downloads: 3.7%
Dependent repos count: 5.2%
Average: 6.1%
Stargazers count: 7.2%
Forks count: 11.4%
Maintainers (1)
Last synced: 4 months ago

Dependencies

.github/workflows/ci.yml actions
  • actions/checkout main composite
  • actions/setup-python main composite
docker-compose.yml docker
setup.py pypi