DUNE DAQ Command Object Creation

Table of Contents

Introduction

DUNE DAQ command objects must rigorously follow a defined schema. From this schema, C++, Python and other artifacts are generated using a command line tool or its Python package both called moo. With the module, moo may generate Python object types ("otypes") from moo "oschema" (object schema) files. These Python types are strongly typed (for Python) and help to guide an editor to produce objects which are valid by construction. The types are also instrumented with docstrings and other things to assist the human using an interactive REPL, such as ipython or ptipython, to interactively discover how to use them.

This document goes through some of the features of moo otypes. It starts with some initial setup instructions and a "quick start" to make some commands while putting aside any explanation. The remaining sections give information useful in making your own command objects. They describe how to load schema and from schema create Python types and interrogate those types to learn how to make instances. It then gives a tour of the schema classes with example types from appfwk schema. Penultimate section describes the construction utilities used in the quick start and finally some description is given of the internals of the Python type generator.

Some moo nomenclature is used in this document. A moo schema consists of instances of class in a fixed set of schema classes. These are boolean, string, number, enum, sequence, record and any. An instance of one of these classes is called a type which is known as by its type name and which is a leaf in a namespace located through a type path. In the schema, a type is a (meta) data structure which may then be represented in or transformed to native programming language forms. For example, in C++ a type is used to generate code for a C++ struct. In Python, the type is used to dynamically create a Python class. From the native linage types we may finally an instance of a type aka an object.

We will also describe data types expressed in the native programming language as "Plain Old Data" or POD.

Thus, we may say: a string schema class type Email produces an object which holds the POD string "bv@bnl.gov".

Setup

We need to install moo and some nice REPL (here, we recommend ptipython)

$ python3.8 -m venv venv
$ source venv/bin/activate
$ pip install git+git://github.com/brettviren/moo.git#egg=moo
$ pip install ptipython

Let's also clone this repository and that of appfwk.

We clone appfwk here in order to access its schema/ directory. If you have appfwk installed as a package you may already have access to this directory and can then skip the explicit cloning. Set MOO_MODEL_PATH if needed.

$ git clone https://github.com/dune-daq/appfwk.git
$ export MOO_MODEL_PATH=$(pwd)/appfwk/schema
$ git clone https://github.com/brettviren/dune-daq-repl.git

From now on we will run the Python REPL from inside dune-daq-repl/

$ cd dune-daq-repl/
$ ptipython
Python 3.8.0 (default, Oct 28 2019, 16:14:01) 
Type 'copyright', 'credits' or 'license' for more information
IPython 7.18.1 -- An enhanced Interactive Python. Type '?' for help.

In [1]: import dd
                  ddcmd 
                  ddrepl

Here, in a poor, uncolored ASCII facsimile, shows some of the nice REPL features that ptipython provides. The last two lines are a drop-down menu of modules matching an initial dd typing. Ctrl-n to highlight ddcmd and Enter to select.

This document is made in Emacs orgmode using ob-ipython. It may be reproduced after also running: pip install ipython jupyter and editing ddcmd.org from an instance of emacs started from the Python environment.

Quick Start

This section shows how to create one particular sequence of command objects while leaving aside any explanation. The remainder of the document will fill in the background information.

import moo
import ddcmd

for mod in ["cmd", "FakeDataProducerDAQModule", "FakeDataConsumerDAQModule"]:
    moo.otypes.load_types(f"appfwk-{mod}-schema.jsonnet")

from dunedaq.appfwk import cmd
from dunedaq.appfwk import fakedataproducerdaqmodule as fdp
from dunedaq.appfwk import fakedataconsumerdaqmodule as fdc

queues = cmd.QueueSpecs([
  cmd.QueueSpec(kind='StdDeQueue', inst="hose", capacity=10)])
modules = cmd.ModSpecs([
  cmd.ModSpec(inst="source", plugin="FakeDataProducerDAQModule",
    data=cmd.QueueInfo(inst="hose", name="output", dir="output")),
  cmd.ModSpec(inst="sink", plugin="FakeDataConsumerDAQModule",
    data=cmd.QueueInfo(inst="host", name="input", dir="input"))])
myinit = ddcmd.init(queues=queues, modules=modules)

myconf = ddcmd.conf([
  cmd.AddressedCmd(match="source", data=fdp.Conf()),
  cmd.AddressedCmd(match="sink", data=fdc.Conf())])

[myinit, myconf]
[<record Command, fields: {id, data}>, <record Command, fields: {id, data}>]

This produces two command objects, one to initialize and one to configure. These can be fed to the appfwk app daq_application. Other commands exist (eg, start and stop).

Read on for explanation on what this quick start is doing and how to learn to use the Python types to make your own command objects.

Loading types

To get Python types with which we may construct objects we first load a schema:

import moo
types = moo.otypes.load_types("appfwk-cmd-schema.jsonnet")
types
{'dunedaq.appfwk.cmd.Match': dunedaq.appfwk.cmd.Match,
'dunedaq.appfwk.cmd.Data': dunedaq.appfwk.cmd.Data,
'dunedaq.appfwk.cmd.AddressedCmd': dunedaq.appfwk.cmd.AddressedCmd,
'dunedaq.appfwk.cmd.AddressedCmds': dunedaq.appfwk.cmd.AddressedCmds,
'dunedaq.appfwk.cmd.CmdId': dunedaq.appfwk.cmd.CmdId,
'dunedaq.appfwk.cmd.CmdObj': dunedaq.appfwk.cmd.CmdObj,
'dunedaq.appfwk.cmd.Command': dunedaq.appfwk.cmd.Command,
'dunedaq.appfwk.cmd.QueueKind': dunedaq.appfwk.cmd.QueueKind,
'dunedaq.appfwk.cmd.InstName': dunedaq.appfwk.cmd.InstName,
'dunedaq.appfwk.cmd.QueueCapacity': dunedaq.appfwk.cmd.QueueCapacity,
'dunedaq.appfwk.cmd.QueueSpec': dunedaq.appfwk.cmd.QueueSpec,
'dunedaq.appfwk.cmd.QueueSpecs': dunedaq.appfwk.cmd.QueueSpecs,
'dunedaq.appfwk.cmd.PluginName': dunedaq.appfwk.cmd.PluginName,
'dunedaq.appfwk.cmd.ModSpec': dunedaq.appfwk.cmd.ModSpec,
'dunedaq.appfwk.cmd.ModSpecs': dunedaq.appfwk.cmd.ModSpecs,
'dunedaq.appfwk.cmd.Init': dunedaq.appfwk.cmd.Init,
'dunedaq.appfwk.cmd.Label': dunedaq.appfwk.cmd.Label,
'dunedaq.appfwk.cmd.QueueDir': dunedaq.appfwk.cmd.QueueDir,
'dunedaq.appfwk.cmd.QueueInfo': dunedaq.appfwk.cmd.QueueInfo,
'dunedaq.appfwk.cmd.QueueInfos': dunedaq.appfwk.cmd.QueueInfos,
'dunedaq.appfwk.cmd.ModInit': dunedaq.appfwk.cmd.ModInit}

The schema variable holds a data structure representing a moo "oschema" (object schema) which is then turned into a set of Python types by the make_otypes() function. It happens to return a dictionary mapping fully-qualified type name to type (class) and these types are also made into first-class Python types in a module hierarchy.

Interrogating types

As an example, let's look at one of the most trivial types in the appfwk schema, a CmdId.

The CmdId is meant to name a command type. It currently allows a fairly free string. At some point it may allow to take one of an enumerated list.

First, we import the Python namespace holding all our types and make an instance of CmdId. We'll see how we know what we had to provide next.

from dunedaq.appfwk import cmd
cid = cmd.CmdId("init")
(cid, cid.pod())
(<string CmdId: init>, 'init')

We can see the representation of the instance of a CmdId shows us:

  • the oschema "class" is string
  • the type of string is CmdId
  • the value held by the instance is init

And, the value itself can be pulled out as Plain Old Data (POD) via the .pod() method. Instances of all "otypes" have this method.

In ptipython (and ipython) we may query the Python class representing the type for hints on how to use it. For example, here is what ptipython shows for cmd.CmdId?

In [11]: cmd.CmdId?
Init signature: cmd.CmdId(val)
Docstring:     
String type CmdId.
- pattern: ^[a-zA-Z][a-zA-Z0-9_]*$
- format: None
The command name.  FIXME: this should be an enum!

The documentation talks about a pattern and a format which are relevant for types of the schema class string. If provided, these constraints are applied when we try to either construct or update() an instance of the string schema class type. Let's try to violate the pattern constraint:

cid.update("very bad")
...
ValidationError: 'very bad' does not match '^[a-zA-Z][a-zA-Z0-9_]*$'
...
ValueError: format mismatch for string CmdId

Typically, a ValueError will be raised if a constraint is violated. In the case of string schema class types, you may also see intermediate ValidationErrors which come from the JSON Schema validation code that does the heavy lifting.

Examples

Here we go through some examples of making instances of types of different schema classes. We saw string above so will skip it.

Boolean

The simplest schema class is boolean. The appfwk schema currently includes no examples. We can however, create one from whole cloth by dropping down to moo.

import moo
moo.otypes.make_type(schema="boolean", name="MyBool", doc="Example Boolean", path="test.junk")
from test.junk import MyBool
MyBool(True)
<boolean MyBool: True>

And, let the REPL show some docs:

In [17]: MyBool?
Init signature: MyBool(val)
Docstring:     
A MyBool boolean
Example Boolean

With moo boolean types we may set them to various "truthy" and "falsie" values. For example the POD string "yes" will register as True.

Numbers

Every type of appfwk queue has a "capacity" which we will provide from instances of the type QueueCapacity which is of the schema class number.

In [12]: cmd.QueueCapacity?
Init signature: cmd.QueueCapacity(val)
Docstring:      A number type QueueCapacity

While the moo schema class number supports defining constraints, more work is needed for their actual implementation both at the representation level and in the generated Python "otypes".

Strings

We saw an example of a string schema class type CmdId above in the section Interrogating types so we will not repeat that here.

Enums

An enum is a string-like type that may take on a value from a predetermined set. The defining schema may specify a default value from this set or if not given then the first value is taken as default.

The appfwk schema uses an enum type QueueKind to identity a "kind" of queue. It's documentation:

In [21]: cmd.QueueKind?
Init signature: cmd.QueueKind(val=None)
Docstring:     
Enum type QueueKind [['Unknown', 'StdDeQueue', 'FollySPSCQueue', 'FollyMPMCQueue']]
The kinds (types/classes) of queues

We can see the default value in action with a default construction:

cmd.QueueKind()
<enum QueueKind: 'Unknown' of ['Unknown', 'StdDeQueue', 'FollySPSCQueue', 'FollyMPMCQueue']>

Let's try to break it:

In [25]: cmd.QueueKind("MagicQueue")
...
ValueError: illegal enum QueueKind value MagicQueue

Records

The Python type which corresponds to a moo oschema class record has some extra functionality to allow instances to mimic Python data objects while assuring type constraints.

One simple record in the appfwk command schema is QueueSpec. Let's let the REPL tell us about it:

In [19]: cmd.QueueSpec?
Init signature:
cmd.QueueSpec(
    *args,
    kind: dunedaq.appfwk.cmd.QueueKind = 'Unknown',
    inst: dunedaq.appfwk.cmd.InstName = None,
    capacity: dunedaq.appfwk.cmd.QueueCapacity = None,
)
Docstring:     
Record type QueueSpec with fields: "kind", "inst", "capacity"

Queue specification

We see that kind is a QueueKind, introduced previously, and that the default value for this enum has been forwarded to be a default value for the field. This is a special handling for enum fields. The schema could have set a field default different than the enum default.

The appfwk schema happens to not provide defaults for the other two fields: inst of a string type InstName and capacity a number type and also introduced previously. We must provide at least these latter two at some point to have a fully constructed instance of the type QueueSpec. Let's start by making an instance with just the capacity record field given.

qs = cmd.QueueSpec(capacity=10)
(qs, qs.kind, qs.capacity)
(<record QueueSpec, fields: {kind, inst, capacity}>, 'Unknown', 10)

Accessing an attribute of an instance of a record type results in POD. Likewise, one may use POD to set an attribute as long as the value is consistent with the associated field type. One may also provide an instance of a consistent schema class type in setting an attribute.

If we try to get the qs.inst attribute or call qs.pod() (which will try to access all required attributes) at this time we will get an error as the record is not yet complete.

print(qs.inst)
...
AttributeError: no such attribute inst

Let's set that last attribute now:

qs.inst = "hose"
qs.pod()
{'kind': 'Unknown', 'inst': 'hose', 'capacity': 10}

Records follow Postel's law by being "generous in what they accept and strict in what they produce". This allows us to delay filling in all the required fields and relying on defaults where desired. As we saw, strictness comes in when accessing the values of the record type instance.

However, when explicitly setting a value, the record type instance will be strict so that no "illegal" values will be accepted. The type InstName can not be just any string but must be short identifier such as might be a valid variable name in a C-like syntax. Let's try to break it:

In [30]: qs.inst = "very wrong"
...
ValidationError: 'very wrong' does not match '^[a-zA-Z][a-zA-Z0-9_]*$'
...
ValueError: format mismatch for string InstName

Like with the string example above, setting the field of a record which is itself a string needs to be done properly.

Sequences

A sequence schema class models an ordered array or list of elements which are themselves each of the same type. The appfwk schema has a type called QueueSpecs which, as the pluralized name implies, is a sequence of record type QueueSpec which we learned above. Our REPL tells us:

In [31]: cmd.QueueSpecs?
Init signature: cmd.QueueSpecs(val)
Docstring:     
A QueueSpecs sequence holding type dunedaq.appfwk.cmd.QueueSpec.
A sequence of QueueSpec

Let's make one with as single item of type QueueSpec.

qs = cmd.QueueSpec(capacity=10, inst="hose")
qss = cmd.QueueSpecs([qs])
(qss, qss.pod())
(<sequence QueueSpecs 1:[dunedaq.appfwk.cmd.QueueSpec]>,
[{'kind': 'Unknown', 'inst': 'hose', 'capacity': 10}])

A sequence type instance may be empty:

qss = cmd.QueueSpecs([])
(qss, qss.pod())
(<sequence QueueSpecs 0:[dunedaq.appfwk.cmd.QueueSpec]>, [])

And, later updated:

qss.update([qs])
(qss, qss.pod())
(<sequence QueueSpecs 1:[dunedaq.appfwk.cmd.QueueSpec]>,
[{'kind': 'Unknown', 'inst': 'hose', 'capacity': 10}])

Some changes may still be made to sequence schema class such as setting constraints on the size of the sequence. The Python sequence types may also be modified to allow list-like mutation operations such as append(). For now, a sequence type in Python treats its data as atomic.

Anys

An any type is a simple form of a dynamic type. It is kind of like a void* in C or a pointer to a polymorphic base class in C++ but there is a twist. Two different any types are not compatible. That is, an instance of any type Data1 can not be used to construct or update an instance of any type Data2.

Another twist is that an instance of an any type may only be constructed or updated with any other non-any "otype" (or an instance of the same any type) but not any POD. Despite that, an instance of an any type can still be turned into POD with the usual .pod() method.

An any type is used in a few places in the appfwk command schema because the internals of appfwk require layers of type erasure so that different types of commands and of modules can be accommodated while retaining a generalized schema. Without any wholly new schema would have to be developed for every possible command type and every possible combination of module implementations.

In the appfwk schema, we currently have one catch-all any type called Data.

The current version of appfwk schema uses a single any type called Data for every point of type erasure. Future versions of the schema may define multiple any types in order to differentiate the different semantics. For example, to differentiate Command.data from Init.data.

In [7]: cmd.Data?
Init signature: cmd.Data(val)
Docstring:     
The any type Data.

Can hold any oschema type except Any types that are not Data.

An opaque object holding lower layer substructure

As above, we must use some schema type instance to set an any type instance. Here we use CmdId for simplicity. In a "real" command object, the any type Data tends to hold some larger record type.

d1 = cmd.Data(cmd.CmdId("conf"))
d1.update(cmd.QueueKind("StdDeQueue"))
d1.pod()
'StdDeQueue'

Let's try to break it:

In [23]: d2 = cmd.Data("this should not work")
...
ValueError: any type Data requires oschema type

In [25]: moo.otypes.make_type(schema="any", name="Other", doc="Another any", path="test.junk")
In [26]: from test.junk import Other
In [28]: d3 = Other(d1)
...
ValueError: cross any updates not allowed

Construct Commands

The schema corresponding to the appfwk commands with IDs init and conf are simple but take some work to construct. Besides setting values which reside in fixed structure, their layers of type erasure (any types) allow for whole substructure to be provided in a parameterized manner. We'll go through these two command objects separately.

Regardless of the type of command object, the top level structure is provided by a simple schema with an ID and an any:

In [29]: cmd.Command?
Init signature:
cmd.Command(
    *args,
    id: dunedaq.appfwk.cmd.CmdId = None,
    data: dunedaq.appfwk.cmd.Data = None,
)
Docstring:     
Record type Command with fields: "id", "data"

Top-level command object structure

The data field should be populated with a subobject of a type that depends on the value of id.

The init command

The init command provides all the information to take a fully generic appfwk application into one that has all of its queues and modules constructed. The queues are also fully configured by this init command. The modules receive notice of the init and may even require custom init information but such is best left for conf.

The substructure for Command.data for an init command is a:

In [30]: cmd.Init?
Init signature:
cmd.Init(
    *args,
    queues: dunedaq.appfwk.cmd.QueueSpecs = None,
    modules: dunedaq.appfwk.cmd.ModSpecs = None,
)
Docstring:     
Record type Init with fields: "queues", "modules"

The app-level init command data object struction

The ModSpecs are new:

In [32]: cmd.ModSpec?
Init signature:
cmd.ModSpec(
    *args,
    plugin: dunedaq.appfwk.cmd.PluginName = None,
    inst: dunedaq.appfwk.cmd.InstName = None,
    data: dunedaq.appfwk.cmd.Data = None,
)
Docstring:     
Record type ModSpec with fields: "plugin", "inst", "data"

Module specification

The any Data type is seen again here. It is used, in principle, to inject a module implementation specific object. This is a "just in case" feature. Most modules should get their custom information as part of their conf command object.

The simplest, and most boring, init command:

myinit = cmd.Command(id=cmd.CmdId("init"), data=cmd.Init(queues=[], modules=[]))
myinit.pod()
{'id': 'init', 'data': {'queues': [], 'modules': []}}

That code has some redundancies so we can replace it with a little helper:

import ddcmd
myinit = ddcmd.init()
myinit.pod()
{'id': 'init', 'data': {'queues': [], 'modules': []}}

As such, this init command would initialize an "empty" and rather useless app instance. The next two sections describe how to make the queue and module sequences with a working example.

Queue initialization

An appfwk queue is initialized with a QueueSpec which we introduced already. The only new thing to know is that a queue inst field (instance name) must used be also in two places in the module init objects. That is, one or two modules need to know the queue name in order to attach to its two endpoints.

Keeping things simple, we make just one queue named "host":

queues = cmd.QueueSpecs([
  cmd.QueueSpec(kind='StdDeQueue', inst="hose", capacity=10)])
queues.pod()
[{'kind': 'StdDeQueue', 'inst': 'hose', 'capacity': 10}]

Module initialized

An appfwk module is initialized with a ModSpec, introduced above. Here, we give initialization for the two "fake" test modules provided by appfwk/test/.

modules = cmd.ModSpecs([
  cmd.ModSpec(inst="source", plugin="FakeDataProducerDAQModule",
    data=cmd.QueueInfo(inst="hose", name="output", dir="output")),
  cmd.ModSpec(inst="sink", plugin="FakeDataConsumerDAQModule",
    data=cmd.QueueInfo(inst="host", name="input", dir="input"))])
modules.pod()
[{'plugin': 'FakeDataProducerDAQModule',
'inst': 'source',
'data': {'inst': 'hose', 'name': 'output', 'dir': 'output'}},
{'plugin': 'FakeDataConsumerDAQModule',
'inst': 'sink',
'data': {'inst': 'host', 'name': 'input', 'dir': 'input'}}]

Putting init together

Let's use the little ddcmd.init() helper again to build a full init command.

import ddcmd
myinit = ddcmd.init(queues=queues, modules=modules)
myinit.pod()
{'id': 'init',
'data': {'queues': [{'kind': 'StdDeQueue', 'inst': 'hose', 'capacity': 10}],
'modules': [{'plugin': 'FakeDataProducerDAQModule',
'inst': 'source',
'data': {'inst': 'hose', 'name': 'output', 'dir': 'output'}},
{'plugin': 'FakeDataConsumerDAQModule',
'inst': 'sink',
'data': {'inst': 'host', 'name': 'input', 'dir': 'input'}}]}}

The conf command

The conf command's any typed .data holds module-level configuration (sub) objects in a .modules attribute. This attribute is of types AddressedCmds which is a sequence of AddressedCmd.

In [14]: cmd.AddressedCmd?
Init signature:
cmd.AddressedCmd(
    *args,
    match: dunedaq.appfwk.cmd.Match = None,
    data: dunedaq.appfwk.cmd.Data = None,
)
Docstring:     
Record type AddressedCmd with fields: "match", "data"

General, non-init module-level command data structure

The Match type is a string to provide a regular expression. All that match will receive the object held in the any type .data. For this conf command we will explicitly match the module names.

Currently Match allows any string. Future work may improve this type by providing a pattern which would be a regex that matches regex, dawg. For now, it relies on care.

A skeleton for making the conf command object might look like:

cmd.AddressedCmds([
  cmd.AddressedCmd(match="source", data=...),
  cmd.AddressedCmd(match="sink", data=...)])

We now turn to making the module-specific configuration objects.

Module configuration objects

Each appfwk module implementation (ie, the C++ subclass of DAQModule) defines its own configuration object schema.

Help on defining module configuration object schema can be found in appfwk/schema/README.org or online here.

As we did with the general appfwk level schema we must load the module-level schema so that we may use corresponding Python types:

import moo
types = dict()
for mod in ["FakeDataConsumerDAQModule", "FakeDataProducerDAQModule"]:
    moo.otypes.load_types(f"appfwk-{mod}-schema.jsonnet")
    types.update(these)
types
{'dunedaq.appfwk.fakedataconsumerdaqmodule.Size': dunedaq.appfwk.fakedataconsumerdaqmodule.Size,
'dunedaq.appfwk.fakedataconsumerdaqmodule.Count': dunedaq.appfwk.fakedataconsumerdaqmodule.Count,
'dunedaq.appfwk.fakedataconsumerdaqmodule.Conf': dunedaq.appfwk.fakedataconsumerdaqmodule.Conf}

Note the different path attribute of the two schema are reflected into the Python module namespace.

from dunedaq.appfwk import fakedataproducerdaqmodule as fdp
from dunedaq.appfwk import fakedataconsumerdaqmodule as fdc
fdc.Conf()
<record Conf, fields: {nIntsPerVector, starting_int, ending_int, queue_timeout_ms}>

For both modules, the Conf type may be used to create the module-level configuration object and both are similarly structured. Here is the producer:

In [24]: fdp.Conf?
Init signature:
fdp.Conf(
    *args,
    nIntsPerVector: dunedaq.appfwk.fdp.Size = 10,
    starting_int: dunedaq.appfwk.fdp.Count = -4,
    ending_int: dunedaq.appfwk.fdp.Count = 14,
    queue_timeout_ms: dunedaq.appfwk.fdp.Count = 100,
    wait_between_sends_ms: dunedaq.appfwk.fdp.Count = 1000,
)
Docstring:     
Record type Conf with fields: "nIntsPerVector", "starting_int", "ending_int", "queue_timeout_ms", "wait_between_sends_ms"

Fake Data Producer DAQ Module Configuration

Both happen to provide defaults so that their default construction results in a fully specified record type instance. We can thus fill in the skeleton simply:

ac = cmd.Command(id="conf", data=cmd.AddressedCmds([
  cmd.AddressedCmd(match="source", data=fdp.Conf()),
  cmd.AddressedCmd(match="sink", data=fdc.Conf())]))
ac.pod()
{'id': 'conf',
'data': [{'match': 'source',
'data': {'nIntsPerVector': 10,
'starting_int': -4,
'ending_int': 14,
'queue_timeout_ms': 100,
'wait_between_sends_ms': 1000}},
{'match': 'sink',
'data': {'nIntsPerVector': 10,
'starting_int': -4,
'ending_int': 14,
'queue_timeout_ms': 100}}]}

Like with init we may provide a little helper:

myconf = ddcmd.conf([
  cmd.AddressedCmd(match="source", data=fdp.Conf()),
  cmd.AddressedCmd(match="sink", data=fdc.Conf())])
myconf
<record Command, fields: {id, data}>

Command delivery

Various means for delivery of commands to appfwk applications are in development. An initial approach is to use the same interactive REPL that we used to build the command objects. This is described in ddrepl.html.

Author: Brett Viren Brett Viren

Created: 2021-01-08 Fri 16:50

Validate