anvilistas / anvil-labs

MIT License
9 stars 3 forks source link

zod: validation module #110

Closed s-cork closed 1 year ago

s-cork commented 1 year ago

WIP: based on source code from https://zod.dev/

I'm quite excited about this one so thought I'd draft PR the proof of concept

TODO:

meatballs commented 1 year ago

Looking very nice!

s-cork commented 1 year ago

@meatballs - I'm pretty close on this one and I think it's turning into something quite nice

A typical form might look like this


# schemas.py
from anvil_labs import zod as z

new_user_schema = z.object(
    {
        "email": z.string().strip().email(),
        "name": z.string().min(5),
        "age": z.integer().lt(100).ge(18),
    }
)

# Form1
from ..schemas import new_user_schema

class Form1(Form1Template):
    def submit_button_click(self, **event_args):
        try:
            new_user = new_user_schema.parse(
                {"email": self.email_input.text, "name": self.name_input.text, "age": self.age_input.text}
            )
            anvil.server.call("add_user", new_user)
        except z.ParseError as e:
            print(e)
            self.email_err_lbl.text = "; ".join(e.errors("email"))
            self.name_err_lbl.text = "; ".joint(e.errors("name"))
            self.age_err_lbl.text = "; ".join(e.errors("age"))
        else:
            ...  # clear the form

# Server1

from . schemas import new_user_schema

@anvil.server.callable
def add_new_user(user_def):
    user_def = new_user_schema.parse(user_def) # might fail here
    ...

Let me know what you think and what questions you have I like that it's not tied to the UI:

I wonder how you might use it for classes because there's currently no support for attribute schemas

I figured you might do something like:


def validate(schema):
    def wrapper(cls):
        def inner(*args, **kws):
            self = cls(*args, **kws)
            schema.parse(self.__dict__)
            return self
        return inner
    return wrapper

schema = z.object({'foo': z.enum([1, 2, 3])})

@validate(schema)
class Foo:
    def __init__(self, foo, bar):
        self.foo = foo
        self.bar = bar

Foo(foo=1, bar=None) # fine
Foo(foo='a', bar=None) # ZodError: Expected 1 | 2 | 3, received string at ['foo']

but maybe there's a better way

meatballs commented 1 year ago

I've been keeping an eye on this and it looks excellent. I also particularly like how it's independent of the UI.

My only question is how do I add a new test? Say, for example, I had a regex that a string needs to match. How would do that?

s-cork commented 1 year ago

Two options The string specific version is just

z.string().regex(re.compile(...), "expected a ...")

But there's also the generic

z.string().refine(str.isalpha, "expected a ...")

Refine takes a callable that should return True/False. Plus an optional message.


Opinions on msg vs message? zod js uses message but I've used msg. I'm tempted to go with message.

meatballs commented 1 year ago

Aha! It was refine that I'd missed! Nice.

I'd go with message. Clearer for non native English speakers.

s-cork commented 1 year ago

minus docs I think this is ready to have a look at (I might do those another time) I added tests adjusted from zod js library

s-cork commented 1 year ago

Tried to use it and one use case for form validation is a bit awkward: See this discussion:

https://github.com/colinhacks/zod/issues/310#issuecomment-794533682

.optional() is really supposed to be used in an zod object to say I don't mind if that key, value pair is missing (since python doesn't have a concept of undefined we use MISSING to represent this)

But when used on a solitary z.string() schema, optional is really quite meaningless like

schema = z.string().email().optional()
try:
    schema.parse(self.input_1.text)
except z.ParseError as e:
   print(e) # will fail for empty string

Currently we'd have to do something like:

schema = z.string().email().or_(z.literal(""))
# or
schema = z.union([z.litera(""), z.string().email()])

I can see a few other ways around this for us we can add kwargs to optional(), like allow_empty=True But i'm not sure about this.

we could do what this library does https://www.remix-validated-form.io/zod-form-data/api-reference#text

schema = z.text(z.string().email().optional())

They also have an api for working with text that should be numeric

schema = z.numeric(z.number().optional())

answers on a post card...

s-cork commented 1 year ago

Ok - I think we can worry about optional in the documentation

Here's an example of it working quite nicely https://anvil.works/build#clone:BXEGXJKXFVCEXEB4=4W3Q7L2PIH3SZARF4K3KW5BI|XCOXK7RMKUZ4GRXD=PTRRADDWEUMQQ2T3HGKA6NDN|C6ZZPAPN4YYF5NVJ= (it'll copy my local version of anvil-labs with the fixes from #112) I made a vanilla and an atomic version just for fun atomic is so satisfying once it's done - took me a while to remember how it all fits together though!

I also played around with stefano's validation library, and adding a method to the validation class with_schema can basically be used in place of all the helper methods.