Coverage for colour/utilities/network.py: 90%
529 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-15 19:39 +1300
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-15 19:39 +1300
1"""
2Network
3=======
5Node-graph and network infrastructure for computational workflows.
7- :class:`colour.utilities.TreeNode`: Basic node object supporting
8 creation of hierarchical node trees.
9- :class:`colour.utilities.Port`: Object that can be added as either an
10 input or output port for data flow.
11- :class:`colour.utilities.PortMode`: Node with support for input and
12 output ports.
13- :class:`colour.utilities.PortGraph`: Graph structure for nodes with
14 input and output ports.
15- :class:`colour.utilities.ExecutionPort`: Object for nodes supporting
16 execution input and output ports.
17- :class:`colour.utilities.ExecutionNode`: Node with built-in input and
18 output execution ports.
19- :class:`colour.utilities.ControlFlowNode`: Base node inherited by
20 control flow nodes.
21- :class:`colour.utilities.For`: Node performing for loops in the
22 node-graph.
23- :class:`colour.utilities.ParallelForThread`: Node performing for loops
24 in parallel in the node-graph using threads.
25- :class:`colour.utilities.ParallelForMultiprocess`: Node performing for
26 loops in parallel in the node-graph using multiprocessing.
27"""
29from __future__ import annotations
31import atexit
32import concurrent.futures
33import multiprocessing
34import os
35import threading
36import typing
38if typing.TYPE_CHECKING:
39 from colour.hints import (
40 Any,
41 Dict,
42 Generator,
43 List,
44 Self,
45 Sequence,
46 Tuple,
47 Type,
48 )
50from colour.utilities import MixinLogging, attest, optional, required
52__author__ = "Colour Developers"
53__copyright__ = "Copyright 2013 Colour Developers"
54__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
55__maintainer__ = "Colour Developers"
56__email__ = "colour-developers@colour-science.org"
57__status__ = "Production"
59__all__ = [
60 "TreeNode",
61 "Port",
62 "PortNode",
63 "ControlFlowNode",
64 "PortGraph",
65 "ExecutionPort",
66 "ExecutionNode",
67 "ControlFlowNode",
68 "For",
69 "ThreadPoolExecutorManager",
70 "ParallelForThread",
71 "ProcessPoolExecutorManager",
72 "ParallelForMultiprocess",
73]
76class TreeNode:
77 """
78 Define a basic node supporting the creation of hierarchical node
79 trees.
81 Parameters
82 ----------
83 name
84 Node name.
85 parent
86 Parent of the node.
87 children
88 Children of the node.
89 data
90 Data belonging to this node.
92 Attributes
93 ----------
94 - :attr:`~colour.utilities.TreeNode.id`
95 - :attr:`~colour.utilities.TreeNode.name`
96 - :attr:`~colour.utilities.TreeNode.parent`
97 - :attr:`~colour.utilities.TreeNode.children`
98 - :attr:`~colour.utilities.TreeNode.root`
99 - :attr:`~colour.utilities.TreeNode.leaves`
100 - :attr:`~colour.utilities.TreeNode.siblings`
101 - :attr:`~colour.utilities.TreeNode.data`
103 Methods
104 -------
105 - :meth:`~colour.utilities.TreeNode.__new__`
106 - :meth:`~colour.utilities.TreeNode.__init__`
107 - :meth:`~colour.utilities.TreeNode.__str__`
108 - :meth:`~colour.utilities.TreeNode.__len__`
109 - :meth:`~colour.utilities.TreeNode.is_root`
110 - :meth:`~colour.utilities.TreeNode.is_inner`
111 - :meth:`~colour.utilities.TreeNode.is_leaf`
112 - :meth:`~colour.utilities.TreeNode.walk_hierarchy`
113 - :meth:`~colour.utilities.TreeNode.render`
115 Examples
116 --------
117 >>> node_a = TreeNode("Node A")
118 >>> node_b = TreeNode("Node B", node_a)
119 >>> node_c = TreeNode("Node C", node_a)
120 >>> node_d = TreeNode("Node D", node_b)
121 >>> node_e = TreeNode("Node E", node_b)
122 >>> node_f = TreeNode("Node F", node_d)
123 >>> node_g = TreeNode("Node G", node_f)
124 >>> node_h = TreeNode("Node H", node_g)
125 >>> [node.name for node in node_a.leaves]
126 ['Node H', 'Node E', 'Node C']
127 >>> print(node_h.root.name)
128 Node A
129 >>> len(node_a)
130 7
131 """
133 _INSTANCE_ID: int = 1
134 """
135 Node id counter.
137 _INSTANCE_ID
138 """
140 def __new__(cls, *args: Any, **kwargs: Any) -> Self: # noqa: ARG004
141 """
142 Return a new instance of the :class:`colour.utilities.TreeNode` class.
144 Other Parameters
145 ----------------
146 args
147 Arguments.
148 kwargs
149 Keywords arguments.
150 """
152 instance = super().__new__(cls)
154 instance._id = TreeNode._INSTANCE_ID # pyright: ignore
155 TreeNode._INSTANCE_ID += 1
157 return instance
159 def __init__(
160 self,
161 name: str | None = None,
162 parent: Self | None = None,
163 children: List[Self] | None = None,
164 data: Any | None = None,
165 ) -> None:
166 self._name: str = f"{self.__class__.__name__}#{self.id}"
167 self.name = optional(name, self._name)
168 self._parent: Self | None = None
169 self.parent = parent
170 self._children: List[Self] = []
171 self.children = optional(children, self._children)
172 self._data: Any | None = data
174 @property
175 def id(self) -> int:
176 """
177 Getter for the node identifier.
179 Returns
180 -------
181 :class:`int`
182 Node identifier.
183 """
185 return self._id # pyright: ignore
187 @property
188 def name(self) -> str:
189 """
190 Getter and setter for the node name.
192 Parameters
193 ----------
194 value
195 Value to set the node name with.
197 Returns
198 -------
199 :class:`str`
200 Node name.
201 """
203 return self._name
205 @name.setter
206 def name(self, value: str) -> None:
207 """Setter for the **self.name** property."""
209 attest(
210 isinstance(value, str),
211 f'"name" property: "{value}" type is not "str"!',
212 )
214 self._name = value
216 @property
217 def parent(self) -> Self | None:
218 """
219 Getter and setter for the node parent.
221 Parameters
222 ----------
223 value
224 Parent to set the node with.
226 Returns
227 -------
228 :class:`TreeNode` or :py:data:`None`
229 Node parent.
230 """
232 return self._parent
234 @parent.setter
235 def parent(self, value: Self | None) -> None:
236 """Setter for the **self.parent** property."""
238 from colour.utilities import attest # noqa: PLC0415
240 if value is not None:
241 attest(
242 issubclass(value.__class__, TreeNode),
243 f'"parent" property: "{value}" is not a '
244 f'"{self.__class__.__name__}" subclass!',
245 )
247 value.children.append(self)
249 self._parent = value
251 @property
252 def children(self) -> List[Self]:
253 """
254 Getter and setter for the node children.
256 Parameters
257 ----------
258 value
259 Children to set the node with.
261 Returns
262 -------
263 :class:`list`
264 Node children.
265 """
267 return self._children
269 @children.setter
270 def children(self, value: List[Self]) -> None:
271 """Setter for the **self.children** property."""
273 from colour.utilities import attest # noqa: PLC0415
275 attest(
276 isinstance(value, list),
277 f'"children" property: "{value}" type is not a "list" instance!',
278 )
280 for element in value:
281 attest(
282 issubclass(element.__class__, TreeNode),
283 f'"children" property: A "{element}" element is not a '
284 f'"{self.__class__.__name__}" subclass!',
285 )
287 for node in value:
288 node.parent = self
290 self._children = value
292 @property
293 def root(self) -> Self:
294 """
295 Getter for the root node of the tree hierarchy.
297 Returns
298 -------
299 :class:`TreeNode`
300 Root node of the tree.
301 """
303 if self.is_root():
304 return self
306 return list(self.walk_hierarchy(ascendants=True))[-1]
308 @property
309 def leaves(self) -> Generator:
310 """
311 Getter for all leaf nodes in the hierarchy.
313 Returns
314 -------
315 Generator
316 Generator yielding all leaf nodes (nodes without children) in
317 the hierarchy.
318 """
320 if self.is_leaf():
321 return (node for node in (self,))
323 return (node for node in self.walk_hierarchy() if node.is_leaf())
325 @property
326 def siblings(self) -> Generator:
327 """
328 Getter for the sibling nodes at the same hierarchical level.
330 Returns
331 -------
332 Generator
333 Generator yielding sibling nodes that share the same parent
334 node in the hierarchy.
335 """
337 if self.parent is None:
338 return (sibling for sibling in ())
340 return (sibling for sibling in self.parent.children if sibling is not self)
342 @property
343 def data(self) -> Any:
344 """
345 Getter and setter for the node data.
347 Parameters
348 ----------
349 value
350 Data to assign to the node.
352 Returns
353 -------
354 :class:`object`
355 Data stored in the node.
356 """
358 return self._data
360 @data.setter
361 def data(self, value: Any) -> None:
362 """Setter for the **self.data** property."""
364 self._data = value
366 def __str__(self) -> str:
367 """
368 Return a formatted string representation of the node.
370 Returns
371 -------
372 :class:`str`
373 Formatted string representation.
374 """
376 return f"{self.__class__.__name__}#{self.id}({self._data})"
378 def __len__(self) -> int:
379 """
380 Return the number of children of the node.
382 Returns
383 -------
384 :class:`int`
385 Number of children of the node.
386 """
388 return len(list(self.walk_hierarchy()))
390 def is_root(self) -> bool:
391 """
392 Determine whether the node is a root node.
394 Returns
395 -------
396 :class:`bool`
397 Whether the node is a root node.
399 Examples
400 --------
401 >>> node_a = TreeNode("Node A")
402 >>> node_b = TreeNode("Node B", node_a)
403 >>> node_c = TreeNode("Node C", node_b)
404 >>> node_a.is_root()
405 True
406 >>> node_b.is_root()
407 False
408 """
410 return self.parent is None
412 def is_inner(self) -> bool:
413 """
414 Determine whether the node is an inner node.
416 Returns
417 -------
418 :class:`bool`
419 Whether the node is an inner node.
421 Examples
422 --------
423 >>> node_a = TreeNode("Node A")
424 >>> node_b = TreeNode("Node B", node_a)
425 >>> node_c = TreeNode("Node C", node_b)
426 >>> node_a.is_inner()
427 False
428 >>> node_b.is_inner()
429 True
430 """
432 return all([not self.is_root(), not self.is_leaf()])
434 def is_leaf(self) -> bool:
435 """
436 Determine whether the node is a leaf node.
438 Returns
439 -------
440 :class:`bool`
441 Whether the node is a leaf node.
443 Examples
444 --------
445 >>> node_a = TreeNode("Node A")
446 >>> node_b = TreeNode("Node B", node_a)
447 >>> node_c = TreeNode("Node C", node_b)
448 >>> node_a.is_leaf()
449 False
450 >>> node_c.is_leaf()
451 True
452 """
454 return len(self._children) == 0
456 def walk_hierarchy(self, ascendants: bool = False) -> Generator:
457 """
458 Generate a generator to walk the :class:`colour.utilities.TreeNode`
459 tree hierarchy.
461 Parameters
462 ----------
463 ascendants
464 Whether to walk up the node tree.
466 Yields
467 ------
468 Generator
469 Node tree walker.
471 Examples
472 --------
473 >>> node_a = TreeNode("Node A")
474 >>> node_b = TreeNode("Node B", node_a)
475 >>> node_c = TreeNode("Node C", node_a)
476 >>> node_d = TreeNode("Node D", node_b)
477 >>> node_e = TreeNode("Node E", node_b)
478 >>> node_f = TreeNode("Node F", node_d)
479 >>> node_g = TreeNode("Node G", node_f)
480 >>> node_h = TreeNode("Node H", node_g)
481 >>> for node in node_a.walk_hierarchy():
482 ... print(node.name)
483 Node B
484 Node D
485 Node F
486 Node G
487 Node H
488 Node E
489 Node C
490 """
492 attribute = "children" if not ascendants else "parent"
494 nodes = getattr(self, attribute)
495 nodes = nodes if isinstance(nodes, list) else [nodes]
497 for node in nodes:
498 yield node
500 if not getattr(node, attribute):
501 continue
503 yield from node.walk_hierarchy(ascendants=ascendants)
505 def render(self, tab_level: int = 0) -> str:
506 """
507 Render the node and its children as a formatted tree string.
509 Parameters
510 ----------
511 tab_level
512 Initial indentation level for the tree structure.
514 Returns
515 -------
516 :class:`str`
517 Formatted tree representation of the node hierarchy.
519 Examples
520 --------
521 >>> node_a = TreeNode("Node A")
522 >>> node_b = TreeNode("Node B", node_a)
523 >>> node_c = TreeNode("Node C", node_a)
524 >>> print(node_a.render())
525 |----"Node A"
526 |----"Node B"
527 |----"Node C"
528 <BLANKLINE>
529 """
531 output = ""
533 for _i in range(tab_level):
534 output += " "
536 tab_level += 1
538 output += f'|----"{self.name}"\n'
540 for child in self._children:
541 output += child.render(tab_level)
543 tab_level -= 1
545 return output
548class Port(MixinLogging):
549 """
550 Define a port object that serves as an input or output port (i.e., a
551 pin) for a :class:`colour.utilities.PortNode` class and connects to
552 other input or output ports.
554 Parameters
555 ----------
556 name
557 Port name.
558 value
559 Initial value to set the port with.
560 description
561 Port description.
562 node
563 Node to add the port to.
565 Attributes
566 ----------
567 - :attr:`~colour.utilities.Port.name`
568 - :attr:`~colour.utilities.Port.value`
569 - :attr:`~colour.utilities.Port.description`
570 - :attr:`~colour.utilities.Port.node`
571 - :attr:`~colour.utilities.Port.connections`
573 Methods
574 -------
575 - :meth:`~colour.utilities.Port.__init__`
576 - :meth:`~colour.utilities.Port.__str__`
577 - :meth:`~colour.utilities.Port.is_input_port`
578 - :meth:`~colour.utilities.Port.is_output_port`
579 - :meth:`~colour.utilities.Port.connect`
580 - :meth:`~colour.utilities.Port.disconnect`
581 - :meth:`~colour.utilities.Port.to_graphviz`
583 Examples
584 --------
585 >>> port = Port("a", 1, "Port A Description")
586 >>> port.name
587 'a'
588 >>> port.value
589 1
590 >>> port.description
591 'Port A Description'
592 """
594 def __init__(
595 self,
596 name: str | None = None,
597 value: Any = None,
598 description: str = "",
599 node: PortNode | None = None,
600 ) -> None:
601 super().__init__()
603 # TODO: Consider using an ordered set instead of a dict.
604 self._connections: Dict[Port, None] = {}
606 self._node: PortNode | None = None
607 self.node = optional(node, self._node)
608 self._name: str = self.__class__.__name__
609 self.name = optional(name, self._name)
610 self._value = None
611 self.value = optional(value, self._value)
612 self.description = description
614 @property
615 def name(self) -> str:
616 """
617 Getter and setter for the port name.
619 Parameters
620 ----------
621 value
622 Value to set the port name with.
624 Returns
625 -------
626 :class:`str`
627 Port name.
628 """
630 return self._name
632 @name.setter
633 def name(self, value: str) -> None:
634 """Setter for the **self.name** property."""
636 attest(
637 isinstance(value, str),
638 f'"name" property: "{value}" type is not "str"!',
639 )
641 self._name = value
643 @property
644 def value(self) -> Any:
645 """
646 Getter and setter for the port value.
648 Parameters
649 ----------
650 value
651 Value to set the port value with.
653 Returns
654 -------
655 :class:`object`
656 Port value.
657 """
659 # NOTE: Assumption is that if the public API is used to set values, the
660 # actual port value is coming from the connected port. Any connected
661 # port is valid as they should all carry the same value, thus the first
662 # connected port is returned.
663 for connection in self._connections:
664 return connection._value # noqa: SLF001
666 return self._value
668 @value.setter
669 def value(self, value: Any) -> None:
670 """Setter for the **self.value** property."""
672 self._value = value
674 if self._node is not None:
675 self.log(f'Dirtying "{self._node}".', "debug")
676 self._node.dirty = True
678 # NOTE: Setting the port value implies that all the connected ports
679 # should be also set to the same specified value.
680 for direct_connection in self._connections:
681 self.log(f'Setting "{direct_connection.node}" value to {value}.', "debug")
682 direct_connection._value = value # noqa: SLF001
684 if direct_connection.node is not None:
685 self.log(f'Dirtying "{direct_connection.node}".', "debug")
686 direct_connection.node.dirty = True
688 for indirect_connection in direct_connection.connections:
689 if indirect_connection == self:
690 continue
692 self.log(
693 f'Setting "{indirect_connection.node}" value to {value}.', "debug"
694 )
695 indirect_connection._value = value # noqa: SLF001
697 if indirect_connection.node is not None:
698 self.log(f'Dirtying "{indirect_connection.node}".', "debug")
699 indirect_connection.node.dirty = True
701 self._value = value
703 @property
704 def description(self) -> str:
705 """
706 Getter and setter for the port description.
708 Parameters
709 ----------
710 value
711 Value to set the port description with.
713 Returns
714 -------
715 :class:`str` or None
716 Port description.
717 """
719 return self._description
721 @description.setter
722 def description(self, value: str) -> None:
723 """Setter for the **self.description** property."""
725 attest(
726 value is None or isinstance(value, str),
727 f'"description" property: "{value}" is not "None" or '
728 f'its type is not "str"!',
729 )
731 self._description = value
733 @property
734 def node(self) -> PortNode | None:
735 """
736 Getter and setter for the port node.
738 Parameters
739 ----------
740 value : PortNode or None
741 Port node to set.
743 Returns
744 -------
745 :class:`PortNode` or None
746 Port node.
747 """
749 return self._node
751 @node.setter
752 def node(self, value: PortNode | None) -> None:
753 """Setter for the **self.node** property."""
755 attest(
756 value is None or isinstance(value, PortNode),
757 f'"node" property: "{value}" is not "None" or its type is not "PortNode"!',
758 )
760 self._node = value
762 @property
763 def connections(self) -> Dict[Port, None]:
764 """
765 Getter for the port connections.
767 Returns
768 -------
769 :class:`dict`
770 Port connections mapping each :class:`Port` instance to
771 ``None``.
772 """
774 return self._connections
776 def __str__(self) -> str:
777 """
778 Return a formatted string representation of the port.
780 Returns
781 -------
782 :class:`str`
783 Formatted string representation.
785 Examples
786 --------
787 >>> print(Port("a"))
788 None.a (-> [])
789 >>> print(Port("a", node=PortNode("Port Node")))
790 Port Node.a (-> [])
791 """
793 connections = [
794 (
795 f"{connection.node.name}.{connection.name}"
796 if connection.node is not None
797 else "None.{connection.name}"
798 )
799 for connection in self._connections
800 ]
802 direction = "<-" if self.is_input_port() else "->"
804 node_name = self._node.name if self._node is not None else "None"
806 return f"{node_name}.{self._name} ({direction} {connections})"
808 def is_input_port(self) -> bool:
809 """
810 Determine whether the port is an input port.
812 Returns
813 -------
814 :class:`bool`
815 Whether the port is an input port.
817 Examples
818 --------
819 >>> Port().is_input_port()
820 False
821 >>> node = PortNode()
822 >>> node.add_input_port("a").is_input_port()
823 True
824 """
826 if self._node is not None:
827 return self._name in self._node.input_ports
829 return False
831 def is_output_port(self) -> bool:
832 """
833 Determine whether the port is an output port.
835 Returns
836 -------
837 :class:`bool`
838 Whether the port is an output port.
840 Examples
841 --------
842 >>> Port().is_output_port()
843 False
844 >>> node = PortNode()
845 >>> node.add_output_port("output").is_output_port()
846 True
847 """
849 if self._node is not None:
850 return self._name in self._node.output_ports
852 return False
854 def connect(self, port: Port) -> None:
855 """
856 Connect this port to the specified port.
858 Parameters
859 ----------
860 port
861 Port to connect to.
863 Raises
864 ------
865 ValueError
866 If an attempt is made to connect an input port to multiple
867 output ports.
869 Examples
870 --------
871 >>> port_a = Port()
872 >>> port_b = Port()
873 >>> port_a.connections
874 {}
875 >>> port_b.connections
876 {}
877 >>> port_a.connect(port_b)
878 >>> port_a.connections # doctest: +ELLIPSIS
879 {<...Port object at 0x...>: None}
880 >>> port_b.connections # doctest: +ELLIPSIS
881 {<...Port object at 0x...>: None}
882 """
884 attest(isinstance(port, Port), f'"{port}" is not a "Port" instance!')
886 self.log(f'Connecting "{self.name}" to "{port.name}".', "debug")
888 self.connections[port] = None
889 port.connections[self] = None
891 def disconnect(self, port: Port) -> None:
892 """
893 Disconnect from the specified port.
895 Parameters
896 ----------
897 port
898 Port to disconnect from.
900 Examples
901 --------
902 >>> port_a = Port()
903 >>> port_b = Port()
904 >>> port_a.connect(port_b)
905 >>> port_a.connections # doctest: +ELLIPSIS
906 {<...Port object at 0x...>: None}
907 >>> port_b.connections # doctest: +ELLIPSIS
908 {<...Port object at 0x...>: None}
909 >>> port_a.disconnect(port_b)
910 >>> port_a.connections
911 {}
912 >>> port_b.connections
913 {}
914 """
916 attest(isinstance(port, Port), f'"{port}" is not a "Port" instance!')
918 self.log(f'Disconnecting "{self.name}" from "{port.name}".', "debug")
920 self.connections.pop(port)
921 port.connections.pop(self)
923 def to_graphviz(self) -> str:
924 """
925 Generate a string representation for port visualisation with
926 *Graphviz*.
928 Returns
929 -------
930 :class:`str`
931 String representation for visualisation of the port with
932 *Graphviz*.
934 Examples
935 --------
936 >>> Port("a").to_graphviz()
937 '<a> a'
938 """
940 return f"<{self._name}> {self.name}"
943class PortNode(TreeNode, MixinLogging):
944 """
945 Define a node with support for input and output ports.
947 Other Parameters
948 ----------------
949 name
950 Node name.
952 Attributes
953 ----------
954 - :attr:`~colour.utilities.PortNode.input_ports`
955 - :attr:`~colour.utilities.PortNode.output_ports`
956 - :attr:`~colour.utilities.PortNode.dirty`
957 - :attr:`~colour.utilities.PortNode.edges`
958 - :attr:`~colour.utilities.PortNode.description`
960 Methods
961 -------
962 - :meth:`~colour.utilities.PortNode.__init__`
963 - :meth:`~colour.utilities.PortNode.add_input_port`
964 - :meth:`~colour.utilities.PortNode.remove_input_port`
965 - :meth:`~colour.utilities.PortNode.add_output_port`
966 - :meth:`~colour.utilities.PortNode.remove_output_port`
967 - :meth:`~colour.utilities.PortNode.get_input`
968 - :meth:`~colour.utilities.PortNode.set_input`
969 - :meth:`~colour.utilities.PortNode.get_output`
970 - :meth:`~colour.utilities.PortNode.set_output`
971 - :meth:`~colour.utilities.PortNode.connect`
972 - :meth:`~colour.utilities.PortNode.disconnect`
973 - :meth:`~colour.utilities.PortNode.process`
974 - :meth:`~colour.utilities.PortNode.to_graphviz`
976 Examples
977 --------
978 >>> class NodeAdd(PortNode):
979 ... def __init__(self, *args: Any, **kwargs: Any):
980 ... super().__init__(*args, **kwargs)
981 ...
982 ... self.description = "Perform the addition of the two input port values."
983 ...
984 ... self.add_input_port("a")
985 ... self.add_input_port("b")
986 ... self.add_output_port("output")
987 ...
988 ... def process(self):
989 ... a = self.get_input("a")
990 ... b = self.get_input("b")
991 ...
992 ... if a is None or b is None:
993 ... return
994 ...
995 ... self._output_ports["output"].value = a + b
996 ...
997 ... self.dirty = False
998 >>> node = NodeAdd()
999 >>> node.set_input("a", 1)
1000 >>> node.set_input("b", 1)
1001 >>> node.process()
1002 >>> node.get_output("output")
1003 2
1004 """
1006 def __init__(self, name: str | None = None, description: str = "") -> None:
1007 super().__init__(name)
1008 self.description = description
1010 self._input_ports = {}
1011 self._output_ports = {}
1012 self._dirty = True
1014 @property
1015 def input_ports(self) -> Dict[str, Port]:
1016 """
1017 Getter for the input ports of the node.
1019 Returns
1020 -------
1021 :class:`dict`
1022 Dictionary mapping port names to their corresponding input port
1023 instances.
1024 """
1026 return self._input_ports
1028 @property
1029 def output_ports(self) -> Dict[str, Port]:
1030 """
1031 Getter for the output ports of the node.
1033 Returns
1034 -------
1035 :class:`dict`
1036 Mapping of output port names to their corresponding :class:`Port`
1037 instances.
1038 """
1040 return self._output_ports
1042 @property
1043 def dirty(self) -> bool:
1044 """
1045 Getter and setter for the node's dirty state.
1047 Parameters
1048 ----------
1049 value
1050 Value to set the node dirty state with.
1052 Returns
1053 -------
1054 :class:`bool`
1055 Whether the node is in a dirty state.
1056 """
1058 return self._dirty
1060 @dirty.setter
1061 def dirty(self, value: bool) -> None:
1062 """Setter for the **self.dirty** property."""
1064 attest(
1065 isinstance(value, bool),
1066 f'"dirty" property: "{value}" type is not "bool"!',
1067 )
1069 self._dirty = value
1071 @property
1072 def edges(
1073 self,
1074 ) -> Tuple[Dict[Tuple[Port, Port], None], Dict[Tuple[Port, Port], None]]:
1075 """
1076 Getter for the edges of the node.
1078 Retrieve the edges representing ports and their connections. Each
1079 edge corresponds to a port and one of its connections within the
1080 node structure.
1082 Returns
1083 -------
1084 :class:`tuple`
1085 Edges of the node as a tuple of input and output edge
1086 dictionaries.
1087 """
1089 # TODO: Consider using ordered set.
1090 input_edges = {}
1091 for port in self.input_ports.values():
1092 for connection in port.connections:
1093 input_edges[(port, connection)] = None
1095 # TODO: Consider using ordered set.
1096 output_edges = {}
1097 for port in self.output_ports.values():
1098 for connection in port.connections:
1099 output_edges[(port, connection)] = None
1101 return input_edges, output_edges
1103 @property
1104 def description(self) -> str:
1105 """
1106 Getter and setter for the node description.
1108 Parameters
1109 ----------
1110 value
1111 Value to set the node description with.
1113 Returns
1114 -------
1115 :class:`str` or None
1116 Node description.
1117 """
1119 return self._description
1121 @description.setter
1122 def description(self, value: str) -> None:
1123 """Setter for the **self.description** property."""
1125 attest(
1126 value is None or isinstance(value, str),
1127 f'"description" property: "{value}" is not "None" or '
1128 f'its type is not "str"!',
1129 )
1131 self._description = value
1133 def add_input_port(
1134 self,
1135 name: str,
1136 value: Any = None,
1137 description: str = "",
1138 port_type: Type[Port] = Port,
1139 ) -> Port:
1140 """
1141 Add an input port with specified name and value to the node.
1143 Parameters
1144 ----------
1145 name
1146 Name of the input port.
1147 value
1148 Value of the input port.
1149 description
1150 Description of the input port.
1151 port_type
1152 Type of the input port.
1154 Returns
1155 -------
1156 :class:`colour.utilities.Port`
1157 Input port.
1159 Examples
1160 --------
1161 >>> node = PortNode()
1162 >>> node.add_input_port("a") # doctest: +ELLIPSIS
1163 <...Port object at 0x...>
1164 """
1166 self._input_ports[name] = port_type(name, value, description, self)
1168 return self._input_ports[name]
1170 def remove_input_port(
1171 self,
1172 name: str,
1173 ) -> Port:
1174 """
1175 Remove the input port with the specified name from the node.
1177 Parameters
1178 ----------
1179 name
1180 Name of the input port to remove.
1182 Returns
1183 -------
1184 :class:`colour.utilities.Port`
1185 Removed input port.
1187 Examples
1188 --------
1189 >>> node = PortNode()
1190 >>> port = node.add_input_port("a")
1191 >>> node.remove_input_port("a") # doctest: +ELLIPSIS
1192 <...Port object at 0x...>
1193 """
1195 attest(
1196 name in self._input_ports,
1197 f'"{name}" port is not a member of {self} input ports!',
1198 )
1200 port = self._input_ports.pop(name)
1202 for connection in port.connections:
1203 port.disconnect(connection)
1205 return port
1207 def add_output_port(
1208 self,
1209 name: str,
1210 value: Any = None,
1211 description: str = "",
1212 port_type: Type[Port] = Port,
1213 ) -> Port:
1214 """
1215 Add an output port with the specified name and value to the node.
1217 Parameters
1218 ----------
1219 name
1220 Name of the output port.
1221 value
1222 Value of the output port.
1223 description
1224 Description of the output port.
1225 port_type
1226 Type of the output port.
1228 Returns
1229 -------
1230 :class:`colour.utilities.Port`
1231 Output port.
1233 Examples
1234 --------
1235 >>> node = PortNode()
1236 >>> node.add_output_port("output") # doctest: +ELLIPSIS
1237 <...Port object at 0x...>
1238 """
1240 self._output_ports[name] = port_type(name, value, description, self)
1242 return self._output_ports[name]
1244 def remove_output_port(
1245 self,
1246 name: str,
1247 ) -> Port:
1248 """
1249 Remove the output port with the specified name from the node.
1251 Parameters
1252 ----------
1253 name
1254 Name of the output port to remove.
1256 Returns
1257 -------
1258 :class:`colour.utilities.Port`
1259 Removed output port.
1261 Examples
1262 --------
1263 >>> node = PortNode()
1264 >>> port = node.add_output_port("a")
1265 >>> node.remove_output_port("a") # doctest: +ELLIPSIS
1266 <...Port object at 0x...>
1267 """
1269 attest(
1270 name in self._output_ports,
1271 f'"{name}" port is not a member of {self} output ports!',
1272 )
1274 port = self._output_ports.pop(name)
1276 for connection in port.connections:
1277 port.disconnect(connection)
1279 return port
1281 def get_input(self, name: str) -> Any:
1282 """
1283 Return the value of the input port with the specified name.
1285 Parameters
1286 ----------
1287 name
1288 Name of the input port.
1290 Returns
1291 -------
1292 :class:`object`
1293 Value of the input port.
1295 Raises
1296 ------
1297 AssertionError
1298 If the input port is not a member of the node input ports.
1300 Examples
1301 --------
1302 >>> node = PortNode()
1303 >>> port = node.add_input_port("a", 1) # doctest: +ELLIPSIS
1304 >>> node.get_input("a")
1305 1
1306 """
1308 attest(
1309 name in self._input_ports,
1310 f'"{name}" is not a member of "{self._name}" input ports!',
1311 )
1313 return self._input_ports[name].value
1315 def set_input(self, name: str, value: Any) -> None:
1316 """
1317 Set the value of an input port with the specified name.
1319 Parameters
1320 ----------
1321 name
1322 Name of the input port to set.
1323 value
1324 Value to assign to the input port.
1326 Raises
1327 ------
1328 AssertionError
1329 If the specified input port is not a member of the node's
1330 input ports.
1332 Examples
1333 --------
1334 >>> node = PortNode()
1335 >>> port = node.add_input_port("a") # doctest: +ELLIPSIS
1336 >>> port.value
1337 >>> node.set_input("a", 1)
1338 >>> port.value
1339 1
1340 """
1342 attest(
1343 name in self._input_ports,
1344 f'"{name}" is not a member of "{self._name}" input ports!',
1345 )
1347 self._input_ports[name].value = value
1349 def get_output(self, name: str) -> Any:
1350 """
1351 Return the value of the output port with the specified name.
1353 Parameters
1354 ----------
1355 name
1356 Name of the output port.
1358 Returns
1359 -------
1360 :class:`object`
1361 Value of the output port.
1363 Raises
1364 ------
1365 AssertionError
1366 If the output port is not a member of the node output
1367 ports.
1369 Examples
1370 --------
1371 >>> node = PortNode()
1372 >>> port = node.add_output_port("output", 1) # doctest: +ELLIPSIS
1373 >>> node.get_output("output")
1374 1
1375 """
1377 attest(
1378 name in self._output_ports,
1379 f'"{name}" is not a member of "{self._name}" output ports!',
1380 )
1382 return self._output_ports[name].value
1384 def set_output(self, name: str, value: Any) -> None:
1385 """
1386 Set the value of the output port with the specified name.
1388 Parameters
1389 ----------
1390 name
1391 Name of the output port.
1392 value
1393 Value to assign to the output port.
1395 Raises
1396 ------
1397 AssertionError
1398 If the output port is not a member of the node output ports.
1400 Examples
1401 --------
1402 >>> node = PortNode()
1403 >>> port = node.add_output_port("output") # doctest: +ELLIPSIS
1404 >>> port.value
1405 >>> node.set_output("output", 1)
1406 >>> port.value
1407 1
1408 """
1410 attest(
1411 name in self._output_ports,
1412 f'"{name}" is not a member of "{self._name}" input ports!',
1413 )
1415 self._output_ports[name].value = value
1417 def connect(
1418 self,
1419 source_port: str,
1420 target_node: PortNode,
1421 target_port: str,
1422 ) -> None:
1423 """
1424 Connect the specified source port to the specified target port of
1425 another node.
1427 The source port can be an input port but the target port must be
1428 an output port and conversely, if the source port is an output
1429 port, the target port must be an input port.
1431 Parameters
1432 ----------
1433 source_port
1434 Source port of the node to connect to the other node target
1435 port.
1436 target_node
1437 Target node that the target port is the member of.
1438 target_port
1439 Target port from the target node to connect the source port to.
1441 Examples
1442 --------
1443 >>> node_1 = PortNode()
1444 >>> port = node_1.add_output_port("output")
1445 >>> node_2 = PortNode()
1446 >>> port = node_2.add_input_port("a")
1447 >>> node_1.connect("output", node_2, "a")
1448 >>> node_1.edges # doctest: +ELLIPSIS
1449 ({}, {(<...Port object at 0x...>, <...Port object at 0x...>): None})
1450 """
1452 port_source = self._output_ports.get(
1453 source_port, self.input_ports.get(source_port)
1454 )
1455 port_target = target_node.input_ports.get(
1456 target_port, target_node.output_ports.get(target_port)
1457 )
1459 port_source.connect(port_target)
1461 def disconnect(
1462 self,
1463 source_port: str,
1464 target_node: PortNode,
1465 target_port: str,
1466 ) -> None:
1467 """
1468 Disconnect the specified source port from the specified target node
1469 port.
1471 The source port can be an input port but the target port must be an
1472 output port and conversely, if the source port is an output port,
1473 the target port must be an input port.
1475 Parameters
1476 ----------
1477 source_port
1478 Source port of the node to disconnect from the other node target
1479 port.
1480 target_node
1481 Target node that the target port is the member of.
1482 target_port
1483 Target port from the target node to disconnect the source port
1484 from.
1486 Examples
1487 --------
1488 >>> node_1 = PortNode()
1489 >>> port = node_1.add_output_port("output")
1490 >>> node_2 = PortNode()
1491 >>> port = node_2.add_input_port("a")
1492 >>> node_1.connect("output", node_2, "a")
1493 >>> node_1.edges # doctest: +ELLIPSIS
1494 ({}, {(<...Port object at 0x...>, <...Port object at 0x...>): None})
1495 >>> node_1.disconnect("output", node_2, "a")
1496 >>> node_1.edges
1497 ({}, {})
1498 """
1500 port_source = self._output_ports.get(
1501 source_port, self.input_ports.get(source_port)
1502 )
1503 port_target = target_node.input_ports.get(
1504 target_port, target_node.output_ports.get(target_port)
1505 )
1507 port_source.disconnect(port_target)
1509 def process(self) -> None:
1510 """
1511 Process the node, must be reimplemented by sub-classes.
1513 This definition is responsible for setting the dirty state of the
1514 node according to the processing outcome.
1516 Examples
1517 --------
1518 >>> class NodeAdd(PortNode):
1519 ... def __init__(self, *args: Any, **kwargs: Any):
1520 ... super().__init__(*args, **kwargs)
1521 ...
1522 ... self.description = (
1523 ... "Perform the addition of the two input port values."
1524 ... )
1525 ...
1526 ... self.add_input_port("a")
1527 ... self.add_input_port("b")
1528 ... self.add_output_port("output")
1529 ...
1530 ... def process(self):
1531 ... a = self.get_input("a")
1532 ... b = self.get_input("b")
1533 ...
1534 ... if a is None or b is None:
1535 ... return
1536 ...
1537 ... self._output_ports["output"].value = a + b
1538 ...
1539 ... self.dirty = False
1540 >>> node = NodeAdd()
1541 >>> node.set_input("a", 1)
1542 >>> node.set_input("b", 1)
1543 >>> node.process()
1544 >>> node.get_output("output")
1545 2
1546 """
1548 self._dirty = False
1550 def to_graphviz(self) -> str:
1551 """
1552 Generate a string representation for node visualisation with
1553 *Graphviz*.
1555 Returns
1556 -------
1557 :class:`str`
1558 String representation for visualisation of the node with
1559 *Graphviz*.
1561 Examples
1562 --------
1563 >>> node_1 = PortNode("PortNode")
1564 >>> port = node_1.add_input_port("a")
1565 >>> port = node_1.add_input_port("b")
1566 >>> port = node_1.add_output_port("output")
1567 >>> node_1.to_graphviz() # doctest: +ELLIPSIS
1568 'PortNode (#...) | {{<a> a|<b> b} | {<output> output}}'
1569 """
1571 input_ports = "|".join(
1572 [port.to_graphviz() for port in self._input_ports.values()]
1573 )
1574 output_ports = "|".join(
1575 [port.to_graphviz() for port in self._output_ports.values()]
1576 )
1578 return f"{self.name} (#{self.id}) | {{{{{input_ports}}} | {{{output_ports}}}}}"
1581class PortGraph(PortNode):
1582 """
1583 Define a node-graph for :class:`colour.utilities.PortNode` class
1584 instances.
1586 Parameters
1587 ----------
1588 name
1589 Name of the node-graph.
1590 description
1591 Description of the node-graph's purpose or functionality.
1593 Attributes
1594 ----------
1595 - :attr:`~colour.utilities.PortGraph.nodes`
1597 Methods
1598 -------
1599 - :meth:`~colour.utilities.PortGraph.__str__`
1600 - :meth:`~colour.utilities.PortGraph.add_node`
1601 - :meth:`~colour.utilities.PortGraph.remove_node`
1602 - :meth:`~colour.utilities.PortGraph.walk_ports`
1603 - :meth:`~colour.utilities.PortGraph.process`
1604 - :meth:`~colour.utilities.PortGraph.to_graphviz`
1606 Examples
1607 --------
1608 >>> class NodeAdd(PortNode):
1609 ... def __init__(self, *args: Any, **kwargs: Any):
1610 ... super().__init__(*args, **kwargs)
1611 ...
1612 ... self.description = "Perform the addition of the two input port values."
1613 ...
1614 ... self.add_input_port("a")
1615 ... self.add_input_port("b")
1616 ... self.add_output_port("output")
1617 ...
1618 ... def process(self):
1619 ... a = self.get_input("a")
1620 ... b = self.get_input("b")
1621 ...
1622 ... if a is None or b is None:
1623 ... return
1624 ...
1625 ... self._output_ports["output"].value = a + b
1626 ...
1627 ... self.dirty = False
1628 >>> node_1 = NodeAdd()
1629 >>> node_1.set_input("a", 1)
1630 >>> node_1.set_input("b", 1)
1631 >>> node_2 = NodeAdd()
1632 >>> node_1.connect("output", node_2, "a")
1633 >>> node_2.set_input("b", 1)
1634 >>> graph = PortGraph()
1635 >>> graph.add_node(node_1)
1636 >>> graph.add_node(node_2)
1637 >>> graph.nodes # doctest: +ELLIPSIS
1638 {'NodeAdd#...': <...NodeAdd object at 0x...>, \
1639'NodeAdd#...': <...NodeAdd object at 0x...>}
1640 >>> graph.process()
1641 >>> node_2.get_output("output")
1642 3
1643 """
1645 def __init__(self, name: str | None = None, description: str = "") -> None:
1646 super().__init__(name, description)
1648 self._name: str = self.__class__.__name__
1649 self.name = optional(name, self._name)
1650 self.description = description
1652 self._nodes = {}
1654 @property
1655 def nodes(self) -> Dict[str, PortNode]:
1656 """
1657 Getter for the node-graph nodes.
1659 Returns
1660 -------
1661 :class:`dict`
1662 Node-graph nodes as a mapping from node identifiers to their
1663 corresponding :class:`PortNode` instances.
1664 """
1666 return self._nodes
1668 def __str__(self) -> str:
1669 """
1670 Return a formatted string representation of the node-graph.
1672 Returns
1673 -------
1674 :class:`str`
1675 Formatted string representation.
1676 """
1678 return f"{self.__class__.__name__}({len(self._nodes)})"
1680 def add_node(self, node: PortNode) -> None:
1681 """
1682 Add specified node to the node-graph.
1684 Parameters
1685 ----------
1686 node
1687 Node to add to the node-graph.
1689 Raises
1690 ------
1691 AssertionError
1692 If the node is not a :class:`colour.utilities.PortNode` class
1693 instance.
1695 Examples
1696 --------
1697 >>> node_1 = PortNode()
1698 >>> node_2 = PortNode()
1699 >>> graph = PortGraph()
1700 >>> graph.nodes
1701 {}
1702 >>> graph.add_node(node_1)
1703 >>> graph.nodes # doctest: +ELLIPSIS
1704 {'PortNode#...': <...PortNode object at 0x...>}
1705 >>> graph.add_node(node_2)
1706 >>> graph.nodes # doctest: +ELLIPSIS
1707 {'PortNode#...': <...PortNode object at 0x...>, 'PortNode#...': \
1708<...PortNode object at 0x...>}
1709 """
1711 attest(isinstance(node, PortNode), f'"{node}" is not a "PortNode" instance!')
1713 attest(
1714 node.name not in self._nodes, f'"{node}" is already a member of the graph!'
1715 )
1717 self._nodes[node.name] = node
1718 self._children.append(node) # pyright: ignore
1719 node._parent = self # noqa: SLF001
1721 def remove_node(self, node: PortNode) -> None:
1722 """
1723 Remove the specified node from the node-graph.
1725 The node input and output ports will be disconnected from all their
1726 connections.
1728 Parameters
1729 ----------
1730 node
1731 Node to remove from the node-graph.
1733 Raises
1734 ------
1735 AsssertionError
1736 If the node is not a member of the node-graph.
1738 Examples
1739 --------
1740 >>> node_1 = PortNode()
1741 >>> node_2 = PortNode()
1742 >>> graph = PortGraph()
1743 >>> graph.add_node(node_1)
1744 >>> graph.add_node(node_2)
1745 >>> graph.nodes # doctest: +ELLIPSIS
1746 {'PortNode#...': <...PortNode object at 0x...>, \
1747'PortNode#...': <...PortNode object at 0x...>}
1748 >>> graph.remove_node(node_2)
1749 >>> graph.nodes # doctest: +ELLIPSIS
1750 {'PortNode#...': <...PortNode object at 0x...>}
1751 >>> graph.remove_node(node_1)
1752 >>> graph.nodes
1753 {}
1754 """
1756 attest(isinstance(node, PortNode), f'"{node}" is not a "PortNode" instance!')
1758 attest(
1759 node.name in self._nodes,
1760 f'"{node}" is not a member of "{self._name}" node-graph!',
1761 )
1763 for port in node.input_ports.values():
1764 for connection in port.connections.copy():
1765 port.disconnect(connection)
1767 for port in node.output_ports.values():
1768 for connection in port.connections.copy():
1769 port.disconnect(connection)
1771 self._nodes.pop(node.name)
1772 self._children.remove(node) # pyright: ignore
1773 node._parent = None # noqa: SLF001
1775 @required("NetworkX")
1776 def walk_ports(self) -> Generator:
1777 """
1778 Return a generator to walk the node-graph in topological order.
1780 Walk the node according to topologically sorted order. A topological
1781 sort is a non-unique permutation of the nodes of a directed graph
1782 such that an edge from :math:`u` to :math:`v` implies that :math:`u`
1783 appears before :math:`v` in the topological sort order. This ordering
1784 is valid only if the graph has no directed cycles.
1786 To walk the node-graph, a *NetworkX* graph is constructed by
1787 connecting the ports together and in turn connecting them to the
1788 nodes.
1790 Yields
1791 ------
1792 Generator
1793 Node-graph walker.
1795 Examples
1796 --------
1797 >>> node_1 = PortNode()
1798 >>> port = node_1.add_output_port("output")
1799 >>> node_2 = PortNode()
1800 >>> port = node_2.add_input_port("a")
1801 >>> graph = PortGraph()
1802 >>> graph.add_node(node_1)
1803 >>> graph.add_node(node_2)
1804 >>> node_1.connect("output", node_2, "a")
1805 >>> list(graph.walk_ports()) # doctest: +ELLIPSIS
1806 [<...PortNode object at 0x...>, <...PortNode object at 0x...>]
1807 """
1809 import networkx as nx # noqa: PLC0415
1811 graph = nx.DiGraph()
1813 for node in self._children:
1814 input_edges, output_edges = node.edges
1816 graph.add_node(node.name, node=node)
1818 if len(node.children) != 0:
1819 continue
1821 for edge in input_edges:
1822 # PortGraph is used a container, it is common to connect its
1823 # input ports to other node input ports and other node output
1824 # ports to its output ports. The graph generated is thus not
1825 # acyclic.
1826 if self in (edge[0].node, edge[1].node):
1827 continue
1829 # Node -> Port -> Port -> Node
1830 # Connected Node Output Port Node -> Connected Node Output Port
1831 graph.add_edge(
1832 edge[1].node.name, # pyright: ignore
1833 str(edge[1]),
1834 edge=edge,
1835 )
1836 # Connected Node Output Port -> Node Input Port
1837 graph.add_edge(str(edge[1]), str(edge[0]), edge=edge)
1838 # Input Port - Input Port Node
1839 graph.add_edge(
1840 str(edge[0]),
1841 edge[0].node.name, # pyright: ignore
1842 edge=edge,
1843 )
1845 for edge in output_edges:
1846 if self in (edge[0].node, edge[1].node):
1847 continue
1849 # Node -> Port -> Port -> Node
1850 # Output Port Node -> Output Port
1851 graph.add_edge(
1852 edge[0].node.name, # pyright: ignore
1853 str(edge[0]),
1854 edge=edge,
1855 )
1856 # Node Output Port -> Connected Node Input Port
1857 graph.add_edge(str(edge[0]), str(edge[1]), edge=edge)
1858 # Connected Node Input Port -> Connected Node Input Port Node
1859 graph.add_edge(
1860 str(edge[1]),
1861 edge[1].node.name, # pyright: ignore
1862 edge=edge,
1863 )
1865 try:
1866 for name in nx.topological_sort(graph):
1867 node = graph.nodes[name].get("node")
1868 if node is not None:
1869 yield node
1870 except nx.NetworkXUnfeasible as error:
1871 filename = "AGraph.png"
1872 self.log( # pyright: ignore
1873 f'A "NetworkX" error occurred, debug graph image has been '
1874 f'saved to "{os.path.join(os.getcwd(), filename)}"!'
1875 )
1876 agraph = nx.nx_agraph.to_agraph(graph)
1877 agraph.draw(filename, prog="dot")
1879 raise error # noqa: TRY201
1881 def process(self, **kwargs: Dict) -> None:
1882 """
1883 Process the node-graph by traversing it and executing the
1884 :func:`colour.utilities.PortNode.process` method for each node.
1886 Other Parameters
1887 ----------------
1888 kwargs
1889 Keyword arguments.
1891 Examples
1892 --------
1893 >>> class NodeAdd(PortNode):
1894 ... def __init__(self, *args: Any, **kwargs: Any):
1895 ... super().__init__(*args, **kwargs)
1896 ...
1897 ... self.description = (
1898 ... "Perform the addition of the two input port values."
1899 ... )
1900 ...
1901 ... self.add_input_port("a")
1902 ... self.add_input_port("b")
1903 ... self.add_output_port("output")
1904 ...
1905 ... def process(self):
1906 ... a = self.get_input("a")
1907 ... b = self.get_input("b")
1908 ...
1909 ... if a is None or b is None:
1910 ... return
1911 ...
1912 ... self._output_ports["output"].value = a + b
1913 ...
1914 ... self.dirty = False
1915 >>> node_1 = NodeAdd()
1916 >>> node_1.set_input("a", 1)
1917 >>> node_1.set_input("b", 1)
1918 >>> node_2 = NodeAdd()
1919 >>> node_1.connect("output", node_2, "a")
1920 >>> node_2.set_input("b", 1)
1921 >>> graph = PortGraph()
1922 >>> graph.add_node(node_1)
1923 >>> graph.add_node(node_2)
1924 >>> graph.nodes # doctest: +ELLIPSIS
1925 {'NodeAdd#...': <...NodeAdd object at 0x...>, \
1926'NodeAdd#...': <...NodeAdd object at 0x...>}
1927 >>> graph.process()
1928 >>> node_2.get_output("output")
1929 3
1930 >>> node_2.dirty
1931 False
1932 """
1934 dry_run = kwargs.get("dry_run", False)
1936 for_node_reached = False
1937 for node in self.walk_ports():
1938 if for_node_reached:
1939 break
1941 # Processing currently stops once a control flow node is reached.
1942 # TODO: Implement solid control flow based processing using a stack.
1943 if isinstance(node, ControlFlowNode):
1944 for_node_reached = True
1946 if not node.dirty:
1947 self.log(f'Skipping "{node}" computed node.')
1948 continue
1950 self.log(f'Processing "{node}" node...')
1952 if dry_run:
1953 continue
1955 node.process()
1957 @required("Pydot")
1958 def to_graphviz(self) -> Dot: # noqa: F821 # pyright: ignore
1959 """
1960 Generate a node-graph visualisation for *Graphviz*.
1962 Returns
1963 -------
1964 :class:`pydot.Dot`
1965 *Pydot* graph.
1967 Examples
1968 --------
1969 >>> node_1 = PortNode()
1970 >>> port = node_1.add_output_port("output")
1971 >>> node_2 = PortNode()
1972 >>> port = node_2.add_input_port("a")
1973 >>> graph = PortGraph()
1974 >>> graph.add_node(node_1)
1975 >>> graph.add_node(node_2)
1976 >>> node_1.connect("output", node_2, "a")
1977 >>> graph.to_graphviz() # doctest: +SKIP
1978 <pydot.core.Dot object at 0x...>
1979 """
1981 if self._parent is not None:
1982 return PortNode.to_graphviz(self)
1984 import pydot # noqa: PLC0415
1986 dot = pydot.Dot(
1987 "digraph", graph_type="digraph", rankdir="LR", splines="polyline"
1988 )
1990 graphs = [node for node in self.walk_ports() if isinstance(node, PortGraph)]
1992 def is_graph_member(node: PortNode) -> bool:
1993 """Determine whether the specified node is member of a graph."""
1995 return any(node in graph.nodes.values() for graph in graphs)
1997 for node in self.walk_ports():
1998 dot.add_node(
1999 pydot.Node(
2000 f"{node.name} (#{node.id})",
2001 label=node.to_graphviz(),
2002 shape="record",
2003 )
2004 )
2005 input_edges, output_edges = node.edges
2007 for edge in input_edges:
2008 # Not drawing node edges that involve a node member of graph.
2009 if is_graph_member(edge[0].node) or is_graph_member(edge[1].node):
2010 continue
2012 dot.add_edge(
2013 pydot.Edge(
2014 f"{edge[1].node.name} (#{edge[1].node.id})",
2015 f"{edge[0].node.name} (#{edge[0].node.id})",
2016 tailport=edge[1].name,
2017 headport=edge[0].name,
2018 key=f"{edge[1]} => {edge[0]}",
2019 dir="forward",
2020 )
2021 )
2023 return dot
2026class ExecutionPort(Port):
2027 """
2028 Define a specialised port for execution flow control in node graphs.
2030 Attributes
2031 ----------
2032 value
2033 Port value accessor for execution state transmission.
2034 """
2036 @property
2037 def value(self) -> Any:
2038 """
2039 Getter and setter for the port value.
2041 Parameters
2042 ----------
2043 value
2044 Value to set the port value with.
2046 Returns
2047 -------
2048 :class:`object`
2049 Port value.
2050 """
2052 @value.setter
2053 def value(self, value: Any) -> None:
2054 """Setter for the **self.value** property."""
2057class ExecutionNode(PortNode):
2058 """
2059 Define a specialised node that manages execution flow through
2060 dedicated input and output ports.
2061 """
2063 def __init__(self, *args: Any, **kwargs: Any) -> None:
2064 super().__init__(*args, **kwargs)
2066 self.add_input_port(
2067 "execution_input", None, "Port for input execution", ExecutionPort
2068 )
2069 self.add_output_port(
2070 "execution_output", None, "Port for output execution", ExecutionPort
2071 )
2074class ControlFlowNode(ExecutionNode):
2075 """
2076 Define a base class for control flow nodes in computational graphs.
2077 """
2079 def __init__(self, *args: Any, **kwargs: Any) -> None:
2080 super().__init__(*args, **kwargs)
2083class For(ControlFlowNode):
2084 """
2085 Define a ``for`` loop node for iterating over arrays.
2087 The node iterates over the input port ``array``, setting the
2088 ``index`` and ``element`` output ports at each iteration and calling
2089 the :meth:`colour.utilities.ExecutionNode.process` method of the
2090 object connected to the ``loop_output`` output port.
2092 Upon completion, the :meth:`colour.utilities.ExecutionNode.process`
2093 method of the object connected to the ``execution_output`` output
2094 port is called.
2096 Notes
2097 -----
2098 - The :class:`colour.utilities.For` loop node does not currently
2099 call more than the two aforementioned
2100 :meth:`colour.utilities.ExecutionNode.process` methods, if a
2101 series of nodes is attached to the ``loop_output`` or
2102 ``execution_output`` output ports, only the left-most node will
2103 be processed. To circumvent this limitation, it is recommended
2104 to use a :class:`colour.utilities.PortGraph` class instance.
2105 """
2107 def __init__(self, *args: Any, **kwargs: Any) -> None:
2108 super().__init__(*args, **kwargs)
2110 self.add_input_port("array", [], "Array to loop onto")
2111 self.add_output_port("index", None, "Index of the current element of the array")
2112 self.add_output_port("element", None, "Current element of the array")
2113 self.add_output_port("loop_output", None, "Port for loop Output", ExecutionPort)
2115 def process(self) -> None:
2116 """Process the *for* loop node execution."""
2118 connection = next(iter(self.output_ports["loop_output"].connections), None)
2119 if connection is None:
2120 return
2122 node = connection.node
2124 if node is None:
2125 return
2127 self.log(f'Processing "{node}" node...')
2129 for i, element in enumerate(self.get_input("array")):
2130 self.log(f"Index {i}, Element {element}", "debug")
2131 self.set_output("index", i)
2132 self.set_output("element", element)
2134 node.process()
2136 execution_output_connection = next(
2137 iter(self.output_ports["execution_output"].connections), None
2138 )
2139 if execution_output_connection is None:
2140 return
2142 execution_output_node = execution_output_connection.node
2144 if execution_output_node is None:
2145 return
2147 execution_output_node.process()
2149 self.dirty = False
2152_THREADING_LOCK = threading.Lock()
2155def _task_thread(args: Sequence) -> tuple[int, Any]:
2156 """
2157 Execute the default task for the
2158 :class:`colour.utilities.ParallelForThread` loop node.
2160 Parameters
2161 ----------
2162 args
2163 Processing arguments for the parallel thread task.
2165 Returns
2166 -------
2167 :class:`tuple`
2168 Index and result pair from the executed task.
2169 """
2171 i, element, sub_graph, node = args
2173 node.log(f"Index {i}, Element {element}", "info")
2175 with _THREADING_LOCK:
2176 node.set_output("index", i)
2177 node.set_output("element", element)
2179 sub_graph.process()
2181 return i, sub_graph.get_output("output")
2184class ThreadPoolExecutorManager:
2185 """
2186 Define a singleton :class:`concurrent.futures.ThreadPoolExecutor`
2187 manager.
2189 Attributes
2190 ----------
2191 - :attr:`~colour.utilities.ThreadPoolExecutorManager.ThreadPoolExecutor`
2193 Methods
2194 -------
2195 - :meth:`~colour.utilities.ThreadPoolExecutorManager.get_executor`
2196 - :meth:`~colour.utilities.ThreadPoolExecutorManager.shutdown_executor`
2197 """
2199 ThreadPoolExecutor: concurrent.futures.ThreadPoolExecutor | None = None
2201 @staticmethod
2202 def get_executor(
2203 max_workers: int | None = None,
2204 ) -> concurrent.futures.ThreadPoolExecutor:
2205 """
2206 Return the :class:`concurrent.futures.ThreadPoolExecutor` class
2207 instance or create it if not existing.
2209 Parameters
2210 ----------
2211 max_workers
2212 Maximum worker count.
2214 Returns
2215 -------
2216 :class:`concurrent.futures.ThreadPoolExecutor`
2217 Thread pool executor instance.
2219 Notes
2220 -----
2221 The :class:`concurrent.futures.ThreadPoolExecutor` class instance is
2222 automatically shutdown on process exit.
2223 """
2225 if ThreadPoolExecutorManager.ThreadPoolExecutor is None:
2226 ThreadPoolExecutorManager.ThreadPoolExecutor = (
2227 concurrent.futures.ThreadPoolExecutor(max_workers=max_workers)
2228 )
2230 return ThreadPoolExecutorManager.ThreadPoolExecutor
2232 @atexit.register
2233 @staticmethod
2234 def shutdown_executor() -> None:
2235 """
2236 Shut down the :class:`concurrent.futures.ThreadPoolExecutor` class
2237 instance.
2238 """
2240 if ThreadPoolExecutorManager.ThreadPoolExecutor is not None:
2241 ThreadPoolExecutorManager.ThreadPoolExecutor.shutdown(wait=True)
2242 ThreadPoolExecutorManager.ThreadPoolExecutor = None
2245class ParallelForThread(ControlFlowNode):
2246 """
2247 Define an advanced ``for`` loop node that distributes work across
2248 multiple threads for parallel execution.
2250 Each generated task receives one ``index`` and ``element`` output port
2251 value. The tasks are executed by a
2252 :class:`concurrent.futures.ThreadPoolExecutor` class instance. The
2253 futures results are collected, sorted, and assigned to the ``results``
2254 output port.
2256 Upon completion, the :meth:`colour.utilities.ExecutionNode.process`
2257 method of the object connected to the ``execution_output`` output port
2258 is called.
2260 Notes
2261 -----
2262 - The :class:`colour.utilities.ParallelForThread` loop node does not
2263 currently call more than the two aforementioned
2264 :meth:`colour.utilities.ExecutionNode.process` methods. If a series
2265 of nodes is attached to the ``loop_output`` or ``execution_output``
2266 output ports, only the left-most node will be processed. To
2267 circumvent this limitation, it is recommended to use a
2268 :class:`colour.utilities.PortGraph` class instance.
2269 - As the graph being processed is shared across the threads, a lock
2270 must be taken in the task callable. This might nullify any speed
2271 gains for heavy processing tasks. In such eventuality, it is
2272 recommended to use the
2273 :class:`colour.utilities.ParallelForMultiprocess` loop node
2274 instead.
2275 """
2277 def __init__(self, *args: Any, **kwargs: Any) -> None:
2278 super().__init__(*args, **kwargs)
2280 self.add_input_port("array", [], "Array to loop onto")
2281 self.add_input_port("task", _task_thread, "Task to execute")
2282 self.add_input_port("workers", 16, "Maximum number of workers")
2283 self.add_output_port("index", None, "Index of the current element of the array")
2284 self.add_output_port("element", None, "Current element of the array")
2285 self.add_output_port("results", [], "Results from the parallel loop")
2286 self.add_output_port("loop_output", None, "Port for loop output", ExecutionPort)
2288 def process(self) -> None:
2289 """
2290 Process the parallel loop node execution.
2291 """
2293 connection = next(iter(self.output_ports["loop_output"].connections), None)
2294 if connection is None:
2295 return
2297 node = connection.node
2299 if node is None:
2300 return
2302 self.log(f'Processing "{node}" node...')
2304 results = {}
2305 thread_pool_executor = ThreadPoolExecutorManager.get_executor(
2306 max_workers=self.get_input("workers")
2307 )
2308 futures = [
2309 thread_pool_executor.submit(
2310 self.get_input("task"), (i, element, node, self)
2311 )
2312 for i, element in enumerate(self.get_input("array"))
2313 ]
2315 for future in concurrent.futures.as_completed(futures):
2316 index, element = future.result()
2317 self.log(f'Processed "{element}" element with index "{index}".')
2318 results[index] = element
2320 results = dict(sorted(results.items()))
2321 self.set_output("results", list(results.values()))
2323 execution_output_connection = next(
2324 iter(self.output_ports["execution_output"].connections), None
2325 )
2326 if execution_output_connection is None:
2327 return
2329 execution_output_node = execution_output_connection.node
2331 if execution_output_node is None:
2332 return
2334 execution_output_node.process()
2336 self.dirty = False
2339def _task_multiprocess(args: Sequence) -> tuple[int, Any]:
2340 """
2341 Execute the default processing task for
2342 :class:`colour.utilities.ParallelForMultiprocess` loop node instances.
2344 Parameters
2345 ----------
2346 args
2347 Processing arguments for the parallel execution task.
2349 Returns
2350 -------
2351 :class:`tuple`
2352 Tuple containing the task index and computed result.
2353 """
2355 i, element, sub_graph, node = args
2357 node.log(f"Index {i}, Element {element}", "info")
2359 node.set_output("index", i)
2360 node.set_output("element", element)
2362 sub_graph.process()
2364 return i, sub_graph.get_output("output")
2367class ProcessPoolExecutorManager:
2368 """
2369 Define a singleton :class:`concurrent.futures.ProcessPoolExecutor`
2370 manager for parallel processing.
2372 Attributes
2373 ----------
2374 - :attr:`~colour.utilities.ProcessPoolExecutorManager.ProcessPoolExecutor`
2376 Methods
2377 -------
2378 - :meth:`~colour.utilities.ProcessPoolExecutorManager.get_executor`
2379 - :meth:`~colour.utilities.ProcessPoolExecutorManager.shutdown_executor`
2380 """
2382 ProcessPoolExecutor: concurrent.futures.ProcessPoolExecutor | None = None
2384 @staticmethod
2385 def get_executor(
2386 max_workers: int | None = None,
2387 ) -> concurrent.futures.ProcessPoolExecutor:
2388 """
2389 Return the :class:`concurrent.futures.ProcessPoolExecutor` class
2390 instance or create it if not existing.
2392 Parameters
2393 ----------
2394 max_workers
2395 Maximum number of worker processes. If ``None``, it will
2396 default to the number of processors on the machine.
2398 Returns
2399 -------
2400 :class:`concurrent.futures.ProcessPoolExecutor`
2401 Process pool executor instance for parallel execution.
2403 Notes
2404 -----
2405 The :class:`concurrent.futures.ProcessPoolExecutor` class instance is
2406 automatically shut down on process exit.
2407 """
2409 if ProcessPoolExecutorManager.ProcessPoolExecutor is None:
2410 context = multiprocessing.get_context("spawn")
2411 ProcessPoolExecutorManager.ProcessPoolExecutor = (
2412 concurrent.futures.ProcessPoolExecutor(
2413 mp_context=context, max_workers=max_workers
2414 )
2415 )
2417 return ProcessPoolExecutorManager.ProcessPoolExecutor
2419 @atexit.register
2420 @staticmethod
2421 def shutdown_executor() -> None:
2422 """
2423 Shut down the :class:`concurrent.futures.ProcessPoolExecutor` class
2424 instance.
2425 """
2427 if ProcessPoolExecutorManager.ProcessPoolExecutor is not None:
2428 ProcessPoolExecutorManager.ProcessPoolExecutor.shutdown(wait=True)
2429 ProcessPoolExecutorManager.ProcessPoolExecutor = None
2432class ParallelForMultiprocess(ControlFlowNode):
2433 """
2434 Define a parallel ``for`` loop node that distributes operations across
2435 multiple processes.
2437 Distribute iteration work by assigning each task one ``index`` and
2438 ``element`` output port value. Execute tasks using a
2439 :class:`multiprocessing.Pool` instance, then collect, sort, and assign
2440 results to the ``results`` output port.
2442 Upon completion, invoke the :meth:`colour.utilities.ExecutionNode.process`
2443 method of the object connected to the ``execution_output`` output port.
2445 Notes
2446 -----
2447 - The :class:`colour.utilities.ParallelForMultiprocess` loop node
2448 currently invokes only the two aforementioned
2449 :meth:`colour.utilities.ExecutionNode.process` methods. When a series
2450 of nodes connects to the ``loop_output`` or ``execution_output``
2451 output ports, only the left-most node processes. To circumvent this
2452 limitation, use a :class:`colour.utilities.PortGraph` class instance.
2453 """
2455 def __init__(self, *args: Any, **kwargs: Any) -> None:
2456 super().__init__(*args, **kwargs)
2458 self.add_input_port("array", [], "Array to loop onto")
2459 self.add_input_port("task", _task_multiprocess, "Task to execute")
2460 self.add_input_port("processes", 4, "Number of processes")
2461 self.add_output_port("index", None, "Index of the current element of the array")
2462 self.add_output_port("element", None, "Current element of the array")
2463 self.add_output_port("results", [], "Results from the parallel loop")
2464 self.add_output_port("loop_output", None, "Port for loop output", ExecutionPort)
2466 def process(self) -> None:
2467 """
2468 Process the ``for`` loop node execution.
2469 """
2471 connection = next(iter(self.output_ports["loop_output"].connections), None)
2472 if connection is None:
2473 return
2475 node = connection.node
2477 if node is None:
2478 return
2480 self.log(f'Processing "{node}" node...')
2482 results = {}
2483 process_pool_executor = ProcessPoolExecutorManager.get_executor(
2484 max_workers=self.get_input("processes")
2485 )
2486 futures = [
2487 process_pool_executor.submit(
2488 self.get_input("task"), (i, element, node, self)
2489 )
2490 for i, element in enumerate(self.get_input("array"))
2491 ]
2493 for future in concurrent.futures.as_completed(futures):
2494 index, element = future.result()
2495 self.log(f'Processed "{element}" element with index "{index}".')
2496 results[index] = element
2498 results = dict(sorted(results.items()))
2499 self.set_output("results", list(results.values()))
2501 execution_output_connection = next(
2502 iter(self.output_ports["execution_output"].connections), None
2503 )
2504 if execution_output_connection is None:
2505 return
2507 execution_output_node = execution_output_connection.node
2509 if execution_output_node is None:
2510 return
2512 execution_output_node.process()
2514 self.dirty = False