From 10de274c2d966b96fe8a8775045cae992682482f Mon Sep 17 00:00:00 2001 From: ken-morel Date: Fri, 19 Jul 2024 23:41:25 +0100 Subject: [PATCH] Improved demo, and Added support for function-less callbacks --- README.md | 11 + docs/conf.py | 6 +- docs/expressions.rst | 79 ++++ docs/index.rst | 24 +- docs/whatsnew.rst | 61 +-- requirements.txt | 2 +- src/djamago/__init__.py | 155 +++++-- src/djamago/demo.py | 84 +++- src/pyoload/__init__.py | 996 ---------------------------------------- 9 files changed, 308 insertions(+), 1110 deletions(-) create mode 100644 docs/expressions.rst delete mode 100644 src/pyoload/__init__.py diff --git a/README.md b/README.md index 7101b40..d79719f 100644 --- a/README.md +++ b/README.md @@ -67,3 +67,14 @@ It will check the patterns as given and return the match with tha maximum score # Enter the Demo Zone +Well, let's now explore the demo and see how it does: + +```python +from djamago.demo import cli + +cli() +``` + +``` + +``` diff --git a/docs/conf.py b/docs/conf.py index 2757fda..7adb269 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,12 +34,12 @@ autoclass_content = "both" sys.setrecursionlimit(500) -project = "pyoload" +project = "djamago" copyright = "2024, ken-morel" author = "ken-morel" -release = "2.0.0" -version = "2.0.0" +release = "0.1.0" +version = "0.1.0" # -- General configuration diff --git a/docs/expressions.rst b/docs/expressions.rst new file mode 100644 index 0000000..3ef2f67 --- /dev/null +++ b/docs/expressions.rst @@ -0,0 +1,79 @@ +=============================================================================== +Registering Expressions +=============================================================================== + +You can register expressions using `Expression.register` to create a new +expression, or `Expression.extend` to add more patterns to an existing +expression or `Expression.override` to override an existing expression. +`Expression.register`It takes two arguments: + +------------------------------------------------------------------------------- +Expression definition +------------------------------------------------------------------------------- + +The definition of the expression, it could just be the expression name +as `asking_no-thing@robot`. you could also subclass an existing expression +using the syntax `name(parent1, parent2, ...)`. + +------------------------------------------------------------------------------- +Expression patterns +------------------------------------------------------------------------------- + +a list of tuples in the for `(score, regex_pattern)`. + +The score is a float or integer value between `-1` for no match, passing by `0` +for default match, to `100` for full match + +The pattern is simply a string pattern to full match. +The captures of the regex will be used for matching with the arguments when +the expression is inferred. + +=============================================================================== +Using expressions +=============================================================================== + +A djamago expression consists of four parts + +------------------------------------------------------------------------------- +The pattern +------------------------------------------------------------------------------- + +The first and only required part of the expression. It may have two value types + +1. **A registerred expression name**: A simple name reference to a list of + mappings of score to regular epression. +2. **A quoted regular expression**: you could simply quote a regular expression + and use it in the same way. + +------------------------------------------------------------------------------- +The arguments +------------------------------------------------------------------------------- + +As a simple function call, the arguments will be fullmatched on the expression +matching groups. +gives something like `how-are(you)` + +------------------------------------------------------------------------------- +The match name +------------------------------------------------------------------------------- + +Add a hash and a string after the pattern name regex or call arguments, +and the match will be available in the `node.vars` under the specified name. + +now like: +- `greetings#greetingMessage` +- `".+"#anything` +- `hello("user")#message` + +------------------------------------------------------------------------------- +The match score +------------------------------------------------------------------------------- + +Fix a specific score to rescale the score match, use syntax `everyThingElse:{score}` +as in: + +- `hello:60.5` +- `'hello':60.5` +- `greetings(".+"#personName:60, ".+"#personName2:40):65` + +Well, you are all set to use `djamago.Expression`. diff --git a/docs/index.rst b/docs/index.rst index c53702a..ea52d64 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -27,23 +27,29 @@ Wealcome to djamago v |version| documentation! :target: https://portfolio-ken-morel-projects.vercel.app/ :alt: Hit count -Hy pythonista, here is `djamago`, what is? +=============================================================================== +Getting introduced +=============================================================================== + +Djamago is a chatbot module which will help you build python chat robots. +It implements regular expression matching and score based evaluation to improve +result accuracy while keeping interesting performance. + +**It does not use AI**, making it fast but dacading result uniquness, making +it better for building simple bots for advertisement, or simple dynamic +answering. + - A python chatbot library -Djamago is a chatbot library which will help you creating simple -chatbots, it inspires of chatbot project, and tries to improve accuracy -using score based answers. .. toctree:: :maxdepth: 1 - examples - usage - api installation + expressions whatsnew + examples + api report genindex modindex - oload-or-multi diff --git a/docs/whatsnew.rst b/docs/whatsnew.rst index ed08c39..4e14f68 100644 --- a/docs/whatsnew.rst +++ b/docs/whatsnew.rst @@ -1,41 +1,20 @@ -================================================== -What's new -================================================== - -Lot's have been done since I started the project -to when I write this doc now, about - -.. image:: https://wakatime.com/badge/user/dbe5b692-a03c-4ea8-b663-a3e6438148b6/project/ab01ce70-02f0-4c96-9912-bafa41a0aa54.svg - - -These are the highlights - --------------------------------------------------- -pyoload v2.0.0 --------------------------------------------------- - -1. Greatly worked on the docs to make them more undetsandable and increase coverage. -2. Renamed overload to multiple dispatch or multimethod as required, since - As from :ref:`Overload or multimethod`. -3. Added new options to :ref:`pyoload.Checks` such as registerring under multiple names. -4. Increased the pytest coverage to ensure the full functionality of `pyoload` - on the several supported python versions. -5. Greatly improved performance using `inspect.Signature`. Providing support - for partial annotating of function.(Yes, from v2.0.0 some annotations may be ommited). -6. Added helper methods for interacting with annotated functions, - They include - - - :ref:`pyoload.annotable` - - :ref:`pyoload.unannotable` - - :ref:`pyoload.is_annotable` - - :ref:`pyoload.is_annotated` - - Those methods will help you prevent some functions from being annotated. - -7. Improved support for python 3.9 and 3.10 -8. renamed functions as the previous `pyoload.typeMatch` to :ref:`pyoload.type_match` to follow - the snake case system of nomenclature. -9. :ref:`pyoload.type_match` returns a tuple of the matchin status and errors - which may have lead to type mismatch, thosse errors are added to traceback - to ease debugging. -10. Now most classes implement `__slots__` to improve memory size. +=============================================================================== +What's new? +=============================================================================== + +------------------------------------------------------------------------------- +v0.0.2 +------------------------------------------------------------------------------- + +- **Added supports for named exprssions**: with the new expression syntax, now + an expression can be named. +- **Improved expression checking errors**: Added name propositions, and error + messages. +- **Added builtin expressions**: Now Djamago implements builtin expressions + prefixed with a `-`. +- **Added .next to Node**: You can now easily specify precisely which method + will follow. +- **simplified node**: now node implements all the session data including + variables, parameters, score and candidates. +- **Added ScoreChange**: raise this in a callback to assign to it a new + score and recheck. diff --git a/requirements.txt b/requirements.txt index acf807d..fd3e050 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -pyoload==2.0.1 +pyoload==2.0.2 diff --git a/src/djamago/__init__.py b/src/djamago/__init__.py index d1cb24a..80f5aad 100644 --- a/src/djamago/__init__.py +++ b/src/djamago/__init__.py @@ -153,7 +153,7 @@ class Expression(Pattern): ( 100, re.compile( - r"please,?\s*(?:do\s*you\s*know|tell\s*me|may\s*I\s*ask)?" + r"(?:please,?)?\s*(?:do you know|tell(?: me)?|may I ask)?" r"\s*(.+)\??", ), ), @@ -241,10 +241,14 @@ def register( if parent not in cls.ENTRIES: similar = [] for expr in cls.ENTRIES.keys(): - similar.append(( - difflib.SequenceMatcher(lambda *_: False, parent, expr).ratio(), - expr, - )) + similar.append( + ( + difflib.SequenceMatcher( + lambda *_: False, parent, expr + ).ratio(), + expr, + ) + ) similar.sort(key=lambda k: -k[0]) add = "" if len(similar) > 0 and similar[0][0]: @@ -282,10 +286,14 @@ def override( if name not in cls.ENTRIES and _raise: similar = [] for expr in cls.ENTRIES.keys(): - similar.append(( - difflib.SequenceMatcher(lambda *_: False, parent, expr).ratio(), - expr, - )) + similar.append( + ( + difflib.SequenceMatcher( + lambda *_: False, parent, expr + ).ratio(), + expr, + ) + ) similar.sort(key=lambda k: -k[0]) add = "" if len(similar) > 0 and similar[0][0]: @@ -314,10 +322,14 @@ def extend( if name not in cls.ENTRIES and _raise: similar = [] for expr in cls.ENTRIES.keys(): - similar.append(( - difflib.SequenceMatcher(lambda *_: False, parent, expr).ratio(), - expr, - )) + similar.append( + ( + difflib.SequenceMatcher( + lambda *_: False, parent, expr + ).ratio(), + expr, + ) + ) similar.sort(key=lambda k: -k[0]) add = "" if len(similar) > 0 and similar[0][0]: @@ -334,6 +346,42 @@ def extend( [(score, re.compile(txt)) for score, txt in vals], ) + @classmethod + @annotate + def alias( + cls, + alias: str, + name: str, + _raise: bool = True, + ) -> None: + """ + Extends an existing expression + :param alias: The alias name + :param name: The expression name to alias + :param _raise: Optional, if should raise `Expression.DoesNotExist` if + does not exist + """ + if name not in cls.ENTRIES and _raise: + similar = [] + for expr in cls.ENTRIES.keys(): + similar.append( + ( + difflib.SequenceMatcher( + lambda *_: False, parent, expr + ).ratio(), + expr, + ) + ) + similar.sort(key=lambda k: -k[0]) + add = "" + if len(similar) > 0 and similar[0][0]: + add = similar[0][1] + "?" + raise Expression.DoesNotExist( + f"you tried aliassing {name!r}, which does not exist " + f"may be you meant " + add + ) + cls.ENTRIES[alias] = cls.ENTRIES[name] + @staticmethod @annotate def parse(text: str) -> tuple[re.Pattern | str, list, str, int | float]: @@ -406,8 +454,9 @@ def parse(text: str) -> tuple[re.Pattern | str, list, str, int | float]: pos = end else: raise Expression.ParsingError( - pos, pos, - f"Primary name or regex expression missing in {text!r}[{pos}]" + pos, + pos, + f"Primary name or regex expression missing in {text!r}[{pos}]", ) if len(text) > pos and text[pos] == "(": # arguments pos += 1 @@ -491,11 +540,14 @@ def _check( except KeyError: similar = [] for expr in cls.ENTRIES.keys(): - similar.append(( - difflib.SequenceMatcher(lambda *_: False, name, expr).ratio(), - expr, - )) - print(name, expr, difflib.SequenceMatcher(lambda *_: False, name, expr).ratio()) + similar.append( + ( + difflib.SequenceMatcher( + lambda *_: False, name, expr + ).ratio(), + expr, + ) + ) similar.sort(key=lambda k: -k[0]) add = "" if len(similar) > 0 and similar[0][0] > 0.5: @@ -509,14 +561,12 @@ def _check( vars = {} mat = regex.fullmatch(string) if not mat: - # print(score, "no", regex, "to", string) continue - # print(score, mat) args = mat.groups() args = args[: len(params)] if len(params) != len(args): continue - match_score = 0 + match_score = -1 for param, arg in zip(params, args): if isinstance(param, tuple): paramname, paramargs, paramvarname, paramscore = param @@ -539,9 +589,10 @@ def _check( return -1, {}, {} else: raise Exception() + if match_score > -1: + match_score += 1 if len(params) == 0: match_score = 100 - # print("scaling:", match_score, "to", score, id, name) tests.append( ( match_score / 100 * score, @@ -565,8 +616,12 @@ def __init__(self, expr: str): :param expr: The expression to be parsed """ + self.text = expr self.regex, self.params, self.name, self.score = Expression.parse(expr) + def __str__(self): + return f"" + @annotate def check(self, node: "Node") -> _check.__annotations__.get("return"): """ @@ -628,15 +683,37 @@ def __init__( self.patterns.append((score, pattern)) @annotate - def __call__(self, func: Callable) -> "Callback": + def __call__( + self, + func: Callable | None = None, + *, + responses: Iterable[str] | str = (), + topics=None, + next=None, + ): """ Simple decorator over the callback the Callback object should call :param func: The callback to use + + :param responses: automatic responses if no func + :param topics: automatic topics if no func + :param next: automatic next if no func :returns: self """ - self.__func__ = func - if hasattr(func, "overload"): - self.overload = func.overload + if isinstance(responses, str): + responses = (responses,) + if func is not None: + self.__func__ = func + else: + def _func(node): + import random + + node.response = random.choice(responses) % node.vars + if topics is not None: + node.set_topics(topics) + if next is not None: + node.next = next + self.__func__ = _func return self def __set_name__(self, obj: Type, name: str) -> None: @@ -670,7 +747,6 @@ def check(self, node: "Node") -> Iterable[tuple[int | float, dict, dict]]: matches = [] for cpid, (pscore, pattern) in enumerate(self.patterns): score, param, var = pattern.check(node) - # print("score for", node, pscore, score, cpid, self.__func__) if score >= 0: matches.append( ( @@ -716,10 +792,14 @@ def get_callback(cls, name: str) -> Callback: else: matches = [] for callback in cl._callbacks: - matches.append(( - difflib.SequenceMatcher(lambda *_: False, name, callback.name).ratio(), - callback.name, - )) + matches.append( + ( + difflib.SequenceMatcher( + lambda *_: False, name, callback.name + ).ratio(), + callback.name, + ) + ) matches.sort(key=lambda k: -k[0]) add = "" if len(matches) > 0 and matches[0][0] > 0.5: @@ -1002,7 +1082,6 @@ def matches(cls, node: "Node") -> Iterable[tuple[int | float, dict, dict]]: matches = [] for qa in cls.QAs: for score, param, var in qa.check(node): - # print(score) matches.append( ( score, @@ -1301,7 +1380,11 @@ def __init__( self.score = score self.param = param self.var = var - super().__init__(f"Score changed to {score}") + super().__init__( + f"Score changed to {score}. Should normally be caught by djamago." + ", if you see this please report at " + "https://github.com/ken-norel/djamago/issues/new" + ) -__version__ = "0.0.1" +__version__ = "0.1.0" diff --git a/src/djamago/demo.py b/src/djamago/demo.py index 0af4f5d..aae56a0 100644 --- a/src/djamago/demo.py +++ b/src/djamago/demo.py @@ -1,23 +1,36 @@ -from __init__ import * +try: + from . import * +except ImportError: + from __init__ import * + import random +import datetime Expression.register( "greetings(-greetings)", # Subclass the predefined -greetings - [], + [ + (100, r"haloa!?"), + ], ) +Expression.alias("greetings_to", "-greetings-to") + Expression.register( - "greetings_to(-greetings-to)", # Subclass the predefined -greetings-to - [], + "aking_current-time", + [ + (100, r"what time is it(?:\s*now)?"), + (100, r"que es la hora"), + (70, r"time please"), + ], ) Expression.register( - "wanting_current-time", + "aking_current-date", [ - (100, r"what\s*time\s*is\s*it(?:\s*now)"), - (100, r"que\s*es\s*la\s*hora"), - (70, r"time please"), + (100, r"what day (?:are|is) (?:it|we)(?:\s*(?:now|today))?"), + (100, r"what the date of today"), + (70, r"date please"), ], ) @@ -31,19 +44,13 @@ def __init__(self): class Main(Topic): @Callback(r"greetings") # matches greetings def greet(node): - print(node.score) node.response = "Hy" - @Callback( - r"greetings_to('.+'#collected_name)" # matches greetings_to, to regex + hy_from = Callback(r"greetings_to('.+'#collected_name)")( + responses=["Hy! (from %(collected_name)s)"], ) # and store match as colletced_name - def greet_to(node): - print(node.score) - node.response = ( - "Hy! (from " + node.vars.get("collected_name", "bot") + ")" - ) - @Callback(r"question('how are you.*')") + @Callback(r"'.*how are you.*'") def how_are_you(node, cache={}): if "asked" not in cache: node.response = ( @@ -62,16 +69,45 @@ def how_are_you(node, cache={}): ) ) + @Callback(r"-question(aking_current-time)") + def current_time(node): + print(node.score) + node.response = datetime.datetime.now().strftime( + random.choice(( + "We are a %A and it is: %I:%M", + "It is: %I:%M", + )), + ) + + @Callback(r"-question(aking_current-date)") + def current_date(node): + node.response = datetime.datetime.now().strftime( + random.choice(( + "We are a %A on the %d of %B", + )), + ) + @Chatbot.topic class HowAreYou(Topic): - @Callback(r"'.*(?:fine|well|ok|nice).*'") - def feel_fine(node): - node.response = "feel fine, that is good!, Well letá change topic" - node.set_topics("main") + feel_fine = Callback(r"'.*(?:fine|well|ok|nice).*'")( + responses=["feel fine, that is good!, Well letá change topic"], + topics=("main",) + ) + fallback = Callback(r"'.*'")( + responses=["that is not what I expected as answer..."], + topics=("howareyou",) + ) bot = Chatbot() -msg = "" -while msg != "quit": - print(bot.respond(msg := input("> ")).response) + + +def cli(): + msg = "" + while msg != "quit": + print(bot.respond(msg := input("> ")).response) + + +if __name__ == '__main__': + cli() diff --git a/src/pyoload/__init__.py b/src/pyoload/__init__.py deleted file mode 100644 index f417f91..0000000 --- a/src/pyoload/__init__.py +++ /dev/null @@ -1,996 +0,0 @@ -""" -`pyoload` is a python module which will help you type check your function -arguments and object attribute types on function call and attribute assignment. - -It supports the various builtin data types supported by :py:`isinstance` and -adds support for: -- :py:`typing.GenericAlias` -- :py:`pyoload.PyoloadAnnoation` subclasses as: - - :py:`pyoload.Cast` - - :py:`pyoload.Values` - - :py:`pyoload.Checks` - -:Authors: - ken-morel - -:Version: 2.0.1 -:Dedication: To the pythonista. -""" - -from functools import partial -from functools import wraps -from inspect import _empty -from inspect import getmodule -from inspect import isclass -from inspect import isfunction -from inspect import ismethod -from inspect import signature -from typing import Any -from typing import Callable -from typing import GenericAlias -from typing import Type -from typing import Union -from typing import get_args -from typing import get_origin - -NoneType = type(None) -try: - from types import UnionType -except ImportError: - UnionType = Union - - -class AnnotationError(ValueError): - """ - base exception for most pyoload errors is raised when a non-subclassable - error occurs. - """ - - -class AnnotationErrors(AnnotationError): - """ - Hosts a list of `AnnotationError` instances. - """ - - -class InternalAnnotationError(Exception): - pass - - -class CastingError(TypeError): - """ - Error during casting, holds the actual error - """ - - -class OverloadError(TypeError): - """ - Error in or during overload calling. - """ - - -class AnnotationResolutionError(AnnotationError): - """ - Annotations could not be resolved or evaluated. - """ - - _raise = False - - -class PyoloadAnnotation: - """ - A parent class for pyoload extra annotations as `Cast` and `Values` - """ - - -class Values(PyoloadAnnotation, tuple): - """ - A tuple subclass which holds several values as possible annotations - """ - - def __call__(self: "Values", val: Any) -> bool: - """ - Checks if the tuple containes the specified value. - - >>> isPrimaryColor = Values(('red', 'green', 'blue')) - >>> isPrimaryColor - Values('red', 'green', 'blue') - >>> isPrimaryColor('red') - True - >>> isPrimaryColor('orange') - False - >>> isPrimaryColor(4) - False - - :param val: the value to be checked - - :returns: if the value `val` is contained in `self` - """ - return val in self - - def __str__(self): - return "Values(" + ", ".join(map(repr, self)) + ")" - - __repr__ = __str__ - - -def get_name(funcOrCls: Any) -> str: - """ - Gives a class or function name, possibly unique gotten from - it's module name and qualifier name - - >>> def foo(): - ... pass - ... - >>> get_name(foo) - '__main__.foo' - >>> get_name(get_name) - 'pyoload.get_name' - >>> get_name(print) - 'builtins.print' - - :param funcOrCls: The object who's name to return - - :returns: modulename + qualname - """ - mod = funcOrCls.__module__ - name = funcOrCls.__qualname__ - return mod + "." + name - - -class Check: - """ - A class basicly abstract which holds registerred checks in pyoload - A new check can be registerred by subclassing whith a non-initializing - callable class, the name will be gotten from the classes :py:`.name` - attribute or the basename of the class if not present. - - A :py:`Check.CheckNameAlreadyExistsError` is raised if the check is already - registerred. - """ - - checks_list = {} - - def __init_subclass__(cls: Any): - """ - register's subclasses as checks - """ - if hasattr(cls, "name"): - name = cls.name - else: - name = cls.__name__ - obj = cls() - obj.__qualname__ = cls.__qualname__ - Check.register(name)(obj) - - @classmethod - def register( - cls: Any, - name: str, - ) -> Callable[[Callable[[Any, Any], NoneType]], Callable]: - """ - returns a callable which registers a new checker method - - used as: - - >>> @Check.register('integer_not_equal neq') # can register on multiple - ... def _(param, val): # names seperated by spaces - ... ''' - ... :param param: The parameter passed as kwarg - ... :param val: The value passed as argument - ... ''' - ... assert param != val # using assertions - ... if not isinstance(param, int) or isinstance(val, int): - ... raise TypeError() # using typeError, handles even unwanted - ... if param == val: # errors in case wrong value passed - ... raise Check.CheckError(f"values {param=!r} and {val=!r} no\ -t equal") - ... - >>> Checks(neq=3) - - >>> Checks(neq=3)(3) - Traceback (most recent call last): - File "C:\\pyoload\\src\\pyoload\\__init__.py", line 172, in check - - File "", line 8, in _ - AssertionError - - The above exception was the direct cause of the following exception: - - Traceback (most recent call last): - File "", line 1, in - File "C:\\pyoload\\src\\pyoload\\__init__.py", line 291, in __call__ - ''' - - File "C:\\pyoload\\src\\pyoload\\__init__.py", line 174, in check - for name in names: - ^^^^^^^^^^^^^^^^^^^ - pyoload.Check.CheckError - - - :param cls: the Check class - :param name: the name to be registerred as. - - :returns: a function which registers the check under the name - """ - names = [x.strip() for x in name.split(" ") if x.strip() != ""] - for name in names: - if name.lstrip("_") in cls.checks_list: - raise Check.CheckNameAlreadyExistsError(name) - - def inner(func: Callable) -> Callable: - for name in names: - cls.checks_list[name] = func - return func - - return inner - - @classmethod - def check(cls: Any, name: str, params: Any, val: Any) -> None: - """ - Performs the specified check with the specified params on - the specified value - - :param cls: pyoload.Check class - :param name: One of the registerred name of the check - if preceded by an underscore, it will be negated - :param params: The parameters to pass to the check - :param val: The value to check - - :returns: :py:`None` - """ - neg = False - if name.startswith("_"): - name = name[1:] - neg = True - check = cls.checks_list.get(name) - if check is None: - raise Check.CheckDoesNotExistError(name) - try: - check(params, val) - except (AssertionError, TypeError, ValueError) as e: - if not neg: - raise Check.CheckError(e) from e - except Check.CheckError: - if not neg: - raise - else: - if neg: - raise Check.CheckError( - f"check {name} did not fail on: {val!r}" - f" for params: {params!r}" - ) - - class CheckNameAlreadyExistsError(ValueError): - """ - The check name to be registerred already exists - """ - - class CheckDoesNotExistError(ValueError): - """ - The specified check does not exist - """ - - class CheckError(Exception): - """ - Error occurring during check call. - """ - - -class BuiltinChecks: - """ - This class holds the check definitions and callables for the varios builtin - checks. - """ - - @staticmethod - @Check.register("len") - def len_check(params: Union[int, slice], val): - """ - This check performs a length check, and may receive as parameter - an integer, where in search for equity between the length and the - integer, on a :py:`slice` instance where it tries to fit the length - in the slice provided parameters, which are optional. - Note: - it is been evaluated as :py:`slice.start <= val < slice.stop` - """ - if isinstance(params, int): - if not len(val) == params: - raise Check.CheckError(f"length of {val!r} not eq {params!r}") - elif isinstance(params, slice): - if params.start is not None: - if not len(val) >= params.start: - raise Check.CheckError( - f"length of {val!r} not gt {params.start!r} not in:" - f" {params!r}" - ) - if params.stop is not None: - if not len(val) < params.stop: - raise Check.CheckError( - f"length of {val!r} not lt {params.stop!r} not in:" - f" {params!r}", - ) - else: - raise Check.CheckError(f"wrong {params=!r} for len") - - @staticmethod - @Check.register("lt") - def lt_check(param: int, val: int): - """ - performs `lt(lesser than)` check - """ - if not val < param: - raise Check.CheckError(f"{val!r} not lt {param!r}") - - @staticmethod - @Check.register("le") - def le_check(param: int, val: int): - """ - performs `le(lesser or equal to)` check - """ - if not val <= param: - raise Check.CheckError(f"{val!r} not gt {param!r}") - - @staticmethod - @Check.register("ge") - def ge_check(param: int, val: int): - """ - performs `ge(greater or equal to)` check - """ - if not val >= param: - raise Check.CheckError(f"{val!r} not ge {param!r}") - - @staticmethod - @Check.register("gt") - def gt_check(param: int, val: int): - """ - performs `gt(greater than)` check - """ - if not val > param: - raise Check.CheckError(f"{val!r} not gt {param!r}") - - @staticmethod - @Check.register("eq") - def eq_check(param: int, val: int): - """ - Checks the two passed values are equal - """ - if not val == param: - raise Check.CheckError(f"{val!r} not eq {param!r}") - - @staticmethod - @Check.register("func") - def func_check(param: Callable[[Any], bool], val: Any): - """ - Uses the function passed as parameter. - The function should return a boolean - """ - if not param(val): - raise Check.CheckError(f"{param!r} call returned false on {val!r}") - - @staticmethod - @Check.register("type") - def matches_check(param, val): - """Uses `type_match(val, param)` to check the value""" - m, e = type_match(val, param) - if not m: - raise Check.CheckError(f"{val!r} foes not match type {param!r}", e) - - @staticmethod - @Check.register("isinstance") - def instance_check(param, val): - """uses :py:`isinstance(val, param)` to check the value""" - if not isinstance(val, param): - raise Check.CheckError(f"{val!r} foes no instance of {param!r}") - - -class Checks(PyoloadAnnotation): - """ - Pyoload annotation holding several checks called on typechecking. - """ - - __slots__ = ("checks",) - - def __init__( - self: PyoloadAnnotation, - *__check_funcs__, - **checks: dict[str, Callable[[Any, Any], NoneType]], - ) -> Any: - """ - crates the check object,e.g - - >>> class foo: - ... bar: pyoload.Checks(gt=4) - - :param checks: the checks to be done. - - :returns: self - """ - self.__func__ = __check_funcs__ - self.checks = checks - - def __call__(self: PyoloadAnnotation, val: Any) -> None: - """ - Performs the several checks contained in `self.checks` - - :param val: The value to check - """ - for func in self.__func__: - Check.check("func", func, val) - for name, params in self.checks.items(): - Check.check(name, params, val) - - def __str__(self: Any) -> str: - ret = "" - return ret - - __repr__ = __str__ - - -class CheckedAttr(Checks): - """ - A descriptor class providing attributes which are checked on assignment - """ - - __slots__ = ("name", "value") - name: str - value: Any - - def __init__( - self: Any, - **checks: dict[str, Callable[[Any, Any], NoneType]], - ) -> Any: - """ - Creates a Checked Attribute descriptor whick does checking on each - assignment, E.G - - >>> class foo: - ... bar = CheckedAttr(gt=4) - - :param checks: The checks to perform - """ - super().__init__(**checks) - - def __set_name__(self: Any, obj: Any, name: str, typo: Any = None): - print("...", self, obj, name, typo) - self.name = name - self.value = None - - def __get__(self: Any, obj: Any, type: Any): - print("getting") - return self.value - - def __set__(self: Any, obj: Any, value: Any): - print("ran checks", self) - self(value) - self.value = value - - -class Cast(PyoloadAnnotation): - """ - Holds a cast object which describes the casts to be performed - """ - - __slots__ = ("type",) - - @staticmethod - def cast(val: Any, totype: Any) -> Any: - """ - **The gratest deal.** - Recursively casts the given value to the specified structure or type - e.g - - >>> Cast.cast({ 1: 2}, dict[str, float]) - {'1': 2.0} - - :param val: the value to cast - :param totype: The type structure to be casted to. - - :returns: An instance of the casting type - """ - if totype == Any: - return val - if isinstance(totype, GenericAlias): - args = get_args(totype) - if get_origin(totype) == dict: - if len(args) == 2: - kt, vt = args - elif len(args) == 1: - kt, vt = args[0], Any - return {Cast.cast(k, kt): Cast.cast(v, vt) for k, v in val.items()} - elif get_origin(totype) == tuple and len(args := get_args(totype)) > 1: - args = get_args(totype) - return tuple(Cast.cast(val, ann) for val, ann in zip(val, args)) - else: - sub = args[0] - return get_origin(totype)([Cast.cast(v, sub) for v in val]) - if get_origin(totype) is Union or get_origin(totype) is UnionType: - errors = [] - for subtype in get_args(totype): - try: - return Cast.cast(val, subtype) - except Exception as e: - errors.append(e) - else: - raise errors - else: - return totype(val) if not isinstance(val, totype) else val - - def __init__(self: PyoloadAnnotation, type: Any): - """ - creates a casting object for the specified type - The object can then be used anywhere for casting, e.g - - >>> caster = Cast(dict[str, list[tuple[float]]]) - >>> raw = { - ... 4: ( - ... ['1.5', 10], - ... [10, '1.5'], - ... ) - ... } - >>> caster(raw) - {'4': [(1.5, 10.0), (10.0, 1.5)]} - - :param type: The type to which the object will cast - - :returns: self - """ - self.type = type - - def __call__(self: PyoloadAnnotation, val: Any): - """ - Calls to the type specified in the object `.type` attribute - - :param val: the value to be casted - - :return: The casted value - """ - try: - return Cast.cast(val, self.type) - except Exception as e: - raise CastingError( - f"Exception({e}) while casting: {val!r} to {self.type}", - ) from e - - def __str__(self): - return f"pyoload.Cast({self.type!r})" - - -class CastedAttr(Cast): - """ - A descriptor class providing attributes which are casted on assignment - """ - - __slots__ = "value" - value: Any - - def __init__(self: Cast, type: Any) -> Cast: - """ - >>> class Person: - ... age = CheckedAttr(gt=0) - ... phone = CastedAttr(tuple[int]) - ... def __init__(self, age, phone): - ... self.age = age - ... self.phone = phone - ... - >>> temeze = Person(17, "678936798") - >>> - >>> print(temeze.age) - 17 - >>> print(temeze.phone) - (6, 7, 8, 9, 3, 6, 7, 9, 8) - >>> mballa = Person(0, "123456") - Traceback (most recent call last): - ... - pyoload.Check.CheckError: 0 not gt 0 - """ - super().__init__(type) - - def __set_name__(self: Any, obj: Any, name: str, typo: Any = None): - self.value = None - - def __get__(self: Any, obj: Any, type: Any): - return self.value - - def __set__(self: Any, obj: Any, value: Any): - self.value = self(value) - - -def type_match(val: Any, spec: Union[Type, PyoloadAnnotation]) -> tuple: - """ - recursively checks if type matches - - :param val: The value to typecheck - :param spec: The type specifier - - :returns: A tuple of the match status and the optional errors - """ - try: - return (isinstance(val, spec), None) - except TypeError: - pass - if spec is any: - raise TypeError("May be have you confused `Any` and `any`") - - if spec is Any or spec is _empty or spec is None or val is None: - return (True, None) - if isinstance(spec, Values): - return (spec(val), None) - elif isinstance(spec, Checks): - try: - spec(val) - except Check.CheckError as e: - return (False, e) - else: - return (True, None) - elif get_origin(spec) in (Union, UnionType): - errs = [] - for arg in get_args(spec): - m, e = type_match(val, arg) - if m: - del errs - return m, e - else: - errs.append(e) - else: - return (False, errs) - - elif isinstance(spec, GenericAlias): - orig = get_origin(spec) - if not isinstance(val, orig): - return (False, None) - - if orig == dict: - args = get_args(spec) - if len(args) == 2: - kt, vt = args - elif len(args) == 1: - kt, vt = args[0], Any - - for k, v in val.items(): - k, e = type_match(k, kt) - if not k: - return (False, e) - v, e = type_match(v, vt) - if not v: - return (False, e) - else: - return (True, None) - elif orig == tuple and len(args := get_args(spec)) > 1: - vals = zip(val, args) - for val, ann in vals: - b, e = type_match(val, ann) - if not b: - return b, e - else: - return (True, None) - else: - sub = get_args(spec)[0] - for val in val: - m, e = type_match(val, sub) - if not m: - return (False, e) - else: - return (True, None) - raise AnnotationError(f"could not match type {spec=!r} to {val=!r}") - - -def resove_annotations(obj: Callable) -> None: - """ - Evaluates all the stringized annotations of the argument - - :param obj: The object of which to evaluate annotations - - :returns: None - """ - if not hasattr(obj, "__annotations__"): - raise AnnotationResolutionError( - f"object {obj=!r} does not have `.__annotations__`", - ) - if isfunction(obj): - for k, v in obj.__annotations__.items(): - if isinstance(v, str): - try: - obj.__annotations__[k] = eval( - v, - obj.__globals__, - dict(vars(getmodule(obj))) - ) - except Exception as e: - raise AnnotationResolutionError( - f"Exception: `{e!s}` while resolving" - f" annotation {v!r} of function {obj!r}", - ) from e - elif isclass(obj) or hasattr(obj, "__class__"): - for k, v in obj.__annotations__.items(): - if isinstance(v, str): - try: - obj.__annotations__[k] = eval( - v, - dict(vars(getmodule(obj))), - dict(vars(obj)) if hasattr(obj, "__dict__") else None, - ) - except Exception as e: - raise AnnotationResolutionError( - ( - f"Exception: {e!s} while resolving" - f" annotation {e}={v!r} of object {obj!r}" - ), - ) from e - - -def annotate( - func: Callable, - *, - force: bool = False, - oload: bool = False, -) -> Callable: - """ - returns a wrapper over the passed function - which typechecks arguments on each call. - - :param func: the function to annotate - :param force: annotate force even on unannotatables - :param oload: internal, if set to True, will raise \ - `InternalAnnotationError` on type mismatch - - :returns: the wrapper function - """ - if isinstance(func, bool): - return partial(annotate, force=True) - if not callable(func) or not hasattr(func, "__annotations__"): - return func - if is_annoted(func): - return func - if isclass(func): - return annotate_class(func) - if not is_annotable(func) and not force: - return func - func.__pyod_signature__ = signature(func) - annotations = func.__annotations__.copy() - - @wraps(func) - def wrapper(*pargs, **kw): - if str in map(type, func.__annotations__.values()): - resove_annotations(func) - if annotations != func.__annotations__: - annotations.clear() - annotations.update(func.__annotations__) - func.__pyod_signature__ = signature(func) - sign = func.__pyod_signature__ - try: - args = sign.bind(*pargs, **kw) - except Exception: - if oload: - raise InternalAnnotationError() - else: - raise - errors = [] - for k, v in args.arguments.items(): - param = sign.parameters.get(k) - if param.annotation is _empty: - continue - if isinstance(param.annotation, Cast): - args.arguments[k] = param.annotation(v) - continue - if not type_match(v, param.annotation)[0]: - if oload: - raise InternalAnnotationError() - errors.append( - AnnotationError( - f"Value: {v!r} does not match annotation:" - f" {param.annotation!r} for " - f"argument {k!r} of function {get_name(func)}", - ), - ) - if len(errors) > 0: - raise AnnotationErrors(errors) - - ret = func(**args.arguments) - - if sign.return_annotation is not _empty: - ann = sign.return_annotation - - if isinstance(ann, Cast): - return ann(ret) - m, e = type_match(ret, ann) - if not m: - raise AnnotationError( - f"return value: {ret!r} does not match annotation:" - f" {ann!r} for " - f"of function {get_name(func)}", - e, - ) - return ret - - wrapper.__pyod_annotate__ = func - return wrapper - - -def unannotate(func: Callable) -> Callable: - """ - Returns the underlying function returned by :py:`annotate`, - if not annotated it returns the passed function. - - :param func: the function to unwrap - - :returns: The unwrapped function - """ - if hasattr(func, "__pyod_annotate__"): - return func.__pyod_annotate__ - else: - return func - - -def unannotable(func: Callable) -> Callable: - """ - Marks a function to be not annotable, the function will then not be wrapped - by :py:`annotate` or :py:`multimethod`, except :py:`force=True` argument - specified. - """ - func = unannotate(func) - func.__pyod_annotable__ = False - return func - - -def annotable(func: Callable) -> Callable: - """ - Marks a function to be annotatble by :py:`annotate` and :py:`overload` - """ - func.__pyod_annotable__ = True - return func - - -def is_annotable(func): - """ - Returns if the function posses the unannotable mark. - """ - return not hasattr(func, "__pyod_annotable__") or func.__pyod_annotable__ - - -def is_annoted(func): - """ - Determines if a function has been annotated. - """ - return hasattr(func, "__pyod_annotate__") - - -__overloads__: dict[str, list[Callable]] = {} - - -def multimethod(func: Callable, name: str = None, force: bool = False) -> Callable: - """ - returns a wrapper over the passed function - which typechecks arguments on each call - and finds the function instance with same name which does not raise - an `InternalAnnotationError` exception. - if `func` is a string, overload will return another registering function - which will register to the specified name. - - The decorated function takes some new attributes: - - __pyod_annotate__: The raw function - - __pyod_dispatches__: The list of the function overloads - - multimethod(func: Callable) registers the passed function under the same\ - name. - - :param func: the function to annotate - :param name: optional name under which to register. - :param force: overloads even unnanotable functions - - :returns: the wrapper function - """ - if isinstance(func, str): - return partial(multimethod, name=func) - if name is None or not isinstance(name, str): - name = get_name(func) - if name not in __overloads__: - __overloads__[name] = [] - __overloads__[name].append(annotate(func, oload=True, force=force)) - - @wraps(func) - def wrapper(*args, **kw): - for f in __overloads__[name]: - try: - val = f(*args, **kw) - except InternalAnnotationError: - continue - else: - break - else: - raise OverloadError( - f"No overload of function: {get_name(func)}" - f" matches types of arguments: {args}, {kw}", - ) - return val - - wrapper.__pyod_dispatches__ = __overloads__[name] - wrapper.__pyod_overloads_name__ = name - wrapper.overload = wrapper.add = partial(overload, name=name) - - return wrapper - - -overload = multimethod - - -def annotate_class(cls: Any, recur: bool = True): - """ - Annotates a class object, wrapping and replacing over it's __setattr__ - and typechecking over each attribute assignment. - If no annotation for the passed object found it ignores it till it is - found - it recursively annotates the classes methods except `__pyod_norecur__` - attribute is defines - """ - - if isinstance(cls, bool): - return partial(annotate_class, recur=cls) - recur = not hasattr(cls, "__pyod_norecur__") and recur - setter = cls.__setattr__ - if recur: - for x in vars(cls): - if x[:2] == x[-2:] == "__" and x != "__init__": - continue - if hasattr(getattr(cls, x), "__annotations__"): - setattr( - cls, - x, - annotate(vars(cls).get(x)), - ) - - @wraps(cls.__setattr__) - def new_setter(self: Any, name: str, value: Any) -> Any: - if str in map(type, self.__annotations__.values()): - resove_annotations(self) - - if name not in self.__annotations__: - return setter(self, name, value) # do not check if no annotations - elif isinstance(self.__annotations__[name], Cast): - return setter(self, name, self.__annotations__[name](value)) - - else: - m, e = type_match(value, self.__annotations__[name]) - if not m: - raise AnnotationError( - f"value {value!r} does not match annotation" - f"of attribute: {name!r}:{self.__annotations__[name]!r}" - f" of object of class {get_name(cls)}", - e, - ) - return setter(self, name, value) - - cls.__setattr__ = new_setter - return cls - - -__all__ = [ - "annotate", - "annotate_class", - "overload", - "multimethod", - "Checks", - "Check", - "annotable", - "unannotable", - "unannotate", - "is_annotable", - "is_annoted", - "resove_annotations", - "Cast", - "CastedAttr", - "CheckedAttr", - "Values", - "AnnotationResolutionError", - "AnnotationError", - "Type", - "Any", - "type_match", -] - -__version__ = "2.0.1" -__author__ = "ken-morel"