Advanced Usage
===============

Adding properties and restrictions to classes is a good way to preserve some desired functionality when classes are serialized so that all new instances of that class have the same property values.

Properties
----------

Three different types of properties are implemented in *pathology-image-features*. These are:

- **Annotation Properties** (strings which describe what a class or instance is)
- **Object Properties** (links between classes/instances and other classes/instances)
- **Datatype Properties** (assignments of data values to classes/instances)

Properties can be accessed for new instances of a class through it's attributes. Properties are automatically given an "alias" which (in most cases) is resolvable to a valid Python attribute name.
This alias is either the "rdfs:label" value for the property, the result of replacing the spaces in the "rdfs:label" value with underscores, or the final portion of the URI for the property after removing it's namespace.

For example, the property "is about" (IAO:0000136) can be accessed from an instance in the following ways:

.. code-block:: python

    print(f'{instance.is_about=}')
    print('------------------------')
    print(f'{instance.get_property("is about")=}')
    print('------------------------')
    print(f'{instance.get_property("http://purl.obolibrary.org/obo/IAO_0000136")=}')
    print('------------------------')


Object and Datatype Properties have a "range" which defines the type of value that can be assigned to them. This is where you can specify both Python data types (for Datatype properties) and class/instance types for Object Properties.
They also have a "domain" which defines which classes can use this property. Both domain and range can consist of either lists of classes or single classes.
New properties can be defined using the following:

.. code-block:: python

    from pathology_image_features.ontology.properties import ObjectProperty, AnnotationProperty, DatatypeProperty

    package_version = AnnotationProperty(iri = "http://example.org/package_version", definition = "The version of a package used.", name = "package version")
    user_login_name = DatatypeProperty(iri = "http://example.org/user_login_name", definition = "The login name of the user.", name = "user login name", range = str)

    # Where User and Group are defined classes elsewhere
    user_group = ObjectProperty(iri = "http://example.org/user_group", definition = "The group the user belongs to.", name = "user group", domain = User, range = Group)


After creating a new property, you can register it with the current graph using the 'register_property' method. This allows you to refer to it in class attributes and restrictions using it's alias.

.. code-block:: python

    from pathology_image_features.ontology import PIFO
    from pathology_image_features.ontology.restrictions import Only
    from somewhere import user_group, User, GroupA

    PIFO.register_property(user_group)

    class UserInGroupA(User):
        """A class which is represented by any user belonging to Group A."""
        equivalent_to = Only(on_property="user_group", on_class=GroupA)



Restrictions
------------

Restrictions are used to describe classes based on some property requirements. In *pathology-image-features*, the class attribute 'equivalent_to' is where restrictions are accessed and applied on the creation of new instances.

Builtin restriction types include:

- Some: Specifying that at least instances of this class must have at least one value for a property that matches a given class.
- Only: Specifying that only instances of this class may have a specified property/value combination.
- Cardinality -> MinCardinality, MaxCardinality, ExactCardinality: Specifying that an instance must have a minimum, maximum, or exact number of values for a given property.

Combinations of Restrictions can also be made using the "And", "Or", and "Not" classes.

.. code-block:: python

    from pathology_image_features.ontology.restrictions import And, Or, Not, Some, Only, MinCardinality, MaxCardinality, ExactCardinality

    class UserInGroup(User):
        """A class representing users which belong to at most one group."""
        equivalent_to = And([
            Some(on_property="user_group", on_class=Group), # Must have the user_group property (whose range is already Group so this is redundant)
            MaxCardinality(on_property="user_group", value = 1) # Must have at most one value for the user_group property
        ])

    # Since the property "user_group" has a defined domain and range, the "Some" restriction can be omitted.
    class UserInGroup(User):
        equivalent_to = MaxCardinality(on_property="user_group", value = 1)

    class UserInMultipleGroups(User):
        """A class representing users which belong to more than one group."""
        equivalent_to = MinCardinality(on_property="user_group", value = 2)


Different behavior can be registered for instance creation when a "equivalent_to" attribute is present for a class. In some cases, it may be desirable to raise an error if a new instance does not have a value for a property which is defined in a Restriction.

Restriction subclasses have a "on_missing" keyword argument on initialization which can be replaced with either "None" or a custom Callable to indicate what should be done if a property is not found for a given instance. By default, *pathology-image-features* will create a new instance of the "on_class" property and assign it to that instance if it is not present.

.. code-block:: python

    class UserInGroup(User):
        equivalent_to = MaxCardinality(on_property="user_group", value = 1, on_missing=None)

    # This will cause an error as no "user_group" value was provided to the instance
    new_user = UserInGroup()

    # This will not raise an error as the "user_group" value is specified for this instance
    new_user = UserInGroup(user_group=GroupA())

    def custom_on_missing(restriction, instance):
        """If you want to set a default value as "GroupA" for non-grouped UserInGroup instances."""
        default_val = GroupA()
        user_group.__set__(instance, default_val)

        setattr(
            instance.__class__,
            "user_group",
            property(
                lambda inst: user_group.__get__(inst),
                lambda inst, val: user_group.__set__(inst,val),
                lambda inst: user_group.__delete__(inst)
            )
        )

    class UserInGroup(User):
        equivalent_to = MaxCardinality(on_property="user_group", value = 1, on_missing=custom_on_missing)

    new_user = UserInGroup()
    print(f'{isinstance(new_user.user_group[0], GroupA)}') # Will print True
