Defining a programmatic IDS

How to create a programmatic IDS

ts-ids-core allows you to export your programmatic IDS to JSON schema. The exported IDS JSON schema is the interface between an IDS definition and the TetraScience platform. If you want to convert an existing JSON schema to a programmatic IDS, see Generate programmatic IDS from JSON schema.

Model definition

IDSs are built using components. Components are individual object definitions that conform to the TetraScience platform requirements of IDSs. To define IDS schema components, inherit from the ts_ids_core.base.ids_element.IdsElement class and define fields similar to how pydantic.BaseModel or dataclasses.dataclass() are defined. Fields can be specified in the following three ways:

  1. With a type annotation. For example,

from ts_ids_core.base.ids_element import IdsElement

class Example(IdsElement):
    field_1: str

This is the equivalent of:

{
  "additionalProperties": false,
  "properties": {
    "field_1": {
      "type": "string"
    }
  },
  "type": "object"
}
  1. With a type annotation and assignment to a default value via the equals operator. For example, if the default value is to be “value”, do

from ts_ids_core.base.ids_element import IdsElement


class Example(IdsElement):
    field_1: str = "value"
  1. Via the ts_ids_core.base.ids_element.IdsField() function. For example,

from ts_ids_core.base.ids_element import IdsElement
from ts_ids_core.base.ids_field import IdsField


class Example(IdsElement):
    field_1: str = IdsField()
    field_2: str = IdsField(default="value")

Specifying const fields

Defining a field as a constant, or const, is accomplished the same way as Pydantic does, using the Literal type hint.

from typing import Literal

from ts_ids_core.base.ids_element import IdsElement

class Example(IdsElement):
    field_1: Literal["value"]

This is the equivalent of:

{
  "additionalProperties": false,
  "properties": {
    "field_1": {
      "const": "value",
      "type": "string"
    }
  },
  "type": "object"
}

Disallowed field types

Note that fields may only be atomic types or subclasses of ts_ids_core.base.ids_element.IdsElement. They cannot be containers, e.g. dict or tuple, or other generics, e.g. typing.TypeVar. Additionally, fields may not have multiple types, with the exception that atomic types may be “nullable” (see below). For example:

Allowed:

from ts_ids_core.base.ids_element import IdsElement
from typing import Union

class Foo(IdsElement):
    foo: str

class Bar(IdsElement):
    bar: Foo
    baz: Union[str, None]

Disallowed:

from ts_ids_core.base.ids_element import IdsElement
from typing import Union, Tuple

class Foo(IdsElement):
    foo: str

class Bar(IdsElement):
    bar: Union[Foo, None]
    baz: Tuple[Foo, str]

Nullable fields

Nullable fields can be defined using the ts_ids_core.annotations.Nullable type hint. Defining a field as nullable is equivalent to defining the field as Union[T, None] where T is any atomic type. Thus, a nullable field may take on the value of None in Python (null in JSON) or a value of type T. This is the only case in which a field can be defined as a union of two types, see Disallowed field types above. Non-nullable fields are fields that cannot take on the value None.

from ts_ids_core.base.ids_element import IdsElement
from ts_ids_core.annotations import Nullable


class Example(IdsElement):
    field: Nullable[str]

Required Fields

There is a difference between how Pydantic defines fields as required vs. how ts-ids-core does. Designating a field as required in Pydantic can be done by either defining a field with no default value or defining it with an ellipsis as the default. For more see here.

from pydantic import BaseModel, Field


class Model(BaseModel):
    a: int
    b: int = ...
    c: int = Field(...)

In this case you cannot instantiate the Model class without passing a value for each field. Fields that are defined with a default value that is not an ellipsis are considered non-required.

from pydantic import BaseModel, Field


class Model(BaseModel):
    a: int = 1
    b: int = Field(default=1)
    c: int = Field(default_factory=lambda: 1)

In this case the Model class can be instantiated without passing a value for any given field. This model of required is conducive to Python’s behavior vs. the behavior of JSON schema validation. In the case of Python, all fields must have a value for the class to be instantiated whether they are required or not. Values for required fields need to be explicitly passed and values for non-required fields can be explicitly passed or will fall back to their defined default values. In the case of JSON schema validation, a JSON instance is valid when you have a non-required field and do not provide value for it (i.e. the field is non-existent in the JSON instance).

“Required” in ts-ids-core is equivalent to that in JSON Schema; required fields must have a value in the JSON instance and non-required fields can either have a value or be missing from the JSON instance. The Pydantic way of defining a field as required is not supported in ts-ids-core. To define a field as required using ts-ids-core, use the ts_ids_core.annotations.Required type annotation. For example:

from ts_ids_core.annotations import Required
from ts_ids_core.base.ids_element import IdsElement

class Example(IdsElement):
    required_field: Required[str]  # required
    non_required_no_default: str  # not required
    non_required_with_default: str = "foo"  # not required

This is the equivalent of:

{
  "additionalProperties": false,
  "properties": {
    "required_field": {
      "type": "string"
    },
    "non_required_no_default": {
      "type": "string"
    },
    "non_required_with_default": {
      "type": "string"
    }
  },
  "required": [
    "required_field"
  ],
  "type": "object"
}

In the example above, unlike Pydantic’s default behavior, the Example class can be instantiated without passing a value for the field non_required_no_default. Under the hood, ts-ids-core will assign the field with a default of ts_ids_core.base.ids_undefined_type.IDS_UNDEFINED. Once you serialize this model’s instance to JSON, non_required_no_default will be omitted from the JSON instance.

Mandatory vs. Non-Mandatory Inheritance of Fields

What is “mandatory” and “non-mandatory” inheritance?

One of the goals of ts-ids-core is standardizing field names and their types. This can be done in large part using inheritance:

from ts_ids_core.base.ids_element import IdsElement
from ts_ids_core.annotations import Nullable

class Parent(IdsElement):
    foo: str
    bar: int

class Child(Parent):
    baz: Nullable[str]

assert "foo" in Child.model_fields
assert "bar" in Child.model_fields

However, what if we want to standardize a field that doesn’t always need to be defined in a child class? A “non-mandatory” inheritance enables precisely that; it’s a recommendation to use a particular field name and type to represent an abstract kind of data. “Mandatory” inheritance is inheritance as used in standard object-oriented programming, seen in the example above.

Non-mandatory inheritance implementation guidelines

The following recommends how to implement “non-mandatory” inheritance using a parent class and two subclasses of said parent class. Suppose we wanted to make the foo field below have “non-mandatory” inheritance.

from ts_ids_core.base.ids_element import IdsElement
from ts_ids_core.annotations import Nullable

class Parent(IdsElement):
    class Foo(IdsElement):
        foo: int  # non-mandatory inherited field
    bar: str

class ChildA(Parent, Parent.Foo):  # inherits non-mandatory field
    baz: Nullable[str]

class ChildB(Parent):  # does not inherit non-mandatory field
    qux: Nullable[str]

assert {"bar"} == set(Parent.model_fields)
assert {"foo", "bar", "baz"} == set(ChildA.model_fields)
assert {"bar", "qux"} == set(ChildB.model_fields)

ChildA contains the foo: int field while ChildB does not. By defining class Foo in the body of Parent – that is, making class Foo a static member of class Parent – we indicate that the foo: int field is associated with Parent and its child classes. If we are confident that foo fields will always be integers, users should consider defining class Foo as a module-level class; such syntax is more commonly found in Python code and may be easier to read.