Coverage for utilities/network.py: 63%

1"""

2 Network

3=======

4

5 Node-graph and network infrastructure for computational workflows.

6

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"""

28

29 from __future__ import annotations

30

31 import atexit

32 import concurrent.futures

33 import multiprocessing

34 import os

35 import threading

36 import typing

37

38 if 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 )

49

50 from colour.utilities import MixinLogging, attest, optional, required

51

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"

58

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]

74

75

76 class TreeNode:

77 """

78 Define a basic node supporting the creation of hierarchical node

79 trees.

80

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.

91

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`

102

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`

114

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 """

132

133 _INSTANCE_ID: int = 1

134 """

135 Node id counter.

136

137 _INSTANCE_ID

138 """

139

140 def __new__(cls, *args: Any, **kwargs: Any) -> Self: # noqa: ARG004

141 """

142 Return a new instance of the :class:`colour.utilities.TreeNode` class.

143

144 Other Parameters

145 ----------------

146 args

147 Arguments.

148 kwargs

149 Keywords arguments.

150 """

151

152 instance = super().__new__(cls)

153

154 instance._id = TreeNode._INSTANCE_ID # pyright: ignore

155 TreeNode._INSTANCE_ID += 1

156

157 return instance

158

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

173

174 @property

175 def id(self) -> int:

176 """

177 Getter for the node identifier.

178

179 Returns

180 -------

181 :class:`int`

182 Node identifier.

183 """

184

185 return self._id # pyright: ignore

186

187 @property

188 def name(self) -> str:

189 """

190 Getter and setter for the node name.

191

192 Parameters

193 ----------

194 value

195 Value to set the node name with.

196

197 Returns

198 -------

199 :class:`str`

200 Node name.

201 """

202

203 return self._name

204

205 @name.setter

206 def name(self, value: str) -> None:

207 """Setter for the **self.name** property."""

208

209 attest(

210 isinstance(value, str),

211 f'"name" property: "{value}" type is not "str"!',

212 )

213

214 self._name = value

215

216 @property

217 def parent(self) -> Self | None:

218 """

219 Getter and setter for the node parent.

220

221 Parameters

222 ----------

223 value

224 Parent to set the node with.

225

226 Returns

227 -------

228 :class:`TreeNode` or :py:data:`None`

229 Node parent.

230 """

231

232 return self._parent

233

234 @parent.setter

235 def parent(self, value: Self | None) -> None:

236 """Setter for the **self.parent** property."""

237

238 from colour.utilities import attest # noqa: PLC0415

239

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 )

246

247 value.children.append(self)

248

249 self._parent = value

250

251 @property

252 def children(self) -> List[Self]:

253 """

254 Getter and setter for the node children.

255

256 Parameters

257 ----------

258 value

259 Children to set the node with.

260

261 Returns

262 -------

263 :class:`list`

264 Node children.

265 """

266

267 return self._children

268

269 @children.setter

270 def children(self, value: List[Self]) -> None:

271 """Setter for the **self.children** property."""

272

273 from colour.utilities import attest # noqa: PLC0415

274

275 attest(

276 isinstance(value, list),

277 f'"children" property: "{value}" type is not a "list" instance!',

278 )

279

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 )

286

287 for node in value:

288 node.parent = self

289

290 self._children = value

291

292 @property

293 def root(self) -> Self:

294 """

295 Getter for the root node of the tree hierarchy.

296

297 Returns

298 -------

299 :class:`TreeNode`

300 Root node of the tree.

301 """

302

303 if self.is_root():

304 return self

305

306 return list(self.walk_hierarchy(ascendants=True))[-1]

307

308 @property

309 def leaves(self) -> Generator:

310 """

311 Getter for all leaf nodes in the hierarchy.

312

313 Returns

314 -------

315 Generator

316 Generator yielding all leaf nodes (nodes without children) in

317 the hierarchy.

318 """

319

320 if self.is_leaf():

321 return (node for node in (self,))

322

323 return (node for node in self.walk_hierarchy() if node.is_leaf())

324

325 @property

326 def siblings(self) -> Generator:

327 """

328 Getter for the sibling nodes at the same hierarchical level.

329

330 Returns

331 -------

332 Generator

333 Generator yielding sibling nodes that share the same parent

334 node in the hierarchy.

335 """

336

337 if self.parent is None:

338 return (sibling for sibling in ())

339

340 return (sibling for sibling in self.parent.children if sibling is not self)

341

342 @property

343 def data(self) -> Any:

344 """

345 Getter and setter for the node data.

346

347 Parameters

348 ----------

349 value

350 Data to assign to the node.

351

352 Returns

353 -------

354 :class:`object`

355 Data stored in the node.

356 """

357

358 return self._data

359

360 @data.setter

361 def data(self, value: Any) -> None:

362 """Setter for the **self.data** property."""

363

364 self._data = value

365

366 def __str__(self) -> str:

367 """

368 Return a formatted string representation of the node.

369

370 Returns

371 -------

372 :class:`str`

373 Formatted string representation.

374 """

375

376 return f"{self.__class__.__name__}#{self.id}({self._data})"

377

378 def __len__(self) -> int:

379 """

380 Return the number of children of the node.

381

382 Returns

383 -------

384 :class:`int`

385 Number of children of the node.

386 """

387

388 return len(list(self.walk_hierarchy()))

389

390 def is_root(self) -> bool:

391 """

392 Determine whether the node is a root node.

393

394 Returns

395 -------

396 :class:`bool`

397 Whether the node is a root node.

398

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 """

409

410 return self.parent is None

411

412 def is_inner(self) -> bool:

413 """

414 Determine whether the node is an inner node.

415

416 Returns

417 -------

418 :class:`bool`

419 Whether the node is an inner node.

420

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 """

431

432 return all([not self.is_root(), not self.is_leaf()])

433

434 def is_leaf(self) -> bool:

435 """

436 Determine whether the node is a leaf node.

437

438 Returns

439 -------

440 :class:`bool`

441 Whether the node is a leaf node.

442

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 """

453

454 return len(self._children) == 0

455

456 def walk_hierarchy(self, ascendants: bool = False) -> Generator:

457 """

458 Generate a generator to walk the :class:`colour.utilities.TreeNode`

459 tree hierarchy.

460

461 Parameters

462 ----------

463 ascendants

464 Whether to walk up the node tree.

465

466 Yields

467 ------

468 Generator

469 Node tree walker.

470

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 """

491

492 attribute = "children" if not ascendants else "parent"

493

494 nodes = getattr(self, attribute)

495 nodes = nodes if isinstance(nodes, list) else [nodes]

496

497 for node in nodes:

498 yield node

499

500 if not getattr(node, attribute):

501 continue

502

503 yield from node.walk_hierarchy(ascendants=ascendants)

504

505 def render(self, tab_level: int = 0) -> str:

506 """

507 Render the node and its children as a formatted tree string.

508

509 Parameters

510 ----------

511 tab_level

512 Initial indentation level for the tree structure.

513

514 Returns

515 -------

516 :class:`str`

517 Formatted tree representation of the node hierarchy.

518

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 """

530

531 output = ""

532

533 for _i in range(tab_level):

534 output += " "

535

536 tab_level += 1

537

538 output += f'|----"{self.name}"\n'

539

540 for child in self._children:

541 output += child.render(tab_level)

542

543 tab_level -= 1

544

545 return output

546

547

548 class 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.

553

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.

564

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`

572

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`

582

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 """

593

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__()

602

603 # TODO: Consider using an ordered set instead of a dict.

604 self._connections: Dict[Port, None] = {}

605

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

613

614 @property

615 def name(self) -> str:

616 """

617 Getter and setter for the port name.

618

619 Parameters

620 ----------

621 value

622 Value to set the port name with.

623

624 Returns

625 -------

626 :class:`str`

627 Port name.

628 """

629

630 return self._name

631

632 @name.setter

633 def name(self, value: str) -> None:

634 """Setter for the **self.name** property."""

635

636 attest(

637 isinstance(value, str),

638 f'"name" property: "{value}" type is not "str"!',

639 )

640

641 self._name = value

642

643 @property

644 def value(self) -> Any:

645 """

646 Getter and setter for the port value.

647

648 Parameters

649 ----------

650 value

651 Value to set the port value with.

652

653 Returns

654 -------

655 :class:`object`

656 Port value.

657 """

658

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

665

666 return self._value

667

668 @value.setter

669 def value(self, value: Any) -> None:

670 """Setter for the **self.value** property."""

671

672 self._value = value

673

674 if self._node is not None:

675 self.log(f'Dirtying "{self._node}".', "debug")

676 self._node.dirty = True

677

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

683

684 if direct_connection.node is not None:

685 self.log(f'Dirtying "{direct_connection.node}".', "debug")

686 direct_connection.node.dirty = True

687

688 for indirect_connection in direct_connection.connections:

689 if indirect_connection == self:

690 continue

691

692 self.log(

693 f'Setting "{indirect_connection.node}" value to {value}.', "debug"

694 )

695 indirect_connection._value = value # noqa: SLF001

696

697 if indirect_connection.node is not None:

698 self.log(f'Dirtying "{indirect_connection.node}".', "debug")

699 indirect_connection.node.dirty = True

700

701 self._value = value

702

703 @property

704 def description(self) -> str:

705 """

706 Getter and setter for the port description.

707

708 Parameters

709 ----------

710 value

711 Value to set the port description with.

712

713 Returns

714 -------

715 :class:`str` or None

716 Port description.

717 """

718

719 return self._description

720

721 @description.setter

722 def description(self, value: str) -> None:

723 """Setter for the **self.description** property."""

724

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 )

730

731 self._description = value

732

733 @property

734 def node(self) -> PortNode | None:

735 """

736 Getter and setter for the port node.

737

738 Parameters

739 ----------

740 value : PortNode or None

741 Port node to set.

742

743 Returns

744 -------

745 :class:`PortNode` or None

746 Port node.

747 """

748

749 return self._node

750

751 @node.setter

752 def node(self, value: PortNode | None) -> None:

753 """Setter for the **self.node** property."""

754

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 )

759

760 self._node = value

761

762 @property

763 def connections(self) -> Dict[Port, None]:

764 """

765 Getter for the port connections.

766

767 Returns

768 -------

769 :class:`dict`

770 Port connections mapping each :class:`Port` instance to

771 ``None``.

772 """

773

774 return self._connections

775

776 def __str__(self) -> str:

777 """

778 Return a formatted string representation of the port.

779

780 Returns

781 -------

782 :class:`str`

783 Formatted string representation.

784

785 Examples

786 --------

787 >>> print(Port("a"))

788 None.a (-> [])

789 >>> print(Port("a", node=PortNode("Port Node")))

790 Port Node.a (-> [])

791 """

792

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 ]

801

802 direction = "<-" if self.is_input_port() else "->"

803

804 node_name = self._node.name if self._node is not None else "None"

805

806 return f"{node_name}.{self._name} ({direction} {connections})"

807

808 def is_input_port(self) -> bool:

809 """

810 Determine whether the port is an input port.

811

812 Returns

813 -------

814 :class:`bool`

815 Whether the port is an input port.

816

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 """

825

826 if self._node is not None:

827 return self._name in self._node.input_ports

828

829 return False

830

831 def is_output_port(self) -> bool:

832 """

833 Determine whether the port is an output port.

834

835 Returns

836 -------

837 :class:`bool`

838 Whether the port is an output port.

839

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 """

848

849 if self._node is not None:

850 return self._name in self._node.output_ports

851

852 return False

853

854 def connect(self, port: Port) -> None:

855 """

856 Connect this port to the specified port.

857

858 Parameters

859 ----------

860 port

861 Port to connect to.

862

863 Raises

864 ------

865 ValueError

866 If an attempt is made to connect an input port to multiple

867 output ports.

868

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 """

883

884 attest(isinstance(port, Port), f'"{port}" is not a "Port" instance!')

885

886 self.log(f'Connecting "{self.name}" to "{port.name}".', "debug")

887

888 self.connections[port] = None

889 port.connections[self] = None

890

891 def disconnect(self, port: Port) -> None:

892 """

893 Disconnect from the specified port.

894

895 Parameters

896 ----------

897 port

898 Port to disconnect from.

899

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 """

915

916 attest(isinstance(port, Port), f'"{port}" is not a "Port" instance!')

917

918 self.log(f'Disconnecting "{self.name}" from "{port.name}".', "debug")

919

920 self.connections.pop(port)

921 port.connections.pop(self)

922

923 def to_graphviz(self) -> str:

924 """

925 Generate a string representation for port visualisation with

926 *Graphviz*.

927

928 Returns

929 -------

930 :class:`str`

931 String representation for visualisation of the port with

932 *Graphviz*.

933

934 Examples

935 --------

936 >>> Port("a").to_graphviz()

937 '<a> a'

938 """

939

940 return f"<{self._name}> {self.name}"

941

942

943 class PortNode(TreeNode, MixinLogging):

944 """

945 Define a node with support for input and output ports.

946

947 Other Parameters

948 ----------------

949 name

950 Node name.

951

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`

959

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`

975

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 """

1005

1006 def __init__(self, name: str | None = None, description: str = "") -> None:

1007 super().__init__(name)

1008 self.description = description

1009

1010 self._input_ports = {}

1011 self._output_ports = {}

1012 self._dirty = True

1013

1014 @property

1015 def input_ports(self) -> Dict[str, Port]:

1016 """

1017 Getter for the input ports of the node.

1018

1019 Returns

1020 -------

1021 :class:`dict`

1022 Dictionary mapping port names to their corresponding input port

1023 instances.

1024 """

1025

1026 return self._input_ports

1027

1028 @property

1029 def output_ports(self) -> Dict[str, Port]:

1030 """

1031 Getter for the output ports of the node.

1032

1033 Returns

1034 -------

1035 :class:`dict`

1036 Mapping of output port names to their corresponding :class:`Port`

1037 instances.

1038 """

1039

1040 return self._output_ports

1041

1042 @property

1043 def dirty(self) -> bool:

1044 """

1045 Getter and setter for the node's dirty state.

1046

1047 Parameters

1048 ----------

1049 value

1050 Value to set the node dirty state with.

1051

1052 Returns

1053 -------

1054 :class:`bool`

1055 Whether the node is in a dirty state.

1056 """

1057

1058 return self._dirty

1059

1060 @dirty.setter

1061 def dirty(self, value: bool) -> None:

1062 """Setter for the **self.dirty** property."""

1063

1064 attest(

1065 isinstance(value, bool),

1066 f'"dirty" property: "{value}" type is not "bool"!',

1067 )

1068

1069 self._dirty = value

1070

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.

1077

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.

1081

1082 Returns

1083 -------

1084 :class:`tuple`

1085 Edges of the node as a tuple of input and output edge

1086 dictionaries.

1087 """

1088

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

1094

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

1100

1101 return input_edges, output_edges

1102

1103 @property

1104 def description(self) -> str:

1105 """

1106 Getter and setter for the node description.

1107

1108 Parameters

1109 ----------

1110 value

1111 Value to set the node description with.

1112

1113 Returns

1114 -------

1115 :class:`str` or None

1116 Node description.

1117 """

1118

1119 return self._description

1120

1121 @description.setter

1122 def description(self, value: str) -> None:

1123 """Setter for the **self.description** property."""

1124

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 )

1130

1131 self._description = value

1132

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.

1142

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.

1153

1154 Returns

1155 -------

1156 :class:`colour.utilities.Port`

1157 Input port.

1158

1159 Examples

1160 --------

1161 >>> node = PortNode()

1162 >>> node.add_input_port("a") # doctest: +ELLIPSIS

1163 <...Port object at 0x...>

1164 """

1165

1166 self._input_ports[name] = port_type(name, value, description, self)

1167

1168 return self._input_ports[name]

1169

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.

1176

1177 Parameters

1178 ----------

1179 name

1180 Name of the input port to remove.

1181

1182 Returns

1183 -------

1184 :class:`colour.utilities.Port`

1185 Removed input port.

1186

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 """

1194

1195 attest(

1196 name in self._input_ports,

1197 f'"{name}" port is not a member of {self} input ports!',

1198 )

1199

1200 port = self._input_ports.pop(name)

1201

1202 for connection in port.connections:

1203 port.disconnect(connection)

1204

1205 return port

1206

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.

1216

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.

1227

1228 Returns

1229 -------

1230 :class:`colour.utilities.Port`

1231 Output port.

1232

1233 Examples

1234 --------

1235 >>> node = PortNode()

1236 >>> node.add_output_port("output") # doctest: +ELLIPSIS

1237 <...Port object at 0x...>

1238 """

1239

1240 self._output_ports[name] = port_type(name, value, description, self)

1241

1242 return self._output_ports[name]

1243

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.

1250

1251 Parameters

1252 ----------

1253 name

1254 Name of the output port to remove.

1255

1256 Returns

1257 -------

1258 :class:`colour.utilities.Port`

1259 Removed output port.

1260

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 """

1268

1269 attest(

1270 name in self._output_ports,

1271 f'"{name}" port is not a member of {self} output ports!',

1272 )

1273

1274 port = self._output_ports.pop(name)

1275

1276 for connection in port.connections:

1277 port.disconnect(connection)

1278

1279 return port

1280

1281 def get_input(self, name: str) -> Any:

1282 """

1283 Return the value of the input port with the specified name.

1284

1285 Parameters

1286 ----------

1287 name

1288 Name of the input port.

1289

1290 Returns

1291 -------

1292 :class:`object`

1293 Value of the input port.

1294

1295 Raises

1296 ------

1297 AssertionError

1298 If the input port is not a member of the node input ports.

1299

1300 Examples

1301 --------

1302 >>> node = PortNode()

1303 >>> port = node.add_input_port("a", 1) # doctest: +ELLIPSIS

1304 >>> node.get_input("a")

1305 1

1306 """

1307

1308 attest(

1309 name in self._input_ports,

1310 f'"{name}" is not a member of "{self._name}" input ports!',

1311 )

1312

1313 return self._input_ports[name].value

1314

1315 def set_input(self, name: str, value: Any) -> None:

1316 """

1317 Set the value of an input port with the specified name.

1318

1319 Parameters

1320 ----------

1321 name

1322 Name of the input port to set.

1323 value

1324 Value to assign to the input port.

1325

1326 Raises

1327 ------

1328 AssertionError

1329 If the specified input port is not a member of the node's

1330 input ports.

1331

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 """

1341

1342 attest(

1343 name in self._input_ports,

1344 f'"{name}" is not a member of "{self._name}" input ports!',

1345 )

1346

1347 self._input_ports[name].value = value

1348

1349 def get_output(self, name: str) -> Any:

1350 """

1351 Return the value of the output port with the specified name.

1352

1353 Parameters

1354 ----------

1355 name

1356 Name of the output port.

1357

1358 Returns

1359 -------

1360 :class:`object`

1361 Value of the output port.

1362

1363 Raises

1364 ------

1365 AssertionError

1366 If the output port is not a member of the node output

1367 ports.

1368

1369 Examples

1370 --------

1371 >>> node = PortNode()

1372 >>> port = node.add_output_port("output", 1) # doctest: +ELLIPSIS

1373 >>> node.get_output("output")

1374 1

1375 """

1376

1377 attest(

1378 name in self._output_ports,

1379 f'"{name}" is not a member of "{self._name}" output ports!',

1380 )

1381

1382 return self._output_ports[name].value

1383

1384 def set_output(self, name: str, value: Any) -> None:

1385 """

1386 Set the value of the output port with the specified name.

1387

1388 Parameters

1389 ----------

1390 name

1391 Name of the output port.

1392 value

1393 Value to assign to the output port.

1394

1395 Raises

1396 ------

1397 AssertionError

1398 If the output port is not a member of the node output ports.

1399

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 """

1409

1410 attest(

1411 name in self._output_ports,

1412 f'"{name}" is not a member of "{self._name}" input ports!',

1413 )

1414

1415 self._output_ports[name].value = value

1416

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.

1426

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.

1430

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.

1440

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 """

1451

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 )

1458

1459 port_source.connect(port_target)

1460

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.

1470

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.

1474

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.

1485

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 """

1499

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 )

1506

1507 port_source.disconnect(port_target)

1508

1509 def process(self) -> None:

1510 """

1511 Process the node, must be reimplemented by sub-classes.

1512

1513 This definition is responsible for setting the dirty state of the

1514 node according to the processing outcome.

1515

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 """

1547

1548 self._dirty = False

1549

1550 def to_graphviz(self) -> str:

1551 """

1552 Generate a string representation for node visualisation with

1553 *Graphviz*.

1554

1555 Returns

1556 -------

1557 :class:`str`

1558 String representation for visualisation of the node with

1559 *Graphviz*.

1560

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 """

1570

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 )

1577

1578 return f"{self.name} (#{self.id}) | {{{{{input_ports}}} | {{{output_ports}}}}}"

1579

1580

1581 class PortGraph(PortNode):

1582 """

1583 Define a node-graph for :class:`colour.utilities.PortNode` class

1584 instances.

1585

1586 Parameters

1587 ----------

1588 name

1589 Name of the node-graph.

1590 description

1591 Description of the node-graph's purpose or functionality.

1592

1593 Attributes

1594 ----------

1595 - :attr:`~colour.utilities.PortGraph.nodes`

1596

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`

1605

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 """

1644

1645 def __init__(self, name: str | None = None, description: str = "") -> None:

1646 super().__init__(name, description)

1647

1648 self._name: str = self.__class__.__name__

1649 self.name = optional(name, self._name)

1650 self.description = description

1651

1652 self._nodes = {}

1653

1654 @property

1655 def nodes(self) -> Dict[str, PortNode]:

1656 """

1657 Getter for the node-graph nodes.

1658

1659 Returns

1660 -------

1661 :class:`dict`

1662 Node-graph nodes as a mapping from node identifiers to their

1663 corresponding :class:`PortNode` instances.

1664 """

1665

1666 return self._nodes

1667

1668 def __str__(self) -> str:

1669 """

1670 Return a formatted string representation of the node-graph.

1671

1672 Returns

1673 -------

1674 :class:`str`

1675 Formatted string representation.

1676 """

1677

1678 return f"{self.__class__.__name__}({len(self._nodes)})"

1679

1680 def add_node(self, node: PortNode) -> None:

1681 """

1682 Add specified node to the node-graph.

1683

1684 Parameters

1685 ----------

1686 node

1687 Node to add to the node-graph.

1688

1689 Raises

1690 ------

1691 AssertionError

1692 If the node is not a :class:`colour.utilities.PortNode` class

1693 instance.

1694

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 """

1710

1711 attest(isinstance(node, PortNode), f'"{node}" is not a "PortNode" instance!')

1712

1713 attest(

1714 node.name not in self._nodes, f'"{node}" is already a member of the graph!'

1715 )

1716

1717 self._nodes[node.name] = node

1718 self._children.append(node) # pyright: ignore

1719 node._parent = self # noqa: SLF001

1720

1721 def remove_node(self, node: PortNode) -> None:

1722 """

1723 Remove the specified node from the node-graph.

1724

1725 The node input and output ports will be disconnected from all their

1726 connections.

1727

1728 Parameters

1729 ----------

1730 node

1731 Node to remove from the node-graph.

1732

1733 Raises

1734 ------

1735 AsssertionError

1736 If the node is not a member of the node-graph.

1737

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 """

1755

1756 attest(isinstance(node, PortNode), f'"{node}" is not a "PortNode" instance!')

1757

1758 attest(

1759 node.name in self._nodes,

1760 f'"{node}" is not a member of "{self._name}" node-graph!',

1761 )

1762

1763 for port in node.input_ports.values():

1764 for connection in port.connections.copy():

1765 port.disconnect(connection)

1766

1767 for port in node.output_ports.values():

1768 for connection in port.connections.copy():

1769 port.disconnect(connection)

1770

1771 self._nodes.pop(node.name)

1772 self._children.remove(node) # pyright: ignore

1773 node._parent = None # noqa: SLF001

1774

1775 @required("NetworkX")

1776 def walk_ports(self) -> Generator:

1777 """

1778 Return a generator to walk the node-graph in topological order.

1779

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.

1785

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.

1789

1790 Yields

1791 ------

1792 Generator

1793 Node-graph walker.

1794

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 """

1808

1809 import networkx as nx # noqa: PLC0415

1810

1811 graph = nx.DiGraph()

1812

1813 for node in self._children:

1814 input_edges, output_edges = node.edges

1815

1816 graph.add_node(node.name, node=node)

1817

1818 if len(node.children) != 0:

1819 continue

1820

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

1828

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 )

1844

1845 for edge in output_edges:

1846 if self in (edge[0].node, edge[1].node):

1847 continue

1848

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 )

1864

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")

1878

1879 raise error # noqa: TRY201

1880

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.

1885

1886 Other Parameters

1887 ----------------

1888 kwargs

1889 Keyword arguments.

1890

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 """

1933

1934 dry_run = kwargs.get("dry_run", False)

1935

1936 for_node_reached = False

1937 for node in self.walk_ports():

1938 if for_node_reached:

1939 break

1940

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

1945

1946 if not node.dirty:

1947 self.log(f'Skipping "{node}" computed node.')

1948 continue

1949

1950 self.log(f'Processing "{node}" node...')

1951

1952 if dry_run:

1953 continue

1954

1955 node.process()

1956

1957 @required("Pydot")

1958 def to_graphviz(self) -> Dot: # noqa: F821 # pyright: ignore

1959 """

1960 Generate a node-graph visualisation for *Graphviz*.

1961

1962 Returns

1963 -------

1964 :class:`pydot.Dot`

1965 *Pydot* graph.

1966

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 """

1980

1981 if self._parent is not None:

1982 return PortNode.to_graphviz(self)

1983

1984 import pydot # noqa: PLC0415

1985

1986 dot = pydot.Dot(

1987 "digraph", graph_type="digraph", rankdir="LR", splines="polyline"

1988 )

1989

1990 graphs = [node for node in self.walk_ports() if isinstance(node, PortGraph)]

1991

1992 def is_graph_member(node: PortNode) -> bool:

1993 """Determine whether the specified node is member of a graph."""

1994

1995 return any(node in graph.nodes.values() for graph in graphs)

1996

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

2006

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

2011

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 )

2022

2023 return dot

2024

2025

2026 class ExecutionPort(Port):

2027 """

2028 Define a specialised port for execution flow control in node graphs.

2029

2030 Attributes

2031 ----------

2032 value

2033 Port value accessor for execution state transmission.

2034 """

2035

2036 @property

2037 def value(self) -> Any:

2038 """

2039 Getter and setter for the port value.

2040

2041 Parameters

2042 ----------

2043 value

2044 Value to set the port value with.

2045

2046 Returns

2047 -------

2048 :class:`object`

2049 Port value.

2050 """

2051

2052 @value.setter

2053 def value(self, value: Any) -> None:

2054 """Setter for the **self.value** property."""

2055

2056

2057 class ExecutionNode(PortNode):

2058 """

2059 Define a specialised node that manages execution flow through

2060 dedicated input and output ports.

2061 """

2062

2063 def __init__(self, *args: Any, **kwargs: Any) -> None:

2064 super().__init__(*args, **kwargs)

2065

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 )

2072

2073

2074 class ControlFlowNode(ExecutionNode):

2075 """

2076 Define a base class for control flow nodes in computational graphs.

2077 """

2078

2079 def __init__(self, *args: Any, **kwargs: Any) -> None:

2080 super().__init__(*args, **kwargs)

2081

2082

2083 class For(ControlFlowNode):

2084 """

2085 Define a ``for`` loop node for iterating over arrays.

2086

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.

2091

2092 Upon completion, the :meth:`colour.utilities.ExecutionNode.process`

2093 method of the object connected to the ``execution_output`` output

2094 port is called.

2095

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 """

2106

2107 def __init__(self, *args: Any, **kwargs: Any) -> None:

2108 super().__init__(*args, **kwargs)

2109

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)

2114

2115 def process(self) -> None:

2116 """Process the *for* loop node execution."""

2117

2118 connection = next(iter(self.output_ports["loop_output"].connections), None)

2119 if connection is None:

2120 return

2121

2122 node = connection.node

2123

2124 if node is None:

2125 return

2126

2127 self.log(f'Processing "{node}" node...')

2128

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)

2133

2134 node.process()

2135

2136 execution_output_connection = next(

2137 iter(self.output_ports["execution_output"].connections), None

2138 )

2139 if execution_output_connection is None:

2140 return

2141

2142 execution_output_node = execution_output_connection.node

2143

2144 if execution_output_node is None:

2145 return

2146

2147 execution_output_node.process()

2148

2149 self.dirty = False

2150

2151

2152 _THREADING_LOCK = threading.Lock()

2153

2154

2155 def _task_thread(args: Sequence) -> tuple[int, Any]:

2156 """

2157 Execute the default task for the

2158 :class:`colour.utilities.ParallelForThread` loop node.

2159

2160 Parameters

2161 ----------

2162 args

2163 Processing arguments for the parallel thread task.

2164

2165 Returns

2166 -------

2167 :class:`tuple`

2168 Index and result pair from the executed task.

2169 """

2170

2171 i, element, sub_graph, node = args

2172

2173 node.log(f"Index {i}, Element {element}", "info")

2174

2175 with _THREADING_LOCK:

2176 node.set_output("index", i)

2177 node.set_output("element", element)

2178

2179 sub_graph.process()

2180

2181 return i, sub_graph.get_output("output")

2182

2183

2184 class ThreadPoolExecutorManager:

2185 """

2186 Define a singleton :class:`concurrent.futures.ThreadPoolExecutor`

2187 manager.

2188

2189 Attributes

2190 ----------

2191 - :attr:`~colour.utilities.ThreadPoolExecutorManager.ThreadPoolExecutor`

2192

2193 Methods

2194 -------

2195 - :meth:`~colour.utilities.ThreadPoolExecutorManager.get_executor`

2196 - :meth:`~colour.utilities.ThreadPoolExecutorManager.shutdown_executor`

2197 """

2198

2199 ThreadPoolExecutor: concurrent.futures.ThreadPoolExecutor | None = None

2200

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.

2208

2209 Parameters

2210 ----------

2211 max_workers

2212 Maximum worker count.

2213

2214 Returns

2215 -------

2216 :class:`concurrent.futures.ThreadPoolExecutor`

2217 Thread pool executor instance.

2218

2219 Notes

2220 -----

2221 The :class:`concurrent.futures.ThreadPoolExecutor` class instance is

2222 automatically shutdown on process exit.

2223 """

2224

2225 if ThreadPoolExecutorManager.ThreadPoolExecutor is None:

2226 ThreadPoolExecutorManager.ThreadPoolExecutor = (

2227 concurrent.futures.ThreadPoolExecutor(max_workers=max_workers)

2228 )

2229

2230 return ThreadPoolExecutorManager.ThreadPoolExecutor

2231

2232 @atexit.register

2233 @staticmethod

2234 def shutdown_executor() -> None:

2235 """

2236 Shut down the :class:`concurrent.futures.ThreadPoolExecutor` class

2237 instance.

2238 """

2239

2240 if ThreadPoolExecutorManager.ThreadPoolExecutor is not None:

2241 ThreadPoolExecutorManager.ThreadPoolExecutor.shutdown(wait=True)

2242 ThreadPoolExecutorManager.ThreadPoolExecutor = None

2243

2244

2245 class ParallelForThread(ControlFlowNode):

2246 """

2247 Define an advanced ``for`` loop node that distributes work across

2248 multiple threads for parallel execution.

2249

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.

2255

2256 Upon completion, the :meth:`colour.utilities.ExecutionNode.process`

2257 method of the object connected to the ``execution_output`` output port

2258 is called.

2259

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 """

2276

2277 def __init__(self, *args: Any, **kwargs: Any) -> None:

2278 super().__init__(*args, **kwargs)

2279

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)

2287

2288 def process(self) -> None:

2289 """

2290 Process the parallel loop node execution.

2291 """

2292

2293 connection = next(iter(self.output_ports["loop_output"].connections), None)

2294 if connection is None:

2295 return

2296

2297 node = connection.node

2298

2299 if node is None:

2300 return

2301

2302 self.log(f'Processing "{node}" node...')

2303

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 ]

2314

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

2319

2320 results = dict(sorted(results.items()))

2321 self.set_output("results", list(results.values()))

2322

2323 execution_output_connection = next(

2324 iter(self.output_ports["execution_output"].connections), None

2325 )

2326 if execution_output_connection is None:

2327 return

2328

2329 execution_output_node = execution_output_connection.node

2330

2331 if execution_output_node is None:

2332 return

2333

2334 execution_output_node.process()

2335

2336 self.dirty = False

2337

2338

2339 def _task_multiprocess(args: Sequence) -> tuple[int, Any]:

2340 """

2341 Execute the default processing task for

2342 :class:`colour.utilities.ParallelForMultiprocess` loop node instances.

2343

2344 Parameters

2345 ----------

2346 args

2347 Processing arguments for the parallel execution task.

2348

2349 Returns

2350 -------

2351 :class:`tuple`

2352 Tuple containing the task index and computed result.

2353 """

2354

2355 i, element, sub_graph, node = args

2356

2357 node.log(f"Index {i}, Element {element}", "info")

2358

2359 node.set_output("index", i)

2360 node.set_output("element", element)

2361

2362 sub_graph.process()

2363

2364 return i, sub_graph.get_output("output")

2365

2366

2367 class ProcessPoolExecutorManager:

2368 """

2369 Define a singleton :class:`concurrent.futures.ProcessPoolExecutor`

2370 manager for parallel processing.

2371

2372 Attributes

2373 ----------

2374 - :attr:`~colour.utilities.ProcessPoolExecutorManager.ProcessPoolExecutor`

2375

2376 Methods

2377 -------

2378 - :meth:`~colour.utilities.ProcessPoolExecutorManager.get_executor`

2379 - :meth:`~colour.utilities.ProcessPoolExecutorManager.shutdown_executor`

2380 """

2381

2382 ProcessPoolExecutor: concurrent.futures.ProcessPoolExecutor | None = None

2383

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.

2391

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.

2397

2398 Returns

2399 -------

2400 :class:`concurrent.futures.ProcessPoolExecutor`

2401 Process pool executor instance for parallel execution.

2402

2403 Notes

2404 -----

2405 The :class:`concurrent.futures.ProcessPoolExecutor` class instance is

2406 automatically shut down on process exit.

2407 """

2408

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 )

2416

2417 return ProcessPoolExecutorManager.ProcessPoolExecutor

2418

2419 @atexit.register

2420 @staticmethod

2421 def shutdown_executor() -> None:

2422 """

2423 Shut down the :class:`concurrent.futures.ProcessPoolExecutor` class

2424 instance.

2425 """

2426

2427 if ProcessPoolExecutorManager.ProcessPoolExecutor is not None:

2428 ProcessPoolExecutorManager.ProcessPoolExecutor.shutdown(wait=True)

2429 ProcessPoolExecutorManager.ProcessPoolExecutor = None

2430

2431

2432 class ParallelForMultiprocess(ControlFlowNode):

2433 """

2434 Define a parallel ``for`` loop node that distributes operations across

2435 multiple processes.

2436

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.

2441

2442 Upon completion, invoke the :meth:`colour.utilities.ExecutionNode.process`

2443 method of the object connected to the ``execution_output`` output port.

2444

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 """

2454

2455 def __init__(self, *args: Any, **kwargs: Any) -> None:

2456 super().__init__(*args, **kwargs)

2457

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)

2465

2466 def process(self) -> None:

2467 """

2468 Process the ``for`` loop node execution.

2469 """

2470

2471 connection = next(iter(self.output_ports["loop_output"].connections), None)

2472 if connection is None:

2473 return

2474

2475 node = connection.node

2476

2477 if node is None:

2478 return

2479

2480 self.log(f'Processing "{node}" node...')

2481

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 ]

2492

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

2497

2498 results = dict(sorted(results.items()))

2499 self.set_output("results", list(results.values()))

2500

2501 execution_output_connection = next(

2502 iter(self.output_ports["execution_output"].connections), None

2503 )

2504 if execution_output_connection is None:

2505 return

2506

2507 execution_output_node = execution_output_connection.node

2508

2509 if execution_output_node is None:

2510 return

2511

2512 execution_output_node.process()

2513

2514 self.dirty = False