<SYSTEM_TASK:>
Given the following text description, write Python code to implement the functionality described below step by step
<END_TASK>
<USER_TASK:>
Description:
 Overview of the Settings Attribute
OpenPNM objects all include a settings attribute which contains certain information used by OpenPNM. The best example is the algorithm classes, which often require numerous settings such as number of iterations and tolerance for iterative calculations.  This tutorial will provide an overview of how these settings work, both from the user perspective as well as for developers.
Step1:  Normal Usage
This section is relevant to users of OpenPNM, while the next section is more relevant to developers
Let's look an algorithm that has numerous settings
Step2:  We can see that many default settings are already present by printing the settings attribute
Step3:  We can override these settings manually
Step4:  We could also have updated these settings when creating the algorithm object by passing in a set of arguments.  This can be in the form of a dictionary
Step5:  Or as a 'dataclass' style, which is how things are done behind the scenes in OpenPNM as described in the section
Step6:  One new feature on OpenPNM V3 is that the datatype of some settings is enforced.  For instance the 'prefix' setting must be a str, otherwise an error is raised
Step7:  OpenPNM uses the traits package to control this behavior, which will be explained in more detail in the next section.
Advanced Usage
The following sections are probably only relevant if you plan to do some development in OpenPN
In the previous section we saw how to define settings, as well as the data-type protections of some settings.  In this section we'll demonstrate this mechanism in more detail.
OpenPNM has two settings related classes
Step8:  Now we can print s to inspect the settings.  We'll see some default values for things that were not initialized like a, while b is the specified value.
Step9:  The traits package enforces the datatype of each of these attributes
Step10:  Let's look at the attribute protection in action again
Step11:  The traits package also enforces the type of values we can put into the list stored in d
Step12:  The first one works because we specified a list of strings, while the second fails because it is attempting to write an integer. 
Also, we can't accidentally overwrite an attribute that is supposed to be a list with a scalar
Step13:  Gotcha With the HasTraits Class
When defining a set of custom settings using the HasTraits or SettingsData class, you MUST specify a type for each attribute value.  If not then it is essentially ignored.
Step14:  However, if you create a custom class from a basic python object it will work
Step15:  The SettingsAttr Class
The problem with the HasTraits class is that there is are lot of helper methods attached to it.  This means that when we use the autocomplete functionality of our favorite IDEs (spyder and jupyter), we will have a hard time finding the attributes we set amongst the noise. For this reason we have created a wrapper class called SettingsAttr which works as follows
Step16:  Importantly only the the user-created attributes show up, which can be test using the dir() command
Step17:  SettingsAttr has as few additional features.  You can add a new batch of settings after instantiation as follows
Step18:  We can see the updated value of a, as well as the newly added e.  Because e contained an integer (6), the datatype of e will be forced to remain an integer
Step19:  Note that the _update method begins with an underscore.  This prevents it from appearing in the autocomplete menu to ensure it stays clean.
For the sake of completeness, it should also be mentioned that the CustomSettings object which was passed to the SettingsAttr constructor was stored under _settings.  The SettingsAttr class has overloaded __getattr__ and __setattr__ methods which dispatch the values to the _settings attribute
Step20:  Another aspect to keep in mind is that the _settings attribute is a HasTraits object.  This means that all values added to the settings must have an enforced datatype.  This is done on the fly, based on the type of value received.  For instance, once you set an attribute to string for instance, its type is set
Step22:  Adding Documentation to a SettingsData and SettingsAttr Class
One the main reasons for using a dataclass style object for holding settings is so that docstrings for each attribute can be defined and explained
Step23:  Note that this docstring was written when we defined DocumentedSettingsData subclass and it attached to it, but we'll be interacting with the SettingsAttr class.  When a SettingsAttr is created is adopts the docstring of the received settings object.  This can be either a proper SettingsData/HasTraits class or a basic dataclass style object.  The docstring can only be set on initialization though, so any new attributes that are created by adding values to the object (i.e. D.zz_top = 'awesome') will not be documented.
Step26:  This machinery was designed with the idea of inheriting docstrings using the docrep package.  The following illustrates not only how the SettingsData class can be subclassed to add new settings (e.g. from GenericTransport to ReactiveTransport), but also how to use the hightly under-rated docrep package to also inherit the docstrings
Step27:  And we can also see that max_iter was added to the values of name and id_num on the parent class
Step28:  Again, as mentioned above, this inherited docstring is adopted by the SettingsAttr
Step29:  Attaching to an OpenPNM Object
The SettingsAttr wrapper class is so named because it is meant to be an attribute (i.e. attr) on OpenPNM objects.  These attached to the settings attribute
Step30:  OpenPNM declares SettingsData classes with each file where class is defined, then this is attached upon initialization.  This is illustrated below
Step31:  Or with some additional user-defined settings and overrides
<ASSISTANT_TASK:>
Python Code:
import openpnm as op
pn = op.network.Cubic([4, 4,])
geo = op.geometry.SpheresAndCylinders(network=pn, pores=pn.Ps, throats=pn.Ts)
air = op.phases.Air(network=pn)
phys = op.physics.Basic(network=pn, phase=air, geometry=geo)
Explanation: Overview of the Settings Attribute
OpenPNM objects all include a settings attribute which contains certain information used by OpenPNM. The best example is the algorithm classes, which often require numerous settings such as number of iterations and tolerance for iterative calculations.  This tutorial will provide an overview of how these settings work, both from the user perspective as well as for developers.
End of explanation
alg = op.algorithms.ReactiveTransport(network=pn, phase=air)
Explanation: Normal Usage
This section is relevant to users of OpenPNM, while the next section is more relevant to developers
Let's look an algorithm that has numerous settings:
End of explanation
print(alg.sets)
Explanation: We can see that many default settings are already present by printing the settings attribute:
End of explanation
alg.sets.prefix = 'rxn'
print(alg.sets)
Explanation: We can override these settings manually:
End of explanation
s = {"prefix": "rxn"}
alg = op.algorithms.ReactiveTransport(network=pn, phase=air, settings=s)
print(alg.sets)
Explanation: We could also have updated these settings when creating the algorithm object by passing in a set of arguments.  This can be in the form of a dictionary:
End of explanation
class MySettings:
    prefix = 'rxn'
# alg = op.algorithms.ReactiveTransport(network=pn, phase=air, settings=MySettings())
# print(alg.sets)
Explanation: Or as a 'dataclass' style, which is how things are done behind the scenes in OpenPNM as described in the section:
End of explanation
from traits.api import TraitError
try:
    alg.sets.phase = 1
except TraitError as e:
    print(e)
Explanation: One new feature on OpenPNM V3 is that the datatype of some settings is enforced.  For instance the 'prefix' setting must be a str, otherwise an error is raised:
End of explanation
from openpnm.utils import SettingsData, SettingsAttr
from traits.api import Int, Str, Float, List, Set
class CustomSettings(SettingsData):
    a = Int()
    b = Float(4.4)
    c = Set()
    d = List(Str)
    
s = CustomSettings()
Explanation: OpenPNM uses the traits package to control this behavior, which will be explained in more detail in the next section.
Advanced Usage
The following sections are probably only relevant if you plan to do some development in OpenPN
In the previous section we saw how to define settings, as well as the data-type protections of some settings.  In this section we'll demonstrate this mechanism in more detail.
OpenPNM has two settings related classes:  SettingsData and SettingsAttr.  The first is a subclass of the HasTraits class from the traits package. It preceeded the Python dataclass by many years and offers far more functionality.  For our purposes the main difference is that dataclasses allow developers to specify the type of attributes (i.e. obj.a must be an int), but these are only enforced during object creation. Once the object is made, any value can be assigned to a.  The traits package offers the same functionality but also enforces the type of a for all subsequent assignments.  We saw this in action in the previous section when we tried to assign an integer to alg.sets.prefix. 
The SettingsData and HasTraits Classes
Let's dissect this process:
End of explanation
print(s)
Explanation: Now we can print s to inspect the settings.  We'll see some default values for things that were not initialized like a, while b is the specified value.
End of explanation
s.a = 2
s.b = 5.5
print(s)
Explanation: The traits package enforces the datatype of each of these attributes:
End of explanation
try:
    s.a = 1.1
except TraitError as e:
    print(e)
Explanation: Let's look at the attribute protection in action again:
End of explanation
s.d.append('item')
try:
    s.d.append(100)
except TraitError as e:
    print(e)
Explanation: The traits package also enforces the type of values we can put into the list stored in d:
End of explanation
try:
    s.d = 5
except TraitError as e:
    print(e)
Explanation: The first one works because we specified a list of strings, while the second fails because it is attempting to write an integer. 
Also, we can't accidentally overwrite an attribute that is supposed to be a list with a scalar:
End of explanation
class MySettings(SettingsData):
    a = Int(1)
    b = 2
mysets = MySettings()
print(mysets)
Explanation: Gotcha With the HasTraits Class
When defining a set of custom settings using the HasTraits or SettingsData class, you MUST specify a type for each attribute value.  If not then it is essentially ignored.
End of explanation
class MySettings:
    a = 1
    b = 2
mysets = MySettings()
print(mysets.a, mysets.b)
Explanation: However, if you create a custom class from a basic python object it will work:
End of explanation
S = SettingsAttr(s)
print(S)
Explanation: The SettingsAttr Class
The problem with the HasTraits class is that there is are lot of helper methods attached to it.  This means that when we use the autocomplete functionality of our favorite IDEs (spyder and jupyter), we will have a hard time finding the attributes we set amongst the noise. For this reason we have created a wrapper class called SettingsAttr which works as follows:
End of explanation
dir(S)
Explanation: Importantly only the the user-created attributes show up, which can be test using the dir() command:
End of explanation
s_new = {'a': 5, 'e': 6}
S._update(s_new)
print(S)
Explanation: SettingsAttr has as few additional features.  You can add a new batch of settings after instantiation as follows:
End of explanation
try:
    S.e = 5.5
except TraitError as e:
    print(e)
Explanation: We can see the updated value of a, as well as the newly added e.  Because e contained an integer (6), the datatype of e will be forced to remain an integer:
End of explanation
S.d is S._settings.d
Explanation: Note that the _update method begins with an underscore.  This prevents it from appearing in the autocomplete menu to ensure it stays clean.
For the sake of completeness, it should also be mentioned that the CustomSettings object which was passed to the SettingsAttr constructor was stored under _settings.  The SettingsAttr class has overloaded __getattr__ and __setattr__ methods which dispatch the values to the _settings attribute:
End of explanation
S.f = 'a string'
try:
    S.f = 1.0
except TraitError as e:
    print(e)
print(S)
Explanation: Another aspect to keep in mind is that the _settings attribute is a HasTraits object.  This means that all values added to the settings must have an enforced datatype.  This is done on the fly, based on the type of value received.  For instance, once you set an attribute to string for instance, its type is set:
End of explanation
class DocumentedSettingsData(SettingsData):
    r
    A class that holds the following settings.
    
    Parameters
    ----------
    name : str
        The name of the object
    id_num : int
        The id number of the object
    
    name = Str('foo')
    id_num = Int(0)
d = DocumentedSettingsData()
print(d.__doc__)
Explanation: Adding Documentation to a SettingsData and SettingsAttr Class
One the main reasons for using a dataclass style object for holding settings is so that docstrings for each attribute can be defined and explained:
End of explanation
D = SettingsAttr(d)
print(D.__doc__)
Explanation: Note that this docstring was written when we defined DocumentedSettingsData subclass and it attached to it, but we'll be interacting with the SettingsAttr class.  When a SettingsAttr is created is adopts the docstring of the received settings object.  This can be either a proper SettingsData/HasTraits class or a basic dataclass style object.  The docstring can only be set on initialization though, so any new attributes that are created by adding values to the object (i.e. D.zz_top = 'awesome') will not be documented.
End of explanation
import docrep
docstr = docrep.DocstringProcessor()
# This docorator tells docrep to fetch the docstring from this class and make it available elsewhere:
@docstr.get_sections(base='DocumentSettingsData', sections=['Parameters'])
class DocumentedSettingsData(SettingsData):
    r
    A class that holds the following settings.
    
    Parameters
    ----------
    name : str
        The name of the object
    id_num : int
        The id number of the object
    
    name = Str('foo')
    id_num = Int(0)
# This tells docrep to parse this docstring and insert text at the %
@docstr.dedent
class ChildSettingsData(DocumentedSettingsData):
    r
    A subclass of DocumentedSettingsData that holds some addtional settings
    
    Parameters
    ----------
    %(DocumentSettingsData.parameters)s
    max_iter : int
        The maximum number of iterations to do
    
    max_iter = Int(10)
    
E = ChildSettingsData()
print(E.__doc__)
Explanation: This machinery was designed with the idea of inheriting docstrings using the docrep package.  The following illustrates not only how the SettingsData class can be subclassed to add new settings (e.g. from GenericTransport to ReactiveTransport), but also how to use the hightly under-rated docrep package to also inherit the docstrings:
End of explanation
E.visible_traits()
Explanation: And we can also see that max_iter was added to the values of name and id_num on the parent class:
End of explanation
S = SettingsAttr(E)
print(S.__doc__)
Explanation: Again, as mentioned above, this inherited docstring is adopted by the SettingsAttr:
End of explanation
isinstance(alg.sets, SettingsAttr)
Explanation: Attaching to an OpenPNM Object
The SettingsAttr wrapper class is so named because it is meant to be an attribute (i.e. attr) on OpenPNM objects.  These attached to the settings attribute:
End of explanation
class SpecificSettings(SettingsData):
    a = Int(4)
    
class SomeAlg:
    def __init__(self, settings={}, **kwargs):
        self.settings = SettingsAttr(SpecificSettings())
        self.settings._update(settings)
        
alg = SomeAlg()
print(alg.settings)
Explanation: OpenPNM declares SettingsData classes with each file where class is defined, then this is attached upon initialization.  This is illustrated below:
End of explanation
s = {'name': 'bob', 'a': 3}
alg2 = SomeAlg(settings=s)
print(alg2.settings)
Explanation: Or with some additional user-defined settings and overrides:
End of explanation
<END_TASK>