August'24: Kamaelia is in maintenance mode and will recieve periodic updates, about twice a year, primarily targeted around Python 3 and ecosystem compatibility. PRs are always welcome. Latest Release: 1.14.32 (2024/3/24)

Inheritable Default Values

Making systemic specialisation more declarative

This feature was introduced in Axon 1.6.0 (Kamaelia 0.6.0)

The idea behind this is to allow a more compact, declarative way of defining more complex Kamaelia systems. It stemmed initially from an observation that two of us wanted to do this:

def ReusableSocketAddrServer(port=100,
                       protocol=EchoProtocol):
    return ServerCore(protocol=protocol,
                      port=port,
                      socketOptions=(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1))


Specifically we noticed that we were creating a fair number of factory functions which only really differed based on on value. The problem we have here is that this is relatively fragile. Specifically, what happens if ServerCore adds in extra arguments - do we also update ReusableSocketAddrServer ? What if we don't, does someone else come along and duplicate our code in order to support those extra arguments? OK, well we can handle this in python if we use the **argd syntax. If we do that, we can do that this way:

def ReusableSocketAddr(**argd):
    argd_local = dict(argd)
    argd_local["socketOptions"] = (socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    return ServerCore(**argd_local)

Whilst that's maybe more reflective of what we wanted to do, it now looks rather obscured. We then realised that there is a useful side effect of python namespaces that we can take advantage of, which is this:

self.attribute first of all looks inside the object self. If this is not found...

self.attribute looks inside self.__class__ . If that's not found,

self.attribute looks inside the parents of self.__class__ all the way up.

This means that if we change the base component class to do this:

def __init__(self, **argd):
     self.__dict__.update(argd)

Then we can do this:

class ReusableSocketAttrServer(ServerCore):
    socketOptions=(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)  

This has a number of advantages over the factory method:

For example, suppose the component we're using creates components as a part of it's operation, and we want to add tracing to these. Normally that code would default to looking like this:

from import Kamaelia.Internet.TCPServer import TCPServer

class ServerCore(...):
...

    def initialiseComponent(self):
...
            myPLS = TCPServer(listenport=self.listenport)

Hypothetical File: ExamplePatch.py


Replacing TCPServer here with our TracedTCPServer would have to look like this:

from Hypothetical import TracedTCPServer
import ExamplePatch
ExamplePatch.TCPServer = TracedTCPServer

Hypothetical File: ExamplePatchUser.py

The downside of this as well is that this is not particularly targetted, and leads to the situation where it would be more natural to create a copy of the code for traced versions. This misses one of the handy features of what inheritance gives us, which is controlled duplication of functionality with little twists of functionality. By comparison, with inheritable default values, we can do this instead:

from import Kamaelia.Internet.TCPServer import TCPServer

class ServerCore(...):
    TCPS = TCPServer
    def initialiseComponent(self):
...
            TCPServer = self.TCPS
            myPLS = TCPServer(listenport=self.listenport)

Hypothetical File: ExamplePatch.py

However, when someone wants to create a traced version they can be far more to the point. Suppose they have code that looks like this:
ServerCore(port = 1500, protocol=WhizzyProto1).run()

They can change it over to use the hypothetical TracedTCPServer like this:

from Hypothetical import TracedTCPServer

ServerCore(port = 1500, protocol=WhizzyProto1, TCPS=TracedTCPServer).run()

Hypothetical File: ExamplePatchUser.py


Not only that, but if they wanted to define this as a common thing they wanted to do, they could do this:

class TracedServerCore(ServerCore):
     TCPS = TracedTCPServer

Which would then get used:

TracedServerCore(port = 1500, protocol=WhizzyProto1).run()

Whilst this seems theoretical, it was bandied about as a possible idea for nearly a year until it suddenly became extremely useful - specifically in the [greylisting code](/KamaeliaGrey.html to allow it to use inactivity timers on connected sockets, as well as configuration of protocol handlers in a declarative manner:

class GreylistServer(ServerCore):
    logfile = config["greylist_log"]
    debuglogfile = config["greylist_debuglog"]
    socketOptions=(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    port = config["port"]
    class TCPS(TCPServer):
        CSA = NoActivityTimeout(ConnectedSocketAdeapter, timeout=config["inactivity_timeout"], debug=False)
    class protocol(GreyListingPolicy):
        servername = config["servername"]
        serverid = config["serverid"]
        smtp_ip = config["smtp_ip"]
        smtp_port = config["smtp_port"]
        allowed_senders = config["allowed_senders"]
        allowed_sender_nets = config["allowed_sender_nets"] # Yes, only class C network style
        allowed_domains = config["allowed_domains"]
        whitelisted_triples = config["whitelisted_triples"]
        whitelisted_nonstandard_triples = config["whitelisted_nonstandard_triples"]

Since then the idiom has been found to be useful in other scenarios.

It's also worth noticing that this also means that the ServerCore code could be repurposed to work with servers that act in a similar way to TCPServer. For example, a hypothetical ConnectionBasedUDPListener, could be created which operated in a similar manner to TCPServer, and then reused as follows:

class UDPServerCore(ServerCore):
     TCPS = ConnectionBasedUDPListener

Thereby making it as simple to create connection oriented UDP servers as it would be to create TCPServers. The only difference between the two being lack of guarantee of ordering or delivery.

Downsides?

The clear downside of this is that the signature of your component's generally initialiser becomes this:

def __init__(self, **argd):
 ...

This in turn puts a greater onus on you as a component writer to document the arguments to your component in a clearer manner.

But WHY???

This is an implicit thing. In Kamaelia when syntactic sugar gets added (and that's precisely what this is), one of the most common aims is to aim to move towards a declarative reusable syntax. After all, if you consider that the starting point was this:

def ReusableSocketAddrServer(port=100,
                       protocol=EchoProtocol):
    return ServerCore(protocol=protocol,
                      port=port,
                      socketOptions=(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1))

You're actually starting off with something very fragile, especially considering that if ServerCore changes it's configuration, you have to change this factory function as well.

Secondly, the next approach for dealing with changing __init__ialiser arguments is to use **argd, you then end up with something which is a bit perl-ish in structure, and obfuscates what's really going on:

def ReusableSocketAddr(**argd):
    argd_local = dict(argd)
    argd_local["socketOptions"] = (socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    return ServerCore(**argd_local)

However, by switching over to an inheritable default value approach you gain something which is declarative, picks up new default values from the base class cleanly and makes it much clearer that actually this returns objects of this type, just preconfigured in a particular way:

class ReusableSocketAttrServer(ServerCore):
    socketOptions=(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)  

So, by aiming for a syntactic sugar that's declarative in nature, we're hopefully making the intent in the system clearer.

Summary


If you want to provide default values for parameters for your components, and please do, providing them in the form of inheritable default values will make your components more useful to others. You don't have to do this, and if you find it odd, simply don't do this. However if you do, it would be appreciated by the users of your code.