Coverage for utilities/network.py: 63%

529 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-16 22:49 +1300

1""" 

2Network 

3======= 

4 

5Node-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 

29from __future__ import annotations 

30 

31import atexit 

32import concurrent.futures 

33import multiprocessing 

34import os 

35import threading 

36import typing 

37 

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 ) 

49 

50from 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 

76class 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 

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. 

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 

943class 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 

1581class 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 

2026class 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 

2057class 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 

2074class 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 

2083class 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 

2155def _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 

2184class 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 

2245class 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 

2339def _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 

2367class 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 

2432class 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