Python like scripting

Nodlin embeds the starlark starlark ‘python’ interpreter for easy scripting. It is recognised as a simple, expressive and powerful language.
Specific extensions have been added to integrate with nodlin (e.g. creating new nodes, links etc) and provide access to the wider execution context (e.g. connected node detail, actions etc).
This page details specific extensions to the starlark language and examples of how to use in nodlin.
For further language reference detail, please refer to the Language Specification and Starlark in Go
Pre-declared variables
The following variables are pre-declared to provide access to the node detail, related nodes, and context of the nodlin request.
These pre-declared variables are reserved and cannot be used by users in named variables.
‘C’ or ‘context’
Context provides the detail regarding the request. This includes user and domain detail etc.
C.userID # the user who made request, a tuple of the form (userid, domain)
C.checkpoint() # creates a new checkpoint as part of the flow for any new operations (new actions or events raised in the script).
Checkpoints are key where there is a dependency between actions. For example, say you wanted to create a new node, and link to the current node. You cannot create a link unless both nodes exist. You only want to create this new node when the current operation is successful (for any changes made). Checkpoints allow for this sequence to be created:
- apply an action to create a new node,
- create a new checkpoint based on the success of the new node,
- create the new link between the current and the new node.
This checkpoint allows you to form a chain of operations that will only execute on success of a previous step.
Note that multiple operations can execute based on a single checkpoint, and these will most likely execute concurrently.
See How does Nodlin manage the propagation of change? for more detail.
‘N’ or ’node’
Represents the current recorded version of the node. It represents the current version prior to any update.
It is readonly and cannot be changed.
N.related # a list of relationships from the node
N.R # abbreviated form of 'related'
N.data # returns the data properties of the node
N.<property> # returns a specific named data property of a node
The following methods on ’node’ allow specific nodlin operations to be performed:
N.hasOne()
N.setName()
N.linkToNewNode()
N.getFYIs()
N.getActions()
N.createAction()
N.createAssignment()
N.assignNewAction()
’named’
In nodlin nodes may have a ’name’ (aka ‘alias’) to allow for simple referencing of nodes from related nodes. This is useful in expressions (like a named cell in a spreadsheet).
This is a python dictionary where the key is the name and the value is the node. For example,
useNode = named["thename"]
print(named)
Users can define a name for a node when creating/updating nodes (setName()). This is represented in the internal properties as ‘__name’.
The names used in the script are ONLY those names of the directly related nodes. The name may be repeated in other nodes and will have no effect and cannot be referenced by their name. Names used across the related nodes (and the central node itself) therefore have to be unique. An error is reported otherwise. This is provided as a useful shorthand value and supports more readable code.
The named value of the current node can be referenced as well through this method but cannot be changed. Use V or ‘value’ to record changes to the current node properties.
If a name for a node is set in the script (aka setName()) then this will override the alias that the user may have defined on the UI.
examples of use:
# print the length of the connected named properties
print(len(named))
# print all the named properties
for name in named:
print(name, named[name])
# check and use the value of a named node
if 'task' in named:
myTaskTotal = myTotal + task.totalEffort
The ’named’ dictionary provides access to the complete node detail. You can access the properties of the node using the ‘.’ notation. In nodlin however the value property has a special meaning (see the expression type). The value property is a json string, that records the result of an expression. Typically however you will want to access the value of the expression whether it be a number (float64), boolean or list. The values dict maintains this translated detail.
‘values’
The values dictionary records all the values of related aliased nodes. The value property for expressions is a json string and can represent lists, numbers (float64), booleans etc. These related aliased values have been translated and available in the values dictionary.
if 'credit' in values:
myBudget = myBudget + values['credit']
‘O’ or ‘operation’
Represents the operation for which the script is executing. This could be an action (create, update) or an event from a related node.
The operation is used in conjunction with actions that can be triggered by button actions that have been defined on a form.
For example if the form has been created with a button ‘Add expense’:
form.addButton('createExpense', 'Add expense')
The action (createExpense) is provided on the ‘operation’ type in the environment. To react to this button, the following logic could be applied in the script:
if type(operation) == "Action" and operation.name == "createExpense":
# ...
A shorthand version is also available:
if operation.isActionName("createExpense"):
# ...
if operation.isEventName("assigned"):
# ...
The 2 shorthand operations work on either type (Action or Event) of operation so reduces the coding required.
‘V’ or ‘value’
This represents the result that is recorded for the node after successful completion of the script (the payload)
It is initialised to the detail specified by the user, or provided from the current record of the node if the script is executed for a related event.
Values can be changed either through the dot accessor, or by a key. e.g.
value.myTotal = 30
# or
value["myTotal"] = 30
As a suggestion, it is good practice to default values at the start of any script to avoid the use of ‘hasattr’ to check for the existence of an attribute during execution. A clean way of specifying these defaults to reduce code verbosity is shown in the following example (taken from the ‘incident’ script example):
defaults = {
"title": "",
"description": "",
"status": "open",
"reportedAt": time.now(),
"startTime": "",
"endTime": "",
"closedAt": "",
"classification": "SEV5",
"impactSummary": {},
}
for name, defval in defaults.items():
if not hasattr(value, name):
value[name] = defval
Note that ‘N’ is also the value of the current node (prior to any user update). This is readonly and represents the version prior to the user update.
All related nodes can also be access from ‘V’ or ‘value’. Those related nodes however cannot be modified.
The result ‘value’ object is used to record the properties that direct nodlins behaviour and display. The properties that are set on the value object are:
image a data URI or SVG element to display for the node
sizeX X and Y size in pixels
sizeY
label short label description to show for the node
summary description of the node to display in popup tooltop and the 'book' summary view
help a help description specifically associated with this node and displayed on the 'help' section of node
hasError if an error in input/user check that you want to display/alert the user
alias an alias for the node that can be used in named code references
hidden by default 'hidden' nodes are not displayed (although users can override)
These properties must be set on the result object (V or value). They are not present on the object at start of execution. The user must therefore derive these properties as required from other related and internal detail. If they are not present in the result then the old values are maintained. The image therefore does not need to be evaluated each time but only on change.
All fields are optional on the result.
Note that all non-core fields listed above are required to be specified in the result for the record.
Reviewing payload properties and debugging
The following is a simple script that will print out the properties and methods of the key types listed above, the Operation, Node, Context and Values. When a script is saved, it will execute and the logs available in the log panel.
print("script 1", time.now())
print("OPERATION")
print(operation)
print(dir(operation))
print("NODE")
print(node)
print(dir(node))
print("CONTEXT")
print(context)
print(dir(context))
print("VALUES")
print(values)
print(dir(values))
value.label="script 1"
The logs panel will show the following:
Previous versions of the logs are also available from the selector (up to a limit defined in the script configuration).
The logs can be viewed based on the current node (for which the script is assigned), or the Node (if the script is in use by other nodes).
Accessing node properties
A node is represented by V/value/O.data (updated node), N/node, and a number of list/set results.
All user properties of a node defined in the script can be accessed through a dot (’.’) notation, or through the ‘data’ property of a node.
The following properties are reserved and provide additional detail for a node:
Property | Value |
---|---|
nodeID | the identifier for the node |
nodeType | the type of the node. Note that this is in FQN (fully qualified name) format |
nodeSubType | a sub type (if defined for a node). For a script this will refer to the FQN for the script |
alias | an alias (if defined) for the node |
label | the label currently assigned to the node |
related | a list of the relations from the node. Note that this represents only the relations on which the node is dependent |
R | an abbreviation for the relations for the node (a python list) |
data | the specific data fields set for the node in the script. Note that these are also available on the node itself through ‘.’ accessors |
Node relationships
The relationships for a node can be accessed for any node using the ‘.related’ or ‘.R’ notation.
The format is:
.related. . => list of nodes e.g.
mymortgage.related.has_schedule.paymentSchedule => [node, node…]
If you want to test whether a specific named relationship exists, you can use the python ‘in’ keyword that applies to dictionaries:
if 'expense' in node.related:
print(node.related.expense)
This is an appropriate check prior to extracting relationship detail.
The relationships exist for the named relationship, and the user label applied to the relationship but translated to camel case. So for example if the user has created a link to a new node in code:
node.linkToNewNode(FQN="agt_core_all_expressionAgent_number", label="Expense")
then the check
if 'expense' in node.related:
would apply to ensure that the ‘Expense’ labeled relationship exists.
The relationships are also iterable. In other words if you want to print out the values for each of the relationships that can be referenced:
for myRelation in node.related:
print("relationship:", myRelation)
The types are also iterable and can be tested with ‘in’ clause. An example of displaying all nodes using relationships and types as iterable python dicts:
for myRelation in node.related:
print(myRelation)
# get all nodes for this relation
for myRelatedNode in myRelation.all():
print(myRelatedNode)
# Display the keys and types of all related nodes
for n in node.related.all():
print(n.label)
for prop in n.data.keys():
print("prop=", prop, "of type", type(n.data[prop]), "with=", n.data[prop])
In many cases you may only expect and require 1 node over that relationship for that type. You could check the resulting list is length 1, and extract the first element (zero based). e.g.
ps = mymortgage.related.has_schedule.paymentSchedule
if len(ps) == 1:
foundNode = ps[0]
A shorthand version is provided (hasOne) to extract 1 and only 1 of the nodes. The script will fail if there are more than one relationship. If there are zero relationships then None is returned.
Note that this shorthand is only useful in certain cases for simple scripts. It results in a fail if >1 relationships exist, and no opportunity to handle an error. Reduces code and checks, however if you require to check for success then use alternative methods.
myNode = mymortgage.R.has_schedule.paymentSchedule.hasOne()
print(myNode.years)
This shorthand can also be used at the relationship level without specifying the type if you expect only 1 type over that relation. e.g.
myNode = mymortgage.R.has_schedule.hasOne()
print(myNode.years)
‘hasOne’ can also be directly applied at the node level. The type is optional.
myNode = mymortgage.hasOne("has_schedule", "paymentSchedule") - returns 1 payment schedule type node over the specified relation.
# or
myNode = mymortgage.hasOne("has_schedule") - returns 1 node over this relation irrespective of type
Note that the relationships are just for the immediate related nodes for the ‘central’ node. The central node is the node for which the operation (event or action) is being performed.
The ‘hasName’ property can be used on the relationship to access the name associated with the relationship (either the formal relationship name or the user defined label). For example:
# During debugging you can display node detail, the list of formal and user named relationships (see Warning below),
# and the types associated with the relation and relationship name
print("node=", node)
print("node.related=", node.related)
for myRelation in node.related:
print("myRelation=", myRelation)
print(dir(myRelation))
print("relationship name=", myRelation.hasName)
Relationships exist for both the formal relationship name and user defined label
A relationship will exist for both the formal relationship name, and the user defined name given to the relationship.
This is to allow for relationships to be accessed using either terminology. The formal relationship name is preferred as this is constant. The user can change the label on the edge on the Nodlin interface and therefore this would effect the behaviour if user has changed.
The length of the relationships therefore does not dicate the number of relationships on which the node is dependent.
There is repetition if the user has a user defined label defined.
Short/ fully qualified relationship names (FQN)
Nodlin requires fully qualified relationship names to be unambiguous. This format is:
domain_user_agentName_relationship
e.g. core_all_scriptAgent_depends_on
The targets can be other script nodes (core_all_scriptAgent_depends_on) and other agent types.
The target is a list of the different types that match that relationship name.
To simplify we provide a short form without the domain/user/agent, so ‘depends_on’ in the above example. This may overlap with different types of course if they share a relationship name across types, however the type list in the target should can be used to restrict the matches.
For scripts nodes this does not provide a lot of flexibility in that the ‘depends_on’ and type selection will not determine more specific nodes. In this case the user labels (transformed into camel case) can be used to filter connected relations.
e.g. ‘core_all_scriptAgent_depends_on’ with a user label ‘interest payment’ will become ‘interestPayment’. Again the type filter on the target can be used to restrict specific types.
Expressions
Python expressions can be evaluated on a node (as the expression type). This are a different syntax from the normal spreadsheet style formulas but achieve the same result.
The pre-declared variables noted above are not all accessible in expressions.
The result must be either a numeric (float64), condition (boolean true/false), object or list result.
Expressions can combine the numeric & condition nodes, but the result of the expression must be the defined type (number or condition).
An expression can refer to any related aliased node. The ’name’ given to the number or condition can be referred to in the immediate connected node as a normal variable (similar to named cells in a spreadsheet).
A special function, related(), can be used to refer to any connected node and will return a list of a specified property. So for example:
max(related(prop="cost"))
will return the ‘cost’ property of all related nodes, and determine the maximum value. By default with no arguments, the ‘value’ or ‘condition’ properties will be obtained if no specific prop is specified. So for example:
max(related())
will return the maxium connected node value for any connected number node. The property does not need to be specified as a fully qualified name (FQN), just by the property name. This will work with connected script and non-script nodes.
The full format of the related() function is:
related([prop="<property>"][, relation="relationship" [,type="type"]])
If the relationship is specified, then only those nodes over the specified relationship are returned. If you specify a type, then only those types over the specified relationship are returned.
The fully qualified name is an explicit name of a type or relationship. This however is verbose and often not required. The short form is also supported.
These short forms where used must be specified in camelcase format. The following are examples:
FQN: agr_core_all_workflowAgent_next
Referenced by: related(relation="next")
FQN: agr_core_all_workflowAgent_open_question
Referenced by: related(relation="openQuestion")
FQN(type and relation): agt_core_all_workflowAgent_task, agr_core_all_workflowAgent_open_question
Referenced by: related(relation="openQuestion", type="task")
The user labels can also be used in the match, but these must be matched in full (correct case and spaces). Note that the user should take care as the relation() is inclusive of matches, and user labels and shortened FQN references may overlap.
Referenced by: sum(related(relation="my reference"))
Math functions in expressions
In an expression you can refer to math functions.
Average and sum functions are also available on lists:
avg(related())
sum(related())
More complex explanations can be defined with list expressions. For example if you want to sum all related values that are greater than 120:
sum([x for x in related() if x > 120])
Retrieve connected nodes as a list
The ‘all()’ method will return a list of nodes. It applies to all node relationships and types, or to a specified relationship irrespective of type. e.g.
myNodeSet = mymortgage.R.all()
myNodeSetOverRelation = mymortgage.R.has_schedule.all()
Note that all does not relate to the fully qualified
Create and link to a new node
Create a new node and a link from the current node.
See the linkToNewNode for details on the function definition.
An internal agent type can be created from its fully qualified name as in the following example:
node.linkToNewNode(FQN="agt_core_all_internalTest_task", label="myInternalTask")
Example with a payload:
assignmentPayload = {"trialValue":5}
newAssignmentNode = node.linkToNewNode(scriptFQN="%s", label="myAssignment", payload=assignmentPayload)
# note that 'newAssignmentNode' will contain the reference of the new node (the nodeID). This can be used to create
# an additional link to that node if a reverse relation is required.
A payload can be assigned and a user defined script as in the following example:
assignmentPayload = {"trialValue":5}
node.linkToNewNode(scriptFQN="...scriptFQN...", label="myAssignment", payload=assignmentPayload)
In cases where you want to create the new node with a reversed link to the current node, you can specify the ‘reverse’ argument:
node.linkToNewNode(scriptFQN="...scriptFQN...", label="myAssignment", payload=assignmentPayload, reverse=True)
Actions and Assignments
A node can have a number of actions (with a record of who created and current state), and each action can be assigned to multiple users and/or groups.
Actions can be retrieved for a node and returns a dict of actions (keyed by action id)
N.getActions()
The properties of an action can be viewed with dir() (e.g. dir(
Each actions properties can be listed with dir():
for a in N.getActions():
print(a.allUsers()) # list of users involved across all assignments for the action (including assignee)
print(a.allGroups()) # list of all groups involved across all assignments
print(a.actionID) # each action property can be accessed with the '.' accessor. (see below for list)
Properties of an action are:
"actionID", "active", "comment", "priority", "forDate", "state", "successCondition", "completed",
"createdAt", "updatedAt", "startedAt", "completedAt", "assignments", "createdBy"
Assignments are a list of assignments of the action to users or groups. The properties of an assignment
"assignmentID", "active", "assignedAt", "assignee", "assignedTo", "assignedToGroup"
Example create action followed by a assignment:
# ...create a new node
# important to checkpoint (cannot create an action until the node is successfully created)
C.checkpoint()
actionID = node.createAction(nodeid=newAssignmentNode, forDate=time.parse_duration("25h") + time.now())
# create an assignment for the action
C.checkpoint()
assignmentID = node.createAssignment(nodeid=newAssignmentNode, actionid=actionID, user=("aUser","aDomain"))
The above example could be written in one step:
actionAndAssignmentID = node.assignNewAction(comment="combined test", user=("aUser", "aDomain"))
Keywords<
see https://github.com/google/starlark-go/blob/master/doc/spec.md for more detail.
Errors/fail
To abort or stop processing, call fail:
fail("reason for fail")
Raw Strings
Raw strings are useful where we require to specify quotes (e.g for SVG strings):
mySVG=r'<svg id="Layer_1" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><g fill-rule="evenodd"><path d="m239.231 17.345-85.531 148.138 101.7 28.736-.577-186.011a17.839 17.839 0 0 0 -15.592 9.137z" fill="#ffe178"/><path d="m255.114 232.433-120.418-34.027-30.762 53.281-30.987 53.671 179.601 50.751 2.566-.725z" fill="#ff6a76"/><path d="m252.548 394.404-198.61-56.122-47.578 82.408-6.36 11.015 255.114 72.089v-110.115z" fill="#6df5c2"/><path d="m255.114 393.679v110.115l256.886-68.833-6.041-10.464-50.482-87.436z" fill="#05e59b"/><path d="m255.114 355.384 181.355-51.246-30.283-52.451-30.087-52.114-118.636 33.524-2.349-.664z" fill="#ff4757"/><path d="m257.463 194.8 99.628-28.152-86.2-149.3a17.852 17.852 0 0 0 -15.831-9.142h-.237l.575 186.011z" fill="#ffd369"/></g></svg>'
Images
Each node requires an image, and SVG and PNG (base64 encoded) can be specified. Images can also be included in forms (e.g. in a header).
2 constants represent the URI prefix format for the string, webform.SVG and webform.PNG.
If there is no prefix to the image string, then SVG is assumed.
The following shows an example (from cause/effect python script) where an SVG is placed in the title area of the form:
# add static title area
form = webform.new()
titleGroup = form.addGroup("_titleGroup")
titleLeftCol = titleGroup.addColumn("_hrcol", 8, webform.opts(align="left"))
titleRightCol = titleGroup.addColumn("_hlcol", 4)
titleLeftCol.addStatic("_Header", "h2", "Cause Effect")
if hasattr(value, "causeEffect"):
titleLeftCol.addStatic("_SubHeader", "h4", value.causeEffect)
# add image to the form. Note that images require a specific URI prefix for SVG or PNG, there are 2 constants defined
# in the webform module, webform.SVG and webform.PNG. These are used to prefix the image URI.
titleIconOpts = webform.opts(align="right")
titleRightCol.addImage("_causeEffectIcon", webform.SVG+causeEffectIcon, titleIconOpts)
SVG
I would recommend Boxy SVG for generating SVG images.
You can view and copy the SVG text from the editor directly into a python string. You may want to
pass parameters and change certain elements of the SVG document. This can be done with the
The following is an example:
svgString = """
<svg viewBox......{param1} .....{param2} .... >
"""
myVars = {'param1':20, 'param2': 'someText'}
value.image = svgString.format(**myVars)
A simple workflow in creating graphics:
- create an SVG in Boxy SVG or some editor. A few useful rules:
- name the elements with titles or id’s so simplify locating in the svg text
- if sizing elements, design as full size to determine the limits and resize down
- edit the SVG in a text editor and replace the dynamic elements with the {$
} string - note that only brackets ‘{}’ required. The use of a ‘$’ prefix however helps transparency.
- set the string value in python with a dictionary of the dynamic element names.
- set the value.image to this string
PNG
All PNG images need to be base64 encoded. These strings need have the prefix ‘data:image/png;base64,’ following the URI format. The constant webform.PNG defines this constant.
Textbox & Icons/Symbols/SVG
Nodlin maintains a local service to provide Material Design Icons.
These icons can be used in TextBox images together with any associated text. These can then be used as the image for the node, or used on a form.
The following example places an icon in a textbox with associated text with the icon taking up 30% of the left hand area.
# test load of a valid icon
myImage=image.mdiIcon(group="action", name="paid")
bc=color.Name("blue")
backColor=color.Name("yellow")
myBold = textbox.fontStyle.Bold
textboxOpts = textbox.Options(fontFamily="Roboto_Mono", fontColor="black", fontStyle=myBold, textHAlign=textbox.textAlign.Left, backgroundColor=backColor, borderColor=bc, borderHeight=0.10, icon=myImage, iconSize=0.3)
# test creation of base 64 image
V.image = textbox.Create("Some descriptive text that wraps. Font, colors and icon are specified.", 300.0, 200.0, textboxOpts)
V.label = "test image box with ICON"
This produces the following icon:
MDI Symbols can also be used and accessible through a different method.
For symbols a color can be specified for the icon used.
# test load of a valid symbol
myImage=image.mdiSymbol(name="change_circle", color=color.Name("blue"))
print("found myImage", myImage)
bc=color.Name("blue")
backColor=color.Name("white")
myBold = textbox.fontStyle.Bold
textboxOpts = textbox.Options(fontFamily="Roboto_Mono", fontColor="black", fontStyle=myBold, textHAlign=textbox.textAlign.Left, backgroundColor=backColor, borderColor=bc, borderHeight=0.10, icon=myImage, iconSize=0.2)
# test creation of base 64 image
V.image = textbox.Create("some descriptive text using MDI symbols in a textbox", 300.0, 200.0, textboxOpts)
You can also craft your own SVG to display as an ICON.
mySVG=r'<svg id="Layer_1" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><g fill-rule="evenodd"><path d="m239.231 17.345-85.531 148.138 101.7 28.736-.577-186.011a17.839 17.839 0 0 0 -15.592 9.137z" fill="#ffe178"/><path d="m255.114 232.433-120.418-34.027-30.762 53.281-30.987 53.671 179.601 50.751 2.566-.725z" fill="#ff6a76"/><path d="m252.548 394.404-198.61-56.122-47.578 82.408-6.36 11.015 255.114 72.089v-110.115z" fill="#6df5c2"/><path d="m255.114 393.679v110.115l256.886-68.833-6.041-10.464-50.482-87.436z" fill="#05e59b"/><path d="m255.114 355.384 181.355-51.246-30.283-52.451-30.087-52.114-118.636 33.524-2.349-.664z" fill="#ff4757"/><path d="m257.463 194.8 99.628-28.152-86.2-149.3a17.852 17.852 0 0 0 -15.831-9.142h-.237l.575 186.011z" fill="#ffd369"/></g></svg>'
fillColor=color.Name("purple")
myImage=image.fromSVG(mySVG, fillColor)
# specify options for the textbox
bc=color.Name("blue")
backColor=color.Name("lightslategrey")
myBold = textbox.fontStyle.Bold
textboxOpts = textbox.Options(fontFamily="Roboto_Mono", fontColor=bc, fontStyle=myBold, textHAlign=textbox.textAlign.Left, backgroundColor=backColor, borderColor=bc, borderHeight=0.10, icon=myImage, iconSize=0.4)
msgText=r'My description for a box with my defined SVG'
V.image = textbox.Create(msgText, 300.0, 200.0, textboxOpts)
The above will result in the following textbox:
Numbers & optional currencies
Numbers can be displayed in a textbox. As with the textbox icon, images can be defined. Numbers can be displayed with a specified precision and currency (optional).
The following example displays a value with a specified SVG icon, currency, and precision.
# Create an image to display in a number textbox
mySVG=r'<svg id="Layer_1" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><g fill-rule="evenodd"><path d="m239.231 17.345-85.531 148.138 101.7 28.736-.577-186.011a17.839 17.839 0 0 0 -15.592 9.137z" fill="#ffe178"/><path d="m255.114 232.433-120.418-34.027-30.762 53.281-30.987 53.671 179.601 50.751 2.566-.725z" fill="#ff6a76"/><path d="m252.548 394.404-198.61-56.122-47.578 82.408-6.36 11.015 255.114 72.089v-110.115z" fill="#6df5c2"/><path d="m255.114 393.679v110.115l256.886-68.833-6.041-10.464-50.482-87.436z" fill="#05e59b"/><path d="m255.114 355.384 181.355-51.246-30.283-52.451-30.087-52.114-118.636 33.524-2.349-.664z" fill="#ff4757"/><path d="m257.463 194.8 99.628-28.152-86.2-149.3a17.852 17.852 0 0 0 -15.831-9.142h-.237l.575 186.011z" fill="#ffd369"/></g></svg>'
fillColor=color.Name("red")
myImage=image.fromSVG(mySVG, fillColor)
# Specify options for the textbox
bc=color.Name("blue")
backColor=color.Name("white")
myBold = textbox.fontStyle.Bold
textboxOpts = textbox.Options(fontFamily="Roboto_Mono", fontColor=bc, fontStyle=myBold, textHAlign=textbox.textAlign.Left, backgroundColor=backColor, borderColor=bc, borderHeight=0.10, icon=myImage, iconSize=0.2)
V.image=textbox.Number(25300.12,300,200, numberOptions=textbox.NumberOptions(currency="GBP", scale=2), textboxOptions=textboxOpts)
The above example produces the following image for the node:
Drawing your own node in python
You can define your own icon in code with circles, paths, lines, and rectangles.
r = color.RGBA(R=245, G=69, B=8, A=1)
circle = gridder.CircleConfig(radius=40.0, stroke=False, color=r, strokeWidth=4.3)
circle2 = gridder.CircleConfig(radius=30.0, stroke=True, color=color.Black)
imageConfig = gridder.ImageConfig(width=600, height=400)
gridConfig = gridder.GridConfig(rows=6, columns=8, lineStrokeWidth=2.1, borderStrokeWidth=10.1)
print(dir(gridConfig), gridConfig)
myImage = gridder.New(imageConfig, gridConfig)
stringColor = color.HEX("#8A2BE2")
myFont = font.face(size=60.1, name="italic")
myFont2 = font.face(size=50.1, name="mono")
myStringConfig = gridder.StringConfig(color=stringColor)
myImage.DrawString(1, 4, "Nodlin", myFont, myStringConfig)
myImage.DrawString(2, 4, "$23,121,443", myFont2, gridder.StringConfig(color=color.Name("red")))
lineCircle = gridder.CircleConfig(radius=30.0, stroke=False, color=r, strokeWidth=2.2)
for x in range(8):
myImage.DrawCircle(3, x, lineCircle)
myPath = gridder.PathConfig(strokeWidth=2.1, color=color.Black, dashes=1.5)
myImage.DrawPath(1, 1, 5, 7, myPath)
myRectColor = color.RGBA(R=1, G=255, B=80, A=1)
myRectangle = gridder.RectangleConfig(width=40.1, height=120.1, color=myRectColor, rotate=50.0)
for recCol in range(3):
myImage.DrawRectangle(4, 2+recCol, myRectangle)
myImage.DrawCircle(2, 2, circle)
myImage.DrawCircle(5, 3, circle2)
lineColor = color.RGBA(R=80, G=200, B=80, A=1)
myLine = gridder.LineConfig(length=160.2, rotate=12.1, color=lineColor, strokeWidth=30.0)
myImage.DrawLine(2, 1, myLine)
myImage.PaintCell(1, 1, color.Name("mediumspringgreen"))
# list available colors
print("colors=", color.colors)
V.image = myImage.GetImage()
This produces the following example:
Charts
Nodlin provides access to the quickchart server for creating chart images. Note that for simple use cases (line/bar charts) you can use the internal chart node type (in the expression agent type) which provides user configurable color, title, size etc on the chart form.
Quickchart however provides many more options and styles, and this can be used directly to produce an image for the node but requires more extensive python code to formulate a request. Nodlin provides a simple method for which to make the JSON request to quickchart to obtain a PNG image. See quickchart for more details on the JSON structure and available styles.
See objectiveChart.py for a comprehensive example that produces the following as example:
V.sizeX = 400
V.sizeY = 400
chart = {
"type": "radar",
"data": {
"labels": value.subObjectives,
"datasets": datasets
},
"options": {
"title": {
"display": True,
"position": "top",
"fontSize": 22,
"fontFamily": "sans-serif",
"fontColor": "#f2ecec",
"fontStyle": "bold",
"padding": 10,
"lineHeight": 1.2,
"text": value.objectiveTitle
},
"scale": {
"id": "radial",
"display": True,
"stacked": False,
"distribution": "linear",
"ticks": {
"display": False,
"min": 0,
"max": 100,
},
"pointLabels": {
"display": True,
"fontColor": "#f2ecec",
"fontSize": 10,
"fontStyle": "bold"
},
"gridLines": {
"display": False,
},
"angleLines": {
"display": True,
"color": "rgba(222, 139, 17, 0.7)",
"borderDash": [
0,
0,
],
"lineWidth": 1
},
},
"plugins": {
"datalabels": {
"display": True,
"backgroundColor": "#eee",
"borderRadius": 6,
"borderWidth": 1,
"padding": 2,
"color": "#666666",
"font": {
"family": "sans-serif",
"size": 10,
"style": "normal"
}
},
},
}
}
chartSpec = {"backgroundColor": "transparent", "width": V.sizeX, "height": V.sizeY, "chart": chart}
print(type(chartSpec))
V.image = image.chart(chartSpec)
Defaults
Obtaining a value and defaulting. Testing the existance of a value can be more cumbersome each time you want to check before using. For example:
if hasattr(expense, 'value'):
totalsgAndA = totalsgAndA + expense.value
However you can use getattr to specify a default if value does not exist
totalsgAndA = totalsgAndA + getattr(expense, 'value', 0)
The syntax is:
getattr(x, name, default)
If you wanted to obtain a named value from the values prop, and a default if it does not exist, you can obtain a default for a dictionary as follows:
values.get("N", 0)
Which would get an aliased 'N' node from the 'values' dictionary with 0 if value not present.
List Comprehension
Lists can be combined:
nums = [1, 2, 3, 4, 5]
letters = ['A', 'B', 'C', 'D', 'E']
nums_letters = [[n, l] for n in nums for l in letters]
print(nums_letters)
Comphrehensions are not limited to lists. A set example:
dict_comp = {x:chr(65+x) for x in range(1, 11)}
print(dict_comp)
Example showing test for all ’true’ values for property in list of dicts using list comphrehension:
customers = [{"is_kyc_passed": False}, {"is_kyc_passed": True}]
matched = all([customer["is_kyc_passed"] for customer in customers])
print(matched)
Forms
todo: move this to a specific forms document
addUserInput
To provide a user selection control use addUserInput
userOpts = webform.nodlinUserOpts(help="user help...", singleContactOnly=False)
form.addUserInput('responsible','Responsible', userOpts)
if 'responsible' in value:
for u in value.responsible['users']:
print("USER %s in domain %s" % (u['userid'], u['domain']))
for g in value.responsible['groups']:
print("GROUP %s in domain %s" % (g['group'], g['domain']))
The above example will return a single contact (in options) with specified help. The result is recorded in the responsible property of the value payload. The property is a dict containing a list of users (the users prop) and groups (the groups prop). The list of users is a list of dict type with the two props userid and domain. The group list is a list of groups with the group and domain props.
Date & Datetime inputs
Date and datetime inputs are handled through the addDateInput method on a form.
The method takes the form general options, and a specific optional list of dateInputOpts.
The date formatting tokens in vueform are as moment.js.
The following are the options that can be specified on dateInputOpts:
time allow time input
displayFormat format to display on the form
loadFormat format expected in the date string on record
valueFormat format expected in the date string result
min date input is restricted to dates within the min/max definition
max
e.g.
dateOpts = webform.dateInputOpts(time=True, min='2025-07-01 00:00',displayFormat='YYYY-MM-DD')
Note that UTC is formatted as 'YYYY-MM-DDTHH:mm:ss[Z]', e.g. "2025-07-16T13:45:00Z"
Example python script with 2 date inputs allowing for time entry:
# add the start time and end time
timeGroup = form.addGroup("_timeGroup")
timeLeftCol = timeGroup.addColumn("_trcol", 6, webform.opts(align="left"))
timeRightCol = timeGroup.addColumn("_tlcol", 6)
# date and times for reported and resolved fields
myDateOpts = webform.dateInputOpts(time=True)
timeLeftCol.addDateInput("reportedAt", webform.opts(label="Reported at", info="Time incident was first reported."), myDateOpts)
timeRightCol.addDateInput("resolvedAt", webform.opts(label="Resolved at", info="Time incident was resolved in the business impacted area."), myDateOpts)
Add static HTML to a form
Static HTML can be added to a form with standard HTML formating.
The following is an example from the incident.py script, showing the HTML formed from a string template (to add content derived during script execution) and displayed on the form:
higherClassifiedImpacts = "orders not processing (SEV1);"
warningTemplate = """
<p style="color: orange;">Higher severity impacts exist for this Incident. Review incident classification:</p>
<p style="color: orange;">{higherClassifiedImpacts}</p>
"""
params = {'higherClassifiedImpacts': higherClassifiedImpacts}
showWarning = warningTemplate.format(**params)
form.addStatic("_clasificationAlert", "div", showWarning)
Add Markdown to a form
In some cases it may be simpler in a script to build a markdown string during execution and then display this on a form. To support this a ‘markdownAsHTML’ method exists on the form, and can be subsequently added to the form using the addStatic method.
The following example defines a markdown string, applies a conversion to HTML, and then uses the conventional div tag to add to the form:
# example production of markdown converstion to html, to present as static content
myMarkdown = """
# markdown title
some text
## markdown sub title
some additional sub text
"""
myHTML = form.markdownToHTML(myMarkdown)
form.addStatic("_markdown", "div", myHTML)
Tabs on a form
Nodlin forms support addition of Tabs. Under the covers the excellent Vueform library is used which provides the capability of specifying a form in a JSON format, and therefore is data driven.
Having a data driven approach allows us to dynamically change and interrogate properties the form. For example, in Nodlin we have a ‘Board’ and a ‘Gantt’ view of the nodes, and we determine nodes that have date types, or option types, from interrogation of the nodes form. You can build and view example forms with their form builder.
If tabs are required, then any controls (text, number inputs, static elements etc), need to be associated with one of the tabs to be displayed. This can be done individually for each new control, or more simply by creating and adding a single control group to a tab, and then adding the controls to the appropriate group.
The following highlights an example of how you can do this.
form = webform.new()
########################################################################
# If separate Tabs are required on a form, the following is the recommended approach.
#
# When elements are added to a form, they are added to the 'schema' for a form. If there are tabs, then the elements added to the form, need to be placed
# on one of the tabs. If there are no tabs, all the controls are placed on the form in the order they have been added.
#
# The most straight forward approach with tabs is to:
# a/ create a tab (the 'tab' response only supports the addition of an element)
# b/ create a group associated with the tab (the group is created on the form but we logically associate with the tab)
# c/ add the group as an 'element' on the tab
# d/ add all the controls (buttons, text etc) to the group
detailTab = form.addTab("_detail", "Detail")
detailGroup = form.addGroup("_detailGroup")
detailTab.addElement("_detailGroup")
settingsTab = form.addTab("_settings", "Settings")
settingsGroup = form.addGroup("_settingsGroup")
settingsTab.addElement("_settingsGroup")
# from here we add specific detail to the created groups (detailGroup and settingsGroup)
myopts = webform.opts(label="Name", info="Please enter a real name franks")
textOpts = webform.textInputOpts(inputType="text")
detailGroup.addTextInput("myName", myopts, textOpts)
detailGroup.addNumberInput("height", webform.opts(label="Height (m)", info="Enter height (in meters)"))
detailGroup.addStatic("_myStatic", "p", "Some static detail...")
detailGroup.addButton("save", "Submit", webform.buttonOpts(submits=True))
settingsGroup.addNumberInput("age", webform.opts(label="Age", info="Enter age (in years)"))
lCol = settingsGroup.addColumn("lcol", 6)
lCol.addTextInput("job", webform.opts(label="Job"))
rCol =settingsGroup.addColumn("rcol", 6)
rCol.addTextInput("location", webform.opts(label="Location"))
settingsGroup.addButton("save", "Submit", webform.buttonOpts(submits=True))
Starlark Syntax
Comments
Single line comments are preceeeded by a ‘#’.
Multiple line comments (aka docstrings in python parlance) use 3 quotes:
"""
this is a docstring comment
"""
Summary
The summary display displayed for a node is set through the following:
value.summary = "this is a summary"
To create a running commentary in a script use an array and join the results when complete:
commentary = []
commentary.append('notes...')
value.summary = "\n".join(commentary)
The commentary is displayed in markdown.
Print (logging)
print() provides a mechanism to log in scripts and useful for debugging. It takes a tuple argument for including values. Note that starlark does not support the python ‘%’ options.
See ‘string interpolation’:
https://github.com/bazelbuild/starlark/blob/master/spec.md#string%C2%B7format
Some useful examples:
x = 0.5
print("my float=%f" % (x)) # prints the longer float
print("my float=%g" % (x)) # prints a compact form (0.5)
print("my float=%s" % (x)) # prints str(x)
print("my float=%r" % (x)) # prints repr(x)
% none literal percent sign
s any as if by str(x)
r any as if by repr(x)
d number signed integer decimal
o number signed octal, no 0o prefix
x number signed hexadecimal, lowercase, no 0x prefix
X number signed hexadecimal, uppercase, no 0x prefix
e number float exponential format, lowercase (1.230000e+12)
E number float exponential format, uppercase (1.230000E+12)
f number float decimal format (1230000000000.000000)
F number same as %f
g number compact format, lowercase (0.0, 1.1, 1200, 1e+45, 1.2e+12)
G number compact format, uppercase (0.0, 1.1, 1200, 1e+45, 1.2E+12)
Additional starlark libraries included
DateTime
The DateTime library provides a number of useful methods for working with dates and times.
Date
Now
StartOfMonth
AddMonths
FirstDaysForMonths
EndOfMonth
DaysInMonth
TruncateToDay
Format
Example:
myYear = 2025
t = datetime.Date(year=myYear, month=8, day=19, tz="Europe/London")
print("t=", t)
formatted_date = datetime.Format(datetime.StartOfMonth(t), "2006-01-02")
print("StartOfMonth=", formatted_date)
monthEnd = datetime.Format(datetime.EndOfMonth(t), "2006-01-02") # "2025-08-31" (00:00 start of last day)
print("EndOfMonth=", monthEnd)
datetime.DaysInMonth(t) # 31
print("DaysInMonth=", datetime.DaysInMonth(t))
print("TruncateToDay=", datetime.Format(datetime.TruncateToDay(datetime.Now(tz="UTC")), "2006-01-02"))
##### First day of each month between two dates (inclusive by month)
s = datetime.Date(year=2024, month=11, day=15, tz="UTC")
e = datetime.Date(year=2025, month=2, day=3, tz="UTC")
print("FirstDaysForMonths=", datetime.FirstDaysForMonths(s, e))
The above example will output:
t= 2025-08-19 00:00:00 +0100 BST
StartOfMonth= 2025-08-01
EndOfMonth= 2025-08-31
DaysInMonth= 31
TruncateToDay= 2025-08-19
FirstDaysForMonths= [2024-11-01 00:00:00 +0000 UTC, 2024-12-01 00:00:00 +0000 UTC, 2025-01-01 00:00:00 +0000 UTC, 2025-02-01 00:00:00 +0000 UTC]
math
time
print("time:", time.now())
print("time in 1 days:", time.parse_duration("24h") + time.now())
Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".
e.g. To obtain a list of 1-12, 3 times, as a list:
[r for i in range(3) for r in range(1,13)]
This can be used to create a vector of months looking out 3 years for example.
Say you wanted a list of 5% increments month over month:
[s+(l) for s in [20] for l in range(12)]
l = [time.parse_duration("%dh" % (24*n)) + time.now() for n in range(20)]
#or
[(time.now() + time.parse_duration("24h") * i).format("2006-01-02") for i in range(50)]
print(dir(l[1]))
print(l[1].month)
[r.month for r in [(time.parse_duration("%dh" % (240*n)) + time.now()) for n in range(20)]]
[r in [range(12),range(12)]]
Struct
The Struct type in starlark is a dictionary type. Although currently included in Starlark this will be removed. The dict is recorded (but no methods) in the UDT, but the data cannot be represented in JSON and therefore does not unmarshal into the named type given to the struct. This is a TBR at some future point.
python - months does not exist
To obtain the current date and display in a specific format:
now = time.now()
print (now.format("2006-01-02 15:04:05"))
for i in range(1,13):
months_choices.append((i, datetime.date(2008, i, 1).strftime('%B')))
See the following for time related functions: https://pkg.go.dev/go.starlark.net/lib/time https://pkg.go.dev/time#ParseDuration
from_timestamp(sec, nsec) - Converts the given Unix time corresponding to the number of seconds
and (optionally) nanoseconds since January 1, 1970 UTC into an object
of type Time. For more details, refer to https://pkg.go.dev/time#Unix.
is_valid_timezone(loc) - Reports whether loc is a valid time zone name.
now() - Returns the current local time. Applications may replace this function by a deterministic one.
parse_duration(d) - Parses the given duration string. For more details, refer to
https://pkg.go.dev/time#ParseDuration.
parseTime(x, format, location) - Parses the given time string using a specific time format and location.
The expected arguments are a time string (mandatory), a time format
(optional, set to RFC3339 by default, e.g. "2021-03-22T23:20:50.52Z")
and a name of location (optional, set to UTC by default). For more details,
refer to https://pkg.go.dev/time#Parse and https://pkg.go.dev/time#ParseInLocation.
time(year, month, day, hour, minute, second, nanosecond, location) - Returns the Time corresponding to
yyyy-mm-dd hh:mm:ss + nsec nanoseconds
in the appropriate zone for that time
in the given location. All the parameters
are optional.
json
Internal type implementation
The following defines the interface types that must be implmented to a type to be supported:
https://github.com/google/starlark-go/blob/master/starlark/value.go