Usage

The aim of audobject is to provide a base class, namely audobject.Object, which allows it to convert an object to a YAML string and re-instantiate the object from it.

Object class

Let’s create a class that derives from audobject.Object.

import audobject


__version__ = '1.0.0'  # pretend we have a package version

class MyObject(audobject.Object):

    def __init__(
            self,
            string: str,
            *,
            num_repeat: int = 1,
    ):
        self.string = string
        self.num_repeat = num_repeat

    def __str__(self) -> str:
        return ' '.join([self.string] * self.num_repeat)

Now we instantiate an object and print it.

o = MyObject('hello object!', num_repeat=2)
print(o)
hello object! hello object!

As expected we see that string is repeated num_repeat times. But since we derived from audobject.Object we get some additional functionality.

For instance, we can get a dictionary with the arguments the object was initialized with.

o.arguments
{'string': 'hello object!', 'num_repeat': 2}

Or a dictionary that also stores module, object name and package version.

o_dict = o.to_dict()
print(o_dict)
{'$__main__.MyObject==1.0.0': {'string': 'hello object!', 'num_repeat': 2}}

And we can re-instantiate the object from it.

o2 = audobject.from_dict(o_dict)
print(o2)
hello object! hello object!

We can also convert it to YAML.

o_yaml = o.to_yaml_s()
print(o_yaml)
$__main__.MyObject==1.0.0:
  string: hello object!
  num_repeat: 2

And create the object from YAML.

o3 = audobject.from_yaml_s(o_yaml)
print(o3)
hello object! hello object!

If we want, we can override arguments when we instantiate an object.

o4 = audobject.from_yaml_s(
    o_yaml,
    override_args={
        'string': 'I was set to a different value!'
    }
)
print(o4)
I was set to a different value! I was set to a different value!

Or save an object to disk and re-instantiate it from there.

file = 'my.yaml'
o.to_yaml(file)
o5 = audobject.from_yaml(file)
print(o5)
hello object! hello object!

Object ID

Every object has an ID.

o = MyObject('I am unique!', num_repeat=2)
print(o.id)
10e6cf43-5246-94cd-04d2-35d51ac22bb2

Objects with exact same arguments share the same ID.

o2 = MyObject('I am unique!', num_repeat=2)
print(o.id == o2.id)
True

When an object is serialized the ID does not change.

o3 = audobject.from_yaml_s(o.to_yaml_s())
print(o3.id == o.id)
True

Objects with different arguments get different IDs.

o4 = MyObject('I am different!', num_repeat=2)
print(o.id == o4.id)
False

Malformed objects

In the constructor of MyObject we have assigned every argument to an attribute with the same name. This ensures that we can re-instantiate the object from YAML. Let’s create a class where we don’t follow this rule.

class MyBadObject(audobject.Object):

    def __init__(
            self,
            string: str,
            *,
            num_repeat: int = 1,
    ):
        self.msg = string
        self.repeat = num_repeat

    def __str__(self) -> str:
        return ' '.join([self.msg] * self.repeat)

At a first glance, everything works as expected.

bad = MyBadObject('test', num_repeat=2)
print(bad)
test test

But if we try to instantiate the object from YAML, we’ll get an error.

bad_yaml = bad.to_yaml_s()
bad2 = audobject.from_yaml_s(bad_yaml)
print(bad2)
---------------------------------------------------------------------------
RuntimeError                              Traceback (most recent call last)
Cell In[17], line 1
----> 1 bad_yaml = bad.to_yaml_s()
      2 bad2 = audobject.from_yaml_s(bad_yaml)
      3 print(bad2)

File ~/work/audobject/audobject/audobject/core/object.py:366, in Object.to_yaml_s(self, include_version)
    342 def to_yaml_s(
    343         self,
    344         *,
    345         include_version: bool = True,
    346 ) -> str:
    347     r"""Convert object to YAML string.
    348 
    349     Args:
   (...)
    364 
    365     """  # noqa: E501
--> 366     return yaml.dump(self.to_dict(include_version=include_version))

File ~/work/audobject/audobject/audobject/core/object.py:309, in Object.to_dict(self, include_version, flatten, root)
    275 r"""Converts object to a dictionary.
    276 
    277 Includes items from :attr:`audobject.Object.arguments`.
   (...)
    298 
    299 """  # noqa: E501
    300 name = utils.create_class_key(self.__class__, include_version)
    302 d = {
    303     key: self._encode_variable(
    304         key,
    305         value,
    306         include_version,
    307         root,
    308     )
--> 309     for key, value in self.arguments.items()
    310 }
    312 return Object._flatten(d) if flatten else {name: d}

File ~/work/audobject/audobject/audobject/core/object.py:80, in Object.arguments(self)
     78         missing.append(name)
     79 if len(missing) > 0:
---> 80     raise RuntimeError(
     81         'Arguments '
     82         f'{missing} '
     83         'of '
     84         f'{self.__class__} '
     85         'not assigned to attributes of same name.'
     86     )
     88 # check borrowed attributes
     89 for key, value in borrowed.items():

RuntimeError: Arguments ['string', 'num_repeat'] of <class '__main__.MyBadObject'> not assigned to attributes of same name.

However, in the next section we’ll learn that it’s possible to hide arguments. If we hide an argument, we don’t have to set it to an attribute of the same name.

Hidden arguments

Hidden arguments are arguments that are not serialized.

Note

Only arguments with a default value can be hidden.

Let’s introduce a new argument verbose and hide it with the audobject.init_decorator() decorator.

class MyObjectWithHiddenArgument(audobject.Object):

    @audobject.init_decorator(
        hide=['verbose'],
    )
    def __init__(
            self,
            string: str,
            *,
            num_repeat: int = 1,
            verbose: bool = False,
    ):
        self.string = string
        self.num_repeat = num_repeat
        self.debug = verbose  # 'verbose' is hidden, so we can set it to a different name

    def __str__(self) -> str:
        if self.debug:
            print('LOG: print message')
        return ' '.join([self.string] * self.num_repeat)

If we set verbose=True, debug message are printed.

o = MyObjectWithHiddenArgument(
    'hello object!',
    num_repeat=3,
    verbose=True,
)
print(o)
LOG: print message
hello object! hello object! hello object!

But since verbose is a hidden argument, it is not stored to YAML.

o_yaml = o.to_yaml_s()
print(o_yaml)
$__main__.MyObjectWithHiddenArgument==1.0.0:
  string: hello object!
  num_repeat: 3

That means when we re-instantiate the object, verbose will be set to its default value (False) and we won’t see debug messages.

o2 = audobject.from_yaml_s(o_yaml)
print(o2)
hello object! hello object! hello object!

However, we can still set verbose to True when we load the object.

o3 = audobject.from_yaml_s(
    o_yaml,
    override_args={
        'verbose': True,
    }
)
print(o3)
LOG: print message
hello object! hello object! hello object!

Note that hidden arguments are not taken into account for the UID.

print(o2.id)
print(o3.id)
b3579494-fb4f-35f3-62be-1ceedd745e38
b3579494-fb4f-35f3-62be-1ceedd745e38

It is possible to get a list of hidden arguments.

o3.hidden_arguments
['verbose']

Borrowed arguments

It is possible to borrow arguments from an instance attribute. For instance, here we borrow the attributes x, y, and z from self.point and a dictionary self.d. Using borrowed arguments it becomes possible to add properties with the name of an argument.

class Point:

    def __init__(
            self,
            x: int,
            y: int,
    ):
        self.x = x
        self.y = y


class ObjectWithBorrowedArguments(audobject.Object):

    @audobject.init_decorator(
        borrow={
            'x': 'point',
            'y': 'point',
            'z': 'd',
        },
    )
    def __init__(
            self,
            x: int,
            y: int,
            z: int,
    ):
        self.point = Point(x, y)
        self.d = {'z': z}

    @property
    def z(self):  # property sharing the name of an argument
        return self.d['z']

o = ObjectWithBorrowedArguments(0, 1, 2)
print(o.to_yaml_s())
$__main__.ObjectWithBorrowedArguments==1.0.0:
  x: 0
  y: 1
  z: 2

Object with kwargs

If the __init__ function accepts **kwargs, we have to pass them to the base class. This is necessary, since we cannot figure out the names of additional keyword arguments from the signature of the function.

class MyObjectWithKwargs(audobject.Object):

    def __init__(
            self,
            string: str,
            **kwargs,
    ):
        super().__init__(**kwargs)  # inform base class about keyword arguments

        self.string = string
        self.num_repeat = kwargs['num_repeat'] if 'num_repeat' in kwargs else 1

    def __str__(self) -> str:
        return ' '.join([self.string] * self.num_repeat)

o = MyObjectWithKwargs('I have kwargs', num_repeat=3)
print(o)
I have kwargs I have kwargs I have kwargs

When we serialize the object, we see that keyword argument num_repeat will be included.

o_yaml = o.to_yaml_s()
print(o_yaml)
$__main__.MyObjectWithKwargs==1.0.0:
  string: I have kwargs
  num_repeat: 3

Object as argument

It is possible to have arguments of type audobject.Object. For instance, we can define the following class.

class MySuperObject(audobject.Object):

    def __init__(
            self,
            obj: MyObject,
    ):
        self.obj = obj

    def __str__(self) -> str:
        return f'[{str(self.obj)}]'

And initialize it with an instance of MyObject.

o = MyObject('eat me!')
w = MySuperObject(o)
print(w)
[eat me!]

This translates to the following YAML string.

w_yaml = w.to_yaml_s()
print(w_yaml)
$__main__.MySuperObject==1.0.0:
  obj:
    $__main__.MyObject==1.0.0:
      string: eat me!
      num_repeat: 1

From which we can re-instantiate the object.

w2 = audobject.from_yaml_s(w_yaml)
print(w2)
[eat me!]

Value resolver

As long as the type of an arguments is one of:

  • bool

  • datetime.datetime

  • dict

  • float

  • int

  • list

  • None

  • Object

  • str

it is ensured that we get a clean YAML file.

Other types may be encoded using the !!python/object tag and clutter the YAML syntax.

To illustrate this, let’s use an instance of timedelta.

from datetime import timedelta


class MyDeltaObject(audobject.Object):

    def __init__(
            self,
            delta: timedelta,
    ):
        self.delta = delta

    def __str__(self) -> str:
        return str(self.delta)

As before, we can create an instance and print it.

delta = timedelta(
    days=50,
    seconds=27,
    microseconds=10,
    milliseconds=29000,
    minutes=5,
    hours=8,
    weeks=2
)
d = MyDeltaObject(delta)
print(d)
64 days, 8:05:56.000010

But if we convert it to YAML, we’ll see a warning.

d_yaml = d.to_yaml_s()
print(d_yaml)
$__main__.MyDeltaObject==1.0.0:
  delta: !!python/object/apply:datetime.timedelta
  - 64
  - 29156
  - 10

/home/runner/work/audobject/audobject/audobject/core/object.py:414: RuntimeWarning: No default encoding exists for type '<class 'datetime.timedelta'>'. Consider to register a custom resolver.
  warnings.warn(

And in fact, we can see that the delta value is encoded with a !!python/object tag, which is followed by some plain numbers. Only after looking into the documentation of timedelta we can guess that they probably encode days, seconds, and microseconds.

We can avoid this by providing a custom resolver that defines how a timedelta object should be encoded and decoded.

class DeltaResolver(audobject.resolver.Base):

    def decode(self, value: dict) -> timedelta:
        return timedelta(
            days=value['days'],
            seconds=value['seconds'],
            microseconds=value['microseconds'],
        )

    def encode(self, value: timedelta) -> dict:
        return {
            'days': value.days,
            'seconds': value.seconds,
            'microseconds': value.microseconds,
        }

    def encode_type(self):
        return dict

To apply our custom resolver to the delta argument, we pass it to the audobject.init_decorator() decorator of the __init__ function.

class MyResolvedDeltaObject(audobject.Object):

    @audobject.init_decorator(
        resolvers={'delta': DeltaResolver},
    )
    def __init__(
            self,
            delta: timedelta,
    ):
        self.delta = delta

    def __str__(self) -> str:
        return str(self.delta)

Now, we don’t get a warning and the !!python/object tag has disappeared.

d = MyResolvedDeltaObject(delta)
d_yaml = d.to_yaml_s()
print(d_yaml)
$__main__.MyResolvedDeltaObject==1.0.0:
  delta:
    days: 64
    seconds: 29156
    microseconds: 10

Resolve file paths

Portability is a core feature of audobject. Assume we have an object that takes as argument the path to a file. When we serialize the object we want to make sure that:

  1. we store the file path relative to the YAML file

  2. the path is correctly expanded when we re-instantiate the object

This can be achieved using audobject.resolver.FilePath.

class MyObjectWithFile(audobject.Object):

    @audobject.init_decorator(
        resolvers={
            'path': audobject.resolver.FilePath,  # ensure portability
        }
    )
    def __init__(
            self,
            path: str,
    ):
        self.path = path

    def read(self):  # print path and content
        print(self.path)
        with open(self.path, 'r') as fp:
            print(fp.readlines())

Here, we create a file and pass it to the object.

import os
import audeer

root = 'root'

res_path = os.path.join(root, 're', 'source.txt')  # root/re/source.txt
audeer.mkdir(os.path.dirname(res_path))
with open(res_path, 'w') as fp:
    fp.write('You found me!')

o = MyObjectWithFile(res_path)
o.read()
root/re/source.txt
['You found me!']

When we serialize the object, the path is stored relative to the directory of the YAML file.

import yaml


yaml_path = os.path.join(root, 'yaml', 'object.yaml')  # root/yaml/object.yaml
o.to_yaml(yaml_path)

with open(yaml_path, 'r') as fp:
    content = yaml.load(fp, Loader=yaml.Loader)
content
{'$__main__.MyObjectWithFile==1.0.0': {'path': '../re/source.txt'}}

When we re-instantiate the object the path gets expanded again.

o2 = audobject.from_yaml(yaml_path)
o2.read()
/home/runner/work/audobject/audobject/docs/tmp/root/re/source.txt
['You found me!']

This will also work from another location. Note that we have to move all referenced files as well, as their relative location to the YAML file must not change.

import shutil

new_root = os.path.join('some', 'where', 'else')
shutil.move(root, new_root)

yaml_path_new = os.path.join(new_root, 'yaml', 'object.yaml')
o3 = audobject.from_yaml(yaml_path_new)
o3.read()
/home/runner/work/audobject/audobject/docs/tmp/some/where/else/re/source.txt
['You found me!']

Serialize functions

To serialize functions, a special resolver audobject.resolver.Function can be used. It encodes the source code of the function and dynamically creates the function during decoding.

The following class takes as arguments a function with two parameters.

import typing


class MyObjectWithFunction(audobject.Object):

    @audobject.init_decorator(
        resolvers={
            'func': audobject.resolver.Function,
        }
    )
    def __init__(
            self,
            func: typing.Callable[[int, int], int],
    ):
        self.func = func

    def __call__(self, a: int, b: int):
        return self.func(a, b)

Here, we initialize an object with a function that sums up the two parameters.

def add(a, b):
    return a + b


o = MyObjectWithFunction(add)
o(1, 2)
3

When we serialize the object, the definition of our function is stored in plain text.

o_yaml = o.to_yaml_s()
print(o_yaml)
$__main__.MyObjectWithFunction==1.0.0:
  func: "def add(a, b):\n    return a + b\n"

From which the function can be dynamically initialized when the object is recreated.

o2 = audobject.from_yaml_s(o_yaml)
o2(2, 3)
5

It also works for lambda expressions.

o3 = MyObjectWithFunction(lambda a, b: a * b)

o3_yaml = o3.to_yaml_s()
print(o3_yaml)
$__main__.MyObjectWithFunction==1.0.0:
  func: 'lambda a, b: a * b'

o4 = audobject.from_yaml_s(o3_yaml)
o4(2, 3)
6

Instead of a function, we can also pass a callable object that derives from audobject.Object.

class MyCallableObject(audobject.Object):

    def __init__(
        self,
        n: int,
    ):
        self.n = n

    def __call__(self, a: int, b: int):
        return (a + b) * self.n


a_callable_object = MyCallableObject(2)
o5 = MyObjectWithFunction(a_callable_object)
o5(4, 5)
18

In that case, the YAML representation is store instead of the function code.

o5_yaml = o5.to_yaml_s()
print(o5_yaml)
$__main__.MyObjectWithFunction==1.0.0:
  func:
    $__main__.MyCallableObject==1.0.0:
      n: 2

And we can still restore the original object.

o6 = audobject.from_yaml_s(o5_yaml)
o6(4, 5)
18

Warning

Since the described mechanism offers a way to execute arbitrary Python code, you should never load objects from a source you do not trust!

Flat dictionary

Let’s create a class that takes as input a string, a list and a dictionary.

class MyListDictObject(audobject.Object):

    def __init__(
            self,
            a_str: str,
            a_list: list,
            a_dict: dict,
    ):
        self.a_str = a_str
        self.a_list = a_list
        self.a_dict = a_dict

And initialize an object.

o = MyListDictObject(
    a_str='test',
    a_list=[1, '2', o],
    a_dict={'pi': 3.1416, 'e': 2.71828},
)
o.to_dict()
{'$__main__.MyListDictObject==1.0.0': {'a_str': 'test',
  'a_list': [1,
   '2',
   {'$__main__.MyObjectWithFunction==1.0.0': {'func': 'def add(a, b):\n    return a + b\n'}}],
  'a_dict': {'pi': 3.1416, 'e': 2.71828}}}

As expected, the dictionary of the object looks pretty nested. This is not always handy, e.g. if we try to store the object to a audfactory.Lookup table, this would not work. Therefore, in can sometimes be useful to get a flatten version of the dictionary.

o.to_dict(flatten=True)
{'a_str': 'test',
 'a_list.0': 1,
 'a_list.1': '2',
 'a_list.2.func': 'def add(a, b):\n    return a + b\n',
 'a_dict.pi': 3.1416,
 'a_dict.e': 2.71828}

However, it’s important to note that it’s not possible to re-instantiate an object from a flattened dictionary.

Versioning

When an object is converted to YAML the package version is stored. But what happens if we later load the object with a different package version?

Let’s create another instance of MyObject.

o = MyObject('I am a 1.0.0!', num_repeat=2)
print(o)
I am a 1.0.0! I am a 1.0.0!

And convert it to YAML.

o_yaml = o.to_yaml_s()
print(o_yaml)
$__main__.MyObject==1.0.0:
  string: I am a 1.0.0!
  num_repeat: 2

Loading it with a newer version of the package works without problems.

__version__ = '1.1.0'

o2 = audobject.from_yaml_s(o_yaml)
print(o2)
I am a 1.0.0! I am a 1.0.0!

But if we load it with an older version, a warning will be shown. We can force it to show no warning at all, or always show a warning when the package version does not match by adjusting audobject.config.PACKAGE_MISMATCH_WARN_LEVEL.

__version__ = '0.9.0'

o3 = audobject.from_yaml_s(o_yaml)
print(o3)
I am a 1.0.0! I am a 1.0.0!
/home/runner/work/audobject/audobject/audobject/core/utils.py:90: RuntimeWarning: Instantiating __main__.MyObject from version '1.0.0' when using version '0.9.0'.
  warnings.warn(

Now we pretend that we update the package to 2.0.0. It includes a new version of MyObject, with a slightly changed __str__ function.

__version__ = '2.0.0'

class MyObject(audobject.Object):

    def __init__(
            self,
            string: str,
            *,
            num_repeat: int = 1,
    ):
        self.string = string
        self.num_repeat = num_repeat

    def __str__(self) -> str:
        return ','.join([self.string] * self.num_repeat)

Since the signature of the constructor has not changed, the object will be created without problems. However, when we print the object the strings are now separated by comma.

o3 = audobject.from_yaml_s(o_yaml)
print(o3)
I am a 1.0.0!,I am a 1.0.0!

In the next release, we decide to introduce an argument that let the user set a custom delimiter.

__version__ = '2.1.0'

class MyObject(audobject.Object):

    def __init__(
            self,
            string: str,
            delimiter: str,
            *,
            num_repeat: int = 1,
    ):
        self.string = string
        self.delimiter = delimiter
        self.num_repeat = num_repeat

    def __str__(self) -> str:
        return ' '.join([self.string] * self.num_repeat)

If we now instantiate the object, we will get an error, because we are missing a value for the new argument.

audobject.from_yaml_s(o_yaml)
---------------------------------------------------------------------------
RuntimeError                              Traceback (most recent call last)
Cell In[62], line 1
----> 1 audobject.from_yaml_s(o_yaml)

File ~/work/audobject/audobject/audobject/core/api.py:176, in from_yaml_s(yaml_string, auto_install, override_args, **kwargs)
    173     for key, value in kwargs.items():
    174         override_args[key] = value
--> 176 return from_dict(
    177     yaml.load(yaml_string, yaml.Loader),
    178     auto_install=auto_install,
    179     override_args=override_args,
    180 )

File ~/work/audobject/audobject/audobject/core/api.py:69, in from_dict(d, root, auto_install, override_args, **kwargs)
     62 for key, value in d[name].items():
     63     params[key] = _decode_value(
     64         value,
     65         auto_install,
     66         override_args,
     67     )
---> 69 object, params = utils.get_object(
     70     cls,
     71     version,
     72     installed_version,
     73     params,
     74     root,
     75     override_args,
     76 )
     78 if isinstance(object, Object):
     79     # create attribute to signal that object was loaded
     80     object.__dict__[define.OBJECT_LOADED] = None

File ~/work/audobject/audobject/audobject/core/utils.py:152, in get_object(cls, version, installed_version, params, root, override_args)
    150 missing_required_params = list(required_params - set(params))
    151 if len(missing_required_params) > 0:
--> 152     raise RuntimeError(
    153         f"Missing mandatory arguments "
    154         f"{missing_required_params} "
    155         f"while instantiating {cls} from "
    156         f"version '{version}' when using "
    157         f"version '{installed_version}'."
    158     )
    160 # check for missing optional arguments
    161 optional_params = set([
    162     p.name for p in signature.parameters.values()
    163     if p.default != inspect.Parameter.empty and p.name not in [
    164         'self', 'args', 'kwargs',
    165     ]
    166 ])

RuntimeError: Missing mandatory arguments ['delimiter'] while instantiating <class '__main__.MyObject'> from version '1.0.0' when using version '2.1.0'.

Since we want to be backward compatible, we decide to release a bug fix, where we initialize the new argument with a default value.

__version__ = '2.1.1'

class MyObject(audobject.Object):

    def __init__(
            self,
            string: str,
            delimiter: str = ',',
            *,
            num_repeat: int = 1,
    ):
        self.string = string
        self.delimiter = delimiter
        self.num_repeat = num_repeat

    def __str__(self) -> str:
        return ' '.join([self.string] * self.num_repeat)

And in fact, it successfully creates the object again. It works, because it now has a default value for the missing argument.

o4 = audobject.from_yaml_s(o_yaml)
print(o4)
I am a 1.0.0! I am a 1.0.0!

Finally, we will do it the other way round. Create an object with version 2.1.1.

o5 = MyObject('I am a 2.1.1!', num_repeat=2)
print(o5)
I am a 2.1.1! I am a 2.1.1!

Convert it to YAML.

o5_yaml = o5.to_yaml_s()
print(o5_yaml)
$__main__.MyObject==2.1.1:
  string: I am a 2.1.1!
  delimiter: ','
  num_repeat: 2

And load it with 1.0.0.

__version__ = '1.0.0'

class MyObject(audobject.Object):

    def __init__(
            self,
            string: str,
            *,
            num_repeat: int = 1,
    ):
        self.string = string
        self.num_repeat = num_repeat

    def __str__(self) -> str:
        return ' '.join([self.string] * self.num_repeat)

o6 = audobject.from_yaml_s(o5_yaml)
print(o6)
I am a 2.1.1! I am a 2.1.1!
/home/runner/work/audobject/audobject/audobject/core/utils.py:90: RuntimeWarning: Instantiating __main__.MyObject from version '2.1.1' when using version '1.0.0'.
  warnings.warn(
/home/runner/work/audobject/audobject/audobject/core/utils.py:186: RuntimeWarning: Ignoring arguments ['delimiter'] while instantiating <class '__main__.MyObject'> from version '2.1.1' when using version '1.0.0'.
  warnings.warn(

In fact, it works, too. However, a warning is given that an argument was ignored.

Dictionary

audobject.Dictionary implements a audobject.Object that can used like a dictionary.

d = audobject.Dictionary(
    string='I am a dictionary!',
    pi=3.14159265359,
)
print(d)
$audobject.core.dictionary.Dictionary:
  string: I am a dictionary!
  pi: 3.14159265359

We can use [] notation to access the values of the dictionary.

d['string'] = 'Still a dictionary!'
d['new'] = None
print(d)
$audobject.core.dictionary.Dictionary:
  string: Still a dictionary!
  pi: 3.14159265359
  new: null

And update from another dictionary.

d2 = audobject.Dictionary(
    string='I will be a dictionary forever!',
    object=MyObject('Hey, I am an object.'),
)
d.update(d2)
print(d)
$audobject.core.dictionary.Dictionary:
  string: I will be a dictionary forever!
  pi: 3.14159265359
  new: null
  object:
    $__main__.MyObject:
      string: Hey, I am an object.
      num_repeat: 1

And we can read/write the dictionary from/to a file.

file = 'dict.yaml'
d.to_yaml(file)
d3 = audobject.from_yaml(file)
print(d3)
$audobject.core.dictionary.Dictionary:
  string: I will be a dictionary forever!
  pi: 3.14159265359
  new: null
  object:
    $__main__.MyObject:
      string: Hey, I am an object.
      num_repeat: 1

Parameters

You have probably used argparse before. It is a package to write user-friendly command-line interfaces that allows the user to define what arguments are required, what are the expected types, default values, etc.

The idea behind audobject.Parameters is similar (in fact, we will see that i even has an interface to argparse). audobject.Parameters is basically a collection of named values that control the behaviour of an object. Each value is wrapped in a audobject.Parameter object and has a specific type and default value, possibly one of a set of choices. And it can be bound a parameter to a specific versions.

Let’s pick up the previous example and define two parameters. A parameter that holds a string.

string = audobject.Parameter(
    value_type=str,
    description='the string we want to repeat',
    value='bar',
    choices=['bar', 'Bar', 'BAR'],
)
print(string)
$audobject.core.parameter.Parameter:
  value_type: str
  description: the string we want to repeat
  value: bar
  default_value: null
  choices:
  - bar
  - Bar
  - BAR
  version: null

And a parameter that defines how many times we want to repeat the string.

repeat = audobject.Parameter(
    value_type=int,
    description='the number of times we want to repeat',
    default_value=1,
)
print(repeat)
$audobject.core.parameter.Parameter:
  value_type: int
  description: the number of times we want to repeat
  value: 1
  default_value: 1
  choices: null
  version: null

Now we combine the two parameters into a list.

params = audobject.Parameters(
    string=string,
    num_repeat=repeat,
)
print(params)
Name        Value  Default  Choices                Description                            Version
----        -----  -------  -------                -----------                            -------
string      bar    None     ['bar', 'Bar', 'BAR']  the string we want to repeat           None   
num_repeat  1      1        None                   the number of times we want to repeat  None   

If we call the list, we get a dictionary of parameter names and values.

params()
{'string': 'bar', 'num_repeat': 1}

We can access the values of the parameters using . notation.

params.string = 'BAR'
params.num_repeat = 2
print(params)
Name        Value  Default  Choices                Description                            Version
----        -----  -------  -------                -----------                            -------
string      BAR    None     ['bar', 'Bar', 'BAR']  the string we want to repeat           None   
num_repeat  2      1        None                   the number of times we want to repeat  None   

If we try to assign a value that is not in choices, we will get an error.

params.string = 'par'
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[77], line 1
----> 1 params.string = 'par'

File ~/work/audobject/audobject/audobject/core/parameter.py:302, in Parameters.__setattr__(self, name, value)
    300 def __setattr__(self, name: str, value: typing.Any):  # noqa: D105
    301     p = self.__dict__[name]
--> 302     p.set_value(value)

File ~/work/audobject/audobject/audobject/core/parameter.py:126, in Parameter.set_value(self, value)
    113 def set_value(self, value: typing.Any):
    114     r"""Sets a new value.
    115 
    116     Applies additional checks, e.g. if value is of the expected type.
   (...)
    124 
    125     """
--> 126     self._check_value(value)
    127     self.value = value

File ~/work/audobject/audobject/audobject/core/parameter.py:137, in Parameter._check_value(self, value)
    132     raise TypeError(
    133         f"Invalid type '{type(value)}', "
    134         f"expected {self.value_type}."
    135     )
    136 if self.choices is not None and value not in self.choices:
--> 137     raise ValueError(
    138         f"Invalid value '{value}', "
    139         f"expected one of {self.choices}."
    140     )

ValueError: Invalid value 'par', expected one of ['bar', 'Bar', 'BAR'].

It is possible to assign a version (or a range of versions) to a parameter.

delim = audobject.Parameter(
    value_type=str,
    description='defines the delimiter',
    default_value=',',
    version='>=2.0.0,<3.0.0'
)
params['delimiter'] = delim
print(params)
Name        Value  Default  Choices                Description                            Version       
----        -----  -------  -------                -----------                            -------       
string      BAR    None     ['bar', 'Bar', 'BAR']  the string we want to repeat           None          
num_repeat  2      1        None                   the number of times we want to repeat  None          
delimiter   ,      ,        None                   defines the delimiter                  >=2.0.0,<3.0.0

We can check if a parameter is available for a specific version.

'1.0.0' in delim, '2.4.0' in delim
(False, True)

We can also filter a list of parameters by version.

params_v3 = params.filter_by_version('3.0.0')
print(params_v3)
Name        Value  Default  Choices                Description                            Version
----        -----  -------  -------                -----------                            -------
string      BAR    None     ['bar', 'Bar', 'BAR']  the string we want to repeat           None   
num_repeat  2      1        None                   the number of times we want to repeat  None   

Or add them to a command line interface.

import argparse


parser = argparse.ArgumentParser()
params.to_command_line(parser)
print(parser.format_help())
usage: ipykernel_launcher.py [-h] [--string {bar,Bar,BAR}]
                             [--num_repeat NUM_REPEAT] [--delimiter DELIMITER]

optional arguments:
  -h, --help            show this help message and exit
  --string {bar,Bar,BAR}
                        the string we want to repeat
  --num_repeat NUM_REPEAT
                        the number of times we want to repeat
  --delimiter DELIMITER
                        defines the delimiter (version: >=2.0.0,<3.0.0)

Or update the values from a command line interface.

args = parser.parse_args(
    args=['--string=Bar', '--delimiter=;']
)
params.from_command_line(args)
print(params)
Name        Value  Default  Choices                Description                            Version       
----        -----  -------  -------                -----------                            -------       
string      Bar    None     ['bar', 'Bar', 'BAR']  the string we want to repeat           None          
num_repeat  1      1        None                   the number of times we want to repeat  None          
delimiter   ;      ,        None                   defines the delimiter                  >=2.0.0,<3.0.0

It is possible to convert it into a file path that keeps track of the parameters.

params.to_path(sort=True)
'delimiter[;]/num_repeat[1]/string[Bar]'

Last but not least, we can read/write the parameters from/to a file.

file = 'params.yaml'
params.to_yaml(file)
params2 = audobject.from_yaml(file)
print(params2)
Name        Value  Default  Choices                Description                            Version       
----        -----  -------  -------                -----------                            -------       
string      Bar    None     ['bar', 'Bar', 'BAR']  the string we want to repeat           None          
num_repeat  1      1        None                   the number of times we want to repeat  None          
delimiter   ;      ,        None                   defines the delimiter                  >=2.0.0,<3.0.0