diff --git a/.github/ISSUE_TEMPLATE/1-issue.md b/.github/ISSUE_TEMPLATE/1-issue.md index 2abb9cf..f58dde5 100644 --- a/.github/ISSUE_TEMPLATE/1-issue.md +++ b/.github/ISSUE_TEMPLATE/1-issue.md @@ -15,7 +15,7 @@ We can then decide if the discussion needs to be escalated into an "Issue" or no This will make sure that everything is organised properly. --- -**polyforce version**: +**Polyforce version**: **Python version**: **OS**: **Platform**: diff --git a/README.md b/README.md index 1f75252..5828336 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,33 @@ -# polyforce +# Polyforce -Enforce annotations in your python code +
+ ++ 🔥 Enforce static typing in your codebase 🔥 +
+ + + +--- + +**Documentation**: [https://polyforce.tarsild.io][polyforce] 📚 + +**Source Code**: [https://github.com/tarsil/polyforce](https://github.com/tarsil/polyforce) + +--- + +[polyforce]: https://polyforce.tarsild.io diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..1e08f95 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,40 @@ +# Config + +This is the object used for [PolyModel](./model.md) configuration options and only contains +a very little amount of paramenters. + +## How to import + +Very easy to import. + +```python +from polyforce import Config +``` + +## How to use + +The `Config` object is used inside objects that inherit from [PolyModel](./model.md). + +```python +{!> ../docs_src/config.py !} +``` + +The same parameters of the config are also present in [polycheck](./decorator.md) decorator as well. + +```python +{!> ../docs_src/decorator.py !} +``` + +## Parameters + +* **ignore** - Flag indicating if the static type checking should be ignored. When this is applied +on a `PolyModel` level, **it will disable the checks for the whole class**, whereas when applied +on a `polycheck` level, it will only disable for the function where the decorator is being applied. + + Default: `False` + +* **ignored_types** - List or tuple of python types, **any type** that should be ignored from the +static type checking. When a type is passed to `ignored_types=(...,)`, the attribute with declared +with the type being ignored will be assumed as `Any`. + + Default: `()` diff --git a/docs/decorator.md b/docs/decorator.md new file mode 100644 index 0000000..f353258 --- /dev/null +++ b/docs/decorator.md @@ -0,0 +1,77 @@ +# Decorator + +**Polyforce** is quite versatile and for different tastes. You can use the [PolyModel](./model.md) +for you own objects or you can simply use the `polycheck` decorator for everything else. + +## polycheck + +This is the decorator that helps you with everything you need without the need of using an inherited +object. + +The same parameters of the `PolyModel` are also allowed within the `polycheck`. + +## How to use it + +When using the `polycheck` you must import it first. + +```python +from polyforce import polycheck +``` + +Once it is imported you can simply subclass it in your objects. Something like this: + +```python hl_lines="5 19 26 32 40" +{!> ../docs_src/decorator/example.py !} +``` + +When adding the `polycheck` object, will enable the static type checking to happen all over the +functions declared. + +### Ignore the checks + +Well, there is not too much benefit of using `polycheck` if you want to ignore the checks, correct? +Well, yes but you still can do it if you want. + +There might be some scenarios where you want to override some checks and ignore the checks. + +For this, **Polyforce** uses the [Config](./config.md) dictionary. + +You simply need to pass `ignore=True` and the static type checking will be disabled for the class. + +It will look like this: + +```python hl_lines="19 26" +{!> ../docs_src/decorator/disable.py !} +``` + +!!! Tip + The decorator has the same fields as the `PolyModel` but since `polycheck` is done + on a function basis, applying `ignore+True` **is the same as not adding the decorator at all**. + This serves as example for documentation purposes only. + +### Ignore specific types + +What if you want to simply ignore some types? Meaning, you might want to pass arbitrary values that +you don't want them to be static checked. + +```python hl_lines="3 22" +{!> ../docs_src/decorator/ignored_types.py !} +``` + +This will make sure that the type `Actor` is actually ignore and assumed as type `Any` which also means +you can pass whatever value you desire since the type `Actor` is no longer checked. + +### Integrations + +**Polyforce** works also really well with integrations, for instance with [Pydantic](https://pydantic.dev). + +The only thing you need to do is to import the [decorator](./decorator.md) and use it inside the +functions you want to enforce. + +This example is exactly the same as the one for [PolyModel](./model.md#integrations). + +```python hl_lines="5 18 22" +{!> ../docs_src/model/integrations.py !} +``` + +This way you can use your favourite libraries with **Polyforce**. diff --git a/docs/index.md b/docs/index.md index 1f75252..7d4d5dc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,239 @@ -# polyforce +# Polyforce -Enforce annotations in your python code + + ++ 🔥 Enforce static typing in your codebase at runtime 🔥 +
+ + + +--- + +**Documentation**: [https://polyforce.tarsild.io][polyforce] 📚 + +**Source Code**: [https://github.com/tarsil/polyforce](https://github.com/tarsil/polyforce) + +--- + +## Motivation + +During software development we face issues where we don't know what do pass as specific parameters +or even return of the functions itself. + +Tools like [mypy][mypy] for example, allow you to run static checking in your code and therefore +allowing to type your codebase properly but **it does not enforce it when running**. + +For those coming from hevily static typed languages like **Java**, **.net** and many others, Python +can be overwhelming and sometimes confusing because of its versatility. + +**Polyforce** was created to make sure you: + +* Don't forget to type your functions and variables. +* Validates the typing in **runtime**. +* Don't forget thr return annotations. + +Wouldn't be cool to have something like this: + +> What if my function that expects a parameter of type string, if something else is passed could +simply fail, as intended? + +This is where **Polyforce enters**. + +## The library + +Polyforce was designed to enforce the static typing **everywhere** in your code base. From functions +to parameters. + +It was also designed to make sure the typing is enforced at runtime. + +In other words, if you declare a type `string` and decide to pass an `integer`, it will blow throw +and intended error. + +The library offers two ways of implementing the solution. + +* [Via model](./model.md) +* [Via decorator](./decorator.md) + +## How to use it + +Let us see some scenarios where the conventional python is applied and then where **Polyforce** +can make the whole difference for you. + +### Conventional Python + +Let us start with a simple python function. + +#### Simple function + +```python +def my_function(name: str): + return name +``` + +In the normal python world, this wouldn't make any difference, and let us be honest, if you don't care +about mypy or any related tools, this will work without any issues. + +This will also allow this to run without any errors: + +```python +my_function("Polyfactory") # returns "Polyfactory" +my_function(1) # returns 1 +my_function(2.0) # returns 2.0 +``` + +The example above is 100% valid for that specific function and all values passed will be returned +equaly valid and the reson for this is because Python **does not enforce the static typing** so +the `str` declared for the parameter `name` **is merely visual**. + +#### With objects + +```python +class MyClass: + + def my_function(name: str): + return name +``` + +And then this will be also valid. + +```python +my_class = MyClass() + +my_class.my_function("Polyfactory") # returns "Polyfactory" +my_class.my_function(1) # returns 1 +my_class.my_function(2.0) # returns 2.0 +``` + +I believe you understand the gist of what is being referred here. So, what if there was a solution +where we actually enforce the typing at runtime? Throw some errors when something is missing from +the typing and also when the wrong type is being sent into a function? + +Enters [Polyforce](#polyforce) + +### Polyforce + +Now, let us use the same examples used before but using **Polyforce** and see what happens? + +#### Simple function + +```python hl_lines="1" +from polyforce import polycheck + + +@polycheck() +def my_function(name: str): + return name +``` + +The example above it will throw a `ReturnSignatureMissing` or a `MissingAnnotation` +because the **missing return annotation** of the function or a parameter annotation respectively. + +```python +my_function("Polyforce") # Throws an exception +``` + +The correct way would be: + +```python hl_lines="1 5" +from polyforce import polycheck + + +@polycheck() +def my_function(name: str) -> str: + return name +``` + +So what if now you pass a value that is not of type string? + +```python +my_function(1) # Throws an exception +``` + +This will also throw a `TypeError` exception because you are trying to pass a type `int` into a +declared type `str`. + +#### With objects + +The same level of validations are applied within class objects too. + +```python hl_lines="1 4" +from polyforce import PolyModel + + +class MyClass(PolyModel): + + def __init__(self, name, age: int): + ... + + def my_function(self, name: str): + return name +``` + +The example above it will throw a `ReturnSignatureMissing` and a `MissingAnnotation` +because the **missing return annotation for both __init__ and the function** as well as the missing +types for the parameters in both. + +The correct way would be: + +```python hl_lines="1 4" +from polyforce import PolyModel + + +class MyClass(PolyModel): + + def __init__(self, name: str, age: int) -> None: + ... + + def my_function(self, name: str) -> str: + return name +``` + +## The Polyforce + +As you can see, utilising the library is very simple and very easy, in fact, it was never so easy to +enforce statuc typing in python. + +For classes, you simply need to import the `PolyModel`. + +```python +from polyforce import PolyModel +``` + +And to use the decorator you simply can: + +```python +from polyforce import polycheck +``` + +## PolyModel vs polycheck + +When using `PolyModel`, there is no need to apply the `polycheck` decorator. The `PolyModel` is +smart enough to apply the same level of validations as the `polycheck`. + +When using the `PolyModel` you can use normal python as you would normally do and that means +`classmethod`, `staticmethod` and normal functions. + +This like this, the `polycheck` is used for all the functions that are not inside a class. + +## Limitations + +For now, **Polyforce** is not looking at **native magic methods** (usually start and end with double underscore). +In the future it is planned to understand those on a class level. + +[polyforce]: https://polyforce.tarsild.io +[mypy]: https://mypy.readthedocs.io/en/stable/ diff --git a/docs/model.md b/docs/model.md new file mode 100644 index 0000000..74968f8 --- /dev/null +++ b/docs/model.md @@ -0,0 +1,67 @@ +# PolyModel + +This is the object used for all the classes that want to enforce the static typing all over +the object itself. + +This object is different from the [decorator](./decorator.md) as you don't need to specify +which functions should be enforced. + +## How to use it + +When using the `PolyModel` you must import it first. + +```python +from polyforce import PolyModel +``` + +Once it is imported you can simply subclass it in your objects. Something like this: + +```python hl_lines="5 8" +{!> ../docs_src/model/example.py !} +``` + +When adding the `PolyModel` object, will enable the static type checking to happen all over the +functions declared in the object. + +### Ignore the checks + +Well, there is not too much benefit of using `PolyModel` if you want to ignore the checks, correct? +Well, yes but you still can do it if you want. + +There might be some scenarios where you want to override some checks and ignore the checks. + +For this, **Polyforce** uses the [Config](./config.md) dictionary. + +You simply need to pass `ignore=True` and the static type checking will be disabled for the class. + +It will look like this: + +```python hl_lines="9" +{!> ../docs_src/model/disable.py !} +``` + +### Ignore specific types + +What if you want to simply ignore some types? Meaning, you might want to pass arbitrary values that +you don't want them to be static checked. + +```python hl_lines="3 10 24" +{!> ../docs_src/model/ignored_types.py !} +``` + +This will make sure that the type `Actor` is actually ignore and assumed as type `Any` which also means +you can pass whatever value you desire since the type `Actor` is no longer checked. + + +### Integrations + +**Polyforce** works also really well with integrations, for instance with [Pydantic](https://pydantic.dev). + +The only thing you need to do is to import the [decorator](./decorator.md) and use it inside the +functions you want to enforce. + +```python hl_lines="5 18 22" +{!> ../docs_src/model/integrations.py !} +``` + +This way you can use your favourite libraries with **Polyforce**. diff --git a/docs/overrides/white.png b/docs/overrides/white.png new file mode 100644 index 0000000..41441f8 Binary files /dev/null and b/docs/overrides/white.png differ diff --git a/docs/sponsorship.md b/docs/sponsorship.md index 4920b2c..558a232 100644 --- a/docs/sponsorship.md +++ b/docs/sponsorship.md @@ -2,9 +2,6 @@ Do you like **Polyforce** and would like to help Polyforce, other user and the author? -## 🐦 Follow Polyforce on Twitter - - ## ⭐ Star **Polyforce** on GitHub Giving a star to Polyforce is very simple and helps promoting the work across the developers around the world. @@ -22,8 +19,6 @@ Following the GitHub repo will allow you to "watch" for any new release of Polyf You can click on "***watch***" and select "***custom***" -> "***Releases***"or any other you may find particular interesting to you. -## 💬 Join the official Polyforce discord channel - ## 🔥 Sponsor the author The author built this framework with all of his heart and dedication and will continue to do it so but that also diff --git a/docs/statics/images/favicon.ico b/docs/statics/images/favicon.ico new file mode 100644 index 0000000..41eb0c4 Binary files /dev/null and b/docs/statics/images/favicon.ico differ diff --git a/docs/statics/images/white.png b/docs/statics/images/white.png new file mode 100644 index 0000000..41441f8 Binary files /dev/null and b/docs/statics/images/white.png differ diff --git a/docs_src/config.py b/docs_src/config.py new file mode 100644 index 0000000..0178367 --- /dev/null +++ b/docs_src/config.py @@ -0,0 +1,6 @@ +from polyforce import Config, PolyModel + + +class Movie(PolyModel): + config: Config = Config(ignore=..., ignored_types=...) + ... diff --git a/docs_src/decorator.py b/docs_src/decorator.py new file mode 100644 index 0000000..ebf47db --- /dev/null +++ b/docs_src/decorator.py @@ -0,0 +1,6 @@ +from polyforce import polycheck + + +@polycheck(ignore=..., ignored_types=...) +def my_function() -> None: + ... diff --git a/docs_src/decorator/disable.py b/docs_src/decorator/disable.py new file mode 100644 index 0000000..7c3b592 --- /dev/null +++ b/docs_src/decorator/disable.py @@ -0,0 +1,44 @@ +from typing import List, Union + +from typing_extensions import Self + +from polyforce import polycheck + + +class Movie: + def __init__( + self, + name: str, + year: int, + tags: Union[List[str], None] = None, + ) -> None: + self.name = name + self.year = year + self.tags = tags + + @polycheck(ignore=True) + def get_movie(self, name: str) -> Self: + """ + Returns a movie + """ + ... + + @polycheck(ignore=True) + def _set_name(self, name: str) -> None: + """ + Sets the name of the movie. + """ + + @classmethod + def create_movie(cls, name: str, year: int) -> Self: + """ + Creates a movie object + """ + return cls(name=name, year=year) + + @staticmethod + def evaluate_movie(name: str, tags: List[str]) -> bool: + """ + Evaluates a movie in good (true) or bad (false) + """ + ... diff --git a/docs_src/decorator/example.py b/docs_src/decorator/example.py new file mode 100644 index 0000000..0099dde --- /dev/null +++ b/docs_src/decorator/example.py @@ -0,0 +1,46 @@ +from typing import List, Union + +from typing_extensions import Self + +from polyforce import polycheck + + +class Movie: + def __init__( + self, + name: str, + year: int, + tags: Union[List[str], None] = None, + ) -> None: + self.name = name + self.year = year + self.tags = tags + + @polycheck() + def get_movie(self, name: str) -> Self: + """ + Returns a movie + """ + ... + + @polycheck() + def _set_name(self, name: str) -> None: + """ + Sets the name of the movie. + """ + + @polycheck() + @classmethod + def create_movie(cls, name: str, year: int) -> Self: + """ + Creates a movie object + """ + return cls(name=name, year=year) + + @polycheck() + @staticmethod + def evaluate_movie(name: str, tags: List[str]) -> bool: + """ + Evaluates a movie in good (true) or bad (false) + """ + ... diff --git a/docs_src/decorator/ignored_types.py b/docs_src/decorator/ignored_types.py new file mode 100644 index 0000000..9e65c68 --- /dev/null +++ b/docs_src/decorator/ignored_types.py @@ -0,0 +1,27 @@ +from typing import List, Union + +from polyforce import polycheck + + +class Actor: + ... + + +class Movie: + def __init__( + self, + name: str, + year: int, + tags: Union[List[str], None] = None, + ) -> None: + self.name = name + self.year = year + self.tags = tags + self.actors: List[Actor] = [] + + @polycheck(ignored_types=(Actor,)) + def add_actor(self, actor: Actor) -> None: + """ + Returns a movie + """ + self.actors.append(actor) diff --git a/docs_src/model/disable.py b/docs_src/model/disable.py new file mode 100644 index 0000000..1af9d6a --- /dev/null +++ b/docs_src/model/disable.py @@ -0,0 +1,44 @@ +from typing import List, Union + +from typing_extensions import Self + +from polyforce import Config, PolyModel + + +class Movie(PolyModel): + config: Config(ignore=True) + + def __init__( + self, + name: str, + year: int, + tags: Union[List[str], None] = None, + ) -> None: + self.name = name + self.year = year + self.tags = tags + + def get_movie(self, name: str) -> Self: + """ + Returns a movie + """ + ... + + def _set_name(self, name: str) -> None: + """ + Sets the name of the movie. + """ + + @classmethod + def create_movie(cls, name: str, year: int) -> Self: + """ + Creates a movie object + """ + return cls(name=name, year=year) + + @staticmethod + def evaluate_movie(name: str, tags: List[str]) -> bool: + """ + Evaluates a movie in good (true) or bad (false) + """ + ... diff --git a/docs_src/model/example.py b/docs_src/model/example.py new file mode 100644 index 0000000..de03efd --- /dev/null +++ b/docs_src/model/example.py @@ -0,0 +1,42 @@ +from typing import List, Union + +from typing_extensions import Self + +from polyforce import PolyModel + + +class Movie(PolyModel): + def __init__( + self, + name: str, + year: int, + tags: Union[List[str], None] = None, + ) -> None: + self.name = name + self.year = year + self.tags = tags + + def get_movie(self, name: str) -> Self: + """ + Returns a movie + """ + ... + + def _set_name(self, name: str) -> None: + """ + Sets the name of the movie. + """ + + @classmethod + def create_movie(cls, name: str, year: int) -> Self: + """ + Creates a movie object + """ + return cls(name=name, year=year) + + @staticmethod + def evaluate_movie(name: str, tags: List[str]) -> bool: + """ + Evaluates a movie in good (true) or bad (false) + """ + ... diff --git a/docs_src/model/ignored_types.py b/docs_src/model/ignored_types.py new file mode 100644 index 0000000..58869ee --- /dev/null +++ b/docs_src/model/ignored_types.py @@ -0,0 +1,28 @@ +from typing import List, Union + +from polyforce import Config, PolyModel + + +class Actor: + ... + + +class Movie(PolyModel): + config: Config(ignored_types=(Actor,)) + + def __init__( + self, + name: str, + year: int, + tags: Union[List[str], None] = None, + ) -> None: + self.name = name + self.year = year + self.tags = tags + self.actors: List[Actor] = [] + + def add_actor(self, actor: Actor) -> None: + """ + Returns a movie + """ + self.actors.append(actor) diff --git a/docs_src/model/integrations.py b/docs_src/model/integrations.py new file mode 100644 index 0000000..9d8fbdb --- /dev/null +++ b/docs_src/model/integrations.py @@ -0,0 +1,24 @@ +from typing import List, Union + +from pydantic import BaseModel, ConfigDict + +from polyforce import polycheck + + +class Actor: + ... + + +class Movie(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + name: str + year: int + actors: Union[List[Actor], None] = None + + @polycheck() + def add_actor(self, actor: Actor) -> None: + self.actors.append(actor) + + @polycheck() + def set_actor(self, actor: Actor) -> None: + ... diff --git a/mkdocs.yml b/mkdocs.yml index a893ed3..501a1c2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: Polyforce -site_description: Enforce annotations in your python code. +site_description: 🔥 Enforce static typing in your codebase at runtime 🔥 site_url: https://polyforce.tarsild.io theme: @@ -8,21 +8,21 @@ theme: language: en palette: - scheme: "default" - primary: "purple" - accent: "amber" + primary: "pink" + accent: "red" media: "(prefers-color-scheme: light)" toggle: icon: "material/lightbulb" name: "Switch to dark mode" - scheme: "slate" media: "(prefers-color-scheme: dark)" - primary: "purple" - accent: "amber" + primary: "pink" + accent: "red" toggle: icon: "material/lightbulb-outline" name: "Switch to light mode" favicon: statics/images/favicon.ico - logo: statics/images/logo-white.svg + logo: statics/images/white.png features: - search.suggest - search.highlight @@ -38,6 +38,9 @@ plugins: nav: - Introduction: "index.md" + - Model: "model.md" + - Decorator: "decorator.md" + - Config: "config.md" - Contributing: "contributing.md" - Sponsorship: "sponsorship.md" - Release Notes: "release-notes.md" diff --git a/polyforce/_internal/_config.py b/polyforce/_internal/_config.py index 9db2ceb..9ae7ded 100644 --- a/polyforce/_internal/_config.py +++ b/polyforce/_internal/_config.py @@ -14,9 +14,14 @@ def __init__( config: Union[Config, Dict[str, Any], Type[Any], None], ignore: bool = False, ignored_types: Union[Any, None] = None, + **kwargs: Any, ): self.config = cast(Config, config) self.ignore = ignore + if ignored_types is not None: + assert isinstance( + ignored_types, (tuple, list) + ), "`ignored_types` must be a tuple or a list" self.ignored_types = ignored_types or () @classmethod diff --git a/polyforce/_internal/_construction.py b/polyforce/_internal/_construction.py index 0f5d725..54a21a7 100644 --- a/polyforce/_internal/_construction.py +++ b/polyforce/_internal/_construction.py @@ -1,7 +1,7 @@ import inspect from inspect import Parameter, Signature from itertools import islice -from typing import TYPE_CHECKING, Any, Dict, List, Set, Tuple, Type, cast +from typing import TYPE_CHECKING, Any, Dict, List, Set, Type, cast from polyforce.exceptions import MissingAnnotation, ReturnSignatureMissing @@ -18,10 +18,13 @@ def complete_poly_class(cls: Type["PolyModel"], config: ConfigWrapper) -> bool: """ methods: List[str] = [ attr - for attr in dir(cls) + for attr in cls.__dict__.keys() if not attr.startswith("__") and not attr.endswith("__") and callable(getattr(cls, attr)) ] - methods.append("__init__") + + if "__init__" in cls.__dict__: + methods.append("__init__") + signatures: Dict[str, Signature] = {} for method in methods: @@ -31,6 +34,17 @@ def complete_poly_class(cls: Type["PolyModel"], config: ConfigWrapper) -> bool: return True +def ignore_signature(signature: Signature) -> Signature: + """ + Ignores the signature and assigns the Any to all the fields and return signature. + """ + merged_params: Dict[str, Parameter] = {} + for param in islice(signature.parameters.values(), 1, None): + param = param.replace(annotation=Any) + merged_params[param.name] = param + return Signature(parameters=list(merged_params.values()), return_annotation=Any) + + def generate_model_signature( cls: Type["PolyModel"], value: str, config: ConfigWrapper ) -> Signature: @@ -38,15 +52,14 @@ def generate_model_signature( Generates a signature for each method of the given class. """ func = getattr(cls, value) - func_signature = Signature.from_callable(func) + signature = Signature.from_callable(func) if config.ignore: - return func_signature + return ignore_signature(signature) - params = func_signature.parameters.values() + params = signature.parameters.values() merged_params: Dict[str, Parameter] = {} - - if func_signature.return_annotation == inspect.Signature.empty: + if signature.return_annotation == inspect.Signature.empty: raise ReturnSignatureMissing(func=value) for param in islice(params, 1, None): # skip self arg @@ -63,7 +76,7 @@ def generate_model_signature( # Generate the new signatures. return Signature( - parameters=list(merged_params.values()), return_annotation=func_signature.return_annotation + parameters=list(merged_params.values()), return_annotation=signature.return_annotation ) @@ -97,16 +110,15 @@ def __new__(cls, name: str, bases: Any, attrs: Any) -> Any: return super().__new__(cls, name, bases, attrs) @staticmethod - def _collect_data_from_bases(bases: Any) -> Tuple[Set[str], Set[str]]: + def _collect_data_from_bases(bases: Any) -> Set[str]: """ Collects all the data from the bases. """ from ..main import PolyModel - field_names: Set[str] = set() class_vars: Set[str] = set() for base in bases: if issubclass(base, PolyModel) and base is not PolyModel: class_vars.update(base.__class_vars__) - return field_names, class_vars + return class_vars diff --git a/polyforce/decorator.py b/polyforce/decorator.py index 1e14092..5925b0c 100644 --- a/polyforce/decorator.py +++ b/polyforce/decorator.py @@ -1,24 +1,27 @@ import inspect import typing -from functools import wraps -from typing import Any, _SpecialForm +from typing import Any, Union, _SpecialForm from typing_extensions import get_args, get_origin from polyforce.constants import CLASS_SPECIAL_WORDS +from polyforce.exceptions import MissingAnnotation, ReturnSignatureMissing -def polycheck(wrapped: Any) -> Any: - """ - Special decorator that enforces the - static typing. +class polycheck: + def __init__(self, ignore: bool = False, ignored_types: Any = None) -> None: + self.wrapped: Any + self.ignore = ignore - Checks if all the fields are typed and if the functions have return - annotations. - """ - args_spec: inspect.FullArgSpec = inspect.getfullargspec(wrapped) + if ignored_types is not None: + assert isinstance( + ignored_types, (tuple, list) + ), "`ignored_types` must be a tuple or a list" - def check_signature(func: Any) -> Any: + self.ignored_types = ignored_types or () + self.args_spec: Union[inspect.FullArgSpec, None] = None + + def check_signature(self, func: Any) -> Any: """ Validates the signature of a function and corresponding annotations of the parameters. @@ -28,34 +31,34 @@ def check_signature(func: Any) -> Any: signature: inspect.Signature = inspect.Signature.from_callable(func) if signature.return_annotation == inspect.Signature.empty: - raise TypeError( - "A return value of a function should be type annotated. " - "If your function doesn't return a value or returns None, annotate it as returning 'NoReturn' or 'None' respectively." - ) + raise ReturnSignatureMissing(func=func.__name__) + for name, parameter in signature.parameters.items(): if name not in CLASS_SPECIAL_WORDS and parameter.annotation == inspect.Signature.empty: - raise TypeError( - f"'{name}' is not typed. If you are not sure, annotate with 'typing.Any'." - ) + raise MissingAnnotation(name=name) - def check_types(*args: Any, **kwargs: Any) -> Any: - params = dict(zip(args_spec.args, args)) + def check_types(self, *args: Any, **kwargs: Any) -> Any: + params = dict(zip(self.args_spec.args, args)) params.update(kwargs) for name, value in params.items(): - type_hint = args_spec.annotations.get(name, Any) + type_hint = self.args_spec.annotations.get(name, Any) - if isinstance(type_hint, _SpecialForm) or type_hint == Any: + if ( + isinstance(type_hint, _SpecialForm) + or type_hint == Any + or type_hint in self.ignored_types + ): continue - actual_type = get_actual_type(type_hint=type_hint, value=value) - if not isinstance(value, actual_type): + actual_type = self.get_actual_type(type_hint=type_hint, value=value) + if not isinstance(value, actual_type) and not self.ignore: raise TypeError( f"Expected type '{type_hint}' for attribute '{name}'" - f" but received type '{type(value)}') instead." + f" but received type '{type(value)}' instead." ) - def get_actual_type(type_hint: Any, value: Any) -> Any: + def get_actual_type(self, type_hint: Any, value: Any) -> Any: """ Checks for all the version of python. """ @@ -71,17 +74,13 @@ def get_actual_type(type_hint: Any, value: Any) -> Any: ) return actual_type or type_hint - def decorate(func: Any) -> Any: - @wraps(func) + def __call__(self, fn: "Any") -> Any: + self.wrapped = fn + self.args_spec = inspect.getfullargspec(self.wrapped) + def wrapper(*args: Any, **kwargs: Any) -> Any: - check_signature(func) - check_types(*args, **kwargs) - return func(*args, **kwargs) + self.check_signature(self.wrapped) + self.check_types(*args, **kwargs) + return self.wrapped(*args, **kwargs) return wrapper - - if inspect.isclass(wrapped): - wrapped.__init__ = decorate(wrapped.__init__) - return wrapped - - return decorate(wrapped) diff --git a/polyforce/exceptions.py b/polyforce/exceptions.py index 08d745f..715db8d 100644 --- a/polyforce/exceptions.py +++ b/polyforce/exceptions.py @@ -24,7 +24,7 @@ def __str__(self) -> str: class ReturnSignatureMissing(PolyException): detail: Union[str, None] = ( - "Missing return: {func}. A return value of a function should be type annotated. " + "Missing return in '{func}'. A return value of a function should be type annotated. " "If your function doesn't return a value or returns None, annotate it as returning 'NoReturn' or 'None' respectively." ) diff --git a/polyforce/main.py b/polyforce/main.py index a94e21b..9429392 100644 --- a/polyforce/main.py +++ b/polyforce/main.py @@ -1,4 +1,4 @@ -from inspect import Signature +from inspect import Parameter, Signature from typing import TYPE_CHECKING, Any, ClassVar, Dict, Set, _SpecialForm from typing_extensions import get_args, get_origin @@ -32,6 +32,15 @@ class MyObject(PolyModel): config = Config() def __getattribute__(self, __name: str) -> Any: + """ + Special action where it adds the static check validation + for the data being passed. + + It checks if the values are properly checked and validated with + the right types. + + The class version of the decorator `polyforce.decorator.polycheck`. + """ try: func = object.__getattribute__(self, __name) signatures = object.__getattribute__(self, "__signature__") @@ -41,11 +50,19 @@ def polycheck(*args: Any, **kwargs: Any) -> Any: nonlocal signature nonlocal func params = dict(zip(signature.parameters.values(), args)) - params_from_kwargs = { - signature.parameters.get(key, type(value)): value - for key, value in kwargs.items() - } - params.update(params_from_kwargs) # type: ignore + params_from_kwargs: Dict[Parameter, Any] = {} + + for key, value in kwargs.items(): + parameter = signature.parameters.get(key) + if parameter: + params_from_kwargs[parameter] = value + continue + + params_from_kwargs[ + Parameter(name=key, kind=Parameter.KEYWORD_ONLY, annotation=type(value)) + ] = value + + params.update(params_from_kwargs) for parameter, value in params.items(): type_hint = parameter.annotation @@ -74,7 +91,7 @@ def polycheck(*args: Any, **kwargs: Any) -> Any: f" but received type '{type(value)}' instead." ) - return func(*args, **kwargs) + return func(*args, **kwargs) return polycheck diff --git a/pyproject.toml b/pyproject.toml index c0d90c4..1539b50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Internet :: WWW/HTTP", ] @@ -53,6 +54,7 @@ test = [ "black>=23.3.0,<24.0.0", "isort>=5.12.0,<6.0.0", "mypy>=1.1.0,<2.0.0", + "pydantic>=2.4.0", "pytest>=7.2.2,<8.0.0", "pytest-cov>=4.0.0,<5.0.0", "requests>=2.28.2", diff --git a/docs_src/.gitkeep b/tests/decorator/__init__.py similarity index 100% rename from docs_src/.gitkeep rename to tests/decorator/__init__.py diff --git a/tests/test_dataclass.py b/tests/decorator/test_dataclass.py similarity index 98% rename from tests/test_dataclass.py rename to tests/decorator/test_dataclass.py index d4ea959..d9e7d4f 100644 --- a/tests/test_dataclass.py +++ b/tests/decorator/test_dataclass.py @@ -6,7 +6,7 @@ from polyforce import polycheck -@polycheck +@polycheck() @dataclass(frozen=True) class User: union_values: Union[int, str, float] diff --git a/tests/test_function.py b/tests/decorator/test_function.py similarity index 73% rename from tests/test_function.py rename to tests/decorator/test_function.py index 5c901b0..261115c 100644 --- a/tests/test_function.py +++ b/tests/decorator/test_function.py @@ -3,9 +3,10 @@ import pytest from polyforce import polycheck +from polyforce.exceptions import MissingAnnotation, ReturnSignatureMissing -@polycheck +@polycheck() def my_function( union_values: Union[int, str, float], value: Any, @@ -26,9 +27,9 @@ def test_polycheck_all(): def test_missing_return_annotation(): - with pytest.raises(TypeError) as raised: + with pytest.raises(ReturnSignatureMissing) as raised: - @polycheck + @polycheck() def test_func(name=None): ... @@ -36,14 +37,14 @@ def test_func(name=None): assert ( str(raised.value) - == "A return value of a function should be type annotated. If your function doesn't return a value or returns None, annotate it as returning 'NoReturn' or 'None' respectively." + == "Missing return in 'test_func'. A return value of a function should be type annotated. If your function doesn't return a value or returns None, annotate it as returning 'NoReturn' or 'None' respectively." ) def test_missing_typing_annotation(): - with pytest.raises(TypeError) as raised: + with pytest.raises(MissingAnnotation) as raised: - @polycheck + @polycheck() def test_func(name=None) -> None: ... diff --git a/tests/decorator/test_ignored_types.py b/tests/decorator/test_ignored_types.py new file mode 100644 index 0000000..c41d5f5 --- /dev/null +++ b/tests/decorator/test_ignored_types.py @@ -0,0 +1,43 @@ +from typing import Any + +import pytest + +from polyforce import polycheck + + +class Dummy: + ... + + +@polycheck(ignore=True) +def my_function( + name: Dummy, +) -> Any: + ... + + +def test_polycheck(): + my_function(name="A test") + + +@polycheck() +def another_function( + name: Dummy, +) -> Any: + ... + + +def test_polycheck_error(): + with pytest.raises(TypeError): + another_function(name="a") + + +@polycheck(ignored_types=(Dummy,)) +def ignore_function( + name: Dummy, +) -> Any: + ... + + +def test_polycheck_ignore_types(): + ignore_function(name="a") diff --git a/tests/models/__init__.py b/tests/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/models/test_ignored_types.py b/tests/models/test_ignored_types.py new file mode 100644 index 0000000..d398e27 --- /dev/null +++ b/tests/models/test_ignored_types.py @@ -0,0 +1,36 @@ +from typing import Any, List, Union + +from polyforce import Config, PolyModel + + +class Dummy: + ... + + +class Actor: + ... + + +class Movie(PolyModel): + config = Config(ignored_types=(Actor,)) + + def __init__( + self, name: str, year: int, tags: Union[List[str], None] = None, **kwargs: Any + ) -> None: + self.name = name + self.year = year + self.tags = tags + self.actors: List[Actor] = [] + + def add_actor(self, actor: Actor) -> None: + """ + Returns a movie + """ + self.actors.append(actor) + + +def test_add_value(): + movie = Movie(name="Avengers", year="2023") + movie.add_actor(actor=Dummy()) + + assert len(movie.actors) == 1 diff --git a/tests/models/test_inheritance.py b/tests/models/test_inheritance.py new file mode 100644 index 0000000..1abe7e3 --- /dev/null +++ b/tests/models/test_inheritance.py @@ -0,0 +1,49 @@ +from polyforce import PolyModel + + +class User(PolyModel): + ... + + +class Profile(User): + def __init__(self) -> None: + super().__init__() + + def get_name(self, name: str) -> str: + return name + + +def test_can_inherit(): + profile = Profile() + name = profile.get_name("poly") + + assert name == "poly" + + +def test_ignores_checks(): + class NewUser(PolyModel): + config = {"ignore": True} + + class NewProfile(NewUser): + def __init__(self): + super().__init__() + + def get_name(self, name): + return name + + profile = NewProfile() + name = profile.get_name(1) + assert name == 1 + + +def test_ignores_types(): + class NewUser(PolyModel): + config = {"ignored_types": (str,)} + + class NewProfile(NewUser): + def get_name(self, name: str) -> str: + return name + + profile = NewProfile() + name = profile.get_name(1) + assert name == 1 diff --git a/tests/models/test_integration.py b/tests/models/test_integration.py new file mode 100644 index 0000000..4783ea8 --- /dev/null +++ b/tests/models/test_integration.py @@ -0,0 +1,45 @@ +from typing import List, Union + +import pytest +from pydantic import BaseModel + +from polyforce import polycheck +from polyforce.exceptions import ReturnSignatureMissing + + +class Dummy: + ... + + +class Actor: + ... + + +class Movie(BaseModel): + model_config = {"arbitrary_types_allowed": True} + + name: str + year: int + actors: Union[List[Actor], None] = None + + @polycheck() + def add_actor(self, actor: Actor) -> None: + self.actors.append(actor) + + @polycheck() + def set_actor(self, actor: Actor): + ... + + +def test_add_value(): + movie = Movie(name="Avengers", year="2023") + + with pytest.raises(TypeError): + movie.add_actor(actor=Dummy()) + + +def test_missing_return(): + movie = Movie(name="Avengers", year="2023") + + with pytest.raises(ReturnSignatureMissing): + movie.set_actor(actor=Dummy()) diff --git a/tests/models/test_model_construction.py b/tests/models/test_model_construction.py index c58e2cc..6e9b861 100644 --- a/tests/models/test_model_construction.py +++ b/tests/models/test_model_construction.py @@ -210,3 +210,11 @@ def test_dict_and_not_str_raise_error_name_function_static(): def test_str_and_not_int_raise_error_function_static(): with pytest.raises(TypeError): Poly.my_function_static(int_value="a") + + +def test_double_underscore(): + with pytest.raises(ReturnSignatureMissing): + + class Double(PolyModel): + def __test(self, name: str): + ...