Repository: monarch-initiative/pyphetools (language: python)

Issue:
Improve ergonomics
Add model objects for the most commonly used elements of the Phenopacket Schema support autocompletion, and to improve ergonomics. 

The ergonomics improvements should include something similar to Phenopacket tools, where we can set sex, karyotype, age, vital status, etc. directly on the individual.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/python_ci.yml b/.github/workflows/python_ci.yml
index b638df38..0e1c4b78 100644
--- a/.github/workflows/python_ci.yml
+++ b/.github/workflows/python_ci.yml
@@ -18,13 +18,15 @@ jobs:
       steps:
         - uses: actions/checkout@v4
 
-        - name: Initialize Python ${{ matrix.python }}
+        - name: Initialize Python 3.10
           uses: actions/setup-python@v5
           with:
-            python-version: "3.11"
+            python-version: "3.10"
 
         - name: Install pyphetools
-          run: python3 -m pip install .[test]
+          run: |
+            python3 -m pip install --editable .[test]
 
         - name: Run all tests
-          run: python3 -m pytest
+          run: |
+            pytest
diff --git a/src/pyphetools/__init__.py b/src/pyphetools/__init__.py
index 3518fbe7..47a9ab70 100644
--- a/src/pyphetools/__init__.py
+++ b/src/pyphetools/__init__.py
@@ -1,5 +1,6 @@
 # top level
 from . import creation
+from . import pp
 from . import visualization
 from . import validation
 
@@ -7,6 +8,7 @@
 
 __all__ = [
     "creation",
+    "pp",
     "visualization",
     "validation"
 ]
diff --git a/src/pyphetools/pp/__init__.py b/src/pyphetools/pp/__init__.py
new file mode 100644
index 00000000..108561af
--- /dev/null
+++ b/src/pyphetools/pp/__init__.py
@@ -0,0 +1,13 @@
+"""
+A package with strongly typed Phenopacket Schema types and the code for I/O and validation.
+"""
+
+from . import parse
+from . import v202
+from ._timestamp import Timestamp
+
+__all__ = [
+    'parse',
+    'v202',
+    'Timestamp',
+]
diff --git a/src/pyphetools/pp/_api.py b/src/pyphetools/pp/_api.py
new file mode 100644
index 00000000..4ca22a20
--- /dev/null
+++ b/src/pyphetools/pp/_api.py
@@ -0,0 +1,18 @@
+import abc
+
+from .parse import Serializable, Deserializable, FromProtobuf, ToProtobuf
+
+
+# TODO:
+# - There is a problem with map<string, string> because it is represented as a JSON object
+#    and the case of keys will be changed during write.
+# - We do not validate the values, just their presence and type
+
+
+class MessageMixin(Serializable, Deserializable, FromProtobuf, ToProtobuf,
+                   metaclass=abc.ABCMeta):
+
+    # MANDATORY
+    @abc.abstractmethod
+    def __eq__(self, other):
+        pass
diff --git a/src/pyphetools/pp/_timestamp.py b/src/pyphetools/pp/_timestamp.py
new file mode 100644
index 00000000..568add28
--- /dev/null
+++ b/src/pyphetools/pp/_timestamp.py
@@ -0,0 +1,173 @@
+import math
+import typing
+from datetime import datetime, timezone
+
+from google.protobuf.message import Message
+from google.protobuf.timestamp_pb2 import Timestamp as PbTimestamp
+
+from .parse import ToProtobuf, FromProtobuf
+
+
+class Timestamp(ToProtobuf, FromProtobuf):
+    """
+    This `Timestamp` implementation is functionally equivalent to protobuf's timestamp.
+
+    Per protobuf API documentation, *A Timestamp represents a point in time
+    independent of any time zone or local calendar, encoded as a count of seconds
+    and fractions of seconds at nanosecond resolution.
+    The count is relative to an epoch at UTC midnight on January 1, 1970,
+    in the proleptic Gregorian calendar which extends the Gregorian calendar
+    backwards to year one.*
+
+    Consult the `Phenopacket Schema <https://phenopacket-schema.readthedocs.io/en/latest/timestamp.html>`_ documentation
+    for more information.
+
+    **Examples**
+
+    Here we show how to create a `Timestamp` from various inputs.
+
+    >>> from pyphetools.pp import Timestamp
+
+    Let's create a timestamp from a date time string:
+
+    >>> ts = Timestamp.from_str('1970-01-01T00:00:30Z')
+    >>> ts.seconds, ts.nanos
+    (30, 0)
+
+    Note, we indicate that the timestamp is in UTC by adding `Z` suffix.
+
+    We can also create a timestamp from a local time.
+    Let's create the same `Timestamp` but now in Eastern Daylight Time (EDT)
+    which is 4 hours behind UTC:
+
+    >>> ts_local = Timestamp.from_str('1969-12-31T20:00:30-04:00')
+    >>> assert ts_local == ts
+
+    We can also create timestamp from a datetime object:
+
+    >>> from datetime import datetime, date, time, timezone
+    >>> d = date(1970, 1, 1)
+    >>> t = time(0, 0, 30)
+    >>> dt = datetime.combine(d, t, tzinfo=timezone.utc)
+    >>> ts_dt = Timestamp.from_datetime(dt)
+    >>> assert ts_dt == ts
+
+    Last, we can create timestamp directly from seconds and nanoseconds:
+
+    >>> ts_raw = Timestamp(30, 0)
+    >>> assert ts_raw == ts
+
+    and we can convert the timestamp to a UTC date time string:
+
+    >>> ts_raw.as_str()
+    '1970-01-01T00:00:30Z'
+    """
+
+    def __init__(
+            self,
+            seconds: int,
+            nanos: int,
+    ):
+        # Seconds can be positive or negative,
+        # The negative seconds represent the timestamps prior the UNIX epoch.
+        self._seconds = seconds
+        self._nanos = nanos  # TODO: check that nanos is a positive `int`
+
+    @property
+    def seconds(self) -> int:
+        return self._seconds
+
+    @seconds.setter
+    def seconds(self, value: int):
+        self._seconds = value
+
+    @property
+    def nanos(self) -> int:
+        return self._nanos
+
+    @nanos.setter
+    def nanos(self, value: int):
+        self._nanos = value
+
+    def as_datetime(self) -> datetime:
+        """
+        Convert timestamp into Python's datetime object.
+
+        The datetime is always in UTC.
+
+        **Example**
+
+        >>> from pyphetools.pp import Timestamp
+        >>> ts = Timestamp(10, 500)
+        >>> dt = ts.as_datetime()
+
+        Now we can access the datetime components:
+
+        >>> dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second
+        (1970, 1, 1, 0, 0, 10)
+
+        including the time zone:
+
+        >>> dt.tzname()
+        'UTC'
+        """
+        return datetime.fromtimestamp(self._seconds + (self._nanos / 10 ** 9), tz=timezone.utc)
+
+    def as_str(self, fmt: str = '%Y-%m-%dT%H:%M:%SZ') -> str:
+        """
+        Convert timestamp into a date time string.
+
+        **Example**
+
+        >>> from pyphetools.pp import Timestamp
+        >>> ts = Timestamp(0, 500_000)
+        >>> ts.as_str()
+        '1970-01-01T00:00:00Z'
+
+        We can use different formatting:
+
+        >>> ts.as_str('%Y-%m-%dT%H:%M:%S.%f%Z')
+        '1970-01-01T00:00:00.000500UTC'
+        """
+        return self.as_datetime().strftime(fmt)
+
+    @staticmethod
+    def from_str(val: str, fmt: str = '%Y-%m-%dT%H:%M:%S%z'):
+        """
+        Create `Timestamp` from a date time string.
+
+        :param val: the date time `str`.
+        :param fmt: the date time format string.
+        """
+        dt = datetime.strptime(val, fmt)
+        return Timestamp.from_datetime(dt)
+
+    @staticmethod
+    def from_datetime(dt: datetime):
+        fractional, integral = math.modf(dt.timestamp())
+        return Timestamp(seconds=int(integral), nanos=int(fractional * 1_000_000))
+
+    def to_message(self) -> Message:
+        return PbTimestamp(seconds=self._seconds, nanos=self._nanos)
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return PbTimestamp
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, PbTimestamp):
+            return Timestamp(
+                seconds=msg.seconds,
+                nanos=msg.nanos,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Timestamp) \
+            and self._seconds == other._seconds \
+            and self._nanos == other._nanos
+
+    def __repr__(self):
+        return f'Timestamp(seconds={self._seconds}, nanos={self._nanos})'
diff --git a/src/pyphetools/pp/parse/__init__.py b/src/pyphetools/pp/parse/__init__.py
new file mode 100644
index 00000000..1c7e71dd
--- /dev/null
+++ b/src/pyphetools/pp/parse/__init__.py
@@ -0,0 +1,14 @@
+"""
+A package to support the (de)serialization of phenopacket schema elements to/from various formats,
+such as JSON, YAML, and protobuf.
+"""
+from . import json
+from ._io import Serializer, Serializable, Deserializer, Deserializable
+from ._io import extract_message_scalar, extract_message_sequence, extract_oneof_scalar
+from ._pb import FromProtobuf, ToProtobuf
+from ._pb import extract_pb_message_scalar, extract_pb_message_seq, extract_pb_oneof_scalar
+
+__all__ = [
+    'Serializer', 'Serializable', 'Deserializer', 'Deserializable',
+    'FromProtobuf', 'ToProtobuf',
+]
diff --git a/src/pyphetools/pp/parse/_io.py b/src/pyphetools/pp/parse/_io.py
new file mode 100644
index 00000000..ef9545b7
--- /dev/null
+++ b/src/pyphetools/pp/parse/_io.py
@@ -0,0 +1,236 @@
+import abc
+import enum
+import io
+import typing
+
+
+class Serializable(metaclass=abc.ABCMeta):
+    """
+    `Serializable` is a mixin class for data classes composed of a hierarchy of primitive types, such as `bool`, `int`,
+    `float`, or `str`, or other `Serializable` objects.
+
+    `Serializable` knows how to convert the data into a hierarchy composed of primitive types
+    or Python compound types such as `list` and `dict`. The hierarchy is an intermediate step before serializing
+    into a data format such as JSON, YAML or others using a :class:Serializer`.
+
+    The mixin requirements include a single method: :func:`field_names` and the other functionality then comes for free.
+    """
+
+    _PRIMITIVES = {
+        bool, int, float, str,
+    }
+
+    @staticmethod
+    @abc.abstractmethod
+    def field_names() -> typing.Iterable[str]:
+        """
+        Get an iterable with the data class field names.
+        """
+        pass
+
+    def to_dict(self,
+                out: typing.MutableMapping[str, typing.Any]):
+        """
+        Write itself into a dictionary composed of primitive or Python compound types.
+        """
+        for name in self.field_names():
+            field = getattr(self, name)
+            Serializable._put_field_to_mapping(name, field, out)
+
+    @staticmethod
+    def _put_field_to_mapping(
+            name: str,
+            field: typing.Optional[typing.Any],
+            out: typing.MutableMapping[str, typing.Any],
+    ):
+        if field is None:
+            pass
+        elif type(field) in Serializable._PRIMITIVES:
+            out[name] = field
+        elif isinstance(field, typing.Sequence):
+            seq = []
+            for subfield in field:
+                Serializable._put_field_to_sequence(subfield, seq)
+            out[name] = seq
+        elif isinstance(field, typing.Mapping):
+            val = {}
+            for k, v in field.items():
+                Serializable._put_field_to_mapping(k, v, val)
+            out[name] = val
+        elif isinstance(field, Serializable):
+            val = {}
+            field.to_dict(val)
+            out[name] = val
+        elif isinstance(field, enum.Enum):
+            out[name] = field.name
+        elif hasattr(field, 'seconds') and hasattr(field, 'nanos') and hasattr(field, 'as_str') and callable(field.as_str):
+            # This quack *exactly* as a Timestamp!
+            out[name] = field.as_str()
+        else:
+            raise ValueError(f'Unexpected field {field}')
+
+    @staticmethod
+    def _put_field_to_sequence(
+            field: typing.Optional[typing.Any],
+            out: typing.MutableSequence[typing.Any],
+    ):
+        if field is None:
+            pass
+        elif type(field) in Serializable._PRIMITIVES:
+            out.append(field)
+        elif isinstance(field, Serializable):
+            val = {}
+            for field_name in field.field_names():
+                sub = getattr(field, field_name)
+                Serializable._put_field_to_mapping(field_name, sub, val)
+            out.append(val)
+        elif isinstance(field, typing.Mapping):
+            val = {}
+            for k, v in field.items():
+                Serializable._put_field_to_mapping(k, v, val)
+            out.append(val)
+        elif isinstance(field, enum.Enum):
+            out.append(field.name)
+        elif hasattr(field, 'seconds') and hasattr(field, 'nanos') and hasattr(field, 'as_str') and callable(field.as_str):
+            # This quack *exactly* as a Timestamp!
+            out.append(field.as_str())
+        else:
+            # We should not have to process a sequence within a sequence.
+            raise ValueError(f'Unexpected field {field}')
+
+
+class Serializer(metaclass=abc.ABCMeta):
+    """
+    `Serializer` serializes a :class:`Serializable` object into a format such as JSON, YAML, or others.
+
+    The format depends on the serializer subclass.
+    """
+
+    @abc.abstractmethod
+    def serialize(
+            self,
+            val: Serializable,
+            fp: typing.IO,
+    ):
+        """
+        Serialize a value `val` into the provided IO object `fp`.
+        """
+        pass
+
+
+E = typing.TypeVar('E', bound=enum.Enum)
+"""
+A type that is a subclass of :class:`enumEnum`.
+"""
+
+
+class Deserializable(metaclass=abc.ABCMeta):
+    """
+    `Deserializable` knows how to initialize itself
+    based on a `dict` with intermediate Python representation.
+
+    See :class:`Serializable` for more info.
+    """
+
+    @classmethod
+    @abc.abstractmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        # Can raise if a required field is missing
+        pass
+
+    @classmethod
+    @abc.abstractmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        # May not be implemented if the class includes a field with oneof Protobuf semantics!
+        pass
+
+    @classmethod
+    def _all_required_fields_are_present(
+            cls,
+            values: typing.Mapping[str, typing.Any]
+    ) -> bool:
+        return all(field in values for field in cls.required_fields())
+
+    @classmethod
+    def _complain_about_missing_field(
+            cls,
+            values: typing.Mapping[str, typing.Any]
+    ):
+        missing = tuple(filter(lambda f: f not in values, cls.required_fields()))
+        raise ValueError(f'{cls.__name__}: missing {len(missing)} required field(s): {missing}')
+
+    @staticmethod
+    def _extract_optional_field(
+            key: str,
+            vals: typing.Mapping[str, typing.Any],
+    ) -> typing.Optional[typing.Any]:
+        return vals[key] if key in vals else None
+
+    @staticmethod
+    def _extract_enum_field(
+            key: str,
+            clz: typing.Type[E],
+            vals: typing.Mapping[str, typing.Any],
+    ) -> typing.Optional[E]:
+        return clz[vals[key]] if key in vals else None
+
+
+D = typing.TypeVar('D', bound=Deserializable)
+"""
+A type that is a subclass of :class:`Deserializable`.
+"""
+
+
+def extract_oneof_scalar(
+        clsd: typing.Mapping[str, typing.Type[D]],
+        vals: typing.Mapping[str, typing.Any],
+) -> typing.Optional[D]:
+    for key, cls in clsd.items():
+        scalar = extract_message_scalar(key, cls, vals)
+        if scalar is not None:
+            return scalar
+    return None
+
+
+def extract_message_scalar(
+        key: str,
+        cls: typing.Type[D],
+        vals: typing.Mapping[str, typing.Any],
+) -> typing.Optional[D]:
+    return cls.from_dict(vals[key]) if key in vals else None
+
+
+def extract_message_sequence(
+        key: str,
+        cls: typing.Type[D],
+        vals: typing.Mapping[str, typing.Any],
+) -> typing.Optional[typing.Sequence[D]]:
+    if key in vals:
+        val = vals[key]
+        if not isinstance(val, typing.Sequence):
+            raise ValueError('Bug')  # TODO: improve error handling
+        else:
+            return [cls.from_dict(item) for item in val]
+    else:
+        return None
+
+
+class Deserializer(metaclass=abc.ABCMeta):
+    """
+    `Deserializer` decodes a :class:`Deserializable` class from a `str` or text IO handle.
+    """
+
+    @abc.abstractmethod
+    def deserialize(
+            self,
+            fp: typing.Union[str, io.TextIOBase],
+            clz: typing.Type[D],
+    ) -> D:
+        """
+        Decode an instance of deserializable class from the input `fp`.
+
+        :param fp: input to decode either as a `str` or a text IO handle.
+        :param clz: type of the class to be created from the input.
+        :returns: a new instance of `D` with attributes set based on the `fp`.
+        """
+        pass
diff --git a/src/pyphetools/pp/parse/_pb.py b/src/pyphetools/pp/parse/_pb.py
new file mode 100644
index 00000000..84d55c53
--- /dev/null
+++ b/src/pyphetools/pp/parse/_pb.py
@@ -0,0 +1,149 @@
+import abc
+import typing
+
+from google.protobuf.message import Message
+
+
+class ToProtobuf(metaclass=abc.ABCMeta):
+    """
+    A mixin for data classes that can encode themselves into some protobuf bytes.
+
+    **Example**
+
+    Let's create an example subject:
+
+    >>> from pyphetools.pp.v202 import Individual
+    >>> i = Individual(id='example.id', alternate_ids=['other', 'identifiers'])
+
+    Now we can serialize the individual into a file or, for the purpose of this test, into :class:`io.BytesIO` buffer:
+
+    >>> import io
+    >>> buf = io.BytesIO()
+
+    >>> i.dump_pb(buf)
+
+    >>> buf.getvalue()
+    b'\\n\\nexample.id\\x12\\x05other\\x12\\x0bidentifiers'
+    """
+
+    @abc.abstractmethod
+    def to_message(self) -> Message:
+        """
+        Get a protobuf representation of the class.
+        """
+        pass
+
+    def dump_pb(self, fp: typing.BinaryIO):
+        """
+        Write the protobuf representation of the class into the provided `fp` byte handle.
+        """
+        msg = self.to_message()
+        fp.write(msg.SerializeToString())
+
+
+class FromProtobuf(metaclass=abc.ABCMeta):
+    """
+    A mixin for data classes/types that can be created from some protobuf bytes.
+
+    In general, the deserialization first constructs an intermediate Protobuf :class:`Message`. The message is then
+    mapped into the actual class.
+
+    **Example**
+
+    Let's load example protobuf file at the following location:
+
+    >>> import os
+    >>> fpath_pb = os.path.join('test', 'data', 'pp', 'retinoblastoma.pb')
+
+
+    The file contains a v2.0.2 phenopacket. Let's import the `Phenopacket` class and load the file:
+
+    >>> from pyphetools.pp.v202 import Phenopacket
+    >>> with open(fpath_pb, 'rb') as fh:
+    ...   pp = Phenopacket.from_pb(fh)
+
+    Now we have a phenopacket that we can work with:
+
+    >>> pp.id
+    'example.retinoblastoma.phenopacket.id'
+
+    """
+
+    @classmethod
+    @abc.abstractmethod
+    def message_type(cls) -> typing.Type[Message]:
+        """
+        Get the type of the protobuf element that this class can be decoded from.
+        """
+        pass
+
+    @classmethod
+    @abc.abstractmethod
+    def from_message(cls, msg: Message):
+        """
+        Decode the message into a new instance of this class.
+        """
+        pass
+
+    @classmethod
+    def from_pb(
+            cls,
+            fp: typing.BinaryIO,
+    ):
+        """
+        Create an instance from some bytes read from `pb`.
+        The bytes are expected to correspond to the state of the message.
+        """
+        msg_type = cls.message_type()
+        msg = msg_type()
+        msg.MergeFromString(fp.read())
+
+        return cls.from_message(msg)
+
+    @classmethod
+    def complain_about_incompatible_msg_type(
+            cls,
+            msg: Message,
+    ):
+        """
+        A utility method for user-friendly complaint regarding attempting to decode from incompatible type.
+        """
+        raise ValueError(f'Cannot decode {cls} from {type(msg)}')
+
+
+FP = typing.TypeVar('FP', bound=FromProtobuf)
+"""
+A type that is a subclass of :class:`FromProtobuf`.
+"""
+
+
+def extract_pb_oneof_scalar(
+        oneof_group: str,
+        clsd: typing.Mapping[str, typing.Type[FP]],
+        msg: Message,
+) -> typing.Optional[FP]:
+    key = msg.WhichOneof(oneof_group)
+    if key is not None:
+        cls = clsd[key]
+        return extract_pb_message_scalar(key, cls, msg)
+    else:
+        return None
+
+
+def extract_pb_message_scalar(
+        key: str,
+        cls: typing.Type[FP],
+        msg: Message,
+) -> typing.Optional[FP]:
+    if msg.HasField(key):
+        return cls.from_message(getattr(msg, key))
+    else:
+        return None
+
+
+def extract_pb_message_seq(
+        key: str,
+        cls: typing.Type[FP],
+        msg: Message,
+) -> typing.Iterable[FP]:
+    return (cls.from_message(i) for i in getattr(msg, key))
diff --git a/src/pyphetools/pp/parse/_util.py b/src/pyphetools/pp/parse/_util.py
new file mode 100644
index 00000000..718a3ee2
--- /dev/null
+++ b/src/pyphetools/pp/parse/_util.py
@@ -0,0 +1,124 @@
+import re
+import typing
+
+
+class CaseConverter:
+    # Not part of the public API!
+
+    def __init__(self):
+        self._camel_to_snake = re.compile(r'(?<!^)(?=[A-Z])')
+
+
+    def camel_to_snake(self, payload: str) -> str:
+        return self._camel_to_snake.sub('_', payload).lower()
+
+    @staticmethod
+    def snake_to_camel(payload: str) -> str:
+        tokens = payload.split('_')
+        if len(tokens) == 0:
+            return ''
+        elif len(tokens) == 1:
+            return payload
+        else:
+            return tokens[0] + ''.join(map(lambda s: s.title(), tokens[1:]))
+
+
+PS_v202_BLACKLIST = (
+    'file_attributes', 'individual_to_file_identifiers', 'individual_to_sample_identifiers',
+    'fileAttributes', 'individualToFileIdentifiers', 'individualToSampleIdentifiers',
+)
+
+
+class HierarchicalKeyMapper:
+    """
+    `HierarchicalKeyMapper` takes a sequence or a mapping of primitive Python types e.g. lists of dicts, or dicts of dicts,
+    and applies a remapping function `func` on the mapping keys, unless the enclosing element key is in a blacklist.
+
+    The mapper takes either a sequence of things via :attr:`remap_sequence` or :attr:`remap_mapping` and applies `func`
+    recursively.
+
+    .. note::
+
+      This is *not* a definitive solution, it only works with Phenopacket Schema where we only have `map<string, string>`.
+
+    :param blacklist: an iterable of `str` with names of keys whose sub-keys should not be subject
+      of the remapping function.
+    """
+
+    _PRIMITIVES = {
+        bool, int, float, str,
+    }
+
+    @staticmethod
+    def ps_v202_mapper():
+        """
+        Create a key mapper for remapping elements of Phenopacket Schema `v2.0.2`.
+        """
+        return HierarchicalKeyMapper(PS_v202_BLACKLIST)
+
+    def __init__(
+            self,
+            blacklist: typing.Iterable[str],
+    ):
+        self._blist = set(blacklist)
+
+    def remap_sequence(
+            self,
+            func: typing.Callable[[str], str],
+            vals: typing.Sequence[typing.Any],
+            ) -> typing.Sequence[typing.Any]:
+        return self._do_sequence(func, vals)
+
+    def remap_mapping(
+            self,
+            func: typing.Callable[[str], str],
+            vals: typing.Mapping[str, typing.Any],
+    ) -> typing.Mapping[str, typing.Any]:
+        return self._do_mapping(func, vals, update=True)
+
+    def _do_mapping(
+            self,
+            func: typing.Callable[[str], str],
+            vals: typing.Mapping[str, typing.Any],
+            update: bool = True,
+    ) -> typing.Mapping[str, typing.Any]:
+        out = {}
+
+        for key, val in vals.items():
+            if type(val) in HierarchicalKeyMapper._PRIMITIVES:
+                new_key = func(key) if update else key
+            else:
+                new_key = func(key) if update else key
+                if isinstance(val, typing.Sequence):
+                    val = self._do_sequence(func, val)
+                elif isinstance(val, typing.Mapping):
+                    update_next = key not in self._blist
+                    val = self._do_mapping(func, val, update_next)
+                else:
+                    raise ValueError(f'Value under {key} was neither a primitive nor a sequence or mapping: {type(val)}')
+
+            out[new_key] = val
+
+        return out
+
+    def _do_sequence(
+            self,
+            func: typing.Callable[[str], str],
+            vals: typing.Sequence[typing.Any],
+    ) -> typing.Sequence[typing.Any]:
+        out = []
+
+        for i, val in enumerate(vals):
+            if type(val) in HierarchicalKeyMapper._PRIMITIVES:
+                pass
+            else:
+                if isinstance(val, typing.Sequence):
+                    val = self._do_sequence(func, val)
+                elif isinstance(val, typing.Mapping):
+                    val = self._do_mapping(func, val, update=True)
+                else:
+                    raise ValueError(f'Value #{i} was neither a primitive nor a sequence or mapping: {type(val)}')
+
+            out.append(val)
+
+        return out
diff --git a/src/pyphetools/pp/parse/json/__init__.py b/src/pyphetools/pp/parse/json/__init__.py
new file mode 100644
index 00000000..319d0d15
--- /dev/null
+++ b/src/pyphetools/pp/parse/json/__init__.py
@@ -0,0 +1,9 @@
+"""
+A package for loading and storing Phenopacket Schema elements in JSON format.
+"""
+
+from ._json import JsonSerializer, JsonDeserializer
+
+__all__ = [
+    'JsonSerializer', 'JsonDeserializer',
+]
diff --git a/src/pyphetools/pp/parse/json/_json.py b/src/pyphetools/pp/parse/json/_json.py
new file mode 100644
index 00000000..e1976ff0
--- /dev/null
+++ b/src/pyphetools/pp/parse/json/_json.py
@@ -0,0 +1,97 @@
+import io
+import json
+import typing
+
+from .._io import Serializer, Serializable, Deserializer, D
+from .._util import CaseConverter, HierarchicalKeyMapper
+
+_PRIMITIVES = {
+    bool, int, float, str,
+}
+
+
+def map_dict_keys_case(
+        func: typing.Callable[[str], str],
+        vals: typing.Union[typing.Mapping[str, typing.Any], typing.Sequence[typing.Any], typing.Any],
+) -> typing.Union[typing.Mapping[str, typing.Any], typing.Any]:
+    if type(vals) in _PRIMITIVES:
+        return vals
+
+    if isinstance(vals, typing.Mapping):
+        out = {}
+        for key, val in vals.items():
+            new_key = func(key)
+            if type(val) in _PRIMITIVES:
+                new_val = val
+            elif isinstance(val, typing.Sequence):
+                new_val = [map_dict_keys_case(func, e) for e in val]
+            elif isinstance(val, typing.Mapping):
+                new_val = map_dict_keys_case(func, val)
+            else:
+                new_val = val
+
+            out[new_key] = new_val
+
+        return out
+    else:
+        raise ValueError('Bug')
+
+
+class JsonSerializer(Serializer):
+    """
+    A serializer to format :class:`Serializable` objects into JSON format.
+
+    The serializer will convert the field names from snake case used in Python into camel case
+    (e.g. `file_attributes` -> `fileAttributes`).
+    For the purpose of working with Phenopacket Schema, the serializer will not convert the map keys,
+    (e.g. keys of `file_attributes` map).
+
+    See :func:`json.dump` for the accepted keyword arguments.
+    """
+
+    def __init__(
+            self,
+            **kwargs
+    ):
+        self._kwargs = kwargs
+        self._case_converter = CaseConverter()
+        self._key_mapper = HierarchicalKeyMapper.ps_v202_mapper()
+
+    def serialize(self, val: Serializable, fp: typing.IO):
+        out = {}
+        val.to_dict(out)
+        mapped = self._key_mapper.remap_mapping(self._case_converter.snake_to_camel, out)
+        json.dump(mapped, fp, **self._kwargs)
+
+
+class JsonDeserializer(Deserializer):
+    """
+    A deserializer to load :class:`Deserializable` objects from JSON format.
+
+    The deserializer supports reading a JSON with keys
+    in both *snake case* (e.g. `file_attributes`) and *camel case* (e.g. `fileAttributes`).
+    """
+
+    def __init__(self):
+        self._case_converter = CaseConverter()
+        self._key_mapper = HierarchicalKeyMapper.ps_v202_mapper()
+
+    def deserialize(
+            self,
+            fp: typing.Union[str, io.TextIOBase],
+            clz: typing.Type[D]) -> D:
+        val = self._decode_json_content(fp)
+
+        mapped = self._key_mapper.remap_mapping(self._case_converter.camel_to_snake, val)
+        return clz.from_dict(mapped)
+
+    @staticmethod
+    def _decode_json_content(fp: typing.Union[str, io.TextIOBase]):
+
+        if isinstance(fp, str):
+            val = json.loads(fp)
+        elif isinstance(fp, io.TextIOBase):
+            val = json.load(fp)
+        else:
+            raise ValueError(f'`fp` must be a `str` or `io.TextIOBase` but was {type(fp)}')
+        return val
diff --git a/src/pyphetools/pp/v202/__init__.py b/src/pyphetools/pp/v202/__init__.py
new file mode 100644
index 00000000..4c00565b
--- /dev/null
+++ b/src/pyphetools/pp/v202/__init__.py
@@ -0,0 +1,35 @@
+"""
+A package with types corresponding to the `v2.0.2` release of the Phenopacket Schema.
+"""
+
+# The private package members structure mimic the structure of the protobuf files of the `phenopacket-schema`.
+
+from ._base import OntologyClass, ExternalReference, Evidence, Procedure
+# We re-export Timestamp
+from ._base import GestationalAge, Age, AgeRange, TimeInterval, TimeElement, Timestamp, File
+from ._biosample import Biosample
+from ._gene_descriptor import GeneDescriptor
+from ._individual import Individual, KaryotypicSex, Sex, VitalStatus
+from ._interpretation import AcmgPathogenicityClassification, TherapeuticActionability, VariantInterpretation
+from ._interpretation import GenomicInterpretation, Diagnosis, Interpretation
+from ._measurement import ReferenceRange, Quantity, TypedQuantity, ComplexValue, Value, Measurement
+from ._medical_action import TherapeuticRegimen, RadiationTherapy, DrugType, DoseInterval, Treatment, MedicalAction
+from ._phenotypic_feature import PhenotypicFeature
+from ._disease import Disease
+from ._meta_data import MetaData, Resource, Update
+from ._phenopackets import Phenopacket
+from ._vrsatile import Expression, Extension, VcfRecord, MoleculeContext, VariationDescriptor
+
+__all__ = [
+    'Phenopacket',
+    'Individual', 'VitalStatus', 'Sex', 'KaryotypicSex',
+    'GeneDescriptor', 'AcmgPathogenicityClassification', 'TherapeuticActionability', 'VariantInterpretation',
+    'GenomicInterpretation', 'Diagnosis', 'Interpretation',
+    'ReferenceRange', 'Quantity', 'TypedQuantity', 'ComplexValue', 'Value', 'Measurement',
+    'TherapeuticRegimen', 'RadiationTherapy', 'DrugType', 'DoseInterval', 'Treatment', 'MedicalAction',
+    'Expression', 'Extension', 'VcfRecord', 'MoleculeContext', 'VariationDescriptor',
+    'PhenotypicFeature', 'Disease', 'Biosample',
+    'MetaData', 'Resource', 'Update',
+    'OntologyClass', 'ExternalReference', 'Evidence', 'Procedure', 'GestationalAge', 'Age', 'AgeRange', 'TimeInterval',
+    'TimeElement', 'Timestamp', 'File',
+]
diff --git a/src/pyphetools/pp/v202/_base.py b/src/pyphetools/pp/v202/_base.py
new file mode 100644
index 00000000..87295f37
--- /dev/null
+++ b/src/pyphetools/pp/v202/_base.py
@@ -0,0 +1,870 @@
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from .._api import MessageMixin
+from .._timestamp import Timestamp
+from ..parse import extract_message_scalar, extract_pb_message_scalar, extract_oneof_scalar, extract_pb_oneof_scalar
+
+
+class OntologyClass(MessageMixin):
+    """
+    `OntologyClass` represents classes (terms) from ontologies, and is used in many places throughout
+    the Phenopacket standard. It is a simple, two element message that represents the `identifier` and the `label`
+    of an ontology class.
+
+    >>> from pyphetools.pp.v202 import OntologyClass
+
+    >>> oc = OntologyClass(id='HP:0001250', label='Seizure')
+    >>> oc.id
+    'HP:0001250'
+    >>> oc.label
+    'Seizure'
+
+    :param id: a `str` with a CURIE-style identifier (e.g. `HP:0001250`).
+    :param label: a `str` with a human-readable class name (e.g. `Seizure`).
+    """
+
+    def __init__(
+            self,
+            id: str,
+            label: str,
+    ):
+        self._id = id
+        self._label = label
+
+    @property
+    def id(self) -> str:
+        """
+        Get a `str` with the ontology class identifier.
+        """
+        return self._id
+
+    @property
+    def label(self) -> str:
+        """
+        Get a `str` with the human-readable class name.
+        """
+        return self._label
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'id', 'label'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'id', 'label'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return OntologyClass(
+                id=values['id'],
+                label=values['label'],
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.OntologyClass(
+            id=self._id,
+            label=self._label,
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.OntologyClass
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.OntologyClass):
+            return OntologyClass(
+                id=msg.id,
+                label=msg.label,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, OntologyClass) \
+            and self._id == other._id \
+            and self._label == other._label
+
+    def __repr__(self):
+        return f'OntologyClass(id={self._id}, label={self._label})'
+
+
+class ExternalReference(MessageMixin):
+
+    def __init__(
+            self,
+            id: typing.Optional[str] = None,
+            reference: typing.Optional[str] = None,
+            description: typing.Optional[str] = None,
+    ):
+        self._id = id
+        self._reference = reference
+        self._description = description
+
+    @property
+    def id(self) -> str:
+        return self._id
+
+    @id.setter
+    def id(self, value: str):
+        self._id = value
+
+    @property
+    def reference(self) -> str:
+        return self._reference
+
+    @reference.setter
+    def reference(self, value: str):
+        self._reference = value
+
+    @reference.deleter
+    def reference(self):
+        self._reference = None
+
+    @property
+    def description(self) -> str:
+        return self._description
+
+    @description.setter
+    def description(self, value: str):
+        self._description = value
+
+    @description.deleter
+    def description(self):
+        self._description = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'id', 'reference', 'description'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return ()
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return ExternalReference(
+                id=MessageMixin._extract_optional_field('id', values),
+                reference=MessageMixin._extract_optional_field('reference', values),
+                description=MessageMixin._extract_optional_field('description', values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.ExternalReference(
+            id=None if self._id is None else self._id,
+            reference=None if self._reference is None else self._reference,
+            description=None if self._description is None else self._description,
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.ExternalReference
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.ExternalReference):
+            return ExternalReference(
+                id=None if msg.id == '' else msg.id,
+                reference=None if msg.reference == '' else msg.reference,
+                description=None if msg.description == '' else msg.description,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, ExternalReference) \
+            and self._id == other._id \
+            and self._reference == other._reference \
+            and self._description == other._description
+
+    def __repr__(self):
+        return f'ExternalReference(id={self._id}, reference={self._reference}, description={self._description})'
+
+
+class Evidence(MessageMixin):
+
+    def __init__(
+            self,
+            evidence_code: OntologyClass,
+            reference: typing.Optional[ExternalReference] = None,
+    ):
+        self._evidence_code = evidence_code
+        self._reference = reference
+
+    @property
+    def evidence_code(self) -> OntologyClass:
+        return self._evidence_code
+
+    @evidence_code.setter
+    def evidence_code(self, value: OntologyClass):
+        self._evidence_code = value
+
+    @property
+    def reference(self) -> typing.Optional[ExternalReference]:
+        return self._reference
+
+    @reference.setter
+    def reference(self, value: typing.Optional[ExternalReference]):
+        self._reference = value
+
+    @reference.deleter
+    def reference(self):
+        self._reference = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'evidence_code', 'reference'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'evidence_code',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Evidence(
+                evidence_code=extract_message_scalar('evidence_code', OntologyClass, values),
+                reference=extract_message_scalar('reference', ExternalReference, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.Evidence(
+            evidence_code=self._evidence_code.to_message(),
+            reference=self._reference.to_message(),
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Evidence
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.Evidence):
+            return Evidence(
+                evidence_code=extract_pb_message_scalar('evidence_code', OntologyClass, msg),
+                reference=extract_pb_message_scalar('reference', ExternalReference, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Evidence) \
+            and self._evidence_code == other._evidence_code \
+            and self._reference == other._reference
+
+    def __repr__(self):
+        return f'Evidence(evidence_code={self._evidence_code}, reference={self._reference})'
+
+
+class GestationalAge(MessageMixin):
+
+    def __init__(
+            self,
+            weeks: int,
+            days: typing.Optional[int] = None,
+    ):
+        # TODO: validate
+        self._weeks = weeks
+        self._days = days
+
+    @property
+    def weeks(self) -> int:
+        return self._weeks
+
+    @weeks.setter
+    def weeks(self, value: int):
+        self._weeks = value
+
+    @property
+    def days(self) -> typing.Optional[int]:
+        return self._days
+
+    @days.setter
+    def days(self, value: typing.Optional[int]):
+        self._days = value
+
+    @days.deleter
+    def days(self):
+        self._days = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'weeks', 'days',
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'weeks',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            weeks = values['weeks']
+            days = values['days'] if 'days' in values else None
+            return GestationalAge(
+                weeks=weeks,
+                days=days,
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.GestationalAge(
+            weeks=self._weeks,
+            days=self._days,
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.GestationalAge
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.GestationalAge):
+            return GestationalAge(
+                weeks=msg.weeks,
+                days=msg.days,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, GestationalAge) \
+            and self._weeks == other._weeks \
+            and self._days == other._days
+
+    def __repr__(self):
+        return f'GestationalAge(weeks={self._weeks}, days={self._days})'
+
+
+class Age(MessageMixin):
+
+    def __init__(
+            self,
+            iso8601duration: str,
+    ):
+        self._iso8601duration = iso8601duration
+
+    @property
+    def iso8601duration(self) -> str:
+        return self._iso8601duration
+
+    @iso8601duration.setter
+    def iso8601duration(self, value: str):
+        self._iso8601duration = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'iso8601duration',
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'iso8601duration',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Age(iso8601duration=values['iso8601duration'])
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.Age(
+            iso8601duration=self._iso8601duration,
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Age
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.Age):
+            return Age(
+                iso8601duration=msg.iso8601duration,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Age) \
+            and self._iso8601duration == other._iso8601duration
+
+    def __repr__(self):
+        return f'Age(iso8601duration={self._iso8601duration})'
+
+
+class AgeRange(MessageMixin):
+
+    def __init__(
+            self,
+            start: Age,
+            end: Age,
+    ):
+        self._start = start
+        self._end = end
+
+    @property
+    def start(self):
+        return self._start
+
+    @start.setter
+    def start(self, value: Age):
+        self._start = value
+
+    @property
+    def end(self) -> Age:
+        return self._end
+
+    @end.setter
+    def end(self, value: Age):
+        self._end = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'start', 'end'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'start', 'end'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return AgeRange(
+                start=Age.from_dict(values['start']),
+                end=Age.from_dict(values['end']),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.AgeRange(
+            start=self._start.to_message(),
+            end=self._end.to_message(),
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.AgeRange
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.AgeRange):
+            return AgeRange(
+                start=extract_pb_message_scalar('start', Age, msg),
+                end=extract_pb_message_scalar('end', Age, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, AgeRange) \
+            and self._start == other._start \
+            and self._end == other._end
+
+    def __repr__(self):
+        return f'AgeRange(start={self._start}, end={self._end})'
+
+
+class TimeInterval(MessageMixin):
+
+    def __init__(
+            self,
+            start: Timestamp,
+            end: Timestamp,
+    ):
+        self._start = start
+        self._end = end
+
+    @property
+    def start(self) -> Timestamp:
+        return self._start
+
+    @start.setter
+    def start(self, value: Timestamp):
+        self._start = value
+
+    @property
+    def end(self) -> Timestamp:
+        return self._end
+
+    @end.setter
+    def end(self, value: Timestamp):
+        self._end = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'start', 'end'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'start', 'end'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return TimeInterval(
+                start=Timestamp.from_str(values['start']),
+                end=Timestamp.from_str(values['end']),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.TimeInterval(
+            start=self._start.to_message(),
+            end=self._end.to_message(),
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.TimeInterval
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.TimeInterval):
+            return TimeInterval(
+                start=extract_pb_message_scalar('start', Timestamp, msg),
+                end=extract_pb_message_scalar('end', Timestamp, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, TimeInterval) \
+            and self._start == other._start \
+            and self._end == other._end
+
+    def __repr__(self):
+        return f'TimeInterval(start={self._start}, end={self._end})'
+
+
+class TimeElement(MessageMixin):
+    """
+    TODO: better description
+    """
+    _ONEOF_ELEMENT = {
+        'gestational_age': GestationalAge, 'age': Age, 'age_range': AgeRange,
+        'ontology_class': OntologyClass, 'timestamp': Timestamp, 'interval': TimeInterval,
+    }
+
+    def __init__(
+            self,
+            element: typing.Union[GestationalAge, Age, AgeRange, OntologyClass, Timestamp, TimeInterval]
+    ):
+        self._element = element
+
+    @property
+    def element(self) -> typing.Union[GestationalAge, Age, AgeRange, OntologyClass, Timestamp, TimeInterval]:
+        return self._element
+
+    @property
+    def age(self) -> typing.Optional[Age]:
+        return self._element if isinstance(self._element, Age) else None
+
+    @age.setter
+    def age(self, value: Age):
+        self._element = value
+
+    @property
+    def age_range(self) -> typing.Optional[AgeRange]:
+        return self._element if isinstance(self._element, AgeRange) else None
+
+    @age_range.setter
+    def age_range(self, value: AgeRange):
+        self._element = value
+
+    @property
+    def ontology_class(self) -> OntologyClass:
+        return self._element if isinstance(self._element, OntologyClass) else None
+
+    @ontology_class.setter
+    def ontology_class(self, value: OntologyClass):
+        self._element = value
+
+    @property
+    def timestamp(self) -> Timestamp:
+        return self._element if isinstance(self._element, Timestamp) else None
+
+    @timestamp.setter
+    def timestamp(self, value: Timestamp):
+        self._element = value
+
+    @property
+    def interval(self) -> TimeInterval:
+        return self._element if isinstance(self._element, TimeInterval) else None
+
+    @interval.setter
+    def interval(self, value: TimeInterval):
+        self._element = value
+
+    @property
+    def gestational_age(self) -> typing.Optional[GestationalAge]:
+        return self._element if isinstance(self._element, GestationalAge) else None
+
+    @gestational_age.setter
+    def gestational_age(self, value: GestationalAge):
+        self._element = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'gestational_age', 'age', 'age_range', 'ontology_class', 'timestamp', 'interval'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        raise NotImplementedError('Should not be called!')
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if any(field in values for field in cls._ONEOF_ELEMENT):
+            return TimeElement(
+                element=extract_oneof_scalar(cls._ONEOF_ELEMENT, values)
+            )
+        else:
+            raise ValueError(
+                f'Missing one of required fields: '
+                f'`{"|".join(cls._ONEOF_ELEMENT)}`: {values}'
+            )
+
+    def to_message(self) -> Message:
+        te = pp202.TimeElement()
+
+        val = self._element.to_message()
+        if isinstance(self._element, Age):
+            te.age.CopyFrom(val)
+        elif isinstance(self._element, AgeRange):
+            te.age_range.CopyFrom(val)
+        elif isinstance(self._element, OntologyClass):
+            te.ontology_class.CopyFrom(val)
+        elif isinstance(self._element, Timestamp):
+            te.timestamp.CopyFrom(val)
+        elif isinstance(self._element, TimeInterval):
+            te.interval.CopyFrom(val)
+        elif isinstance(self._element, GestationalAge):
+            te.gestational_age.CopyFrom(val)
+        else:
+            raise ValueError('Bug')
+
+        return te
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.TimeElement
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return TimeElement(
+                element=extract_pb_oneof_scalar(
+                    'element',
+                    {
+                        'gestational_age': GestationalAge, 'age': Age, 'age_range': AgeRange,
+                        'ontology_class': OntologyClass, 'timestamp': Timestamp, 'interval': TimeInterval,
+                    }, msg)
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, TimeElement) and self._element == other._element
+
+    def __repr__(self):
+        return f'TimeElement(element={self._element})'
+
+
+class Procedure(MessageMixin):
+
+    def __init__(
+            self,
+            code: OntologyClass,
+            body_site: typing.Optional[OntologyClass] = None,
+            performed: typing.Optional[TimeElement] = None,
+    ):
+        self._code = code
+        self._body_site = body_site
+        self._performed = performed
+
+    @property
+    def code(self) -> OntologyClass:
+        return self._code
+
+    @code.setter
+    def code(self, value: OntologyClass):
+        self._code = value
+
+    @property
+    def body_site(self) -> typing.Optional[OntologyClass]:
+        return self._body_site
+
+    @body_site.setter
+    def body_site(self, value: typing.Optional[OntologyClass]):
+        self._body_site = value
+
+    @body_site.deleter
+    def body_site(self):
+        self._body_site = None
+
+    @property
+    def performed(self) -> typing.Optional[TimeElement]:
+        return self._performed
+
+    @performed.setter
+    def performed(self, value: typing.Optional[TimeElement]):
+        self._performed = value
+
+    @performed.deleter
+    def performed(self):
+        self._performed = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'code', 'body_site', 'performed'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'code',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Procedure(
+                code=extract_message_scalar('code', OntologyClass, values),
+                body_site=extract_message_scalar('body_site', OntologyClass, values),
+                performed=extract_message_scalar('performed', TimeElement, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        procedure = pp202.Procedure(
+            code=self._code.to_message(),
+        )
+
+        if self._body_site is not None:
+            procedure.body_site.CopyFrom(self._body_site.to_message())
+
+        if self._performed is not None:
+            procedure.performed.CopyFrom(self._performed.to_message())
+
+        return procedure
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Procedure
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.Procedure):
+            return Procedure(
+                code=extract_pb_message_scalar('code', OntologyClass, msg),
+                body_site=extract_pb_message_scalar('body_site', OntologyClass, msg),
+                performed=extract_pb_message_scalar('performed', TimeElement, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Procedure) \
+            and self._code == other._code \
+            and self._body_site == other._body_site \
+            and self._performed == other._performed
+
+    def __repr__(self):
+        return f'Procedure(code={self._code}, body_site={self._body_site}, performed={self._performed})'
+
+
+class File(MessageMixin):
+
+    def __init__(
+            self,
+            uri: str,
+            individual_to_file_identifiers: typing.Optional[typing.Mapping[str, str]] = None,
+            file_attributes: typing.Optional[typing.Mapping[str, str]] = None,
+    ):
+        self._uri = uri
+        self._individual_to_file_identifiers = dict() \
+            if individual_to_file_identifiers is None \
+            else dict(individual_to_file_identifiers)
+        self._file_attributes = dict() if file_attributes is None else dict(file_attributes)
+
+    @property
+    def uri(self) -> str:
+        return self._uri
+
+    @uri.setter
+    def uri(self, value: str):
+        self._uri = value
+
+    @property
+    def individual_to_file_identifiers(self) -> typing.MutableMapping[str, str]:
+        return self._individual_to_file_identifiers
+
+    @property
+    def file_attributes(self) -> typing.MutableMapping[str, str]:
+        return self._file_attributes
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'uri', 'individual_to_file_identifiers', 'file_attributes'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'uri',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return File(
+                uri=values['uri'],
+                individual_to_file_identifiers=values['individual_to_file_identifiers'],
+                file_attributes=values['file_attributes'],
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        file = pp202.File(uri=self._uri)
+        for k, v in self._individual_to_file_identifiers.items():
+            file.individual_to_file_identifiers[k] = v
+
+        for k, v in self._file_attributes.items():
+            file.file_attributes[k] = v
+
+        return file
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.File
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.File):
+            return File(
+                uri=msg.uri,
+                individual_to_file_identifiers=msg.individual_to_file_identifiers,
+                file_attributes=msg.file_attributes,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, File) and self._uri == other._uri \
+            and self._individual_to_file_identifiers == other._individual_to_file_identifiers \
+            and self._file_attributes == other._file_attributes
+
+    def __repr__(self):
+        return 'File(' \
+               f'uri={self._uri},' \
+               f' individual_to_file_identifiers={self._individual_to_file_identifiers},' \
+               f' file_attributes={self._file_attributes})'
diff --git a/src/pyphetools/pp/v202/_biosample.py b/src/pyphetools/pp/v202/_biosample.py
new file mode 100644
index 00000000..d6a4d373
--- /dev/null
+++ b/src/pyphetools/pp/v202/_biosample.py
@@ -0,0 +1,420 @@
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from ._base import OntologyClass, TimeElement, File, Procedure
+from ._measurement import Measurement
+from ._phenotypic_feature import PhenotypicFeature
+from .._api import MessageMixin
+from ..parse import extract_message_scalar, extract_message_sequence, extract_pb_message_scalar, extract_pb_message_seq
+
+
+class Biosample(MessageMixin):
+
+    def __init__(
+            self,
+            id: str,
+            individual_id: typing.Optional[str] = None,
+            derived_from_id: typing.Optional[str] = None,
+            description: typing.Optional[str] = None,
+            sampled_tissue: typing.Optional[OntologyClass] = None,
+            sample_type: typing.Optional[OntologyClass] = None,
+            phenotypic_features: typing.Optional[typing.Iterable[PhenotypicFeature]] = None,
+            measurements: typing.Optional[typing.Iterable[Measurement]] = None,
+            taxonomy: typing.Optional[OntologyClass] = None,
+            time_of_collection: typing.Optional[TimeElement] = None,
+            histological_diagnosis: typing.Optional[OntologyClass] = None,
+            tumor_progression: typing.Optional[OntologyClass] = None,
+            tumor_grade: typing.Optional[OntologyClass] = None,
+            pathological_stage: typing.Optional[OntologyClass] = None,
+            pathological_tnm_finding: typing.Optional[typing.Iterable[OntologyClass]] = None,
+            diagnostic_markers: typing.Optional[typing.Iterable[OntologyClass]] = None,
+            procedure: typing.Optional[Procedure] = None,
+            files: typing.Optional[typing.Iterable[File]] = None,
+            material_sample: typing.Optional[OntologyClass] = None,
+            sample_processing: typing.Optional[OntologyClass] = None,
+            sample_storage: typing.Optional[OntologyClass] = None,
+    ):
+        self._id = id
+        self._individual_id = individual_id
+        self._derived_from_id = derived_from_id
+        self._description = description
+        self._sampled_tissue = sampled_tissue
+        self._sample_type = sample_type
+        self._phenotypic_features = [] if phenotypic_features is None else list(phenotypic_features)
+        self._measurements = [] if measurements is None else list(measurements)
+        self._taxonomy = taxonomy
+        self._time_of_collection = time_of_collection
+        self._histological_diagnosis = histological_diagnosis
+        self._tumor_progression = tumor_progression
+        self._tumor_grade = tumor_grade
+        self._pathological_stage = pathological_stage
+        self._pathological_tnm_finding = [] if pathological_tnm_finding is None else list(pathological_tnm_finding)
+        self._diagnostic_markers = [] if diagnostic_markers is None else list(diagnostic_markers)
+        self._procedure = procedure
+        self._files = [] if files is None else list(files)
+        self._material_sample = material_sample
+        self._sample_processing = sample_processing
+        self._sample_storage = sample_storage
+
+    @property
+    def id(self) -> str:
+        return self._id
+
+    @id.setter
+    def id(self, value: str):
+        self._id = value
+
+    @property
+    def individual_id(self) -> typing.Optional[str]:
+        return self._individual_id
+
+    @individual_id.setter
+    def individual_id(self, value: str):
+        self._individual_id = value
+
+    @individual_id.deleter
+    def individual_id(self):
+        self._individual_id = None
+
+    @property
+    def derived_from_id(self) -> typing.Optional[str]:
+        return self._derived_from_id
+
+    @derived_from_id.setter
+    def derived_from_id(self, value: str):
+        self._derived_from_id = value
+
+    @derived_from_id.deleter
+    def derived_from_id(self):
+        self._derived_from_id = None
+
+    @property
+    def description(self) -> typing.Optional[str]:
+        return self._description
+
+    @description.setter
+    def description(self, value: str):
+        self._description = value
+
+    @description.deleter
+    def description(self):
+        self._description = None
+
+    @property
+    def sampled_tissue(self) -> typing.Optional[OntologyClass]:
+        return self._sampled_tissue
+
+    @sampled_tissue.setter
+    def sampled_tissue(self, value: OntologyClass):
+        self._sampled_tissue = value
+
+    @sampled_tissue.deleter
+    def sampled_tissue(self):
+        self._sampled_tissue = None
+
+    @property
+    def sample_type(self) -> typing.Optional[OntologyClass]:
+        return self._sample_type
+
+    @sample_type.setter
+    def sample_type(self, value: OntologyClass):
+        self._sample_type = value
+
+    @sample_type.deleter
+    def sample_type(self):
+        self._sample_type = None
+
+    @property
+    def phenotypic_features(self) -> typing.MutableSequence[PhenotypicFeature]:
+        return self._phenotypic_features
+
+    @property
+    def measurements(self) -> typing.MutableSequence[Measurement]:
+        return self._measurements
+
+    @property
+    def taxonomy(self) -> typing.Optional[OntologyClass]:
+        return self._taxonomy
+
+    @taxonomy.setter
+    def taxonomy(self, value: OntologyClass):
+        self._taxonomy = value
+
+    @taxonomy.deleter
+    def taxonomy(self):
+        self._taxonomy = None
+
+    @property
+    def time_of_collection(self) -> typing.Optional[TimeElement]:
+        return self._time_of_collection
+
+    @time_of_collection.setter
+    def time_of_collection(self, value: TimeElement):
+        self._time_of_collection = value
+
+    @time_of_collection.deleter
+    def time_of_collection(self):
+        self._time_of_collection = None
+
+    @property
+    def histological_diagnosis(self) -> typing.Optional[OntologyClass]:
+        return self._histological_diagnosis
+
+    @histological_diagnosis.setter
+    def histological_diagnosis(self, value: OntologyClass):
+        self._histological_diagnosis = value
+
+    @histological_diagnosis.deleter
+    def histological_diagnosis(self):
+        self._histological_diagnosis = None
+
+    @property
+    def tumor_progression(self) -> typing.Optional[OntologyClass]:
+        return self._tumor_progression
+
+    @tumor_progression.setter
+    def tumor_progression(self, value: OntologyClass):
+        self._tumor_progression = value
+
+    @tumor_progression.deleter
+    def tumor_progression(self):
+        self._tumor_progression = None
+
+    @property
+    def tumor_grade(self) -> typing.Optional[OntologyClass]:
+        return self._tumor_grade
+
+    @tumor_grade.setter
+    def tumor_grade(self, value: OntologyClass):
+        self._tumor_grade = value
+
+    @tumor_grade.deleter
+    def tumor_grade(self):
+        self._tumor_grade = None
+
+    @property
+    def pathological_stage(self) -> typing.Optional[OntologyClass]:
+        return self._pathological_stage
+
+    @pathological_stage.setter
+    def pathological_stage(self, value: OntologyClass):
+        self._pathological_stage = value
+
+    @pathological_stage.deleter
+    def pathological_stage(self):
+        self._pathological_stage = None
+
+    @property
+    def pathological_tnm_finding(self) -> typing.MutableSequence[OntologyClass]:
+        return self._pathological_tnm_finding
+
+    @property
+    def diagnostic_markers(self) -> typing.MutableSequence[OntologyClass]:
+        return self._diagnostic_markers
+
+    @property
+    def procedure(self) -> typing.Optional[Procedure]:
+        return self._procedure
+
+    @procedure.setter
+    def procedure(self, value: Procedure):
+        self._procedure = value
+
+    @procedure.deleter
+    def procedure(self):
+        self._procedure = None
+
+    @property
+    def files(self) -> typing.MutableSequence[File]:
+        return self._files
+
+    @property
+    def material_sample(self) -> typing.Optional[OntologyClass]:
+        return self._material_sample
+
+    @material_sample.setter
+    def material_sample(self, value: OntologyClass):
+        self._material_sample = value
+
+    @material_sample.deleter
+    def material_sample(self):
+        self._material_sample = None
+
+    @property
+    def sample_processing(self) -> typing.Optional[OntologyClass]:
+        return self._sample_processing
+
+    @sample_processing.setter
+    def sample_processing(self, value: OntologyClass):
+        self._sample_processing = value
+
+    @sample_processing.deleter
+    def sample_processing(self):
+        self._sample_processing = None
+
+    @property
+    def sample_storage(self) -> typing.Optional[OntologyClass]:
+        return self._sample_storage
+
+    @sample_storage.setter
+    def sample_storage(self, value: OntologyClass):
+        self._sample_storage = value
+
+    @sample_storage.deleter
+    def sample_storage(self):
+        self._sample_storage = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return (
+            'id', 'individual_id', 'derived_from_id', 'description', 'sampled_tissue', 'sample_type',
+            'phenotypic_features', 'measurements',
+            'taxonomy', 'time_of_collection', 'histological_diagnosis', 'tumor_progression', 'tumor_grade',
+            'pathological_stage', 'pathological_tnm_finding', 'diagnostic_markers', 'procedure',
+            'files', 'material_sample', 'sample_processing', 'sample_storage',
+        )
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'id',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Biosample(
+                id=values['id'],
+                individual_id=values['individual_id'] if 'individual_id' in values else None,
+                derived_from_id=values['derived_from_id'] if 'derived_from_id' in values else None,
+                description=values['description'] if 'description' in values else None,
+                sampled_tissue=extract_message_scalar('sampled_tissue', OntologyClass, values),
+                sample_type=extract_message_scalar('sample_type', OntologyClass, values),
+                phenotypic_features=extract_message_sequence('phenotypic_features', PhenotypicFeature, values),
+                measurements=extract_message_sequence('measurements', Measurement, values),
+                taxonomy=extract_message_scalar('taxonomy', OntologyClass, values),
+                time_of_collection=extract_message_scalar('time_of_collection', OntologyClass, values),
+                histological_diagnosis=extract_message_scalar('histological_diagnosis', OntologyClass, values),
+                tumor_progression=extract_message_scalar('tumor_progression', OntologyClass, values),
+                tumor_grade=extract_message_scalar('tumor_grade', OntologyClass, values),
+                pathological_stage=extract_message_scalar('pathological_stage', OntologyClass, values),
+                pathological_tnm_finding=extract_message_sequence('pathological_tnm_finding', OntologyClass, values),
+                diagnostic_markers=extract_message_sequence('diagnostic_markers', OntologyClass, values),
+                procedure=extract_message_scalar('procedure', Procedure, values),
+                files=extract_message_sequence('files', File, values),
+                material_sample=extract_message_scalar('material_sample', OntologyClass, values),
+                sample_processing=extract_message_scalar('sample_processing', OntologyClass, values),
+                sample_storage=extract_message_scalar('sample_storage', OntologyClass, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        biosample = pp202.Biosample(id=self._id)
+
+        if self._individual_id is not None:
+            biosample.individual_id = self._individual_id
+
+        if self._derived_from_id is not None:
+            biosample.derived_from_id = self._derived_from_id
+
+        if self._description is not None:
+            biosample.description = self._description
+
+        # Set optional compound singular & sequence fields
+        for name in (
+                'sampled_tissue', 'sample_type', 'phenotypic_features', 'measurements',
+                'taxonomy', 'time_of_collection', 'histological_diagnosis', 'tumor_progression', 'tumor_grade',
+                'pathological_stage', 'pathological_tnm_finding', 'diagnostic_markers', 'procedure',
+                'files', 'material_sample', 'sample_processing', 'sample_storage',
+        ):
+            field = getattr(self, name)
+            if field is not None:
+                target = getattr(biosample, name)
+                if isinstance(field, typing.List):
+                    target.extend(item.to_message() for item in field)
+                else:
+                    target.CopyFrom(field.to_message())
+
+        return biosample
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Biosample
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return Biosample(
+                id=msg.id,
+                individual_id=None if msg.individual_id == '' else msg.individual_id,
+                derived_from_id=None if msg.derived_from_id == '' else msg.derived_from_id,
+                description=None if msg.description == '' else msg.description,
+                sampled_tissue=extract_pb_message_scalar('sampled_tissue', OntologyClass, msg),
+                sample_type=extract_pb_message_scalar('sample_type', OntologyClass, msg),
+                phenotypic_features=extract_pb_message_seq('phenotypic_features', PhenotypicFeature, msg),
+                measurements=extract_pb_message_seq('measurements', Measurement, msg),
+                taxonomy=extract_pb_message_scalar('taxonomy', OntologyClass, msg),
+                time_of_collection=extract_pb_message_scalar('time_of_collection', OntologyClass, msg),
+                histological_diagnosis=extract_pb_message_scalar('histological_diagnosis', OntologyClass, msg),
+                tumor_progression=extract_pb_message_scalar('tumor_progression', OntologyClass, msg),
+                tumor_grade=extract_pb_message_scalar('tumor_grade', OntologyClass, msg),
+                pathological_stage=extract_pb_message_scalar('pathological_stage', OntologyClass, msg),
+                pathological_tnm_finding=extract_pb_message_seq('pathological_tnm_finding', OntologyClass, msg),
+                diagnostic_markers=extract_pb_message_seq('diagnostic_markers', OntologyClass, msg),
+                procedure=extract_pb_message_scalar('procedure', Procedure, msg),
+                files=extract_pb_message_seq('files', File, msg),
+                material_sample=extract_pb_message_scalar('material_sample', OntologyClass, msg),
+                sample_processing=extract_pb_message_scalar('sample_processing', OntologyClass, msg),
+                sample_storage=extract_pb_message_scalar('sample_storage', OntologyClass, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Biosample) \
+            and self._id == other._id \
+            and self._individual_id == other._individual_id \
+            and self._derived_from_id == other._derived_from_id \
+            and self._description == other._description \
+            and self._sampled_tissue == other._sampled_tissue \
+            and self._sample_type == other._sample_type \
+            and self._phenotypic_features == other._phenotypic_features \
+            and self._measurements == other._measurements \
+            and self._taxonomy == other._taxonomy \
+            and self._time_of_collection == other._time_of_collection \
+            and self._histological_diagnosis == other._histological_diagnosis \
+            and self._tumor_progression == other._tumor_progression \
+            and self._tumor_grade == other._tumor_grade \
+            and self._pathological_stage == other._pathological_stage \
+            and self._pathological_tnm_finding == other._pathological_tnm_finding \
+            and self._diagnostic_markers == other._diagnostic_markers \
+            and self._procedure == other._procedure \
+            and self._files == other._files \
+            and self._material_sample == other._material_sample \
+            and self._sample_processing == other._sample_processing \
+            and self._sample_storage == other._sample_storage
+
+    def __repr__(self):
+        return f'Biosample(' \
+               f'id={self._id}, ' \
+               f'individual_id={self._individual_id}, ' \
+               f'derived_from_id={self._derived_from_id}, ' \
+               f'description={self._description}, ' \
+               f'sampled_tissue={self._sampled_tissue}, ' \
+               f'sample_type={self._sample_type}, ' \
+               f'phenotypic_features={self._phenotypic_features}, ' \
+               f'measurements={self._measurements}, ' \
+               f'taxonomy={self._taxonomy}, ' \
+               f'time_of_collection={self._time_of_collection}, ' \
+               f'histological_diagnosis={self._histological_diagnosis}, ' \
+               f'tumor_progression={self._tumor_progression}, ' \
+               f'tumor_grade={self._tumor_grade}, ' \
+               f'pathological_stage={self._pathological_stage}, ' \
+               f'pathological_tnm_finding={self._pathological_tnm_finding}, ' \
+               f'diagnostic_markers={self._diagnostic_markers}, ' \
+               f'procedure={self._procedure}, ' \
+               f'files={self._files}, ' \
+               f'material_sample={self._material_sample}, ' \
+               f'sample_processing={self._sample_processing}, ' \
+               f'sample_storage={self._sample_storage})'
diff --git a/src/pyphetools/pp/v202/_disease.py b/src/pyphetools/pp/v202/_disease.py
new file mode 100644
index 00000000..22a0baca
--- /dev/null
+++ b/src/pyphetools/pp/v202/_disease.py
@@ -0,0 +1,189 @@
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from ._base import OntologyClass, TimeElement
+from .._api import MessageMixin
+from ..parse import extract_message_scalar, extract_message_sequence, extract_pb_message_scalar, extract_pb_message_seq
+
+
+class Disease(MessageMixin):
+
+    def __init__(
+            self,
+            term: OntologyClass,
+            excluded: bool = False,
+            onset: typing.Optional[TimeElement] = None,
+            resolution: typing.Optional[TimeElement] = None,
+            disease_stage: typing.Optional[typing.Iterable[OntologyClass]] = None,
+            clinical_tnm_finding: typing.Optional[typing.Iterable[OntologyClass]] = None,
+            primary_site: typing.Optional[OntologyClass] = None,
+            laterality: typing.Optional[OntologyClass] = None,
+    ):
+        self._term = term
+        self._excluded = excluded
+        self._onset = onset
+        self._resolution = resolution
+        self._disease_stage = [] if disease_stage is None else list(disease_stage)
+        self._clinical_tnm_finding = [] if clinical_tnm_finding is None else list(clinical_tnm_finding)
+        self._primary_site = primary_site
+        self._laterality = laterality
+
+    @property
+    def term(self) -> OntologyClass:
+        return self._term
+
+    @term.setter
+    def term(self, value: OntologyClass):
+        self._term = value
+
+    @property
+    def excluded(self) -> bool:
+        return self._excluded
+
+    @excluded.setter
+    def excluded(self, value: bool):
+        self._excluded = value
+
+    @property
+    def onset(self) -> typing.Optional[TimeElement]:
+        return self._onset
+
+    @onset.setter
+    def onset(self, value: TimeElement):
+        self._onset = value
+
+    @onset.deleter
+    def onset(self):
+        self._onset = None
+
+    @property
+    def resolution(self) -> typing.Optional[TimeElement]:
+        return self._resolution
+
+    @resolution.setter
+    def resolution(self, value: TimeElement):
+        self._resolution = value
+
+    @resolution.deleter
+    def resolution(self):
+        self._resolution = None
+
+    @property
+    def disease_stage(self) -> typing.MutableSequence[OntologyClass]:
+        return self._disease_stage
+
+    @property
+    def clinical_tnm_finding(self) -> typing.MutableSequence[OntologyClass]:
+        return self._clinical_tnm_finding
+
+    @property
+    def primary_site(self) -> typing.Optional[OntologyClass]:
+        return self._primary_site
+
+    @primary_site.setter
+    def primary_site(self, value: OntologyClass):
+        self._primary_site = value
+
+    @primary_site.deleter
+    def primary_site(self):
+        self._primary_site = None
+
+    @property
+    def laterality(self) -> typing.Optional[OntologyClass]:
+        return self._laterality
+
+    @laterality.setter
+    def laterality(self, value: OntologyClass):
+        self._laterality = value
+
+    @laterality.deleter
+    def laterality(self):
+        self._laterality = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'term', 'excluded', 'onset', 'resolution', 'disease_stage', 'clinical_tnm_finding', 'primary_site', 'laterality'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'term',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Disease(
+                term=extract_message_scalar('term', OntologyClass, values),
+                excluded=values['excluded'] if 'excluded' in values else False,
+                onset=extract_message_scalar('onset', TimeElement, values),
+                resolution=extract_message_scalar('resolution', TimeElement, values),
+                disease_stage=extract_message_sequence('disease_stage', OntologyClass, values),
+                clinical_tnm_finding=extract_message_sequence('clinical_tnm_finding', OntologyClass, values),
+                primary_site=extract_message_scalar('primary_site', OntologyClass, values),
+                laterality=extract_message_scalar('laterality', OntologyClass, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        disease = pp202.Disease(term=self._term.to_message(), excluded=self._excluded)
+
+        if self._onset is not None:
+            disease.onset.CopyFrom(self._onset.to_message())
+
+        if self._resolution is not None:
+            disease.resolution.CopyFrom(self._resolution.to_message())
+
+        disease.disease_stage.extend(ds.to_message() for ds in self._disease_stage)
+        disease.clinical_tnm_finding.extend(tnm.to_message() for tnm in self._clinical_tnm_finding)
+
+        if self._primary_site is not None:
+            disease.primary_site.CopyFrom(self._primary_site.to_message())
+
+        if self._laterality is not None:
+            disease.laterality.CopyFrom(self._laterality.to_message())
+
+        return disease
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Disease
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return Disease(
+                term=extract_pb_message_scalar('term', OntologyClass, msg),
+                excluded=msg.excluded,
+                onset=extract_pb_message_scalar('onset', TimeElement, msg),
+                resolution=extract_pb_message_scalar('resolution', TimeElement, msg),
+                disease_stage=extract_pb_message_seq('disease_stage', OntologyClass, msg),
+                clinical_tnm_finding=extract_pb_message_seq('clinical_tnm_finding', OntologyClass, msg),
+                primary_site=extract_pb_message_scalar('primary_site', OntologyClass, msg),
+                laterality=extract_pb_message_scalar('laterality', OntologyClass, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Disease) \
+            and self._term == other._term \
+            and self._excluded == other._excluded \
+            and self._onset == other._onset \
+            and self._resolution == other._resolution \
+            and self._disease_stage == other._disease_stage \
+            and self._clinical_tnm_finding == other._clinical_tnm_finding \
+            and self._primary_site == other._primary_site \
+            and self._laterality == other._laterality
+
+    def __repr__(self):
+        return (f'Disease(term={self._term}, '
+                f'excluded={self._excluded}, '
+                f'onset={self._onset}, '
+                f'resolution={self._resolution}, '
+                f'disease_stage={self._disease_stage}, '
+                f'clinical_tnm_finding={self._clinical_tnm_finding}, '
+                f'primary_site={self._primary_site}, '
+                f'laterality={self._laterality}'
+                ')')
diff --git a/src/pyphetools/pp/v202/_gene_descriptor.py b/src/pyphetools/pp/v202/_gene_descriptor.py
new file mode 100644
index 00000000..e039e525
--- /dev/null
+++ b/src/pyphetools/pp/v202/_gene_descriptor.py
@@ -0,0 +1,135 @@
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from .._api import MessageMixin
+
+
+class GeneDescriptor(MessageMixin):
+
+    def __init__(
+            self,
+            value_id: str,
+            symbol: str,
+            description: typing.Optional[str] = None,
+            alternate_ids: typing.Optional[typing.Iterable[str]] = None,
+            xrefs: typing.Optional[typing.Iterable[str]] = None,
+            alternate_symbols: typing.Optional[typing.Iterable[str]] = None,
+    ):
+        self._value_id = value_id
+        self._symbol = symbol
+        self._description = description
+        self._alternate_ids = [] if alternate_ids is None else list(alternate_ids)
+        self._xrefs = [] if xrefs is None else list(xrefs)
+        self._alternate_symbols = [] if alternate_symbols is None else list(alternate_symbols)
+
+    @property
+    def value_id(self) -> str:
+        return self._value_id
+
+    @value_id.setter
+    def value_id(self, value: str):
+        self._value_id = value
+
+    @property
+    def symbol(self) -> str:
+        return self._symbol
+
+    @symbol.setter
+    def symbol(self, value: str):
+        self._symbol = value
+
+    @property
+    def description(self) -> str:
+        return self._description
+
+    @description.setter
+    def description(self, value: str):
+        self._description = value
+
+    @description.deleter
+    def description(self):
+        self._description = None
+
+    @property
+    def alternate_ids(self) -> typing.MutableSequence[str]:
+        return self._alternate_ids
+
+    @property
+    def xrefs(self) -> typing.MutableSequence[str]:
+        return self._xrefs
+
+    @property
+    def alternate_symbols(self) -> typing.MutableSequence[str]:
+        return self._alternate_symbols
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'value_id', 'symbol', 'description', 'alternate_ids', 'xrefs', 'alternate_symbols',
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'value_id', 'symbol',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return GeneDescriptor(
+                value_id=values['value_id'],
+                symbol=values['symbol'],
+                description=values.get('description', None),
+                alternate_ids=values.get('alternate_ids', None),
+                xrefs=values.get('xrefs', None),
+                alternate_symbols=values.get('alternate_symbols', None),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        gene_descriptor = pp202.GeneDescriptor(value_id=self._value_id, symbol=self._symbol)
+
+        if self._description is not None:
+            gene_descriptor.description = self._description
+
+        gene_descriptor.alternate_ids.extend(self._alternate_ids)
+        gene_descriptor.xrefs.extend(self._xrefs)
+        gene_descriptor.alternate_symbols.extend(self._alternate_symbols)
+
+        return gene_descriptor
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.GeneDescriptor
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return GeneDescriptor(
+                value_id=msg.value_id,
+                symbol=msg.symbol,
+                description=None if msg.description == '' else msg.description,
+                alternate_ids=msg.alternate_ids,
+                xrefs=msg.xrefs,
+                alternate_symbols=msg.alternate_symbols,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, GeneDescriptor) \
+            and self._value_id == other._value_id \
+            and self._symbol == other._symbol \
+            and self._description == other._description \
+            and self._alternate_ids == other._alternate_ids \
+            and self._xrefs == other._xrefs \
+            and self._alternate_symbols == other._alternate_symbols
+
+    def __repr__(self):
+        return f'GeneDescriptor(' \
+               f'value_id={self._value_id}, ' \
+               f'symbol={self._symbol}, ' \
+               f'description={self._description}, ' \
+               f'alternate_ids={self._alternate_ids}, ' \
+               f'xrefs={self._xrefs}, ' \
+               f'alternate_symbols={self._alternate_symbols})'
diff --git a/src/pyphetools/pp/v202/_individual.py b/src/pyphetools/pp/v202/_individual.py
new file mode 100644
index 00000000..7bf9c125
--- /dev/null
+++ b/src/pyphetools/pp/v202/_individual.py
@@ -0,0 +1,415 @@
+import enum
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from ._base import TimeElement, OntologyClass
+from .._api import MessageMixin
+from .._timestamp import Timestamp
+from ..parse import extract_message_scalar, extract_pb_message_scalar
+
+
+class Sex(enum.Enum):
+    """
+    An enumeration used to represent the sex of an individual. This element does not represent gender identity
+    or :class:`KaryotypicSex`, but instead represents typical “phenotypic sex”, as would be determined
+    by a midwife or physician at birth.
+    """
+    # The `int` values correspond to protobuf fields in Phenopacket Schema.
+
+    UNKNOWN_SEX = 0
+    """
+    Not assessed or not available. 
+    Maps to `NCIT:C17998 <https://www.ebi.ac.uk/ols/ontologies/ncit/terms?iri=http%3A%2F%2Fpurl.obolibrary.org%2Fobo%2FNCIT_C17998>`_
+    """
+
+    FEMALE = 1
+    """
+    Female sex. 
+    Maps to `NCIT:C46113 <https://www.ebi.ac.uk/ols/ontologies/ncit/terms?iri=http%3A%2F%2Fpurl.obolibrary.org%2Fobo%2FNCIT_C46113>`_.
+    """
+
+    MALE = 2
+    """
+    Male sex. 
+    Maps to `NCIT:C46112 <https://www.ebi.ac.uk/ols/ontologies/ncit/terms?iri=http%3A%2F%2Fpurl.obolibrary.org%2Fobo%2FNCIT_C46112>`_
+    """
+
+    OTHER_SEX = 3
+    """
+    It is not possible to accurately assess the applicability of `MALE`/`FEMALE`. 
+    Maps to `NCIT:C45908 <https://www.ebi.ac.uk/ols/ontologies/ncit/terms?iri=http%3A%2F%2Fpurl.obolibrary.org%2Fobo%2FNCIT_C45908>`_
+    """
+
+
+class KaryotypicSex(enum.Enum):
+
+    # The `int` values correspond to protobuf fields in Phenopacket Schema.
+    UNKNOWN_KARYOTYPE = 0
+    XX = 1
+    XY = 2
+    XO = 3
+    XXY = 4
+    XXX = 5
+    XXYY = 6
+    XXXY = 7
+    XXXX = 8
+    XYY = 9
+    OTHER_KARYOTYPE = 10
+
+
+class VitalStatus(MessageMixin):
+    """
+    TODO: add docs.
+    """
+
+    class Status(enum.Enum):
+        """
+        TODO: add docs.
+        """
+        # The `int` values correspond to protobuf fields in Phenopacket Schema.
+
+        UNKNOWN_STATUS = 0
+        ALIVE = 1
+        DECEASED = 2
+
+    def __init__(
+            self,
+            status: Status,
+            time_of_death: typing.Optional[TimeElement] = None,
+            cause_of_death: typing.Optional[OntologyClass] = None,
+            survival_time_in_days: typing.Optional[int] = None,
+    ):
+        self._status = status
+        self._time_of_death = time_of_death
+        self._cause_of_death = cause_of_death
+        self._survival_time_in_days = survival_time_in_days
+
+    @property
+    def status(self) -> Status:
+        return self._status
+
+    @status.setter
+    def status(self, value: Status):
+        self._status = value
+
+    @property
+    def time_of_death(self) -> typing.Optional[TimeElement]:
+        return self._time_of_death
+
+    @time_of_death.setter
+    def time_of_death(self, value: typing.Optional[TimeElement]):
+        self._time_of_death = value
+
+    @time_of_death.deleter
+    def time_of_death(self):
+        self._time_of_death = None
+
+    @property
+    def cause_of_death(self) -> typing.Optional[OntologyClass]:
+        return self._cause_of_death
+
+    @cause_of_death.setter
+    def cause_of_death(self, value: typing.Optional[OntologyClass]):
+        self._cause_of_death = value
+
+    @cause_of_death.deleter
+    def cause_of_death(self):
+        self._cause_of_death = None
+
+    @property
+    def survival_time_in_days(self) -> typing.Optional[int]:
+        return self._survival_time_in_days
+
+    @survival_time_in_days.setter
+    def survival_time_in_days(self, value: typing.Optional[int]):
+        self._survival_time_in_days = value
+
+    @survival_time_in_days.deleter
+    def survival_time_in_days(self):
+        self._survival_time_in_days = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'status', 'time_of_death', 'cause_of_death', 'survival_time_in_days'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'status',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return VitalStatus(
+                status=MessageMixin._extract_enum_field('status', VitalStatus.Status, values),
+                time_of_death=extract_message_scalar('time_of_death', TimeElement, values),
+                cause_of_death=extract_message_scalar('cause_of_death', OntologyClass, values),
+                survival_time_in_days=int(values['survival_time_in_days']) if 'survival_time_in_days' in values else None,
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        vs = pp202.VitalStatus(
+            status=pp202.VitalStatus.Status.Value(self._status.name),
+        )
+
+        if self._time_of_death is not None:
+            vs.time_of_death.CopyFrom(self._time_of_death.to_message())
+
+        if self._cause_of_death is not None:
+            vs.cause_of_death.CopyFrom(self._cause_of_death.to_message())
+
+        if self._survival_time_in_days is not None:
+            vs.survival_time_in_days = self._survival_time_in_days
+
+        return vs
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.VitalStatus
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.VitalStatus):
+            return VitalStatus(
+                status=VitalStatus.Status(msg.status),
+                time_of_death=extract_pb_message_scalar('time_of_death', TimeElement, msg),
+                cause_of_death=extract_pb_message_scalar('cause_of_death', OntologyClass, msg),
+                survival_time_in_days=None if msg.survival_time_in_days == 0 else msg.survival_time_in_days,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, VitalStatus) \
+            and self._status == other._status \
+            and self._time_of_death == other._time_of_death \
+            and self._cause_of_death == other._cause_of_death \
+            and self._survival_time_in_days == other._survival_time_in_days
+
+    def __repr__(self):
+        return f'VitalStatus(' \
+               f'status={self._status}, ' \
+               f'time_of_death={self._time_of_death}, ' \
+               f'cause_of_death={self._cause_of_death}, ' \
+               f'survival_time_in_days={self._survival_time_in_days}' \
+               ')'
+
+
+class Individual(MessageMixin):
+
+    def __init__(
+            self,
+            id: str,
+            alternate_ids: typing.Optional[typing.Iterable[str]] = None,
+            date_of_birth: typing.Optional[Timestamp] = None,
+            time_at_last_encounter: typing.Optional[TimeElement] = None,
+            vital_status: typing.Optional[VitalStatus] = None,
+            sex: typing.Optional[Sex] = None,
+            karyotypic_sex: typing.Optional[KaryotypicSex] = None,
+            gender: typing.Optional[OntologyClass] = None,
+            taxonomy: typing.Optional[OntologyClass] = None,
+    ):
+        # TODO: validate
+        self._id = id
+        self._alt_ids = [] if alternate_ids is None else list(alternate_ids)
+        self._date_of_birth = date_of_birth
+        self._time_at_last_encounter = time_at_last_encounter
+        self._vital_status = vital_status
+        self._sex = sex
+        self._karyotypic_sex = karyotypic_sex
+        self._gender = gender
+        self._taxonomy = taxonomy
+
+    @property
+    def id(self) -> typing.Optional[str]:
+        return self._id
+
+    @property
+    def alternate_ids(self) -> typing.MutableSequence[str]:
+        return self._alt_ids
+
+    @property
+    def date_of_birth(self) -> typing.Optional[Timestamp]:
+        return self._date_of_birth
+
+    @date_of_birth.setter
+    def date_of_birth(self, value: typing.Optional[Timestamp]):
+        self._date_of_birth = value
+
+    @date_of_birth.deleter
+    def date_of_birth(self):
+        self._date_of_birth = None
+
+    @property
+    def time_at_last_encounter(self) -> typing.Optional[TimeElement]:
+        return self._time_at_last_encounter
+
+    @time_at_last_encounter.setter
+    def time_at_last_encounter(self, value: typing.Optional[TimeElement]):
+        self._time_at_last_encounter = value
+
+    @time_at_last_encounter.deleter
+    def time_at_last_encounter(self):
+        self._time_at_last_encounter = None
+
+    @property
+    def vital_status(self) -> typing.Optional[VitalStatus]:
+        return self._vital_status
+
+    @vital_status.setter
+    def vital_status(self, value: typing.Optional[VitalStatus]):
+        self._vital_status = value
+
+    @vital_status.deleter
+    def vital_status(self):
+        self._vital_status = None
+
+    @property
+    def sex(self) -> typing.Optional[Sex]:
+        return self._sex
+
+    @sex.setter
+    def sex(self, value: typing.Optional[Sex]):
+        self._sex = value
+
+    @sex.deleter
+    def sex(self):
+        self._sex = None
+
+    @property
+    def karyotypic_sex(self) -> typing.Optional[KaryotypicSex]:
+        return self._karyotypic_sex
+
+    @karyotypic_sex.setter
+    def karyotypic_sex(self, value: typing.Optional[KaryotypicSex]):
+        self._karyotypic_sex = value
+
+    @karyotypic_sex.deleter
+    def karyotypic_sex(self):
+        self._karyotypic_sex = None
+
+    @property
+    def gender(self) -> typing.Optional[OntologyClass]:
+        return self._gender
+
+    @gender.setter
+    def gender(self, value: typing.Optional[OntologyClass]):
+        self._gender = value
+
+    @gender.deleter
+    def gender(self):
+        self._gender = None
+
+    @property
+    def taxonomy(self) -> typing.Optional[OntologyClass]:
+        return self._taxonomy
+
+    @taxonomy.setter
+    def taxonomy(self, value: typing.Optional[OntologyClass]):
+        self._taxonomy = value
+
+    @taxonomy.deleter
+    def taxonomy(self):
+        self._taxonomy = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'id', 'alternate_ids', 'date_of_birth', 'time_at_last_encounter', 'vital_status', 'sex', 'karyotypic_sex', 'gender', 'taxonomy'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'id',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Individual(
+                id=values['id'],
+                alternate_ids=MessageMixin._extract_optional_field('alternate_ids', values),
+                date_of_birth=extract_message_scalar('date_of_birth', Timestamp, values),
+                time_at_last_encounter=extract_message_scalar('time_at_last_encounter', TimeElement, values),
+                vital_status=extract_message_scalar('vital_status', VitalStatus, values),
+                sex=MessageMixin._extract_enum_field('sex', Sex, values),
+                karyotypic_sex=MessageMixin._extract_enum_field('karyotypic_sex', KaryotypicSex, values),
+                gender=extract_message_scalar('gender', OntologyClass, values),
+                taxonomy=extract_message_scalar('taxonomy', OntologyClass, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        i = pp202.Individual(
+            id=self._id,
+        )
+
+        if len(self._alt_ids) != 0:
+            i.alternate_ids.extend(self._alt_ids)
+
+        if self._date_of_birth is not None:
+            i.time_at_last_encounter.CopyFrom(self._date_of_birth.to_message())
+
+        if self._time_at_last_encounter is not None:
+            i.time_at_last_encounter.CopyFrom(self._time_at_last_encounter.to_message())
+
+        if self._vital_status is not None:
+            i.vital_status.CopyFrom(self._vital_status.to_message())
+
+        if self._sex is not None:
+            i.sex = pp202.Sex.Value(self._sex.name)
+
+        if self._karyotypic_sex is not None:
+            i.karyotypic_sex = pp202.KaryotypicSex.Value(self._karyotypic_sex.name)
+
+        if self._gender is not None:
+            i.gender.CopyFrom(self._gender.to_message())
+
+        if self._taxonomy is not None:
+            i.taxonomy.CopyFrom(self._taxonomy.to_message())
+
+        return i
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Individual
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.Individual):
+            return Individual(
+                id=msg.id,
+                alternate_ids=msg.alternate_ids,
+                date_of_birth=extract_pb_message_scalar('date_of_birth', Timestamp, msg),
+                time_at_last_encounter=extract_pb_message_scalar('time_at_last_encounter', TimeElement, msg),
+                sex=Sex(msg.sex),
+                karyotypic_sex=KaryotypicSex(msg.karyotypic_sex),
+                vital_status=extract_pb_message_scalar('vital_status', VitalStatus, msg),
+                gender=extract_pb_message_scalar('gender', OntologyClass, msg),
+                taxonomy=extract_pb_message_scalar('taxonomy', OntologyClass, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Individual) \
+            and self._id == other._id \
+            and self._alt_ids == other._alt_ids \
+            and self._date_of_birth == other._date_of_birth \
+            and self._time_at_last_encounter == other._time_at_last_encounter \
+            and self._sex == other._sex \
+            and self._karyotypic_sex == other._karyotypic_sex \
+            and self._gender == other._gender \
+            and self._taxonomy == other._taxonomy
+
+    def __repr__(self):
+        return f'Individual(id={self._id},' \
+               f' alternate_ids={self._alt_ids},' \
+               f' date_of_birth={self._date_of_birth},' \
+               f' time_at_last_encounter={self._time_at_last_encounter},' \
+               f' sex={self._sex},' \
+               f' karyotypic_sex={self._karyotypic_sex},' \
+               f' gender={self._gender},' \
+               f' taxonomy={self._taxonomy},' \
+               ')'
diff --git a/src/pyphetools/pp/v202/_interpretation.py b/src/pyphetools/pp/v202/_interpretation.py
new file mode 100644
index 00000000..aca746c3
--- /dev/null
+++ b/src/pyphetools/pp/v202/_interpretation.py
@@ -0,0 +1,500 @@
+import enum
+
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from ._base import OntologyClass
+from ._vrsatile import VariationDescriptor
+from ._gene_descriptor import GeneDescriptor
+from .._api import MessageMixin
+from ..parse import extract_message_scalar, extract_message_sequence, extract_pb_message_scalar, extract_pb_message_seq
+
+
+class AcmgPathogenicityClassification(enum.Enum):
+    NOT_PROVIDED = 0
+    BENIGN = 1
+    LIKELY_BENIGN = 2
+    UNCERTAIN_SIGNIFICANCE = 3
+    LIKELY_PATHOGENIC = 4
+    PATHOGENIC = 5
+
+
+class TherapeuticActionability(enum.Enum):
+    UNKNOWN_ACTIONABILITY = 0
+    NOT_ACTIONABLE = 1
+    ACTIONABLE = 2
+
+
+class VariantInterpretation(MessageMixin):
+
+    def __init__(
+            self,
+            acmg_pathogenicity_classification: AcmgPathogenicityClassification,
+            therapeutic_actionability: TherapeuticActionability,
+            variation_descriptor: VariationDescriptor,
+    ):
+        self._acmg_pathogenicity_classification = acmg_pathogenicity_classification
+        self._therapeutic_actionability = therapeutic_actionability
+        self._variation_descriptor = variation_descriptor
+
+    @property
+    def acmg_pathogenicity_classification(self) -> AcmgPathogenicityClassification:
+        return self._acmg_pathogenicity_classification
+
+    @acmg_pathogenicity_classification.setter
+    def acmg_pathogenicity_classification(self, value: AcmgPathogenicityClassification):
+        self._acmg_pathogenicity_classification = value
+
+    @property
+    def therapeutic_actionability(self) -> TherapeuticActionability:
+        return self._therapeutic_actionability
+
+    @therapeutic_actionability.setter
+    def therapeutic_actionability(self, value: TherapeuticActionability):
+        self._therapeutic_actionability = value
+
+    @property
+    def variation_descriptor(self) -> VariationDescriptor:
+        return self._variation_descriptor
+
+    @variation_descriptor.setter
+    def variation_descriptor(self, value: VariationDescriptor):
+        self._variation_descriptor = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'acmg_pathogenicity_classification', 'therapeutic_actionability', 'variation_descriptor'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'acmg_pathogenicity_classification', 'therapeutic_actionability', 'variation_descriptor'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return VariantInterpretation(
+                acmg_pathogenicity_classification=MessageMixin._extract_enum_field(
+                    'acmg_pathogenicity_classification', AcmgPathogenicityClassification, values),
+                therapeutic_actionability=MessageMixin._extract_enum_field(
+                    'therapeutic_actionability', TherapeuticActionability, values),
+                variation_descriptor=extract_message_scalar('variation_descriptor', VariationDescriptor, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.VariantInterpretation(
+            acmg_pathogenicity_classification=pp202.AcmgPathogenicityClassification.Value(
+                self._acmg_pathogenicity_classification.name
+            ),
+            therapeutic_actionability=pp202.TherapeuticActionability.Value(
+                self._therapeutic_actionability.name
+            ),
+            variation_descriptor=self._variation_descriptor.to_message(),
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.VariantInterpretation
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.VariantInterpretation):
+            return VariantInterpretation(
+                acmg_pathogenicity_classification=AcmgPathogenicityClassification(
+                    msg.acmg_pathogenicity_classification),
+                therapeutic_actionability=TherapeuticActionability(msg.therapeutic_actionability),
+                variation_descriptor=extract_pb_message_scalar('variation_descriptor', VariationDescriptor, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, VariantInterpretation) \
+            and self._acmg_pathogenicity_classification == other._acmg_pathogenicity_classification \
+            and self._therapeutic_actionability == other._therapeutic_actionability \
+            and self._variation_descriptor == other._variation_descriptor
+
+    def __repr__(self):
+        return f'VariantInterpretation(acmg_pathogenicity_classification={self._acmg_pathogenicity_classification}, ' \
+               f'therapeutic_actionability={self._therapeutic_actionability}, ' \
+               f'variation_descriptor={self._variation_descriptor})'
+
+
+class GenomicInterpretation(MessageMixin):
+    class InterpretationStatus(enum.Enum):
+        UNKNOWN_STATUS = 0
+        REJECTED = 1
+        CANDIDATE = 2
+        CONTRIBUTORY = 3
+        CAUSATIVE = 4
+
+    def __init__(
+            self,
+            subject_or_biosample_id: str,
+            interpretation_status: InterpretationStatus,
+            gene_descriptor: typing.Optional[GeneDescriptor] = None,
+            variant_interpretation: typing.Optional[VariantInterpretation] = None,
+    ):
+        self._subject_or_biosample_id = subject_or_biosample_id
+        self._interpretation_status = interpretation_status
+        one_ofs = (gene_descriptor, variant_interpretation)
+        if sum(1 for arg in one_ofs if arg is not None) != 1:
+            cnt = sum(1 for arg in one_ofs if arg is not None)
+            raise ValueError(
+                f'GenomicInterpretation must be provided with exactly 1 argument but {cnt} arguments were provided!')
+
+        if gene_descriptor is not None:
+            self._discriminant = 0
+            self._call = gene_descriptor
+        elif variant_interpretation is not None:
+            self._discriminant = 1
+            self._call = variant_interpretation
+        else:
+            raise ValueError('Bug')  # TODO: wording
+
+    @property
+    def subject_or_biosample_id(self) -> str:
+        return self._subject_or_biosample_id
+
+    @subject_or_biosample_id.setter
+    def subject_or_biosample_id(self, value: str):
+        self._subject_or_biosample_id = value
+
+    @property
+    def interpretation_status(self) -> InterpretationStatus:
+        return self._interpretation_status
+
+    @interpretation_status.setter
+    def interpretation_status(self, value: InterpretationStatus):
+        self._interpretation_status = value
+
+    @property
+    def gene_descriptor(self) -> typing.Optional[GeneDescriptor]:
+        return self._call if self._discriminant == 0 else None
+
+    @gene_descriptor.setter
+    def gene_descriptor(self, value: GeneDescriptor):
+        self._discriminant = 0
+        self._call = value
+
+    @property
+    def variant_interpretation(self) -> typing.Optional[VariantInterpretation]:
+        return self._call if self._discriminant == 1 else None
+
+    @variant_interpretation.setter
+    def variant_interpretation(self, value: VariantInterpretation):
+        self._discriminant = 1
+        self._call = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'subject_or_biosample_id', 'interpretation_status', 'gene_descriptor', 'variant_interpretation'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'subject_or_biosample_id', 'interpretation_status',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            if 'gene_descriptor' in values:
+                assert 'variant_interpretation' not in values, \
+                    'Variant interpretation must be unset when Gene descriptor is set!'
+                return GenomicInterpretation(
+                    subject_or_biosample_id=values['subject_or_biosample_id'],
+                    interpretation_status=MessageMixin._extract_enum_field(
+                        'interpretation_status', GenomicInterpretation.InterpretationStatus, values
+                    ),
+                    gene_descriptor=extract_message_scalar('gene_descriptor', GeneDescriptor, values),
+                )
+
+            elif 'variant_interpretation' in values:
+                assert 'gene_descriptor' not in values, \
+                    'Gene descriptor must be unset when Variant interpretation is set!'
+                return GenomicInterpretation(
+                    subject_or_biosample_id=values['subject_or_biosample_id'],
+                    interpretation_status=MessageMixin._extract_enum_field(
+                        'interpretation_status', GenomicInterpretation.InterpretationStatus, values
+                    ),
+                    variant_interpretation=extract_message_scalar(
+                        'variant_interpretation', VariantInterpretation, values
+                    ),
+                )
+
+            else:
+                raise ValueError('Either `gene_descriptor` or `variant_interpretation` must be set!')
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        msg = pp202.GenomicInterpretation(
+            subject_or_biosample_id=self._subject_or_biosample_id,
+            interpretation_status=pp202.GenomicInterpretation.InterpretationStatus.Value(
+                self._interpretation_status.name
+            ),
+        )
+
+        val = self._call.to_message()
+        if self._discriminant == 0:
+            msg.gene_descriptor.CopyFrom(val)
+        elif self._discriminant == 1:
+            msg.variant_interpretation.CopyFrom(val)
+        else:
+            raise ValueError(f'Invalid discriminant {self._discriminant}')
+
+        return msg
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.GenomicInterpretation
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.GenomicInterpretation):
+            subject_or_biosample_id = msg.subject_or_biosample_id
+            interpretation_status = GenomicInterpretation.InterpretationStatus(msg.interpretation_status)
+
+            case = msg.WhichOneof('call')
+            if case == 'gene_descriptor':
+                return GenomicInterpretation(
+                    subject_or_biosample_id=subject_or_biosample_id,
+                    interpretation_status=interpretation_status,
+                    gene_descriptor=extract_pb_message_scalar(
+                        'gene_descriptor', GeneDescriptor, msg
+                    ),
+                )
+            elif case == 'variant_interpretation':
+                return GenomicInterpretation(
+                    subject_or_biosample_id=subject_or_biosample_id,
+                    interpretation_status=interpretation_status,
+                    variant_interpretation=extract_pb_message_scalar(
+                        'variant_interpretation', VariantInterpretation, msg
+                    ),
+                )
+            else:
+                raise ValueError(f'Unknown one of field set {case}')
+
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, GenomicInterpretation) \
+            and self._subject_or_biosample_id == other._subject_or_biosample_id \
+            and self._interpretation_status == other._interpretation_status \
+            and self._discriminant == other._discriminant \
+            and self._call == other._call
+
+    def __repr__(self):
+        if self._discriminant == 0:
+            val = f'gene_descriptor={self._call}'
+        elif self._discriminant == 1:
+            val = f'variant_interpretation={self._call}'
+        else:
+            raise ValueError(f'Invalid discriminant {self._discriminant}')
+
+        return f'GenomicInterpretation(' \
+               f'subject_or_biosample_id={self._subject_or_biosample_id}, ' \
+               f'interpretation_status={self._interpretation_status}, ' \
+               f'{val})'
+
+
+class Diagnosis(MessageMixin):
+
+    def __init__(
+            self,
+            disease: OntologyClass,
+            genomic_interpretations: typing.Optional[typing.Iterable[GenomicInterpretation]] = None,
+    ):
+        self._disease = disease
+        self._genomic_interpretations = [] if genomic_interpretations is None else list(genomic_interpretations)
+
+    @property
+    def disease(self) -> OntologyClass:
+        return self._disease
+
+    @disease.setter
+    def disease(self, value: OntologyClass):
+        self._disease = value
+
+    @property
+    def genomic_interpretations(self) -> typing.MutableSequence[GenomicInterpretation]:
+        return self._genomic_interpretations
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'disease', 'genomic_interpretations'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'disease',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Diagnosis(
+                disease=extract_message_scalar('disease', OntologyClass, values),
+                genomic_interpretations=extract_message_sequence(
+                    'genomic_interpretations', GenomicInterpretation, values
+                ),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.Diagnosis(
+            disease=self._disease.to_message(),
+            genomic_interpretations=(gi.to_message() for gi in self._genomic_interpretations),
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Diagnosis
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.Diagnosis):
+            return Diagnosis(
+                disease=extract_pb_message_scalar('disease', OntologyClass, msg),
+                genomic_interpretations=extract_pb_message_seq('genomic_interpretations', GenomicInterpretation, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Diagnosis) \
+            and self._disease == other._disease \
+            and self._genomic_interpretations == other._genomic_interpretations
+
+    def __repr__(self):
+        return f'Diagnosis(' \
+               f'disease={self._disease}' \
+               f'genomic_interpretations={self._genomic_interpretations})'
+
+
+class Interpretation(MessageMixin):
+    class ProgressStatus(enum.Enum):
+        UNKNOWN_PROGRESS = 0
+        IN_PROGRESS = 1
+        COMPLETED = 2
+        SOLVED = 3
+        UNSOLVED = 4
+
+    def __init__(
+            self,
+            id: str,
+            progress_status: ProgressStatus,
+            diagnosis: typing.Optional[Diagnosis] = None,
+            summary: typing.Optional[str] = None,
+    ):
+        self._id = id
+        self._progress_status = progress_status
+        self._diagnosis = diagnosis
+        self._summary = summary
+
+    @property
+    def id(self) -> str:
+        return self._id
+
+    @id.setter
+    def id(self, value: str):
+        self._id = value
+
+    @property
+    def progress_status(self) -> ProgressStatus:
+        return self._progress_status
+
+    @progress_status.setter
+    def progress_status(self, value: ProgressStatus):
+        self._progress_status = value
+
+    @property
+    def diagnosis(self) -> typing.Optional[Diagnosis]:
+        return self._diagnosis
+
+    @diagnosis.setter
+    def diagnosis(self, value: Diagnosis):
+        self._diagnosis = value
+
+    @diagnosis.deleter
+    def diagnosis(self):
+        self._diagnosis = None
+
+    @property
+    def summary(self) -> typing.Optional[str]:
+        return self._summary
+
+    @summary.setter
+    def summary(self, value: str):
+        self._summary = value
+
+    @summary.deleter
+    def summary(self):
+        self._summary = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'id', 'progress_status', 'diagnosis', 'summary'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'id', 'progress_status',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Interpretation(
+                id=values['id'],
+                progress_status=MessageMixin._extract_enum_field('progress_status', Interpretation.ProgressStatus,
+                                                                 values),
+                diagnosis=extract_message_scalar('diagnosis', Diagnosis, values),
+                summary=MessageMixin._extract_optional_field('summary', values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        i = pp202.Interpretation(
+            id=self._id,
+            progress_status=pp202.Interpretation.ProgressStatus.Value(self._progress_status.name),
+        )
+
+        if self._diagnosis is not None:
+            i.diagnosis.CopyFrom(self._diagnosis.to_message())
+
+        if self._summary is not None:
+            i.summary = self._summary
+
+        return i
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Interpretation
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.Interpretation):
+            return Interpretation(
+                id=msg.id,
+                progress_status=Interpretation.ProgressStatus(msg.progress_status),
+                diagnosis=extract_pb_message_scalar('diagnosis', Diagnosis, msg),
+                summary=None if msg.summary == '' else msg.summary,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Interpretation) \
+            and self._id == other._id \
+            and self._progress_status == other._progress_status \
+            and self._diagnosis == other._diagnosis \
+            and self._summary == other._summary
+
+    def __repr__(self):
+        return f'Interpretation(' \
+               f'id={self._id}, ' \
+               f'progress_status={self._progress_status}, ' \
+               f'diagnosis={self._diagnosis}, ' \
+               f'summary={self._summary})'
diff --git a/src/pyphetools/pp/v202/_measurement.py b/src/pyphetools/pp/v202/_measurement.py
new file mode 100644
index 00000000..50a2c5f8
--- /dev/null
+++ b/src/pyphetools/pp/v202/_measurement.py
@@ -0,0 +1,550 @@
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from .._api import MessageMixin
+from ._base import OntologyClass, Procedure, TimeElement
+from ..parse import extract_message_scalar, extract_message_sequence, extract_oneof_scalar
+from ..parse import extract_pb_message_scalar, extract_pb_message_seq, extract_pb_oneof_scalar
+
+
+class ReferenceRange(MessageMixin):
+
+    def __init__(
+            self,
+            unit: OntologyClass,
+            low: float,
+            high: float,
+    ):
+        self._unit = unit
+        self._low = low
+        self._high = high
+
+    @property
+    def unit(self) -> OntologyClass:
+        return self._unit
+
+    @unit.setter
+    def unit(self, value: OntologyClass):
+        self._unit = value
+
+    @property
+    def low(self) -> float:
+        return self._low
+
+    @low.setter
+    def low(self, value: float):
+        self._low = value
+
+    @property
+    def high(self) -> float:
+        return self._high
+
+    @high.setter
+    def high(self, value: float):
+        self._high = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'unit', 'low', 'high'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'unit', 'low', 'high'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return ReferenceRange(
+                unit=extract_message_scalar('unit', OntologyClass, values),
+                low=values['low'],
+                high=values['high'],
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.ReferenceRange(
+            unit=self._unit.to_message(),
+            low=self._low,
+            high=self._high,
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.ReferenceRange
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return ReferenceRange(
+                unit=extract_pb_message_scalar('unit', OntologyClass, msg),
+                low=msg.low,
+                high=msg.high,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, ReferenceRange) \
+            and self._unit == other._unit \
+            and self._low == other._low \
+            and self._high == other._high
+
+    def __repr__(self):
+        return f'ReferenceRange(unit={self._unit}, low={self._low}, high={self._high})'
+
+
+class Quantity(MessageMixin):
+
+    def __init__(
+            self,
+            unit: OntologyClass,
+            value: float,
+            reference_range: typing.Optional[ReferenceRange] = None,
+    ):
+        self._unit = unit
+        self._value = value
+        self._reference_range = reference_range
+
+    @property
+    def unit(self) -> OntologyClass:
+        return self._unit
+
+    @unit.setter
+    def unit(self, value: OntologyClass):
+        self._unit = value
+
+    @property
+    def value(self) -> float:
+        return self._value
+
+    @value.setter
+    def value(self, value: float):
+        self._value = value
+
+    @property
+    def reference_range(self) -> typing.Optional[ReferenceRange]:
+        return self._reference_range
+
+    @reference_range.setter
+    def reference_range(self, value: ReferenceRange):
+        self._reference_range = value
+
+    @reference_range.deleter
+    def reference_range(self):
+        self._reference_range = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'unit', 'value', 'reference_range'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'unit', 'value'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Quantity(
+                unit=extract_message_scalar('unit', OntologyClass, values),
+                value=values['value'],
+                reference_range=extract_message_scalar('reference_range', ReferenceRange, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Quantity
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return Quantity(
+                unit=extract_pb_message_scalar('unit', OntologyClass, msg),
+                value=msg.value,
+                reference_range=extract_pb_message_scalar('reference_range', ReferenceRange, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def to_message(self) -> Message:
+        quantity = pp202.Quantity(unit=self._unit.to_message(), value=self._value)
+
+        if self._reference_range is not None:
+            quantity.reference_range.CopyFrom(self._reference_range.to_message())
+
+        return quantity
+
+    def __eq__(self, other):
+        return isinstance(other, Quantity) \
+            and self._unit == other._unit \
+            and self._value == other._value \
+            and self._reference_range == other._reference_range
+
+    def __repr__(self):
+        return f'Quantity(unit={self._unit}, value={self._value}, reference_range={self._reference_range})'
+
+
+class TypedQuantity(MessageMixin):
+
+    def __init__(
+            self,
+            type: OntologyClass,
+            quantity: Quantity,
+    ):
+        self._type = type
+        self._quantity = quantity
+
+    @property
+    def type(self) -> OntologyClass:
+        return self._type
+
+    @type.setter
+    def type(self, value: OntologyClass):
+        self._type = value
+
+    @property
+    def quantity(self) -> Quantity:
+        return self._quantity
+
+    @quantity.setter
+    def quantity(self, value: Quantity):
+        self._quantity = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'type', 'quantity'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'type', 'quantity'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return TypedQuantity(
+                type=extract_message_scalar('type', OntologyClass, values),
+                quantity=extract_message_scalar('quantity', Quantity, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.TypedQuantity(
+            type=self._type.to_message(),
+            quantity=self._quantity.to_message(),
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.TypedQuantity
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return TypedQuantity(
+                type=extract_pb_message_scalar('type', OntologyClass, msg),
+                quantity=extract_pb_message_scalar('quantity', Quantity, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, TypedQuantity) \
+            and self._type == other._type \
+            and self._quantity == other._quantity
+
+    def __repr__(self):
+        return f'TypedQuantity(type={self._type}, quantity={self._quantity})'
+
+
+class ComplexValue(MessageMixin):
+
+    def __init__(
+            self,
+            typed_quantities: typing.Iterable[TypedQuantity],
+    ):
+        self._typed_quantities = list(typed_quantities)
+        if len(self._typed_quantities) < 0:
+            raise ValueError(f'At least 1 typed quantity must be present!')
+
+    @property
+    def typed_quantities(self) -> typing.MutableSequence[TypedQuantity]:
+        return self._typed_quantities
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'typed_quantities',
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'typed_quantities',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return ComplexValue(
+                typed_quantities=extract_message_sequence('typed_quantities', TypedQuantity, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.ComplexValue
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return ComplexValue(
+                typed_quantities=extract_pb_message_seq('typed_quantities', TypedQuantity, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def to_message(self) -> Message:
+        cv = pp202.ComplexValue()
+        cv.typed_quantities.extend(m.to_message() for m in self._typed_quantities)
+        return cv
+
+    def __eq__(self, other):
+        return isinstance(other, ComplexValue) \
+            and self._typed_quantities == other._typed_quantities
+
+    def __repr__(self):
+        return f'ComplexValue(typed_quantities={self._typed_quantities})'
+
+
+class Value(MessageMixin):
+    _ONEOF_VALUE = {'quantity': Quantity, 'ontology_class': OntologyClass}
+
+    def __init__(
+            self,
+            value: typing.Union[Quantity, OntologyClass],
+    ):
+        self._value = value
+
+    @property
+    def value(self) -> typing.Union[Quantity, OntologyClass]:
+        return self._value
+
+    @property
+    def quantity(self) -> typing.Optional[Quantity]:
+        return self._value if isinstance(self._value, Quantity) else None
+
+    @quantity.setter
+    def quantity(self, value: Quantity):
+        self._value = value
+
+    @property
+    def ontology_class(self) -> typing.Optional[OntologyClass]:
+        return self._value if isinstance(self._value, OntologyClass) else None
+
+    @ontology_class.setter
+    def ontology_class(self, value: OntologyClass):
+        self._value = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'quantity', 'ontology_class'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        raise NotImplementedError('Should not be called!')
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if any(field in values for field in cls._ONEOF_VALUE):
+            return Value(
+                value=extract_oneof_scalar(cls._ONEOF_VALUE, values),
+            )
+        else:
+            raise ValueError(f'Missing one of required fields: `quantity|ontology_class`: {values}')
+
+    def to_message(self) -> Message:
+        v = pp202.Value()
+
+        if isinstance(self._value, Quantity):
+            v.quantity.CopyFrom(self._value.to_message())
+        elif isinstance(self._value, OntologyClass):
+            v.ontology_class.CopyFrom(self._value.to_message())
+        else:
+            raise ValueError('Bug')
+
+        return v
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Value
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return Value(
+                value=extract_pb_oneof_scalar('value', cls._ONEOF_VALUE, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Value) and self._value == other._value
+
+    def __repr__(self):
+        return f'Value(value={self._value})'
+
+
+class Measurement(MessageMixin):
+    _ONEOF_MEASUREMENT_VALUE = {'value': Value, 'complex_value': ComplexValue}
+
+    def __init__(
+            self,
+            assay: OntologyClass,
+            measurement_value: typing.Union[Value, ComplexValue],
+            description: typing.Optional[str] = None,
+            time_observed: typing.Optional[TimeElement] = None,
+            procedure: typing.Optional[Procedure] = None,
+    ):
+        self._assay = assay
+        self._measurement_value = measurement_value
+        self._description = description
+        self._time_observed = time_observed
+        self._procedure = procedure
+
+    @property
+    def assay(self) -> OntologyClass:
+        return self._assay
+
+    @assay.setter
+    def assay(self, value: OntologyClass):
+        self._assay = value
+
+    @property
+    def measurement_value(self) -> typing.Union[Value, ComplexValue]:
+        return self._measurement_value
+
+    @property
+    def value(self) -> typing.Optional[Value]:
+        return self._measurement_value if isinstance(self._measurement_value, Value) else None
+
+    @property
+    def complex_value(self) -> typing.Optional[ComplexValue]:
+        return self._measurement_value if isinstance(self._measurement_value, ComplexValue) else None
+
+    @property
+    def description(self) -> typing.Optional[str]:
+        return self._description
+
+    @description.setter
+    def description(self, value: str):
+        self._description = value
+
+    @description.deleter
+    def description(self):
+        self._description = None
+
+    @property
+    def time_observed(self) -> typing.Optional[TimeElement]:
+        return self._time_observed
+
+    @time_observed.setter
+    def time_observed(self, value: TimeElement):
+        self._time_observed = value
+
+    @time_observed.deleter
+    def time_observed(self):
+        self._time_observed = None
+
+    @property
+    def procedure(self) -> typing.Optional[Procedure]:
+        return self._procedure
+
+    @procedure.setter
+    def procedure(self, value: Procedure):
+        self._procedure = value
+
+    @procedure.deleter
+    def procedure(self):
+        self._procedure = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'assay', 'value', 'complex_value', 'description', 'time_observed', 'procedure'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        raise NotImplementedError('Should not be called!')
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if 'assay' in values and any(field in values for field in cls._ONEOF_MEASUREMENT_VALUE):
+            return Measurement(
+                assay=extract_message_scalar('assay', OntologyClass, values),
+                measurement_value=extract_oneof_scalar(cls._ONEOF_MEASUREMENT_VALUE, values),
+                description=values.get('description', None),
+                time_observed=extract_message_scalar('time_observed', TimeElement, values),
+                procedure=extract_message_scalar('procedure', Procedure, values),
+            )
+        else:
+            raise ValueError(f'Missing one of required fields: `assay, value|complex_value` {values}')
+
+    def to_message(self) -> Message:
+        m = pp202.Measurement(
+            assay=self._assay.to_message(),
+        )
+
+        if isinstance(self._measurement_value, Value):
+            m.value.CopyFrom(self._measurement_value.to_message())
+        elif isinstance(self._measurement_value, ComplexValue):
+            m.complex_value.CopyFrom(self._measurement_value.to_message())
+        else:
+            raise ValueError('Bug')
+
+        if self._description is not None:
+            m.description = self._description
+
+        if self._time_observed is not None:
+            m.time_observed.CopyFrom(self._time_observed.to_message())
+
+        if self._procedure is not None:
+            m.procedure.CopyFrom(self._procedure.to_message())
+
+        return m
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Measurement
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return Measurement(
+                assay=extract_pb_message_scalar('assay', OntologyClass, msg),
+                measurement_value=extract_pb_oneof_scalar(
+                    'measurement_value',
+                    cls._ONEOF_MEASUREMENT_VALUE,
+                    msg),
+                description=None if msg.description == '' else msg.description,
+                time_observed=extract_pb_message_scalar('time_observed', TimeElement, msg),
+                procedure=extract_pb_message_scalar('procedure', Procedure, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Measurement) \
+            and self._assay == other._assay \
+            and self._measurement_value == other._measurement_value \
+            and self._description == other._description \
+            and self._time_observed == other._time_observed \
+            and self._procedure == other._procedure
+
+    def __repr__(self):
+        return f'Measurement(' \
+               f'assay={self._assay}, ' \
+               f'measurement_value={self._measurement_value}, ' \
+               f'description={self._description}, ' \
+               f'time_observed={self._time_observed}, ' \
+               f'procedure={self._procedure})'
diff --git a/src/pyphetools/pp/v202/_medical_action.py b/src/pyphetools/pp/v202/_medical_action.py
new file mode 100644
index 00000000..0f460f23
--- /dev/null
+++ b/src/pyphetools/pp/v202/_medical_action.py
@@ -0,0 +1,704 @@
+import enum
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from ._base import OntologyClass, TimeElement, ExternalReference, TimeInterval, Procedure
+from ._measurement import Quantity
+
+from .._api import MessageMixin
+from ..parse import extract_message_scalar, extract_message_sequence, extract_oneof_scalar
+from ..parse import extract_pb_message_scalar, extract_pb_message_seq, extract_pb_oneof_scalar
+
+
+class TherapeuticRegimen(MessageMixin):
+    _ONEOF_IDENTIFIER = {'external_reference': ExternalReference, 'ontology_class': OntologyClass}
+
+    class RegimenStatus(enum.Enum):
+        UNKNOWN_STATUS = 0
+        STARTED = 1
+        COMPLETED = 2
+        DISCONTINUED = 3
+
+    def __init__(
+            self,
+            identifier: typing.Union[ExternalReference, OntologyClass],
+            regimen_status: RegimenStatus,
+            start_time: typing.Optional[TimeElement] = None,
+            end_time: typing.Optional[TimeElement] = None,
+    ):
+        self._identifier = identifier
+        self._regimen_status = regimen_status
+        self._start_time = start_time
+        self._end_time = end_time
+
+    @property
+    def identifier(self) -> typing.Union[ExternalReference, OntologyClass]:
+        return self._identifier
+
+    @property
+    def external_reference(self) -> typing.Optional[ExternalReference]:
+        return self._identifier if isinstance(self._identifier, ExternalReference) else None
+
+    @external_reference.setter
+    def external_reference(self, value: ExternalReference):
+        self._identifier = value
+
+    @property
+    def ontology_class(self) -> typing.Optional[OntologyClass]:
+        return self._identifier if isinstance(self._identifier, OntologyClass) else None
+
+    @ontology_class.setter
+    def ontology_class(self, value: OntologyClass):
+        self._identifier = value
+
+    @property
+    def regimen_status(self) -> RegimenStatus:
+        return self._regimen_status
+
+    @regimen_status.setter
+    def regimen_status(self, value: RegimenStatus):
+        self._regimen_status = value
+
+    @property
+    def start_time(self) -> typing.Optional[TimeElement]:
+        return self._start_time
+
+    @start_time.setter
+    def start_time(self, value: TimeElement):
+        self._start_time = value
+
+    @start_time.deleter
+    def start_time(self):
+        self._start_time = None
+
+    @property
+    def end_time(self) -> typing.Optional[TimeElement]:
+        return self._end_time
+
+    @end_time.setter
+    def end_time(self, value: TimeElement):
+        self._end_time = value
+
+    @end_time.deleter
+    def end_time(self):
+        self._end_time = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'external_reference', 'ontology_class', 'regimen_status', 'start_time', 'end_time'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        raise NotImplementedError('Should not be called!')
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if 'regimen_status' in values and any(field in values for field in cls._ONEOF_IDENTIFIER):
+            return TherapeuticRegimen(
+                identifier=extract_oneof_scalar(cls._ONEOF_IDENTIFIER, values),
+                regimen_status=MessageMixin._extract_enum_field(
+                    'regimen_status', TherapeuticRegimen.RegimenStatus, values,
+                ),
+                start_time=extract_message_scalar('start_time', TimeElement, values),
+                end_time=extract_message_scalar('end_time', TimeElement, values),
+            )
+        else:
+            raise ValueError(
+                f'Missing one of required fields: `external_reference|ontology_class, regimen_status` {values}'
+            )
+
+    def to_message(self) -> Message:
+        tr = pp202.TherapeuticRegimen(
+            regimen_status=pp202.TherapeuticRegimen.RegimenStatus.Value(self._regimen_status.name),
+        )
+
+        if isinstance(self._identifier, ExternalReference):
+            tr.external_reference.CopyFrom(self._identifier.to_message())
+        elif isinstance(self._identifier, OntologyClass):
+            tr.ontology_class.CopyFrom(self._identifier.to_message())
+        else:
+            raise ValueError('Bug')
+
+        if self._start_time is not None:
+            tr.start_time.CopyFrom(self._start_time.to_message())
+
+        if self._end_time is not None:
+            tr.end_time.CopyFrom(self._end_time.to_message())
+
+        return tr
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.TherapeuticRegimen
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return TherapeuticRegimen(
+                identifier=extract_pb_oneof_scalar('identifier', cls._ONEOF_IDENTIFIER, msg),
+                regimen_status=TherapeuticRegimen.RegimenStatus(msg.regimen_status),
+                start_time=extract_pb_message_scalar('start_time', TimeElement, msg),
+                end_time=extract_pb_message_scalar('end_time', TimeElement, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, TherapeuticRegimen) \
+            and self._identifier == other._identifier \
+            and self._regimen_status == other._regimen_status \
+            and self._start_time == other._start_time \
+            and self._end_time == other._end_time
+
+    def __repr__(self):
+        return f'TherapeuticRegimen(identifier={self._identifier}, ' \
+               f'regimen_status={self._regimen_status}, ' \
+               f'start_time={self._start_time}, ' \
+               f'end_time={self._end_time})'
+
+
+class RadiationTherapy(MessageMixin):
+
+    def __init__(
+            self,
+            modality: OntologyClass,
+            body_site: OntologyClass,
+            dosage: int,
+            fractions: int,
+    ):
+        self._modality = modality
+        self._body_site = body_site
+        self._dosage = dosage
+        self._fractions = fractions
+
+    @property
+    def modality(self) -> OntologyClass:
+        return self._modality
+
+    @modality.setter
+    def modality(self, value: OntologyClass):
+        self._modality = value
+
+    @property
+    def body_site(self) -> OntologyClass:
+        return self._body_site
+
+    @body_site.setter
+    def body_site(self, value: OntologyClass):
+        self._body_site = value
+
+    @property
+    def dosage(self) -> int:
+        return self._dosage
+
+    @dosage.setter
+    def dosage(self, value: int):
+        self._dosage = value
+
+    @property
+    def fractions(self) -> int:
+        return self._fractions
+
+    @fractions.setter
+    def fractions(self, value: int):
+        self._fractions = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'modality', 'body_site', 'dosage', 'fractions'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'modality', 'body_site', 'dosage', 'fractions'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return RadiationTherapy(
+                modality=extract_message_scalar('modality', OntologyClass, values),
+                body_site=extract_message_scalar('body_site', OntologyClass, values),
+                dosage=values['dosage'],
+                fractions=values['fractions'],
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.RadiationTherapy(
+            modality=self._modality.to_message(),
+            body_site=self._body_site.to_message(),
+            dosage=self._dosage,
+            fractions=self._fractions,
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.RadiationTherapy
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.RadiationTherapy):
+            return RadiationTherapy(
+                modality=extract_pb_message_scalar('modality', OntologyClass, msg),
+                body_site=extract_pb_message_scalar('body_site', OntologyClass, msg),
+                dosage=msg.dosage,
+                fractions=msg.fractions,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, RadiationTherapy) \
+            and self._modality == other._modality \
+            and self._body_site == other._body_site \
+            and self._dosage == other._dosage \
+            and self._fractions == other._fractions
+
+    def __repr__(self):
+        return f'RadiationTherapy(modality={self._modality}, ' \
+               f'body_site={self._modality}, ' \
+               f'dosage={self._body_site}, ' \
+               f'fractions={self._dosage})'
+
+
+class DrugType(enum.Enum):
+    UNKNOWN_DRUG_TYPE = 0
+    PRESCRIPTION = 1
+    EHR_MEDICATION_LIST = 2
+    ADMINISTRATION_RELATED_TO_PROCEDURE = 3
+
+
+class DoseInterval(MessageMixin):
+
+    def __init__(
+            self,
+            quantity: Quantity,
+            schedule_frequency: OntologyClass,
+            interval: TimeInterval,
+    ):
+        self._quantity = quantity
+        self._schedule_frequency = schedule_frequency
+        self._interval = interval
+
+    @property
+    def quantity(self) -> Quantity:
+        return self._quantity
+
+    @quantity.setter
+    def quantity(self, value: Quantity):
+        self._quantity = value
+
+    @property
+    def schedule_frequency(self) -> OntologyClass:
+        return self._schedule_frequency
+
+    @schedule_frequency.setter
+    def schedule_frequency(self, value: OntologyClass):
+        self._schedule_frequency = value
+
+    @property
+    def interval(self) -> TimeInterval:
+        return self._interval
+
+    @interval.setter
+    def interval(self, value: TimeInterval):
+        self._interval = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'quantity', 'schedule_frequency', 'interval'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'quantity', 'schedule_frequency', 'interval'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return DoseInterval(
+                quantity=extract_message_scalar('quantity', Quantity, values),
+                schedule_frequency=extract_message_scalar('schedule_frequency', OntologyClass, values),
+                interval=extract_message_scalar('interval', TimeInterval, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.DoseInterval(
+            quantity=self._quantity.to_message(),
+            schedule_frequency=self._schedule_frequency.to_message(),
+            interval=self._interval.to_message(),
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.DoseInterval
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.DoseInterval):
+            return DoseInterval(
+                quantity=extract_pb_message_scalar('quantity', Quantity, msg),
+                schedule_frequency=extract_pb_message_scalar('schedule_frequency', OntologyClass, msg),
+                interval=extract_pb_message_scalar('interval', TimeInterval, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, DoseInterval) \
+            and self._quantity == other._quantity \
+            and self._schedule_frequency == other._schedule_frequency \
+            and self._interval == other._interval
+
+    def __repr__(self):
+        return f'DoseInterval(quantity={self._quantity}, ' \
+               f'schedule_frequency={self._schedule_frequency}, ' \
+               f'interval={self._interval})'
+
+
+class Treatment(MessageMixin):
+
+    def __init__(
+            self,
+            agent: OntologyClass,
+            route_of_administration: typing.Optional[OntologyClass] = None,
+            dose_intervals: typing.Optional[typing.Iterable[DoseInterval]] = None,
+            drug_type: typing.Optional[DrugType] = None,
+            cumulative_dose: typing.Optional[Quantity] = None,
+    ):
+        self._agent = agent
+        self._route_of_administration = route_of_administration
+        self._dose_intervals = [] if dose_intervals is None else list(dose_intervals)
+        self._drug_type = DrugType.UNKNOWN_DRUG_TYPE if drug_type is None else drug_type
+        self._cumulative_dose = cumulative_dose
+
+    @property
+    def agent(self) -> OntologyClass:
+        return self._agent
+
+    @agent.setter
+    def agent(self, value: OntologyClass):
+        self._agent = value
+
+    @property
+    def route_of_administration(self) -> typing.Optional[OntologyClass]:
+        return self._route_of_administration
+
+    @route_of_administration.setter
+    def route_of_administration(self, value: OntologyClass):
+        self._route_of_administration = value
+
+    @route_of_administration.deleter
+    def route_of_administration(self):
+        self._route_of_administration = None
+
+    @property
+    def dose_intervals(self) -> typing.MutableSequence[DoseInterval]:
+        return self._dose_intervals
+
+    @property
+    def drug_type(self) -> typing.Optional[DrugType]:
+        return self._drug_type
+
+    @drug_type.setter
+    def drug_type(self, value: DrugType):
+        self._drug_type = value
+
+    @drug_type.deleter
+    def drug_type(self):
+        self._drug_type = None
+
+    @property
+    def cumulative_dose(self) -> typing.Optional[Quantity]:
+        return self._cumulative_dose
+
+    @cumulative_dose.setter
+    def cumulative_dose(self, value: Quantity):
+        self._cumulative_dose = value
+
+    @cumulative_dose.deleter
+    def cumulative_dose(self):
+        self._cumulative_dose = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'agent', 'route_of_administration', 'dose_intervals', 'drug_type', 'cumulative_dose'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'agent',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Treatment(
+                agent=extract_message_scalar('agent', OntologyClass, values),
+                route_of_administration=extract_message_scalar('route_of_administration', OntologyClass, values),
+                dose_intervals=extract_message_sequence('dose_intervals', DoseInterval, values),
+                drug_type=MessageMixin._extract_enum_field('drug_type', DrugType, values),
+                cumulative_dose=extract_message_scalar('cumulative_dose', Quantity, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Treatment
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.Treatment):
+            return Treatment(
+                agent=extract_pb_message_scalar('agent', OntologyClass, msg),
+                route_of_administration=extract_pb_message_scalar('route_of_administration', OntologyClass, msg),
+                dose_intervals=extract_pb_message_seq('dose_intervals', DoseInterval, msg),
+                drug_type=DrugType(msg.drug_type),
+                cumulative_dose=extract_pb_message_scalar('cumulative_dose', Quantity, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def to_message(self) -> Message:
+        t = pp202.Treatment(agent=self._agent.to_message(), )
+
+        if self._route_of_administration is not None:
+            t.route_of_administration.CopyFrom(self._route_of_administration.to_message())
+
+        t.dose_intervals.extend(di.to_message() for di in self._dose_intervals)
+
+        if self._drug_type is None:
+            t.drug_type = pp202.DrugType.Value(DrugType.UNKNOWN_DRUG_TYPE.name)
+        else:
+            t.drug_type = pp202.DrugType.Value(self._drug_type.name)
+
+        if self._cumulative_dose is not None:
+            t.cumulative_dose.CopyFrom(self._cumulative_dose.to_message())
+
+        return t
+
+    def __eq__(self, other):
+        return isinstance(other, Treatment) \
+            and self._agent == other._agent \
+            and self._route_of_administration == other._route_of_administration \
+            and self._dose_intervals == other._dose_intervals \
+            and self._drug_type == other._drug_type \
+            and self._cumulative_dose == other._cumulative_dose
+
+    def __repr__(self):
+        return f'Treatment(agent={self._agent}, ' \
+               f'route_of_administration={self._route_of_administration}, ' \
+               f'dose_intervals={self._dose_intervals}, ' \
+               f'drug_type={self._drug_type}, ' \
+               f'cumulative_dose={self._cumulative_dose})'
+
+
+class MedicalAction(MessageMixin):
+    _CLS_ACTION = {
+        'procedure': Procedure, 'treatment': Treatment,
+        'radiation_therapy': RadiationTherapy,
+        'therapeutic_regimen': TherapeuticRegimen,
+    }
+
+    def __init__(
+            self,
+            action: typing.Union[Procedure, Treatment, RadiationTherapy, TherapeuticRegimen],
+            treatment_target: typing.Optional[OntologyClass] = None,
+            treatment_intent: typing.Optional[OntologyClass] = None,
+            response_to_treatment: typing.Optional[OntologyClass] = None,
+            adverse_events: typing.Optional[typing.Iterable[OntologyClass]] = None,
+            treatment_termination_reason: typing.Optional[OntologyClass] = None,
+    ):
+        self._action = action
+        self._treatment_target = treatment_target
+        self._treatment_intent = treatment_intent
+        self._response_to_treatment = response_to_treatment
+        self._adverse_events = [] if adverse_events is None else list(adverse_events)
+        self._treatment_termination_reason = treatment_termination_reason
+
+    @property
+    def action(self) -> typing.Union[Procedure, Treatment, RadiationTherapy, TherapeuticRegimen]:
+        return self._action
+
+    @property
+    def procedure(self) -> Procedure:
+        return self._action if isinstance(self._action, Procedure) else None
+
+    @procedure.setter
+    def procedure(self, value: Procedure):
+        self._action = value
+
+    @property
+    def treatment(self) -> Treatment:
+        return self._action if isinstance(self._action, Treatment) else None
+
+    @treatment.setter
+    def treatment(self, value: Treatment):
+        self._action = value
+
+    @property
+    def radiation_therapy(self) -> RadiationTherapy:
+        return self._action if isinstance(self._action, RadiationTherapy) else None
+
+    @radiation_therapy.setter
+    def radiation_therapy(self, value: RadiationTherapy):
+        self._action = value
+
+    @property
+    def therapeutic_regimen(self) -> TherapeuticRegimen:
+        return self._action if isinstance(self._action, TherapeuticRegimen) else None
+
+    @therapeutic_regimen.setter
+    def therapeutic_regimen(self, value: TherapeuticRegimen):
+        self._action = value
+
+    @property
+    def treatment_target(self) -> typing.Optional[OntologyClass]:
+        return self._treatment_target
+
+    @treatment_target.setter
+    def treatment_target(self, value: OntologyClass):
+        self._treatment_target = value
+
+    @treatment_target.deleter
+    def treatment_target(self):
+        self._treatment_target = None
+
+    @property
+    def treatment_intent(self) -> typing.Optional[OntologyClass]:
+        return self._treatment_intent
+
+    @treatment_intent.setter
+    def treatment_intent(self, value: OntologyClass):
+        self._treatment_intent = value
+
+    @treatment_intent.deleter
+    def treatment_intent(self):
+        self._treatment_intent = None
+
+    @property
+    def response_to_treatment(self) -> typing.Optional[OntologyClass]:
+        return self._response_to_treatment
+
+    @response_to_treatment.setter
+    def response_to_treatment(self, value: OntologyClass):
+        self._response_to_treatment = value
+
+    @response_to_treatment.deleter
+    def response_to_treatment(self):
+        self._response_to_treatment = None
+
+    @property
+    def adverse_events(self) -> typing.MutableSequence[OntologyClass]:
+        return self._adverse_events
+
+    @property
+    def treatment_termination_reason(self) -> typing.Optional[OntologyClass]:
+        return self._treatment_termination_reason
+
+    @treatment_termination_reason.setter
+    def treatment_termination_reason(self, value: OntologyClass):
+        self._treatment_termination_reason = value
+
+    @treatment_termination_reason.deleter
+    def treatment_termination_reason(self):
+        self._treatment_termination_reason = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return (
+            'procedure', 'treatment', 'radiation_therapy', 'therapeutic_regimen',
+            'treatment_target', 'treatment_intent', 'response_to_treatment', 'adverse_events',
+            'treatment_termination_reason',
+        )
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        raise NotImplementedError('Should not be called!')
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if any(field in values for field in cls._CLS_ACTION):
+            return MedicalAction(
+                action=extract_oneof_scalar(cls._CLS_ACTION, values),
+                treatment_target=extract_message_scalar('treatment_target', OntologyClass, values),
+                treatment_intent=extract_message_scalar('treatment_intent', OntologyClass, values),
+                response_to_treatment=extract_message_scalar('response_to_treatment', OntologyClass, values),
+                adverse_events=extract_message_sequence('adverse_events', OntologyClass, values),
+                treatment_termination_reason=extract_message_scalar(
+                    'treatment_termination_reason', OntologyClass, values),
+            )
+        else:
+            raise ValueError(
+                f'Missing one of required fields: `procedure|treatment|radiation_therapy|therapeutic_regimen` {values}'
+            )
+
+    def to_message(self) -> Message:
+        m = pp202.MedicalAction()
+
+        if isinstance(self._action, Procedure):
+            m.procedure.CopyFrom(self._action.to_message())
+        elif isinstance(self._action, Treatment):
+            m.treatment.CopyFrom(self._action.to_message())
+        elif isinstance(self._action, RadiationTherapy):
+            m.radiation_therapy.CopyFrom(self._action.to_message())
+        elif isinstance(self._action, TherapeuticRegimen):
+            m.therapeutic_regimen.CopyFrom(self._action.to_message())
+        else:
+            raise ValueError('Bug')
+
+        if self._treatment_target is not None:
+            m.treatment_target.CopyFrom(self._treatment_target.to_message())
+
+        if self._treatment_intent is not None:
+            m.treatment_intent.CopyFrom(self._treatment_intent.to_message())
+
+        if self._response_to_treatment is not None:
+            m.response_to_treatment.CopyFrom(self._response_to_treatment.to_message())
+
+        m.adverse_events.extend(a.to_message() for a in self._adverse_events)
+
+        if self._treatment_termination_reason is not None:
+            m.treatment_termination_reason.CopyFrom(self._treatment_termination_reason.to_message())
+
+        return m
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.MedicalAction
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return MedicalAction(
+                action=extract_pb_oneof_scalar('action', cls._CLS_ACTION, msg),
+                treatment_target=extract_pb_message_scalar('treatment_target', OntologyClass, msg),
+                treatment_intent=extract_pb_message_scalar('treatment_intent', OntologyClass, msg),
+                response_to_treatment=extract_pb_message_scalar('response_to_treatment', OntologyClass,
+                                                                msg),
+                adverse_events=extract_pb_message_seq('adverse_events', OntologyClass, msg),
+                treatment_termination_reason=extract_pb_message_scalar(
+                    'treatment_termination_reason', OntologyClass, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, MedicalAction) \
+            and self._action == other._action \
+            and self._treatment_target == other._treatment_target \
+            and self._treatment_intent == other._treatment_intent \
+            and self._response_to_treatment == other._response_to_treatment \
+            and self._adverse_events == other._adverse_events \
+            and self._treatment_termination_reason == other._treatment_termination_reason
+
+    def __repr__(self):
+        return f'MedicalAction(' \
+               f'action={self._action}, ' \
+               f'treatment_target={self._treatment_target}, ' \
+               f'treatment_intent={self._treatment_intent}, ' \
+               f'response_to_treatment={self._response_to_treatment}, ' \
+               f'adverse_events={self._adverse_events}, ' \
+               f'treatment_termination_reason={self._treatment_termination_reason})'
diff --git a/src/pyphetools/pp/v202/_meta_data.py b/src/pyphetools/pp/v202/_meta_data.py
new file mode 100644
index 00000000..50709dde
--- /dev/null
+++ b/src/pyphetools/pp/v202/_meta_data.py
@@ -0,0 +1,390 @@
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from ._base import ExternalReference
+from .._api import MessageMixin
+from .._timestamp import Timestamp
+from ..parse import extract_message_scalar, extract_message_sequence, extract_pb_message_scalar, extract_pb_message_seq
+
+
+class Resource(MessageMixin):
+
+    def __init__(
+            self,
+            id: str,
+            name: str,
+            url: str,
+            version: str,
+            namespace_prefix: str,
+            iri_prefix: str,
+    ):
+        self._id = id
+        self._name = name
+        self._url = url
+        self._version = version
+        self._namespace_prefix = namespace_prefix
+        self._iri_prefix = iri_prefix
+
+    @property
+    def id(self) -> str:
+        return self._id
+
+    @id.setter
+    def id(self, value: str):
+        self._id = value
+
+    @property
+    def name(self) -> str:
+        return self._name
+
+    @name.setter
+    def name(self, value: str):
+        self._name = value
+
+    @property
+    def url(self) -> str:
+        return self._url
+
+    @url.setter
+    def url(self, value: str):
+        self._url = value
+
+    @property
+    def version(self) -> str:
+        return self._version
+
+    @version.setter
+    def version(self, value: str):
+        self._version = value
+
+    @property
+    def namespace_prefix(self) -> str:
+        return self._namespace_prefix
+
+    @namespace_prefix.setter
+    def namespace_prefix(self, value: str):
+        self._namespace_prefix = value
+
+    @property
+    def iri_prefix(self) -> str:
+        return self._iri_prefix
+
+    @iri_prefix.setter
+    def iri_prefix(self, value: str):
+        self._iri_prefix = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'id', 'name', 'url', 'version', 'namespace_prefix', 'iri_prefix'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'id', 'name', 'url', 'version', 'namespace_prefix', 'iri_prefix'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Resource(
+                id=values['id'],
+                name=values['name'],
+                url=values['url'],
+                version=values['version'],
+                namespace_prefix=values['namespace_prefix'],
+                iri_prefix=values['iri_prefix'],
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Resource
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return Resource(
+                id=msg.id,
+                name=msg.name,
+                url=msg.url,
+                version=msg.version,
+                namespace_prefix=msg.namespace_prefix,
+                iri_prefix=msg.iri_prefix,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def to_message(self) -> Message:
+        return pp202.Resource(
+            id=self._id,
+            name=self._name,
+            url=self._url,
+            version=self._version,
+            namespace_prefix=self._namespace_prefix,
+            iri_prefix=self._iri_prefix,
+        )
+
+    def __eq__(self, other):
+        return isinstance(other, Resource) \
+            and self._id == other._id \
+            and self._name == other._name \
+            and self._url == other._url \
+            and self._version == other._version \
+            and self._namespace_prefix == other._namespace_prefix \
+            and self._iri_prefix == other._iri_prefix
+
+    def __repr__(self):
+        return (f'Resource(id={self._id}, '
+                f'name={self._name}, '
+                f'url={self._url}, '
+                f'version={self._version}, '
+                f'namespace_prefix={self._namespace_prefix}, '
+                f'iri_prefix={self._iri_prefix})')
+
+
+class Update(MessageMixin):
+
+    def __init__(
+            self,
+            timestamp: Timestamp,
+            updated_by: typing.Optional[str] = None,
+            comment: typing.Optional[str] = None,
+    ):
+        self._timestamp = timestamp
+        self._updated_by = updated_by
+        self._comment = comment
+
+    @property
+    def timestamp(self) -> Timestamp:
+        return self._timestamp
+
+    @timestamp.setter
+    def timestamp(self, value: Timestamp):
+        self._timestamp = value
+
+    @property
+    def updated_by(self) -> str:
+        return self._updated_by
+
+    @updated_by.setter
+    def updated_by(self, value: str):
+        self._updated_by = value
+
+    @updated_by.deleter
+    def updated_by(self):
+        self._updated_by = None
+
+    @property
+    def comment(self) -> str:
+        return self._comment
+
+    @comment.setter
+    def comment(self, value: str):
+        self._comment = value
+
+    @comment.deleter
+    def comment(self):
+        self._comment = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'timestamp', 'updated_by', 'comment'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'timestamp',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Update(
+                timestamp=extract_message_scalar('timestamp', Timestamp, values),
+                updated_by=values.get('updated_by', None),
+                comment=values.get('comment', None),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Update
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return Update(
+                timestamp=extract_pb_message_scalar('timestamp', Timestamp, msg),
+                updated_by=None if msg.updated_by == '' else msg.updated_by,
+                comment=None if msg.comment == '' else msg.comment,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def to_message(self) -> Message:
+        update = pp202.Update(timestamp=self._timestamp.to_message(), )
+
+        if self._updated_by is not None:
+            update.updated_by = self._updated_by
+
+        if self._comment is not None:
+            update.comment = self._comment
+
+        return update
+
+    def __eq__(self, other):
+        return isinstance(other, Update) \
+            and self._timestamp == other._timestamp \
+            and self._updated_by == other._updated_by \
+            and self._comment == other._comment
+
+    def __repr__(self):
+        return f'Update(timestamp={self._timestamp}, updated_by={self._updated_by}, comment={self._comment})'
+
+
+class MetaData(MessageMixin):
+    # TODO: this entire class must be implemented!
+
+    def __init__(
+            self,
+            created: Timestamp,
+            created_by: str,
+            submitted_by: typing.Optional[str] = None,
+            resources: typing.Optional[typing.Iterable[Resource]] = None,
+            updates: typing.Optional[typing.Iterable[Update]] = None,
+            phenopacket_schema_version: str = '2.0.2',
+            external_references: typing.Optional[typing.Iterable[ExternalReference]] = None,
+    ):
+        self._created = created
+        self._created_by = created_by
+        self._submitted_by = submitted_by
+        self._resources = [] if resources is None else list(resources)
+        self._updates = [] if updates is None else list(updates)
+        self._phenopacket_schema_version = phenopacket_schema_version
+        self._external_references = [] if external_references is None else list(external_references)
+
+    @property
+    def created(self) -> Timestamp:
+        return self._created
+
+    @created.setter
+    def created(self, value: Timestamp):
+        self._created = value
+
+    @property
+    def created_by(self) -> str:
+        return self._created_by
+
+    @created_by.setter
+    def created_by(self, value: str):
+        self._created_by = value
+
+    @property
+    def submitted_by(self) -> typing.Optional[str]:
+        return self._submitted_by
+
+    @submitted_by.setter
+    def submitted_by(self, value: typing.Optional[str]):
+        self._submitted_by = value
+
+    @submitted_by.deleter
+    def submitted_by(self):
+        self._submitted_by = None
+
+    @property
+    def resources(self) -> typing.MutableSequence[Resource]:
+        return self._resources
+
+    @property
+    def updates(self) -> typing.MutableSequence[Update]:
+        return self._updates
+
+    @property
+    def phenopacket_schema_version(self) -> str:
+        return self._phenopacket_schema_version
+
+    @phenopacket_schema_version.setter
+    def phenopacket_schema_version(self, value: str):
+        self._phenopacket_schema_version = value
+
+    @property
+    def external_references(self) -> typing.MutableSequence[ExternalReference]:
+        return self._external_references
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'created', 'created_by', 'submitted_by', 'resources', 'updates', 'phenopacket_schema_version', 'external_references'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'created', 'created_by', 'phenopacket_schema_version',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return MetaData(
+                created=Timestamp.from_str(values['created']),
+                created_by=values['created_by'],
+                submitted_by=values.get('submitted_by', None),
+                resources=extract_message_sequence('resources', Resource, values),
+                updates=extract_message_sequence('updates', Update, values),
+                phenopacket_schema_version=values['phenopacket_schema_version'],
+                external_references=extract_message_sequence('external_references', ExternalReference, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        meta_data = pp202.MetaData(
+            created=self._created.to_message(),
+            created_by=self._created_by,
+            phenopacket_schema_version=self._phenopacket_schema_version,
+        )
+
+        if self._submitted_by is not None:
+            meta_data.submitted_by = self._submitted_by
+
+        meta_data.resources.extend(r.to_message() for r in self._resources)
+        meta_data.resources.extend(u.to_message() for u in self._updates)
+        meta_data.external_references.extend(er.to_message() for er in self._external_references)
+
+        return meta_data
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.MetaData
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.MetaData):
+            return MetaData(
+                created=extract_pb_message_scalar('created', Timestamp, msg),
+                created_by=msg.created_by,
+                submitted_by=None if msg.submitted_by == '' else msg.submitted_by,
+                resources=extract_pb_message_seq('resources', Resource, msg),
+                updates=extract_pb_message_seq('updates', Update, msg),
+                phenopacket_schema_version=msg.phenopacket_schema_version,
+                external_references=extract_pb_message_seq('external_references', ExternalReference, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, MetaData) \
+            and self._created == other._created \
+            and self._created_by == other._created_by \
+            and self._submitted_by == other._submitted_by \
+            and self._resources == other._resources \
+            and self._updates == other._updates \
+            and self._phenopacket_schema_version == other._phenopacket_schema_version \
+            and self._external_references == other._external_references
+
+    def __repr__(self):
+        return (f'MetaData('
+                f'created={self._created}, '
+                f'created_by={self._created_by}, '
+                f'submitted_by={self._submitted_by}, '
+                f'resources={self._resources}, '
+                f'updates={self._updates}, '
+                f'phenopacket_schema_version={self._phenopacket_schema_version}, '
+                f'external_references={self._external_references})')
diff --git a/src/pyphetools/pp/v202/_phenopackets.py b/src/pyphetools/pp/v202/_phenopackets.py
new file mode 100644
index 00000000..2c4229e1
--- /dev/null
+++ b/src/pyphetools/pp/v202/_phenopackets.py
@@ -0,0 +1,191 @@
+import typing
+
+import phenopackets as pp202
+
+from google.protobuf.message import Message
+
+from ._base import File
+from ._biosample import Biosample
+from ._individual import Individual
+from ._interpretation import Interpretation
+from ._measurement import Measurement
+from ._medical_action import MedicalAction
+from ._phenotypic_feature import PhenotypicFeature
+from ._disease import Disease
+from ._meta_data import MetaData
+from .._api import MessageMixin
+from ..parse import extract_message_scalar, extract_message_sequence, extract_pb_message_scalar, extract_pb_message_seq
+
+
+class Phenopacket(MessageMixin):
+
+    def __init__(
+            self,
+            id: str,
+            meta_data: MetaData,
+            subject: typing.Optional[Individual] = None,
+            phenotypic_features: typing.Optional[typing.Iterable[PhenotypicFeature]] = None,
+            measurements: typing.Optional[typing.Iterable[Measurement]] = None,
+            biosamples: typing.Optional[typing.Iterable[Biosample]] = None,
+            interpretations: typing.Optional[typing.Iterable[Interpretation]] = None,
+            diseases: typing.Optional[typing.Iterable[Disease]] = None,
+            medical_actions: typing.Optional[typing.Iterable[MedicalAction]] = None,
+            files: typing.Optional[typing.Iterable[File]] = None,
+    ):
+        self._id = id
+        self._subject = subject
+        self._phenotypic_features = [] if phenotypic_features is None else list(phenotypic_features)
+        self._measurements = [] if measurements is None else list(measurements)
+        self._biosamples = [] if biosamples is None else list(biosamples)
+        self._interpretations = [] if interpretations is None else list(interpretations)
+        self._diseases = [] if diseases is None else list(diseases)
+        self._medical_actions = [] if medical_actions is None else list(medical_actions)
+        self._files = [] if files is None else list(files)
+        self._meta_data = meta_data
+
+    @property
+    def id(self) -> str:
+        return self._id
+
+    @id.setter
+    def id(self, value: str):
+        self._id = value
+
+    @property
+    def subject(self) -> typing.Optional[Individual]:
+        return self._subject
+
+    @subject.setter
+    def subject(self, value: Individual):
+        self._subject = value
+
+    @subject.deleter
+    def subject(self):
+        self._subject = None
+
+    @property
+    def phenotypic_features(self) -> typing.MutableSequence[PhenotypicFeature]:
+        return self._phenotypic_features
+
+    @property
+    def measurements(self) -> typing.MutableSequence[Measurement]:
+        return self._measurements
+
+    @property
+    def biosamples(self) -> typing.MutableSequence[Biosample]:
+        return self._biosamples
+
+    @property
+    def interpretations(self) -> typing.MutableSequence[Interpretation]:
+        return self._interpretations
+
+    @property
+    def diseases(self) -> typing.MutableSequence[Disease]:
+        return self._diseases
+
+    @property
+    def medical_actions(self) -> typing.MutableSequence[MedicalAction]:
+        return self._medical_actions
+
+    @property
+    def files(self) -> typing.MutableSequence[File]:
+        return self._files
+
+    @property
+    def meta_data(self) -> MetaData:
+        return self._meta_data
+
+    @meta_data.setter
+    def meta_data(self, value: MetaData):
+        self._meta_data = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return (
+            'id', 'subject', 'phenotypic_features', 'measurements', 'biosamples',
+            'interpretations', 'diseases', 'medical_actions', 'files', 'meta_data',
+        )
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'id', 'meta_data'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Phenopacket(
+                id=values['id'],
+                subject=extract_message_scalar('subject', Individual, values),
+                phenotypic_features=extract_message_sequence('phenotypic_features', PhenotypicFeature, values),
+                measurements=extract_message_sequence('measurements', Measurement, values),
+                biosamples=extract_message_sequence('biosamples', Biosample, values),
+                interpretations=extract_message_sequence('interpretations', Interpretation, values),
+                diseases=extract_message_sequence('diseases', Disease, values),
+                medical_actions=extract_message_sequence('medical_actions', MedicalAction, values),
+                files=extract_message_sequence('files', File, values),
+                meta_data=extract_message_scalar('meta_data', MetaData, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.Phenopacket(
+            id=self._id,
+            subject=self._subject.to_message(),
+            phenotypic_features=(pf.to_message() for pf in self._phenotypic_features),
+            measurements=(m.to_message() for m in self._measurements),
+            biosamples=(b.to_message() for b in self._biosamples),
+            interpretations=(i.to_message() for i in self._interpretations),
+            diseases=(d.to_message() for d in self._diseases),
+            medical_actions=(ma.to_message() for ma in self._medical_actions),
+            files=(f.to_message() for f in self._files),
+            meta_data=self._meta_data.to_message(),
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Phenopacket
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.Phenopacket):
+            return Phenopacket(
+                id=msg.id,
+                subject=extract_pb_message_scalar('subject', Individual, msg),
+                phenotypic_features=extract_pb_message_seq('phenotypic_features', PhenotypicFeature, msg),
+                measurements=extract_pb_message_seq('measurements', Measurement, msg),
+                biosamples=extract_pb_message_seq('biosamples', Biosample, msg),
+                interpretations=extract_pb_message_seq('interpretations', Interpretation, msg),
+                diseases=extract_pb_message_seq('diseases', Disease, msg),
+                medical_actions=extract_pb_message_seq('medical_actions', MedicalAction, msg),
+                files=extract_pb_message_seq('files', File, msg),
+                meta_data=extract_pb_message_scalar('meta_data', MetaData, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Phenopacket) \
+            and self._id == other._id \
+            and self._subject == other._subject \
+            and self._phenotypic_features == other._phenotypic_features \
+            and self._measurements == other._measurements \
+            and self._biosamples == other._biosamples \
+            and self._interpretations == other._interpretations \
+            and self._diseases == other._diseases \
+            and self._medical_actions == other._medical_actions \
+            and self._files == other._files \
+            and self._meta_data == other._meta_data
+
+    def __repr__(self):
+        return f'Phenopacket(' \
+               f'id={self._id}, ' \
+               f'subject={self._subject}, ' \
+               f'phenotypic_features={self._phenotypic_features}, ' \
+               f'measurements={self._measurements}, ' \
+               f'biosamples={self._biosamples}, ' \
+               f'interpretations={self._interpretations}, ' \
+               f'diseases={self._diseases}, ' \
+               f'medical_actions={self._medical_actions}, ' \
+               f'files={self._files}, ' \
+               f'meta_data={self._meta_data})'
diff --git a/src/pyphetools/pp/v202/_phenotypic_feature.py b/src/pyphetools/pp/v202/_phenotypic_feature.py
new file mode 100644
index 00000000..d42c1160
--- /dev/null
+++ b/src/pyphetools/pp/v202/_phenotypic_feature.py
@@ -0,0 +1,191 @@
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from ._base import OntologyClass, TimeElement, Evidence
+from .._api import MessageMixin
+from ..parse import extract_message_scalar, extract_message_sequence, extract_pb_message_scalar, extract_pb_message_seq
+
+
+class PhenotypicFeature(MessageMixin):
+
+    def __init__(
+            self,
+            type: OntologyClass,
+            excluded: bool = False,
+            description: typing.Optional[str] = None,
+            severity: typing.Optional[OntologyClass] = None,
+            modifiers: typing.Optional[typing.Iterable[OntologyClass]] = None,
+            onset: typing.Optional[TimeElement] = None,
+            resolution: typing.Optional[TimeElement] = None,
+            evidence: typing.Optional[typing.Iterable[Evidence]] = None,
+    ):
+        self._type = type
+        self._excluded = excluded
+        self._description = description
+        self._severity = severity
+        self._modifiers = [] if modifiers is None else list(modifiers)
+        self._onset = onset
+        self._resolution = resolution
+        self._evidence = [] if evidence is None else list(evidence)
+
+    @property
+    def type(self) -> OntologyClass:
+        return self._type
+
+    @type.setter
+    def type(self, value: OntologyClass):
+        self._type = value
+
+    @property
+    def excluded(self) -> bool:
+        return self._excluded
+
+    @excluded.setter
+    def excluded(self, value: bool):
+        self._excluded = value
+
+    @property
+    def description(self) -> typing.Optional[str]:
+        return self._description
+
+    @description.setter
+    def description(self, value: str):
+        self._description = value
+
+    @description.deleter
+    def description(self):
+        self._description = None
+
+    @property
+    def severity(self) -> typing.Optional[OntologyClass]:
+        return self._severity
+
+    @severity.setter
+    def severity(self, value: OntologyClass):
+        self._severity = value
+
+    @severity.deleter
+    def severity(self):
+        self._severity = None
+
+    @property
+    def modifiers(self) -> typing.MutableSequence[OntologyClass]:
+        return self._modifiers
+
+    @property
+    def onset(self) -> typing.Optional[TimeElement]:
+        return self._onset
+
+    @onset.setter
+    def onset(self, value: TimeElement):
+        self._onset = value
+
+    @onset.deleter
+    def onset(self):
+        self._onset = None
+
+    @property
+    def resolution(self) -> typing.Optional[TimeElement]:
+        return self._resolution
+
+    @resolution.setter
+    def resolution(self, value: TimeElement):
+        self._resolution = value
+
+    @resolution.deleter
+    def resolution(self):
+        self._resolution = None
+
+    @property
+    def evidence(self) -> typing.MutableSequence[Evidence]:
+        return self._evidence
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'type', 'excluded', 'description', 'severity', 'modifiers', 'onset', 'resolution', 'evidence'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'type',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return PhenotypicFeature(
+                type=extract_message_scalar('type', OntologyClass, values),
+                excluded=values['excluded'] if 'excluded' in values else False,
+                description=values['description'] if 'description' in values else None,
+                severity=extract_message_scalar('severity', OntologyClass, values),
+                modifiers=extract_message_sequence('modifiers', OntologyClass, values),
+                onset=extract_message_scalar('onset', TimeElement, values),
+                resolution=extract_message_scalar('resolution', TimeElement, values),
+                evidence=extract_message_sequence('evidence', Evidence, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        pf = pp202.PhenotypicFeature(
+            type=self._type.to_message(), excluded=self._excluded,
+        )
+
+        if self._description is not None:
+            pf.description = self._description
+
+        if self._severity is not None:
+            pf.severity.CopyFrom(self._severity.to_message())
+
+        pf.modifiers.extend(m.to_message() for m in self._modifiers)
+
+        if self._onset is not None:
+            pf.onset.CopyFrom(self._onset.to_message())
+
+        if self._resolution is not None:
+            pf.resolution.CopyFrom(self._resolution.to_message())
+
+        pf.evidence.extend(e.to_message() for e in self._evidence)
+
+        return pf
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.PhenotypicFeature
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return PhenotypicFeature(
+                type=extract_pb_message_scalar('type', OntologyClass, msg),
+                excluded=msg.excluded,
+                description=None if msg.description == '' else msg.description,
+                severity=extract_pb_message_scalar('severity', OntologyClass, msg),
+                modifiers=extract_pb_message_seq('modifiers', OntologyClass, msg),
+                onset=extract_pb_message_scalar('onset', TimeElement, msg),
+                resolution=extract_pb_message_scalar('resolution', TimeElement, msg),
+                evidence=extract_pb_message_seq('evidence', Evidence, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, PhenotypicFeature) \
+            and self._type == other._type \
+            and self._excluded == other._excluded \
+            and self._description == other._description \
+            and self._severity == other._severity \
+            and self._modifiers == other._modifiers \
+            and self._onset == other._onset \
+            and self._resolution == other._resolution \
+            and self._evidence == other._evidence
+
+    def __repr__(self):
+        return (f'PhenotypicFeature(type={self._type}, '
+                f'excluded={self._excluded}, '
+                f'description={self._description}, '
+                f'severity={self._severity}, '
+                f'modifiers={self._modifiers}, '
+                f'onset={self._onset}, '
+                f'resolution={self._resolution}, '
+                f'evidence={self._evidence})')
diff --git a/src/pyphetools/pp/v202/_vrsatile.py b/src/pyphetools/pp/v202/_vrsatile.py
new file mode 100644
index 00000000..5798c8b4
--- /dev/null
+++ b/src/pyphetools/pp/v202/_vrsatile.py
@@ -0,0 +1,657 @@
+import enum
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from ._gene_descriptor import GeneDescriptor
+from ._base import OntologyClass
+
+from .._api import MessageMixin
+from ..parse import extract_message_scalar, extract_message_sequence, extract_pb_message_scalar, extract_pb_message_seq
+
+
+class Expression(MessageMixin):
+
+    def __init__(
+            self,
+            syntax: str,
+            value: str,
+            version: typing.Optional[str] = None,
+    ):
+        self._syntax = syntax
+        self._value = value
+        self._version = version
+
+    @property
+    def syntax(self) -> str:
+        return self._syntax
+
+    @syntax.setter
+    def syntax(self, value: str):
+        self._syntax = value
+
+    @property
+    def value(self) -> str:
+        return self._value
+
+    @value.setter
+    def value(self, value: str):
+        self._value = value
+
+    @property
+    def version(self) -> typing.Optional[str]:
+        return self._version
+
+    @version.setter
+    def version(self, value: str):
+        self._version = value
+
+    @version.deleter
+    def version(self):
+        self._version = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'syntax', 'value', 'version'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'syntax', 'value',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Expression(
+                syntax=values['syntax'],
+                value=values['value'],
+                version=values.get('version', None),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        expression = pp202.Expression(syntax=self._syntax, value=self._value)
+
+        if self._version is not None:
+            expression.version = self._version
+
+        return expression
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Expression
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return Expression(
+                syntax=msg.syntax,
+                value=msg.value,
+                version=None if msg.version == '' else msg.version,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Expression) \
+            and self._syntax == other._syntax \
+            and self._value == other._value \
+            and self._version == other._version
+
+    def __repr__(self):
+        return 'Expression(' \
+               f'syntax={self._syntax}, ' \
+               f'value={self._value}, ' \
+               f'version={self._version})'
+
+
+class Extension(MessageMixin):
+
+    def __init__(
+            self,
+            name: str,
+            value: str,
+    ):
+        self._name = name
+        self._value = value
+
+    @property
+    def name(self) -> str:
+        return self._name
+
+    @name.setter
+    def name(self, value: str):
+        self._name = value
+
+    @property
+    def value(self) -> str:
+        return self._value
+
+    @value.setter
+    def value(self, value: str):
+        self._value = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'name', 'value'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'name', 'value'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Extension(name=values['name'], value=values['value'])
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.Extension(name=self._name, value=self._value)
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Extension
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return Extension(
+                name=msg.name,
+                value=msg.value,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Extension) and self._name == other._name and self._value == other._value
+
+    def __repr__(self):
+        return f'Extension(name={self._name}, value={self._value})'
+
+
+class VcfRecord(MessageMixin):
+
+    def __init__(
+            self,
+            genome_assembly: str,
+            chrom: str,
+            pos: int,
+            ref: str,
+            alt: str,
+            id: typing.Optional[str] = None,
+            qual: typing.Optional[str] = None,
+            filter: typing.Optional[str] = None,
+            info: typing.Optional[str] = None,
+    ):
+        self._genome_assembly = genome_assembly
+        self._chrom = chrom
+        self._pos = pos
+        self._ref = ref
+        self._alt = alt
+        self._id = id
+        self._qual = qual
+        self._filter = filter
+        self._info = info
+
+    @property
+    def genome_assembly(self) -> str:
+        return self._genome_assembly
+
+    @genome_assembly.setter
+    def genome_assembly(self, value: str):
+        self._genome_assembly = value
+
+    @property
+    def chrom(self) -> str:
+        return self._chrom
+
+    @chrom.setter
+    def chrom(self, value: str):
+        self._chrom = value
+
+    @property
+    def pos(self) -> int:
+        return self._pos
+
+    @pos.setter
+    def pos(self, value: int):
+        self._pos = value
+
+    @property
+    def ref(self) -> str:
+        return self._ref
+
+    @ref.setter
+    def ref(self, value: str):
+        self._ref = value
+
+    @property
+    def alt(self) -> str:
+        return self._alt
+
+    @alt.setter
+    def alt(self, value: str):
+        self._alt = value
+
+    @property
+    def id(self) -> typing.Optional[str]:
+        return self._id
+
+    @id.setter
+    def id(self, value: str):
+        self._id = value
+
+    @id.deleter
+    def id(self):
+        self._id = None
+
+    @property
+    def qual(self) -> typing.Optional[str]:
+        return self._qual
+
+    @qual.setter
+    def qual(self, value: str):
+        self._qual = value
+
+    @qual.deleter
+    def qual(self):
+        self._qual = None
+
+    @property
+    def filter(self) -> typing.Optional[str]:
+        return self._filter
+
+    @filter.setter
+    def filter(self, value: str):
+        self._filter = value
+
+    @filter.deleter
+    def filter(self):
+        self._filter = None
+
+    @property
+    def info(self) -> typing.Optional[str]:
+        return self._info
+
+    @info.setter
+    def info(self, value: str):
+        self._info = value
+
+    @info.deleter
+    def info(self):
+        self._info = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'genome_assembly', 'chrom', 'pos', 'ref', 'alt', 'id', 'qual', 'filter', 'info'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'genome_assembly', 'chrom', 'pos', 'ref', 'alt'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return VcfRecord(
+                genome_assembly=values['genome_assembly'],
+                chrom=values['chrom'],
+                pos=values['pos'],
+                ref=values['ref'],
+                alt=values['alt'],
+                id=MessageMixin._extract_optional_field('id', values),
+                qual=MessageMixin._extract_optional_field('qual', values),
+                filter=MessageMixin._extract_optional_field('filter', values),
+                info=MessageMixin._extract_optional_field('info', values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        vcf = pp202.VcfRecord(
+            genome_assembly=self._genome_assembly,
+            chrom=self._chrom,
+            pos=self._pos,
+            ref=self._ref,
+            alt=self._alt,
+        )
+
+        if self._id is not None:
+            vcf.id = self._id
+
+        if self._qual is not None:
+            vcf.qual = self._qual
+
+        if self._filter is not None:
+            vcf.filter = self._filter
+
+        if self._info is not None:
+            vcf.info = self._info
+
+        return vcf
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.VcfRecord
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return VcfRecord(
+                genome_assembly=msg.genome_assembly,
+                chrom=msg.chrom,
+                pos=msg.pos,
+                ref=msg.ref,
+                alt=msg.alt,
+                id=None if msg.id == '' else msg.id,
+                qual=None if msg.qual == '' else msg.qual,
+                filter=None if msg.filter == '' else msg.filter,
+                info=None if msg.info == '' else msg.info,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, VcfRecord) \
+            and self._genome_assembly == other._genome_assembly \
+            and self._chrom == other._chrom \
+            and self._pos == other._pos \
+            and self._ref == other._ref \
+            and self._alt == other._alt \
+            and self._id == other._id \
+            and self._qual == other._qual \
+            and self._filter == other._filter \
+            and self._info == other._info
+
+    def __repr__(self):
+        return f'VcfRecord(genome_assembly={self._genome_assembly}, ' \
+               f'chrom={self._chrom}, ' \
+               f'pos={self._pos}, ' \
+               f'ref={self._ref}, ' \
+               f'alt={self._alt}, ' \
+               f'id={self._id}, ' \
+               f'qual={self._qual}, ' \
+               f'filter={self._filter}, ' \
+               f'info={self._info})'
+
+
+class MoleculeContext(enum.Enum):
+    unspecified_molecule_context = 0
+    genomic = 1
+    transcript = 2
+    protein = 3
+
+
+class VariationDescriptor(MessageMixin):
+
+    def __init__(
+            self,
+            id: str,
+            molecule_context: MoleculeContext,
+            # variation: Variation  # TODO: implement
+            label: typing.Optional[str] = None,
+            description: typing.Optional[str] = None,
+            gene_context: typing.Optional[GeneDescriptor] = None,
+            expressions: typing.Optional[typing.Iterable[Expression]] = None,
+            vcf_record: typing.Optional[VcfRecord] = None,
+            xrefs: typing.Optional[typing.Iterable[str]] = None,
+            alternate_labels: typing.Optional[typing.Iterable[str]] = None,
+            extensions: typing.Optional[typing.Iterable[Extension]] = None,
+            structural_type: typing.Optional[OntologyClass] = None,
+            vrs_ref_allele_seq: typing.Optional[str] = None,
+            allelic_state: typing.Optional[OntologyClass] = None,
+    ):
+        self._id = id
+        self._molecule_context = molecule_context
+        self._label = label
+        self._description = description
+        self._gene_context = gene_context
+        self._expressions = [] if expressions is None else list(expressions)
+        self._vcf_record = vcf_record
+        self._xrefs = [] if xrefs is None else list(xrefs)
+        self._alternate_labels = [] if alternate_labels is None else list(alternate_labels)
+        self._extensions = [] if extensions is None else list(extensions)
+        self._structural_type = structural_type
+        self._vrs_ref_allele_seq = vrs_ref_allele_seq
+        self._allelic_state = allelic_state
+
+    @property
+    def id(self) -> str:
+        return self._id
+
+    @id.setter
+    def id(self, value: str):
+        self._id = value
+
+    @property
+    def molecule_context(self) -> MoleculeContext:
+        return self._molecule_context
+
+    @molecule_context.setter
+    def molecule_context(self, value: MoleculeContext):
+        self._molecule_context = value
+
+    @property
+    def label(self) -> typing.Optional[str]:
+        return self._label
+
+    @label.setter
+    def label(self, value: str):
+        self._label = value
+
+    @label.deleter
+    def label(self):
+        self._label = None
+
+    @property
+    def description(self) -> typing.Optional[str]:
+        return self._description
+
+    @description.setter
+    def description(self, value: str):
+        self._description = value
+
+    @description.deleter
+    def description(self):
+        self._description = None
+
+    @property
+    def gene_context(self) -> typing.Optional[GeneDescriptor]:
+        return self._gene_context
+
+    @gene_context.setter
+    def gene_context(self, value: GeneDescriptor):
+        self._gene_context = value
+
+    @gene_context.deleter
+    def gene_context(self):
+        self._gene_context = None
+
+    @property
+    def expressions(self) -> typing.MutableSequence[Expression]:
+        return self._expressions
+
+    @property
+    def vcf_record(self) -> typing.Optional[VcfRecord]:
+        return self._vcf_record
+
+    @vcf_record.setter
+    def vcf_record(self, value: VcfRecord):
+        self._vcf_record = value
+
+    @vcf_record.deleter
+    def vcf_record(self):
+        self._vcf_record = None
+
+    @property
+    def xrefs(self) -> typing.MutableSequence[str]:
+        return self._xrefs
+
+    @property
+    def alternate_labels(self) -> typing.MutableSequence[str]:
+        return self._alternate_labels
+
+    @property
+    def extensions(self) -> typing.MutableSequence[Extension]:
+        return self._extensions
+
+    @property
+    def structural_type(self) -> typing.Optional[OntologyClass]:
+        return self._structural_type
+
+    @structural_type.setter
+    def structural_type(self, value: OntologyClass):
+        self._structural_type = value
+
+    @structural_type.deleter
+    def structural_type(self):
+        self._structural_type = None
+
+    @property
+    def vrs_ref_allele_seq(self) -> typing.Optional[str]:
+        return self._vrs_ref_allele_seq
+
+    @vrs_ref_allele_seq.setter
+    def vrs_ref_allele_seq(self, value: str):
+        self._vrs_ref_allele_seq = value
+
+    @vrs_ref_allele_seq.deleter
+    def vrs_ref_allele_seq(self):
+        self._vrs_ref_allele_seq = None
+
+    @property
+    def allelic_state(self) -> typing.Optional[OntologyClass]:
+        return self._allelic_state
+
+    @allelic_state.setter
+    def allelic_state(self, value: OntologyClass):
+        self._allelic_state = value
+
+    @allelic_state.deleter
+    def allelic_state(self):
+        self._allelic_state = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return (
+            'id', 'molecule_context', 'label', 'description', 'gene_context', 'expressions', 'vcf_record', 'xrefs',
+            'alternate_labels', 'extensions', 'structural_type', 'vrs_ref_allele_seq', 'allelic_state',
+        )
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'id', 'molecule_context',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return VariationDescriptor(
+                id=values['id'],
+                molecule_context=MessageMixin._extract_enum_field('molecule_context', MoleculeContext, values),
+                label=values.get('label', None),
+                description=values.get('description', None),
+                gene_context=extract_message_scalar('gene_context', GeneDescriptor, values),
+                expressions=extract_message_sequence('expressions', Expression, values),
+                vcf_record=extract_message_scalar('vcf_record', VcfRecord, values),
+                xrefs=values.get('xrefs', None),
+                alternate_labels=values.get('alternate_labels', None),
+                extensions=extract_message_sequence('extensions', Extension, values),
+                structural_type=extract_message_scalar('structural_type', OntologyClass, values),
+                vrs_ref_allele_seq=values.get('vrs_ref_allele_seq', None),
+                allelic_state=extract_message_scalar('allelic_state', OntologyClass, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        vd = pp202.VariationDescriptor(
+            id=self._id,
+            molecule_context=pp202.MoleculeContext.Value(self._molecule_context.name),
+        )
+
+        if self._label is not None:
+            vd.label = self._label
+
+        if self._description is not None:
+            vd.description = self._description
+
+        if self._gene_context is not None:
+            vd.gene_context.CopyFrom(self._gene_context.to_message())
+
+        vd.expressions.extend(e.to_message() for e in self._expressions)
+
+        if self._vcf_record is not None:
+            vd.vcf_record.CopyFrom(self._vcf_record.to_message())
+
+        vd.xrefs.extend(self._xrefs)
+        vd.alternate_labels.extend(self._alternate_labels)
+        vd.extensions.extend(e.to_message() for e in self._extensions)
+
+        if self._structural_type is not None:
+            vd.structural_type.CopyFrom(self._structural_type.to_message())
+
+        if self._vrs_ref_allele_seq is not None:
+            self.vrs_ref_allele_seq = self._vrs_ref_allele_seq
+
+        if self._allelic_state is not None:
+            vd.allelic_state.CopyFrom(self._allelic_state.to_message())
+
+        return vd
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.VariationDescriptor
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return VariationDescriptor(
+                id=msg.id,
+                molecule_context=MoleculeContext(msg.molecule_context),
+                label=None if msg.label == '' else msg.label,
+                description=None if msg.description == '' else msg.description,
+                gene_context=extract_pb_message_scalar('gene_context', GeneDescriptor, msg),
+                expressions=extract_pb_message_seq('expressions', Expression, msg),
+                vcf_record=extract_pb_message_scalar('vcf_record', VcfRecord, msg),
+                xrefs=msg.xrefs,
+                alternate_labels=msg.alternate_labels,
+                extensions=extract_pb_message_seq('extensions', Extension, msg),
+                structural_type=extract_pb_message_scalar('structural_type', OntologyClass, msg),
+                vrs_ref_allele_seq=None if msg.vrs_ref_allele_seq == '' else msg.vrs_ref_allele_seq,
+                allelic_state=extract_pb_message_scalar('allelic_state', OntologyClass, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, VariationDescriptor) \
+            and self._id == other._id \
+            and self._molecule_context == other._molecule_context \
+            and self._label == other._label \
+            and self._description == other._description \
+            and self._gene_context == other._gene_context \
+            and self._expressions == other._expressions \
+            and self._vcf_record == other._vcf_record \
+            and self._xrefs == other._xrefs \
+            and self._alternate_labels == other._alternate_labels \
+            and self._extensions == other._extensions \
+            and self._structural_type == other._structural_type \
+            and self._vrs_ref_allele_seq == other._vrs_ref_allele_seq \
+            and self._allelic_state == other._allelic_state
+
+    def __repr__(self):
+        return 'VariationDescriptor(' \
+               f'id={self._id}, ' \
+               f'molecule_context={self._molecule_context}, ' \
+               f'label={self._label}, ' \
+               f'description={self._description}, ' \
+               f'gene_context={self._gene_context}, ' \
+               f'expressions={self._expressions}, ' \
+               f'vcf_record={self._vcf_record}, ' \
+               f'xrefs={self._xrefs}, ' \
+               f'alternate_labels={self._alternate_labels}, ' \
+               f'extensions={self._extensions}, ' \
+               f'structural_type={self._structural_type}, ' \
+               f'vrs_ref_allele_seq={self._vrs_ref_allele_seq}, ' \
+               f'allelic_state={self._allelic_state})'


Test patch:
diff --git a/pytest.ini b/pytest.ini
index df3eb518..75c0714d 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,2 +1,7 @@
 [pytest]
 addopts = --doctest-modules
+
+; We want pytest to look for both test files prefixed with underscore `_` and "regular* test files.
+; The unittest files, the test files located in the source code, are prepended with underscore
+; to preclude their inclusion into API reference.
+python_files = _test*.py test*.py
diff --git a/src/pyphetools/pp/parse/_test_util.py b/src/pyphetools/pp/parse/_test_util.py
new file mode 100644
index 00000000..6980b337
--- /dev/null
+++ b/src/pyphetools/pp/parse/_test_util.py
@@ -0,0 +1,105 @@
+import pytest
+
+from ._util import CaseConverter
+from ._util import HierarchicalKeyMapper
+
+
+class TestCaseConverter:
+
+    @pytest.fixture(scope='class')
+    def converter(self) -> CaseConverter:
+        return CaseConverter()
+
+    @pytest.mark.parametrize(
+        'payload, expected',
+        [
+            ('', ''),
+            ('a', 'a'),
+            ('A', 'a'),
+            ('AbcDef', 'abc_def'),
+            ('abcDef', 'abc_def'),
+            ('ABcDef', 'a_bc_def'),
+            ('ABCDEF', 'a_b_c_d_e_f'),
+
+            # The snake case is, however, left untouched.
+            ('abc_def', 'abc_def'),
+            ('abc_def_g_h', 'abc_def_g_h'),
+        ]
+    )
+    def test_camel_to_snake(
+            self,
+            converter: CaseConverter,
+            payload: str,
+            expected: str,
+    ):
+        actual = converter.camel_to_snake(payload)
+
+        assert actual == expected
+
+    @pytest.mark.parametrize(
+        'payload, expected',
+        [
+            ('', ''),
+            ('a', 'a'),
+            ('a_', 'a'),
+            ('a_b_', 'aB'),
+            ('abc', 'abc'),
+            ('a_b_c', 'aBC'),
+            ('abc_def', 'abcDef'),
+            ('a_bc_def', 'aBcDef'),
+            ('a_b_c_d_e_f', 'aBCDEF'),
+        ]
+    )
+    def test_snake_to_camel(
+            self,
+            converter: CaseConverter,
+            payload: str,
+            expected: str,
+    ):
+        actual = converter.snake_to_camel(payload)
+
+        assert actual == expected
+
+
+class TestHierarchicalKeyMapper:
+
+    @pytest.fixture
+    def mapper(self) -> HierarchicalKeyMapper:
+        return HierarchicalKeyMapper((
+            'file_attributes', 'individual_to_file_identifiers',
+            'fileAttributes', 'individualToFileIdentifiers',
+        ))
+
+    @pytest.fixture
+    def case_converter(self) -> CaseConverter:
+        return CaseConverter()
+
+    def test_remap(
+            self,
+            mapper: HierarchicalKeyMapper,
+            case_converter: CaseConverter,
+    ):
+        payload = {
+          "files": [
+            {
+              "uri": "file://data/germlineWgs.vcf.gz",
+              "individual_to_file_identifiers": {"proband A": "sample1", "proband B": "sample2", },
+              "file_attributes": {"genome_assembly": "GRCh38", "file_format": "VCF", }
+            }, {
+              "uri": "file://data/somaticWgs.vcf.gz",
+              "individual_to_file_identifiers": {"proband A": "sample1", "proband B": "sample2", },
+              "file_attributes": { "genome_assembly": "GRCh38", "file_format": "VCF", }
+            }
+          ]
+        }
+
+        remapped = mapper.remap_mapping(case_converter.snake_to_camel, payload)
+
+        # We remapped the fields
+        assert all('individualToFileIdentifiers' in f for f in remapped['files'])
+        assert all('fileAttributes' in f for f in remapped['files'])
+
+        # but not their sub-elements.
+        for file in remapped['files']:
+            assert all(x in file['individualToFileIdentifiers'] for x in ('proband A', 'proband B'))
+            assert all(x in file['fileAttributes'] for x in ('genome_assembly', 'file_format'))
diff --git a/src/pyphetools/pp/v202/_test_individual.py b/src/pyphetools/pp/v202/_test_individual.py
new file mode 100644
index 00000000..17df65cb
--- /dev/null
+++ b/src/pyphetools/pp/v202/_test_individual.py
@@ -0,0 +1,19 @@
+import pytest
+
+from ._individual import Sex
+
+
+@pytest.mark.parametrize(
+    'left, right, expected',
+    [
+        (Sex.MALE, Sex.MALE, True),
+        (Sex.FEMALE, Sex.FEMALE, True),
+        (Sex.OTHER_SEX, Sex.OTHER_SEX, True),
+        (Sex.UNKNOWN_SEX, Sex.UNKNOWN_SEX, True),
+
+        (Sex.FEMALE, Sex.MALE, False),
+        (Sex.MALE, Sex.FEMALE, False),
+    ]
+)
+def test_sex_eq(left: Sex, right: Sex, expected: bool):
+    assert (left == right) == expected
diff --git a/test/conftest.py b/test/conftest.py
index e0fe872d..686695a1 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -18,3 +18,18 @@ def fpath_hpo(fpath_data: str) -> str:
 @pytest.fixture(scope='session')
 def hpo(fpath_hpo: str) -> hpotk.Ontology:
     return hpotk.load_ontology(fpath_hpo)
+
+
+@pytest.fixture(scope='session')
+def fpath_pp(fpath_data: str) -> str:
+    return os.path.join(fpath_data, 'pp')
+
+
+@pytest.fixture(scope='session')
+def fpath_retinoblastoma_json(fpath_pp: str) -> str:
+    """
+    Path to a JSON file with retinoblastoma phenopacket.
+
+    Note: the phenopacket is pulled from *Phenopacket tools*.
+    """
+    return os.path.join(fpath_pp, 'retinoblastoma.json')
diff --git a/test/data/pp/retinoblastoma.json b/test/data/pp/retinoblastoma.json
new file mode 100644
index 00000000..6f2edd32
--- /dev/null
+++ b/test/data/pp/retinoblastoma.json
@@ -0,0 +1,519 @@
+{
+  "id": "example.retinoblastoma.phenopacket.id",
+  "subject": {
+    "id": "proband A",
+    "alternateIds": ["some", "other", "identifiers"],
+    "timeAtLastEncounter": {
+      "age": {
+        "iso8601duration": "P6M"
+      }
+    },
+    "sex": "FEMALE",
+    "karyotypicSex": "XX",
+    "vitalStatus": {
+      "status": "DECEASED",
+      "timeOfDeath": {
+        "age": {
+          "iso8601duration": "P1Y"
+        }
+      },
+      "causeOfDeath": {
+        "id": "NCIT:C7541",
+        "label": "Retinoblastoma"
+      },
+      "survivalTimeInDays": 180
+    },
+    "taxonomy": {
+        "id": "NCBITaxon:9606",
+        "label": "Homo sapiens"
+      }
+  },
+  "phenotypicFeatures": [{
+    "type": {
+      "id": "HP:0030084",
+      "label": "Clinodactyly"
+    },
+    "modifiers": [{
+      "id": "HP:0012834",
+      "label": "Right"
+    }],
+    "onset": {
+      "age": {
+        "iso8601duration": "P3M"
+      }
+    }
+  }, {
+    "type": {
+      "id": "HP:0000555",
+      "label": "Leukocoria"
+    },
+    "modifiers": [{
+      "id": "HP:0012835",
+      "label": "Left"
+    }],
+    "onset": {
+      "age": {
+        "iso8601duration": "P4M"
+      }
+    }
+  }, {
+    "type": {
+      "id": "HP:0000486",
+      "label": "Strabismus"
+    },
+    "modifiers": [{
+      "id": "HP:0012835",
+      "label": "Left"
+    }],
+    "onset": {
+      "age": {
+        "iso8601duration": "P5M15D"
+      }
+    }
+  }, {
+    "type": {
+      "id": "HP:0000541",
+      "label": "Retinal detachment"
+    },
+    "modifiers": [{
+      "id": "HP:0012835",
+      "label": "Left"
+    }],
+    "onset": {
+      "age": {
+        "iso8601duration": "P6M"
+      }
+    }
+  }],
+  "measurements": [{
+    "assay": {
+      "id": "LOINC:79893-4",
+      "label": "Left eye Intraocular pressure"
+    },
+    "value": {
+      "quantity": {
+        "unit": {
+          "id": "UCUM:mm[Hg]",
+          "label": "millimetres of mercury"
+        },
+        "value": 25.0,
+        "referenceRange": {
+          "unit": {
+            "id": "LOINC:56844-4",
+            "label": "Intraocular pressure of Eye"
+          },
+          "low": 10.0,
+          "high": 21.0
+        }
+      }
+    },
+    "timeObserved": {
+      "age": {
+        "iso8601duration": "P6M"
+      }
+    }
+  }, {
+    "assay": {
+      "id": "LOINC:79892-6",
+      "label": "Right eye Intraocular pressure"
+    },
+    "value": {
+      "quantity": {
+        "unit": {
+          "id": "UCUM:mm[Hg]",
+          "label": "millimetres of mercury"
+        },
+        "value": 15.0,
+        "referenceRange": {
+          "unit": {
+            "id": "LOINC:56844-4",
+            "label": "Intraocular pressure of Eye"
+          },
+          "low": 10.0,
+          "high": 21.0
+        }
+      }
+    },
+    "timeObserved": {
+      "age": {
+        "iso8601duration": "P6M"
+      }
+    }
+  }],
+  "biosamples": [{
+    "id": "biosample.1",
+    "sampledTissue": {
+      "id": "UBERON:0000970",
+      "label": "eye"
+    },
+    "phenotypicFeatures": [{
+      "type": {
+        "id": "NCIT:C35941",
+        "label": "Flexner-Wintersteiner Rosette Formation"
+      }
+    }, {
+      "type": {
+        "id": "NCIT:C132485",
+        "label": "Apoptosis and Necrosis"
+      }
+    }],
+    "measurements": [{
+      "assay": {
+        "id": "LOINC:33728-7",
+        "label": "Size.maximum dimension in Tumor"
+      },
+      "value": {
+        "quantity": {
+          "unit": {
+            "id": "UCUM:mm",
+            "label": "millimeter"
+          },
+          "value": 15.0
+        }
+      },
+      "timeObserved": {
+        "age": {
+          "iso8601duration": "P8M2W"
+        }
+      }
+    }],
+    "tumorProgression": {
+      "id": "NCIT:C8509",
+      "label": "Primary Neoplasm"
+    },
+    "pathologicalTnmFinding": [{
+      "id": "NCIT:C140720",
+      "label": "Retinoblastoma pT3 TNM Finding v8"
+    }, {
+      "id": "NCIT:C140711",
+      "label": "Retinoblastoma pN0 TNM Finding v8"
+    }],
+    "procedure": {
+      "code": {
+        "id": "NCIT:C48601",
+        "label": "Enucleation"
+      },
+      "bodySite": {
+        "id": "UBERON:0004548",
+        "label": "left eye"
+      },
+      "performed": {
+        "age": {
+          "iso8601duration": "P8M2W"
+        }
+      }
+    },
+    "files": [{
+      "uri": "file://data/fileSomaticWgs.vcf.gz",
+      "individualToFileIdentifiers": {
+        "biosample.1": "specimen.1"
+      },
+      "fileAttributes": {
+        "fileFormat": "VCF",
+        "genomeAssembly": "GRCh38"
+      }
+    }]
+  }],
+  "interpretations": [{
+    "id": "interpretation.id",
+    "progressStatus": "SOLVED",
+    "diagnosis": {
+      "disease": {
+        "id": "NCIT:C7541",
+        "label": "Retinoblastoma"
+      },
+      "genomicInterpretations": [{
+        "subjectOrBiosampleId": "proband A",
+        "interpretationStatus": "CAUSATIVE",
+        "variantInterpretation": {
+          "acmgPathogenicityClassification": "PATHOGENIC",
+          "therapeuticActionability": "ACTIONABLE",
+          "variationDescriptor": {
+            "id": "example-cnv",
+            "moleculeContext": "genomic",
+            "variation": {
+              "copyNumber": {
+                "derivedSequenceExpression": {
+                  "location": {
+                    "sequenceId": "refseq:NC_000013.14",
+                    "sequenceInterval": {
+                      "startNumber": {
+                        "value": "25981249"
+                      },
+                      "endNumber": {
+                        "value": "61706822"
+                      }
+                    }
+                  }
+                },
+                "number": {
+                  "value": "1"
+                }
+              }
+            },
+            "extensions": [{
+              "name": "mosaicism",
+              "value": "40.0%"
+            }]
+          }
+        }
+      }, {
+        "subjectOrBiosampleId": "biosample.1",
+        "interpretationStatus": "CAUSATIVE",
+        "variantInterpretation": {
+          "acmgPathogenicityClassification": "PATHOGENIC",
+          "therapeuticActionability": "ACTIONABLE",
+          "variationDescriptor": {
+            "id": "rs121913300",
+            "variation": {
+              "allele": {
+                "sequenceLocation": {
+                  "sequenceId": "refseq:NC_000013.11",
+                  "sequenceInterval": {
+                    "startNumber": {
+                      "value": "48367511"
+                    },
+                    "endNumber": {
+                      "value": "48367512"
+                    }
+                  }
+                },
+                "literalSequenceExpression": {
+                  "sequence": "T"
+                }
+              }
+            },
+            "label": "RB1 c.958C\u003eT (p.Arg320Ter)",
+            "geneContext": {
+              "valueId": "HGNC:9884",
+              "symbol": "RB1"
+            },
+            "expressions": [{
+              "syntax": "hgvs.c",
+              "value": "NM_000321.2:c.958C\u003eT"
+            }, {
+              "syntax": "transcript_reference",
+              "value": "NM_000321.2"
+            }],
+            "vcfRecord": {
+              "genomeAssembly": "GRCh38",
+              "chrom": "NC_000013.11",
+              "pos": 48367512,
+              "ref": "C",
+              "alt": "T"
+            },
+            "extensions": [{
+              "name": "allele-frequency",
+              "value": "25.0%"
+            }],
+            "moleculeContext": "genomic",
+            "allelicState": {
+              "id": "GENO:0000135",
+              "label": "heterozygous"
+            }
+          }
+        }
+      }]
+    }
+  }],
+  "diseases": [{
+    "term": {
+      "id": "NCIT:C7541",
+      "label": "Retinoblastoma"
+    },
+    "onset": {
+      "age": {
+        "iso8601duration": "P4M"
+      }
+    },
+    "diseaseStage": [{
+      "id": "LOINC:LA24739-7",
+      "label": "Group E"
+    }],
+    "clinicalTnmFinding": [{
+      "id": "NCIT:C140678",
+      "label": "Retinoblastoma cM0 TNM Finding v8"
+    }],
+    "primarySite": {
+      "id": "UBERON:0004548",
+      "label": "left eye"
+    }
+  }],
+  "medicalActions": [{
+    "treatment": {
+      "agent": {
+        "id": "DrugCentral:1678",
+        "label": "melphalan"
+      },
+      "routeOfAdministration": {
+        "id": "NCIT:C38222",
+        "label": "Intraarterial Route of Administration"
+      },
+      "doseIntervals": [{
+        "quantity": {
+          "unit": {
+            "id": "UCUM:mg.kg-1",
+            "label": "milligram per kilogram"
+          },
+          "value": 0.4
+        },
+        "scheduleFrequency": {
+          "id": "NCIT:C64576",
+          "label": "Once"
+        },
+        "interval": {
+          "start": "2020-09-02T00:00:00Z",
+          "end": "2020-09-02T00:00:00Z"
+        }
+      }]
+    },
+    "treatmentTarget": {
+      "id": "NCIT:C7541",
+      "label": "Retinoblastoma"
+    },
+    "treatmentIntent": {
+      "id": "NCIT:C62220",
+      "label": "Cure"
+    },
+    "adverseEvents": [{
+      "id": "HP:0025637",
+      "label": "Vasospasm"
+    }],
+    "treatmentTerminationReason": {
+      "id": "NCIT:C41331",
+      "label": "Adverse Event"
+    }
+  }, {
+    "therapeuticRegimen": {
+      "ontologyClass": {
+        "id": "NCIT:C10894",
+        "label": "Carboplatin/Etoposide/Vincristine"
+      },
+      "startTime": {
+        "age": {
+          "iso8601duration": "P7M"
+        }
+      },
+      "endTime": {
+        "age": {
+          "iso8601duration": "P8M"
+        }
+      },
+      "regimenStatus": "COMPLETED"
+    },
+    "treatmentTarget": {
+      "id": "NCIT:C7541",
+      "label": "Retinoblastoma"
+    },
+    "treatmentIntent": {
+      "id": "NCIT:C62220",
+      "label": "Cure"
+    }
+  }, {
+    "procedure": {
+      "code": {
+        "id": "NCIT:C48601",
+        "label": "Enucleation"
+      },
+      "bodySite": {
+        "id": "UBERON:0004548",
+        "label": "left eye"
+      },
+      "performed": {
+        "age": {
+          "iso8601duration": "P8M2W"
+        }
+      }
+    },
+    "treatmentTarget": {
+      "id": "NCIT:C7541",
+      "label": "Retinoblastoma"
+    },
+    "treatmentIntent": {
+      "id": "NCIT:C62220",
+      "label": "Cure"
+    }
+  }],
+  "files": [{
+    "uri": "file://data/germlineWgs.vcf.gz",
+    "individualToFileIdentifiers": {
+      "proband A": "sample1",
+      "proband B": "sample2"
+    },
+    "fileAttributes": {
+      "fileFormat": "VCF",
+      "genomeAssembly": "GRCh38"
+    }
+  }, {
+    "uri": "file://data/somaticWgs.vcf.gz",
+    "individualToFileIdentifiers": {
+      "proband A": "sample1",
+      "proband B": "sample2"
+    },
+    "fileAttributes": {
+      "fileFormat": "VCF",
+      "genomeAssembly": "GRCh38"
+    }
+  }],
+  "metaData": {
+    "created": "2021-05-14T10:35:00Z",
+    "createdBy": "anonymous biocurator",
+    "resources": [{
+      "id": "hp",
+      "name": "human phenotype ontology",
+      "url": "http://purl.obolibrary.org/obo/hp.owl",
+      "version": "2021-08-02",
+      "namespacePrefix": "HP",
+      "iriPrefix": "http://purl.obolibrary.org/obo/HP_"
+    },
+      {
+        "id": "geno",
+        "name": "Genotype Ontology",
+        "url": "http://purl.obolibrary.org/obo/geno.owl",
+        "version": "2020-03-08",
+        "namespacePrefix": "GENO",
+        "iriPrefix": "http://purl.obolibrary.org/obo/GENO_"
+      },
+      {
+        "id": "ncit",
+        "name": "NCI Thesaurus",
+        "url": "http://purl.obolibrary.org/obo/ncit.owl",
+        "version": "VERSION",
+        "namespacePrefix": "NCIT",
+        "iriPrefix": "http://purl.obolibrary.org/obo/NCIT_"
+      },
+      {
+        "id": "uberon",
+        "name": "Uber-anatomy ontology",
+        "url": "http://purl.obolibrary.org/obo/uberon.owl",
+        "version": "VERSION",
+        "namespacePrefix": "UBERON",
+        "iriPrefix": "http://purl.obolibrary.org/obo/UBERON_"
+      },
+      {
+        "id": "loinc",
+        "name": "Logical Observation Identifiers Names and Codes",
+        "url": "https://loinc.org",
+        "version": "VERSION",
+        "namespacePrefix": "LOINC",
+        "iriPrefix": "https://loinc.org"
+      },
+      {
+        "id": "drugcentral",
+        "name": "Drug Central",
+        "url": "https://drugcentral.org/",
+        "version": "VERSION",
+        "namespacePrefix": "DrugCentral",
+        "iriPrefix": "https://drugcentral.org/drugcard"
+      },
+      {
+        "id": "ucum",
+        "name": "Unified Code for Units of Measure",
+        "url": "https://ucum.org",
+        "version": "2.1",
+        "namespacePrefix": "UCUM",
+        "iriPrefix": "https://ucum.org/"
+      }],
+    "phenopacketSchemaVersion": "2.0.0"
+  }
+}
\ No newline at end of file
diff --git a/test/data/pp/retinoblastoma.pb b/test/data/pp/retinoblastoma.pb
new file mode 100644
index 00000000..ad894fc5
Binary files /dev/null and b/test/data/pp/retinoblastoma.pb differ
diff --git a/test/pp/__init__.py b/test/pp/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/test/pp/conftest.py b/test/pp/conftest.py
new file mode 100644
index 00000000..8eb94244
--- /dev/null
+++ b/test/pp/conftest.py
@@ -0,0 +1,388 @@
+import typing
+
+import pytest
+
+from pyphetools.pp.v202 import *
+
+
+@pytest.fixture(scope='package')
+def retinoblastoma(
+        individual: Individual,
+        phenotypic_features: typing.Sequence[PhenotypicFeature],
+        measurements: typing.Sequence[Measurement],
+        biosamples: typing.Sequence[Biosample],
+        interpretations: typing.Sequence[Interpretation],
+        diseases: typing.Sequence[Disease],
+        medical_actions: typing.Sequence[MedicalAction],
+        files: typing.Sequence[File],
+        meta_data: MetaData,
+) -> Phenopacket:
+    """
+    Phenopacket with the same values that we can find in `test/data/pp/retinoblastoma.json`.
+    """
+    return Phenopacket(
+        id='example.retinoblastoma.phenopacket.id',
+        subject=individual,
+        phenotypic_features=phenotypic_features,
+        measurements=measurements,
+        biosamples=biosamples,
+        interpretations=interpretations,
+        diseases=diseases,
+        medical_actions=medical_actions,
+        files=files,
+        meta_data=meta_data,
+    )
+
+
+@pytest.fixture(scope='package')
+def individual() -> Individual:
+    """
+    `Individual` from the `retinoblastoma` phenopacket.
+    """
+    return Individual(
+        id='proband A',
+        alternate_ids=('some', 'other', 'identifiers'),
+        time_at_last_encounter=TimeElement(
+            element=Age(
+                iso8601duration='P6M',
+            )
+        ),
+        vital_status=VitalStatus(
+            status=VitalStatus.Status.DECEASED,
+            time_of_death=TimeElement(element=Age(iso8601duration='P1Y')),
+            cause_of_death=OntologyClass(id='NCIT:C7541', label='Retinoblastoma'),
+            survival_time_in_days=180,
+        ),
+        sex=Sex.FEMALE,
+        karyotypic_sex=KaryotypicSex.XX,
+        taxonomy=OntologyClass(id='NCBITaxon:9606', label='Homo sapiens')
+    )
+
+
+@pytest.fixture(scope='package')
+def phenotypic_features() -> typing.Sequence[PhenotypicFeature]:
+    return (
+        PhenotypicFeature(
+            type=OntologyClass(id='HP:0030084', label='Clinodactyly'),
+            modifiers=(OntologyClass(id='HP:0012834', label='Right'),),
+            onset=TimeElement(element=Age(iso8601duration='P3M')),
+        ),
+        PhenotypicFeature(
+            type=OntologyClass(id='HP:0000555', label='Leukocoria'),
+            modifiers=(OntologyClass(id='HP:0012835', label='Left'),),
+            onset=TimeElement(element=Age(iso8601duration='P4M')),
+        ),
+        PhenotypicFeature(
+            type=OntologyClass(id='HP:0000486', label='Strabismus'),
+            modifiers=(OntologyClass(id='HP:0012835', label='Left'),),
+            onset=TimeElement(element=Age(iso8601duration='P5M15D')),
+        ),
+        PhenotypicFeature(
+            type=OntologyClass(id='HP:0000541', label='Retinal detachment'),
+            modifiers=(OntologyClass(id='HP:0012835', label='Left'),),
+            onset=TimeElement(element=Age(iso8601duration='P6M')),
+        ),
+    )
+
+
+@pytest.fixture(scope='package')
+def measurements() -> typing.Sequence[Measurement]:
+    return (
+        Measurement(
+            assay=OntologyClass(id='LOINC:79893-4', label='Left eye Intraocular pressure'),
+            measurement_value=Value(
+                value=Quantity(
+                    unit=OntologyClass(id='UCUM:mm[Hg]', label='millimetres of mercury'),
+                    value=25.0,
+                    reference_range=ReferenceRange(
+                        unit=OntologyClass(id='LOINC:56844-4', label='Intraocular pressure of Eye'),
+                        low=10.0,
+                        high=21.0,
+                    ),
+                ),
+            ),
+            time_observed=TimeElement(
+                element=Age(iso8601duration='P6M'),
+            ),
+        ),
+        Measurement(
+            assay=OntologyClass(id='LOINC:79892-6', label='Right eye Intraocular pressure'),
+            measurement_value=Value(
+                value=Quantity(
+                    unit=OntologyClass(id='UCUM:mm[Hg]', label='millimetres of mercury'),
+                    value=15.0,
+                    reference_range=ReferenceRange(
+                        unit=OntologyClass(id='LOINC:56844-4', label='Intraocular pressure of Eye'),
+                        low=10.0,
+                        high=21.0,
+                    ),
+                ),
+            ),
+            time_observed=TimeElement(
+                element=Age(iso8601duration='P6M'),
+            ),
+        ),
+    )
+
+
+@pytest.fixture(scope='package')
+def diseases() -> typing.Sequence[Disease]:
+    return (
+        Disease(
+            term=OntologyClass(id='NCIT:C7541', label='Retinoblastoma'),
+            onset=TimeElement(element=Age(iso8601duration='P4M')),
+            disease_stage=(OntologyClass(id='LOINC:LA24739-7', label='Group E'),),
+            clinical_tnm_finding=(OntologyClass(id='NCIT:C140678', label='Retinoblastoma cM0 TNM Finding v8'),),
+            primary_site=OntologyClass(id='UBERON:0004548', label='left eye'),
+        ),
+    )
+
+
+@pytest.fixture(scope='package')
+def interpretations() -> typing.Sequence[Interpretation]:
+    return (
+        Interpretation(
+            id='interpretation.id',
+            progress_status=Interpretation.ProgressStatus.SOLVED,
+            diagnosis=Diagnosis(
+                disease=OntologyClass(id='NCIT:C7541', label='Retinoblastoma'),
+                genomic_interpretations=(
+                    GenomicInterpretation(
+                        subject_or_biosample_id='proband A',
+                        interpretation_status=GenomicInterpretation.InterpretationStatus.CAUSATIVE,
+                        variant_interpretation=VariantInterpretation(
+                            acmg_pathogenicity_classification=AcmgPathogenicityClassification.PATHOGENIC,
+                            therapeutic_actionability=TherapeuticActionability.ACTIONABLE,
+                            variation_descriptor=VariationDescriptor(
+                                id='example-cnv',
+                                molecule_context=MoleculeContext.genomic,
+                                # TODO: variation
+                                extensions=(
+                                    Extension(name='mosaicism', value='40.0%'),
+                                ),
+                            )
+                        )
+                    ),
+                    GenomicInterpretation(
+                        subject_or_biosample_id='biosample.1',
+                        interpretation_status=GenomicInterpretation.InterpretationStatus.CAUSATIVE,
+                        variant_interpretation=VariantInterpretation(
+                            acmg_pathogenicity_classification=AcmgPathogenicityClassification.PATHOGENIC,
+                            therapeutic_actionability=TherapeuticActionability.ACTIONABLE,
+                            variation_descriptor=VariationDescriptor(
+                                id='rs121913300',
+                                molecule_context=MoleculeContext.genomic,
+                                # TODO: variation
+                                label='RB1 c.958C>T (p.Arg320Ter)',
+                                gene_context=GeneDescriptor(value_id='HGNC:9884', symbol='RB1'),
+                                expressions=(
+                                    Expression(syntax='hgvs.c', value='NM_000321.2:c.958C>T'),
+                                    Expression(syntax='transcript_reference', value='NM_000321.2'),
+                                ),
+                                vcf_record=VcfRecord(
+                                    genome_assembly="GRCh38",
+                                    chrom="NC_000013.11",
+                                    pos=48367512,
+                                    ref="C",
+                                    alt="T",
+                                ),
+                                extensions=(
+                                    Extension(name='allele-frequency', value='25.0%'),
+                                ),
+                                allelic_state=OntologyClass(id='GENO:0000135', label='heterozygous'),
+                            )
+                        )
+                    )
+                ),
+            ),
+        ),
+    )
+
+
+@pytest.fixture(scope='package')
+def biosamples() -> typing.Sequence[Biosample]:
+    return (
+        Biosample(
+            id='biosample.1',
+            sampled_tissue=OntologyClass(id='UBERON:0000970', label='eye'),
+            phenotypic_features=(
+                PhenotypicFeature(type=OntologyClass(id='NCIT:C35941', label='Flexner-Wintersteiner Rosette Formation')),
+                PhenotypicFeature(type=OntologyClass(id='NCIT:C132485', label='Apoptosis and Necrosis')),
+            ),
+            measurements=(
+                Measurement(
+                    assay=OntologyClass(id='LOINC:33728-7', label='Size.maximum dimension in Tumor'),
+                    measurement_value=Value(
+                        value=Quantity(
+                            unit=OntologyClass(id='UCUM:mm', label='millimeter'),
+                            value=15.0,
+                        )
+                    ),
+                    time_observed=TimeElement(
+                        element=Age(iso8601duration='P8M2W')
+                    )
+                ),
+            ),
+            tumor_progression=OntologyClass(id='NCIT:C8509', label='Primary Neoplasm'),
+            pathological_tnm_finding=(
+                OntologyClass(id='NCIT:C140720', label='Retinoblastoma pT3 TNM Finding v8'),
+                OntologyClass(id='NCIT:C140711', label='Retinoblastoma pN0 TNM Finding v8'),
+            ),
+            procedure=Procedure(
+                code=OntologyClass(id='NCIT:C48601', label='Enucleation'),
+                body_site=OntologyClass(id='UBERON:0004548', label='left eye'),
+                performed=TimeElement(element=Age(iso8601duration='P8M2W'))
+            ),
+            files=(
+                File(
+                    uri='file://data/fileSomaticWgs.vcf.gz',
+                    individual_to_file_identifiers={
+                        'biosample.1': 'specimen.1',
+                    },
+                    file_attributes={
+                        'fileFormat': 'VCF',
+                        'genomeAssembly': 'GRCh38',
+                    },
+                ),
+            ),
+        ),
+    )
+
+
+@pytest.fixture(scope='package')
+def medical_actions() -> typing.Sequence[MedicalAction]:
+    return (
+        MedicalAction(
+            action=Treatment(
+                agent=OntologyClass(id='DrugCentral:1678', label='melphalan'),
+                route_of_administration=OntologyClass(id='NCIT:C38222', label='Intraarterial Route of Administration'),
+                dose_intervals=(
+                    DoseInterval(
+                        quantity=Quantity(
+                            unit=OntologyClass(id='UCUM:mg.kg-1', label='milligram per kilogram'),
+                            value=0.4,
+                        ),
+                        schedule_frequency=OntologyClass(id='NCIT:C64576', label='Once'),
+                        interval=TimeInterval(
+                            start=Timestamp.from_str('2020-09-02T00:00:00Z'),
+                            end=Timestamp.from_str('2020-09-02T00:00:00Z'),
+                        )
+                    ),
+                )
+            ),
+            treatment_target=OntologyClass(id='NCIT:C7541', label='Retinoblastoma'),
+            treatment_intent=OntologyClass(id='NCIT:C62220', label='Cure'),
+            adverse_events=(
+                OntologyClass(id='HP:0025637', label='Vasospasm'),
+            ),
+            treatment_termination_reason=OntologyClass(id='NCIT:C41331', label='Adverse Event')
+        ),
+        MedicalAction(
+            action=TherapeuticRegimen(
+                identifier=OntologyClass(id='NCIT:C10894', label='Carboplatin/Etoposide/Vincristine'),
+                start_time=TimeElement(
+                    element=Age(iso8601duration='P7M'),
+                ),
+                end_time=TimeElement(
+                    element=Age(iso8601duration='P8M'),
+                ),
+                regimen_status=TherapeuticRegimen.RegimenStatus.COMPLETED,
+            ),
+            treatment_target=OntologyClass(id='NCIT:C7541', label='Retinoblastoma'),
+            treatment_intent=OntologyClass(id='NCIT:C62220', label='Cure'),
+        ),
+        MedicalAction(
+            action=Procedure(
+                code=OntologyClass(id='NCIT:C48601', label='Enucleation'),
+                body_site=OntologyClass(id='UBERON:0004548', label='left eye'),
+                performed=TimeElement(element=Age(iso8601duration='P8M2W'))
+            ),
+            treatment_target=OntologyClass(id='NCIT:C7541', label='Retinoblastoma'),
+            treatment_intent=OntologyClass(id='NCIT:C62220', label='Cure'),
+        ),
+    )
+
+
+@pytest.fixture(scope='package')
+def files() -> typing.Sequence[File]:
+    return (
+        File(
+            uri='file://data/germlineWgs.vcf.gz',
+            individual_to_file_identifiers={
+                'proband A': 'sample1',
+                'proband B': 'sample2'
+            },
+            file_attributes={
+                'fileFormat': 'VCF',
+                'genomeAssembly': 'GRCh38',
+            },
+        ),
+        File(
+            uri='file://data/somaticWgs.vcf.gz',
+            individual_to_file_identifiers={
+                'proband A': 'sample1',
+                'proband B': 'sample2'
+            },
+            file_attributes={
+                'fileFormat': 'VCF',
+                'genomeAssembly': 'GRCh38',
+            },
+        ),
+    )
+
+
+@pytest.fixture(scope='package')
+def meta_data() -> MetaData:
+    """
+    `MetaData` from the `retinoblastoma` phenopacket.
+    """
+    return MetaData(
+        created=Timestamp.from_str('2021-05-14T10:35:00Z'),
+        created_by='anonymous biocurator',
+        submitted_by=None,
+        resources=(
+            Resource(
+                id='hp', name='human phenotype ontology',
+                url='http://purl.obolibrary.org/obo/hp.owl',
+                version='2021-08-02', namespace_prefix='HP',
+                iri_prefix='http://purl.obolibrary.org/obo/HP_'
+            ),
+            Resource(
+                id='geno', name='Genotype Ontology',
+                url='http://purl.obolibrary.org/obo/geno.owl',
+                version='2020-03-08', namespace_prefix='GENO',
+                iri_prefix='http://purl.obolibrary.org/obo/GENO_'
+            ),
+            Resource(
+                id='ncit', name='NCI Thesaurus',
+                url='http://purl.obolibrary.org/obo/ncit.owl',
+                version='VERSION', namespace_prefix='NCIT',
+                iri_prefix='http://purl.obolibrary.org/obo/NCIT_'
+            ),
+            Resource(
+                id='uberon', name='Uber-anatomy ontology',
+                url='http://purl.obolibrary.org/obo/uberon.owl',
+                version='VERSION', namespace_prefix='UBERON',
+                iri_prefix='http://purl.obolibrary.org/obo/UBERON_'
+            ),
+            Resource(
+                id='loinc', name='Logical Observation Identifiers Names and Codes',
+                url='https://loinc.org',
+                version='VERSION', namespace_prefix='LOINC',
+                iri_prefix='https://loinc.org'
+            ),
+            Resource(
+                id='drugcentral', name='Drug Central',
+                url='https://drugcentral.org/',
+                version='VERSION', namespace_prefix='DrugCentral',
+                iri_prefix='https://drugcentral.org/drugcard'
+            ),
+            Resource(
+                id='ucum', name='Unified Code for Units of Measure',
+                url='https://ucum.org',
+                version='2.1', namespace_prefix='UCUM',
+                iri_prefix='https://ucum.org/'
+            ),
+        ),
+        phenopacket_schema_version='2.0.0',
+    )
diff --git a/test/pp/parse/__init__.py b/test/pp/parse/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/test/pp/parse/test_json.py b/test/pp/parse/test_json.py
new file mode 100644
index 00000000..f9cbbf3a
--- /dev/null
+++ b/test/pp/parse/test_json.py
@@ -0,0 +1,45 @@
+import io
+
+import pytest
+
+from pyphetools.pp.parse.json import JsonSerializer, JsonDeserializer
+from pyphetools.pp.v202 import *
+
+
+class TestJsonSerializers:
+
+    @pytest.fixture
+    def serializer(self) -> JsonSerializer:
+        return JsonSerializer(indent=2)
+
+    @pytest.fixture
+    def deserializer(self) -> JsonDeserializer:
+        return JsonDeserializer()
+
+    def test_deserialize(
+            self,
+            fpath_retinoblastoma_json: str,
+            retinoblastoma: Phenopacket,
+            deserializer: JsonDeserializer,
+    ):
+        with open(fpath_retinoblastoma_json) as fh:
+            pp = deserializer.deserialize(fh, Phenopacket)
+
+        assert pp == retinoblastoma
+
+    def test_round_trip(
+            self,
+            retinoblastoma: Phenopacket,
+            serializer: JsonSerializer,
+            deserializer: JsonDeserializer,
+    ):
+        # Serialize
+        buf = io.StringIO()
+        serializer.serialize(retinoblastoma, buf)
+
+        # Deserialize
+        buf.seek(0)  # Rewind to allow reading from the buffer.
+        out = deserializer.deserialize(buf, Phenopacket)
+
+        # Compare
+        assert out == retinoblastoma
diff --git a/test/pp/parse/test_pb.py b/test/pp/parse/test_pb.py
new file mode 100644
index 00000000..3ba46ba9
--- /dev/null
+++ b/test/pp/parse/test_pb.py
@@ -0,0 +1,48 @@
+import os
+
+import pytest
+
+from pyphetools.pp.v202 import *
+
+import io
+
+
+class TestProtobuf:
+    """
+    Test that we can write a :class:`Phenopacket` to protobuf representation and get the same content back.
+    """
+
+    @pytest.fixture(scope='class')
+    def fpath_retinoblastoma_pb(self, fpath_pp: str) -> str:
+        return os.path.join(fpath_pp, 'retinoblastoma.pb')
+
+    @pytest.mark.skip('Only run upon changes in the retinoblastoma Phenopacket')
+    def test_dump_retinoblastoma_pb(
+            self,
+            retinoblastoma: Phenopacket,
+            fpath_retinoblastoma_pb: str,
+    ):
+        with open(fpath_retinoblastoma_pb, 'wb') as fh:
+            retinoblastoma.dump_pb(fh)
+
+    def test_round_trip_to_protobuf_and_back(
+            self,
+            retinoblastoma: Phenopacket,
+    ):
+        buf = io.BytesIO()
+
+        retinoblastoma.dump_pb(buf)
+        buf.seek(0)
+
+        actual = Phenopacket.from_pb(buf)
+        assert actual == retinoblastoma
+
+    def test_from_pb(
+            self,
+            retinoblastoma: Phenopacket,
+            fpath_retinoblastoma_pb: str,
+    ):
+        with open(fpath_retinoblastoma_pb, 'rb') as fh:
+            actual = Phenopacket.from_pb(fh)
+
+        assert actual == retinoblastoma
diff --git a/test/pp/test_mixin.py b/test/pp/test_mixin.py
new file mode 100644
index 00000000..c85d2a9e
--- /dev/null
+++ b/test/pp/test_mixin.py
@@ -0,0 +1,27 @@
+from pyphetools.pp.v202 import Individual, Phenopacket
+
+
+class TestMessageMixin:
+
+    def test_from_dict(self, individual: Individual):
+        payload = {
+            'id': 'example',
+            'alternate_ids': ('other', 'yet', 'something'),
+            'time_at_last_encounter': {
+                'age': {'iso8601duration': 'P11Y6M'}
+            }
+        }
+
+        individual = Individual.from_dict(payload)
+
+        assert individual.id == 'example'
+        assert individual.alternate_ids == ['other', 'yet', 'something']
+        assert individual.time_at_last_encounter.age.iso8601duration == 'P11Y6M'
+
+    def test_round_trip(self, retinoblastoma: Phenopacket):
+        out = {}
+        retinoblastoma.to_dict(out)
+
+        other = Phenopacket.from_dict(out)
+
+        assert other == retinoblastoma
diff --git a/test/pp/test_serialization.py b/test/pp/test_serialization.py
new file mode 100644
index 00000000..f66d2782
--- /dev/null
+++ b/test/pp/test_serialization.py
@@ -0,0 +1,64 @@
+
+from google.protobuf.message import Message
+
+from pyphetools.pp.v202 import *
+
+
+class TestSerializable:
+
+    def test_to_message__no_compound_fields(self):
+        # Test a message with no compound fields.
+        oc = OntologyClass(id='HP:0001250', label='Seizure')
+        msg = oc.to_message()
+
+        assert isinstance(msg, Message)
+        assert msg.id == 'HP:0001250'
+        assert msg.label == 'Seizure'
+
+    def test_to_message__with_a_compound_field(self):
+        # Test a message with a compound field.
+        age_range = AgeRange(start=Age(iso8601duration='P1Y'), end=Age(iso8601duration='P2Y'))
+        msg = age_range.to_message()
+
+        assert isinstance(msg, Message)
+        assert isinstance(msg.start, Message)
+        assert msg.start.iso8601duration == 'P1Y'
+
+        assert isinstance(msg.end, Message)
+        assert msg.end.iso8601duration == 'P2Y'
+
+    def test_to_message__map_field(self):
+        # Test a message that has a map field.
+        file = File(uri='file://path/to/some/file.bam',)
+        file.individual_to_file_identifiers['Jim'] = 'jimmy'
+        file.individual_to_file_identifiers['Fred'] = 'freddy'
+
+        file.file_attributes['genomeAssembly'] = 'GRCh38'
+        file.file_attributes['fileFormat'] = 'BAM'
+
+        msg = file.to_message()
+
+        assert isinstance(msg, Message)
+        assert msg.uri == 'file://path/to/some/file.bam'
+
+        assert msg.individual_to_file_identifiers['Jim'] == 'jimmy'
+        assert msg.individual_to_file_identifiers['Fred'] == 'freddy'
+
+        assert msg.file_attributes['genomeAssembly'] == 'GRCh38'
+        assert msg.file_attributes['fileFormat'] == 'BAM'
+
+    def test_to_message__oneof_field(self):
+        te = TimeElement(element=Age(iso8601duration='P1Y'))
+
+        msg = te.to_message()
+
+        assert isinstance(msg, Message)
+        assert msg.age.iso8601duration == 'P1Y'
+
+        # Try other one-of case
+        te = TimeElement(element=GestationalAge(weeks=6, days=4))
+
+        msg = te.to_message()
+        assert isinstance(msg, Message)
+        assert msg.gestational_age.weeks == 6
+        assert msg.gestational_age.days == 4
diff --git a/test/pp/test_timestamp.py b/test/pp/test_timestamp.py
new file mode 100644
index 00000000..0731fd86
--- /dev/null
+++ b/test/pp/test_timestamp.py
@@ -0,0 +1,59 @@
+import pytest
+
+from pyphetools.pp import Timestamp
+
+
+class TestTimestamp:
+
+    @pytest.mark.parametrize(
+        'left, right, expected',
+        [
+            ('1970-01-01T00:00:01Z', '1970-01-01T00:00:01Z', True),
+            ('1970-01-01T10:00:00Z', '1970-01-01T06:00:00-04:00', True),  # EDT is 4 hrs behind UTC
+        ]
+    )
+    def test_time_zones(
+            self,
+            left: str, right: str, expected: bool):
+        left = Timestamp.from_str(left)
+        right = Timestamp.from_str(right)
+        assert (left == right) == expected
+
+    @pytest.mark.parametrize(
+        'dt, seconds, nanos',
+        [
+            ('1970-01-01T00:00:00Z', 0, 0),
+            ('1970-01-01T04:00:00+04:00', 0, 0),
+        ]
+    )
+    def test_from_str(
+            self,
+            dt: str, seconds: int, nanos: int,
+    ):
+        ts = Timestamp.from_str(dt)
+
+        assert ts.seconds == seconds
+        assert ts.nanos == nanos
+
+    @pytest.mark.parametrize(
+        'seconds, nanos, expected',
+        [
+            (0, 0, '1970-01-01T00:00:00Z'),
+            (10, 0, '1970-01-01T00:00:10Z'),
+            (-10, 0, '1969-12-31T23:59:50Z'),
+            (-10, 0, '1969-12-31T23:59:50Z'),
+        ]
+    )
+    def test_as_str(
+            self,
+            seconds: int, nanos: int, expected: str):
+        ts = Timestamp(seconds, nanos)
+        assert ts.as_str() == expected
+
+    def test_nanos(self):
+        ts = Timestamp(10, 10_000)
+        assert ts.seconds == 10
+        assert ts.nanos == 10_000
+
+        assert ts.as_str() == '1970-01-01T00:00:10Z'
+        assert ts.as_str(fmt='%Y-%m-%dT%H:%M:%S.%fZ') == '1970-01-01T00:00:10.000010Z'