oblio
oblio

Reputation: 1643

How can I implement multiple optional fields but with at least one of them mandatory in MyPy or Pydantic?

I'm trying to write a minimal, custom, JSON:API 1.1 implementation using Python.

For the top level, the RFC/doc says:

https://jsonapi.org/format/#document-top-level

A document MUST contain at least one of the following top-level members:

  • data: the document’s “primary data”.
  • errors: an array of error objects.
  • meta: a meta object that contains non-standard meta-information.
  • a member defined by an applied extension.

The members data and errors MUST NOT coexist in the same document.

The way I read that is:

However, at least 1 of them needs to be present in the document. Also "data" and "errors" must never be in the same document.

I'm having a hard time modeling this. Is there a way to do it using the type system or do I have to do custom validation of some sort?

Data|Errors|Meta|ExtensionMember doesn't cut it.

Upvotes: 1

Views: 376

Answers (1)

Fynn
Fynn

Reputation: 750

With Pydantic you can write a validator for this. Here is an example:

from pydantic import BaseModel, model_validator, ValidationError


class Document(BaseModel):
    data: str | None
    error: str | None
    meta: str | None
    extension: str | None

    @model_validator(mode="after")
    def _has_necessary_member(self):
        """Ensure the document has one of "data", "error", "meta" or "extension" set."""
        has_data = self.data is not None
        has_error = self.error is not None
        has_meta = self.meta is not None
        has_extension = self.extension is not None

        if not (has_data or has_error or has_meta or has_extension):
            raise ValueError('This model has to have one of "data", "error", "meta" or "extension" set.')

        return self

    @model_validator(mode="after")
    def _data_and_error_are_mutex(self):
        """Ensure the document has EITHER data, OR an error."""
        has_data = self.data is not None
        has_error = self.error is not None

        if has_data and has_error:
            raise ValueError("This model can't have data and error set at the same time.")

        return self


if __name__ == '__main__':
    d = Document(data="Foo", error=None, meta=None, extension=None)  # works
    print(d)

    try:
        Document(data="Foo", error="Error", meta=None, extension=None)  # fails
    except ValidationError as e:
        print(e)

    try:
        Document(data=None, error=None, meta=None, extension=None)  # fails
    except ValidationError as e:
        print(e)

You could also choose to use a union to represent the data/error, and validate this.

class Document(BaseModel):
    result: Data | Error | None
    meta: Meta | None
    extension: Extension | None

This is a matter of opinion, and the choice is yours. The validators are similar in both cases.

Note that Pydantic will only give you runtime checks and not static analysis like mypy does. There's a mypy plugin for pydantic, but personally I find it to be a bit hit or miss sometimes.

Upvotes: 2

Related Questions