Coverage for utilities/structures.py: 64%

1"""

2 Data Structures

3===============

4

5 Provide various data structure classes for flexible data manipulation.

6

7- :class:`colour.utilities.Structure`: An object similar to C/C++ structured

8 type.

9- :class:`colour.utilities.Lookup`: A :class:`dict` sub-class acting as a

10 lookup to retrieve keys by values.

11- :class:`colour.utilities.CanonicalMapping`: A delimiter and

12 case-insensitive :class:`dict`-like object allowing values retrieval from

13 keys while ignoring the key case.

14- :class:`colour.utilities.LazyCanonicalMapping`: Another delimiter and

15 case-insensitive mapping allowing lazy values retrieval from keys while

16 ignoring the key case.

17

18 References

19----------

20- :cite:`Mansencalc` : Mansencal, T. (n.d.). Lookup.

21 https://github.com/KelSolaar/Foundations/blob/develop/foundations/\

22 structures.py

23- :cite:`Rakotoarison2017` : Rakotoarison, H. (2017). Bunch.

24 https://github.com/scikit-learn/scikit-learn/blob/\

25 fb5a498d0bd00fc2b42fbd19b6ef18e1dfeee47e/sklearn/utils/__init__.py#L65

26"""

27

28 from __future__ import annotations

29

30 import re

31 import typing

32 from collections import Counter

33 from collections.abc import MutableMapping

34

35 if typing.TYPE_CHECKING:

36 from colour.hints import (

37 Any,

38 Generator,

39 Iterable,

40 )

41 from colour.hints import Mapping

42 from colour.utilities.documentation import is_documentation_building

43

44 __author__ = "Colour Developers"

45 __copyright__ = "Copyright 2013 Colour Developers"

46 __license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"

47 __maintainer__ = "Colour Developers"

48 __email__ = "colour-developers@colour-science.org"

49 __status__ = "Production"

50

51 __all__ = [

52 "Structure",

53 "Lookup",

54 "CanonicalMapping",

55 "LazyCanonicalMapping",

56]

57

58

59 class Structure(dict):

60 """

61 Represent a :class:`dict`-like structure that enables access to key

62 values through dot notation syntax.

63

64 This class extends the built-in :class:`dict` to provide

65 attribute-style access to dictionary items, allowing both traditional

66 dictionary access patterns and object-oriented dot notation for

67 improved code readability and convenience.

68

69 Other Parameters

70 ----------------

71 args

72 Arguments.

73 kwargs

74 Key / value pairs.

75

76 Methods

77 -------

78 - :meth:`~colour.utilities.Structure.__init__`

79 - :meth:`~colour.utilities.Structure.__setattr__`

80 - :meth:`~colour.utilities.Structure.__delattr__`

81 - :meth:`~colour.utilities.Structure.__dir__`

82 - :meth:`~colour.utilities.Structure.__getattr__`

83 - :meth:`~colour.utilities.Structure.__setstate__`

84

85 References

86 ----------

87 :cite:`Rakotoarison2017`

88

89 Examples

90 --------

91 >>> person = Structure(first_name="John", last_name="Doe", gender="male")

92 >>> person.first_name

93 'John'

94 >>> sorted(person.keys())

95 ['first_name', 'gender', 'last_name']

96 >>> person["gender"]

97 'male'

98 """

99

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

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

102

103 def __setattr__(self, name: str, value: Any) -> None:

104 """

105 Assign the specified value to the attribute with the specified name.

106

107 Parameters

108 ----------

109 name

110 Name of the attribute to assign the ``value`` to.

111 value

112 Value to assign to the attribute.

113 """

114

115 self[name] = value

116

117 def __delattr__(self, name: str) -> None:

118 """

119 Delete the attribute with the specified name.

120

121 Parameters

122 ----------

123 name

124 Name of the attribute to delete.

125 """

126

127 del self[name]

128

129 def __dir__(self) -> Iterable:

130 """

131 Return the list of valid attributes for the :class:`dict`-like

132 object.

133

134 Returns

135 -------

136 :class:`list`

137 List of valid attributes for the :class:`dict`-like object.

138 """

139

140 return self.keys()

141

142 def __getattr__(self, name: str) -> Any:

143 """

144 Return the value from the attribute with the specified name.

145

146 Parameters

147 ----------

148 name

149 Name of the attribute to get the value from.

150

151 Returns

152 -------

153 :class:`object`

154 Value of the specified attribute.

155

156 Raises

157 ------

158 AttributeError

159 If the attribute is not defined.

160 """

161

162 try:

163 return self[name]

164 except KeyError as error:

165 raise AttributeError(name) from error

166

167 def __setstate__(self, state: Any) -> None:

168 """Set the object state when unpickling."""

169 # See https://github.com/scikit-learn/scikit-learn/issues/6196 for more

170 # information.

171

172

173 class Lookup(dict):

174 """

175 Represent a :class:`dict`-like object that provides lookup functionality by

176 value(s).

177

178 This class extends the built-in :class:`dict` to provide reverse lookup

179 capabilities for dictionary values, enabling retrieval of keys based on

180 their associated values. Support both single and multiple key retrieval

181 for matching values.

182

183 Methods

184 -------

185 - :meth:`~colour.utilities.Lookup.keys_from_value`

186 - :meth:`~colour.utilities.Lookup.first_key_from_value`

187

188 References

189 ----------

190 :cite:`Mansencalc`

191

192 Examples

193 --------

194 >>> person = Lookup(first_name="John", last_name="Doe", gender="male")

195 >>> person.first_key_from_value("John")

196 'first_name'

197 >>> persons = Lookup(John="Doe", Jane="Doe", Luke="Skywalker")

198 >>> sorted(persons.keys_from_value("Doe"))

199 ['Jane', 'John']

200 """

201

202 def keys_from_value(self, value: Any) -> list:

203 """

204 Return the keys associated with the specified value.

205

206 Parameters

207 ----------

208 value

209 Value to find the associated keys.

210

211 Returns

212 -------

213 :class:`list`

214 Keys associated with the specified value.

215 """

216

217 keys = []

218 for key, data in self.items():

219 matching = data == value

220 try:

221 matching = all(matching)

222

223 except TypeError:

224 matching = all((matching,))

225

226 if matching:

227 keys.append(key)

228

229 return keys

230

231 def first_key_from_value(self, value: Any) -> Any:

232 """

233 Return the first key associated with the specified value.

234

235 Parameters

236 ----------

237 value

238 Value to find the associated first key.

239

240 Returns

241 -------

242 :class:`object`

243 First key associated with the specified value.

244 """

245

246 return self.keys_from_value(value)[0]

247

248

249 class CanonicalMapping(MutableMapping):

250 """

251 Represent a delimiter and case-insensitive :class:`dict`-like object

252 supporting both slug keys (*SEO*-friendly, human-readable versions with

253 delimiters) and canonical keys (slugified keys without delimiters).

254

255 This class extends :class:`MutableMapping` to provide flexible key

256 matching that accepts various transformations of the original key while

257 maintaining the original key structure for storage. Item keys must be

258 :class:`str`-like objects supporting the :meth:`str.lower` method. Set

259 items using the specified original keys. Retrieve, delete, or test item

260 existence by transforming the query key through the following sequence:

261

262 - *Original Key*

263 - *Lowercase Key*

264 - *Slugified Key*

265 - *Canonical Key*

266

267 For example, using the ``McCamy 1992`` key:

268

269 - *Original Key* : ``McCamy 1992``

270 - *Lowercase Key* : ``mccamy 1992``

271 - *Slugified Key* : ``mccamy-1992``

272 - *Canonical Key* : ``mccamy1992``

273

274 Parameters

275 ----------

276 data

277 Data to store into the delimiter and case-insensitive

278 :class:`dict`-like object at initialisation.

279

280 Other Parameters

281 ----------------

282 kwargs

283 Key / value pairs to store into the mapping at initialisation.

284

285 Attributes

286 ----------

287 - :attr:`~colour.utilities.CanonicalMapping.data`

288

289 Methods

290 -------

291 - :meth:`~colour.utilities.CanonicalMapping.__init__`

292 - :meth:`~colour.utilities.CanonicalMapping.__repr__`

293 - :meth:`~colour.utilities.CanonicalMapping.__setitem__`

294 - :meth:`~colour.utilities.CanonicalMapping.__getitem__`

295 - :meth:`~colour.utilities.CanonicalMapping.__delitem__`

296 - :meth:`~colour.utilities.CanonicalMapping.__contains__`

297 - :meth:`~colour.utilities.CanonicalMapping.__iter__`

298 - :meth:`~colour.utilities.CanonicalMapping.__len__`

299 - :meth:`~colour.utilities.CanonicalMapping.__eq__`

300 - :meth:`~colour.utilities.CanonicalMapping.__ne__`

301 - :meth:`~colour.utilities.CanonicalMapping.copy`

302 - :meth:`~colour.utilities.CanonicalMapping.lower_keys`

303 - :meth:`~colour.utilities.CanonicalMapping.lower_items`

304 - :meth:`~colour.utilities.CanonicalMapping.slugified_keys`

305 - :meth:`~colour.utilities.CanonicalMapping.slugified_items`

306 - :meth:`~colour.utilities.CanonicalMapping.canonical_keys`

307 - :meth:`~colour.utilities.CanonicalMapping.canonical_items`

308

309 Examples

310 --------

311 >>> methods = CanonicalMapping({"McCamy 1992": 1, "Hernandez 1999": 2})

312 >>> methods["mccamy 1992"]

313 1

314 >>> methods["MCCAMY 1992"]

315 1

316 >>> methods["mccamy-1992"]

317 1

318 >>> methods["mccamy1992"]

319 1

320 """

321

322 def __init__(self, data: Generator | Mapping | None = None, **kwargs: Any) -> None:

323 self._data: dict = {}

324

325 self.update({} if data is None else data, **kwargs)

326

327 @property

328 def data(self) -> dict:

329 """

330 Getter for the delimiter and case-insensitive :class:`dict`-like

331 object data.

332

333 Returns

334 -------

335 :class:`dict`

336 Internal data storage.

337 """

338

339 return self._data

340

341 def __repr__(self) -> str:

342 """

343 Return an evaluable string representation of the delimiter and

344 case-insensitive :class:`dict`-like object.

345

346 Returns

347 -------

348 :class:`str`

349 Evaluable string representation.

350 """

351

352 if is_documentation_building(): # pragma: no cover

353 representation = repr(

354 dict(zip(self.keys(), ["..."] * len(self), strict=True))

355 ).replace("'...'", "...")

356

357 return f"{self.__class__.__name__}({representation})"

358

359 return f"{self.__class__.__name__}({dict(self.items())})"

360

361 def __setitem__(self, item: str | Any, value: Any) -> None:

362 """

363 Set the specified item with the specified value in the delimiter and

364 case-insensitive :class:`dict`-like object.

365

366 Parameters

367 ----------

368 item

369 Item to set in the delimiter and case-insensitive

370 :class:`dict`-like object.

371 value

372 Value to store in the delimiter and case-insensitive

373 :class:`dict`-like object.

374 """

375

376 self._data[item] = value

377

378 def __getitem__(self, item: str | Any) -> Any:

379 """

380 Return the value of the specified item from the delimiter and

381 case-insensitive :class:`dict`-like object.

382

383 Parameters

384 ----------

385 item

386 Item to retrieve the value of from the delimiter and

387 case-insensitive :class:`dict`-like object.

388

389 Returns

390 -------

391 :class:`object`

392 Item value.

393

394 Notes

395 -----

396 - The item value can be retrieved by using either its lower-case,

397 slugified or canonical variant.

398 """

399

400 try:

401 return self._data[item]

402 except KeyError:

403 pass

404

405 try:

406 return self[

407 dict(zip(self.lower_keys(), self.keys(), strict=True))[

408 str(item).lower()

409 ]

410 ]

411 except KeyError:

412 pass

413

414 try:

415 return self[

416 dict(zip(self.slugified_keys(), self.keys(), strict=True))[item]

417 ]

418 except KeyError:

419 pass

420

421 return self[dict(zip(self.canonical_keys(), self.keys(), strict=True))[item]]

422

423 def __delitem__(self, item: str | Any) -> None:

424 """

425 Delete the specified item from the delimiter and case-insensitive

426 :class:`dict`-like object.

427

428 Parameters

429 ----------

430 item

431 Item to delete from the delimiter and case-insensitive

432 :class:`dict`-like object.

433

434 Notes

435 -----

436 - The item can be deleted by using either its lower-case,

437 slugified or canonical variant.

438 """

439

440 try:

441 del self._data[item]

442 except KeyError:

443 pass

444 else:

445 return

446

447 try:

448 del self._data[

449 dict(zip(self.lower_keys(), self.keys(), strict=True))[

450 str(item).lower()

451 ]

452 ]

453 except KeyError:

454 pass

455 else:

456 return

457

458 try:

459 del self[dict(zip(self.slugified_keys(), self.keys(), strict=True))[item]]

460 except KeyError:

461 pass

462 else:

463 return

464

465 del self[dict(zip(self.canonical_keys(), self.keys(), strict=True))[item]]

466

467 def __contains__(self, item: str | Any) -> bool:

468 """

469 Return whether the delimiter and case-insensitive :class:`dict`-like

470 object contains the specified item.

471

472 Parameters

473 ----------

474 item

475 Item to check for presence in the delimiter and case-insensitive

476 :class:`dict`-like object.

477

478 Returns

479 -------

480 :class:`bool`

481 Whether the specified item exists in the delimiter and

482 case-insensitive :class:`dict`-like object.

483

484 Notes

485 -----

486 - Item presence can be checked using its lower-case, slugified, or

487 canonical variant.

488 """

489

490 return bool(

491 any(

492 [

493 item in self._data,

494 str(item).lower() in self.lower_keys(),

495 item in self.slugified_keys(),

496 item in self.canonical_keys(),

497 ]

498 )

499 )

500

501 def __iter__(self) -> Generator:

502 """

503 Iterate over the items of the delimiter and case-insensitive

504 :class:`dict`-like object.

505

506 Yields

507 ------

508 Generator

509 Item generator.

510

511 Notes

512 -----

513 - The iterated items are the original items.

514 """

515

516 yield from self._data.keys()

517

518 def __len__(self) -> int:

519 """

520 Return the item count of the container.

521

522 Returns

523 -------

524 :class:`int`

525 Item count.

526 """

527

528 return len(self._data)

529

530 __hash__ = None

531

532 def __eq__(self, other: object) -> bool:

533 """

534 Test whether the delimiter and case-insensitive :class:`dict`-like

535 object equals the specified object.

536

537 Parameters

538 ----------

539 other

540 Object to test for equality with the delimiter and

541 case-insensitive :class:`dict`-like object.

542

543 Returns

544 -------

545 :class:`bool`

546 Whether the specified object equals the delimiter and

547 case-insensitive :class:`dict`-like object.

548 """

549

550 if isinstance(other, Mapping):

551 other_mapping = CanonicalMapping(other)

552 else:

553 error = (

554 f"Impossible to test equality with "

555 f'"{other.__class__.__name__}" class type!'

556 )

557

558 raise TypeError(error)

559

560 return self._data == other_mapping.data

561

562 def __ne__(self, other: object) -> bool:

563 """

564 Test whether the delimiter and case-insensitive

565 :class:`dict`-like object is not equal to the specified other object.

566

567 Parameters

568 ----------

569 other

570 Object to test whether it is not equal to the delimiter and

571 case-insensitive :class:`dict`-like object.

572

573 Returns

574 -------

575 :class:`bool`

576 Whether the specified object is not equal to the delimiter and

577 case-insensitive :class:`dict`-like object.

578 """

579

580 return not (self == other)

581

582 @staticmethod

583 def _collision_warning(keys: list) -> None:

584 """

585 Issue a runtime warning for colliding keys.

586

587 Parameters

588 ----------

589 keys

590 Keys to check for collisions.

591 """

592

593 from colour.utilities import usage_warning # noqa: PLC0415

594

595 collisions = [key for (key, value) in Counter(keys).items() if value > 1]

596

597 if collisions:

598 usage_warning(f"{list(set(keys))} key(s) collide(s)!")

599

600 def copy(self) -> CanonicalMapping:

601 """

602 Return a copy of the delimiter and case-insensitive

603 :class:`dict`-like object.

604

605 Returns

606 -------

607 :class:`CanonicalMapping`

608 Case-insensitive :class:`dict`-like object copy.

609

610 Warnings

611 --------

612 - The :class:`CanonicalMapping` class copy returned is a *copy* of

613 the object not a *deepcopy*!

614 """

615

616 return CanonicalMapping(dict(**self._data))

617

618 def lower_keys(self) -> Generator:

619 """

620 Iterate over the lower-case keys of the delimiter and case-insensitive

621 :class:`dict`-like object.

622

623 Yields

624 ------

625 Generator

626 Lower-case key generator.

627 """

628

629 lower_keys = [str(key).lower() for key in self._data]

630

631 self._collision_warning(lower_keys)

632

633 yield from iter(lower_keys)

634

635 def lower_items(self) -> Generator:

636 """

637 Iterate over the lower-case items of the delimiter and case-insensitive

638 :class:`dict`-like object.

639

640 Yields

641 ------

642 Generator

643 Item generator.

644 """

645

646 yield from ((str(key).lower(), value) for (key, value) in self._data.items())

647

648 def slugified_keys(self) -> Generator:

649 """

650 Iterate over the slugified keys of the delimiter and

651 case-insensitive :class:`dict`-like object.

652

653 Yields

654 ------

655 Generator

656 Item generator.

657 """

658

659 from colour.utilities import slugify # noqa: PLC0415

660

661 slugified_keys = [slugify(key) for key in self.lower_keys()]

662

663 self._collision_warning(slugified_keys)

664

665 yield from iter(slugified_keys)

666

667 def slugified_items(self) -> Generator:

668 """

669 Iterate over the slugified items of the delimiter and

670 case-insensitive :class:`dict`-like object.

671

672 Yields

673 ------

674 Generator

675 Item generator.

676 """

677

678 yield from zip(self.slugified_keys(), self.values(), strict=True)

679

680 def canonical_keys(self) -> Generator:

681 """

682 Iterate over the canonical keys of the delimiter and

683 case-insensitive :class:`dict`-like object.

684

685 Yields

686 ------

687 Generator

688 Item generator.

689 """

690

691 canonical_keys = [re.sub("-|_", "", key) for key in self.slugified_keys()]

692

693 self._collision_warning(canonical_keys)

694

695 yield from iter(canonical_keys)

696

697 def canonical_items(self) -> Generator:

698 """

699 Iterate over the canonical items of the delimiter and case-insensitive

700 :class:`dict`-like object.

701

702 Yields

703 ------

704 Generator

705 Item generator.

706 """

707

708 yield from zip(self.canonical_keys(), self.values(), strict=True)

709

710

711 class LazyCanonicalMapping(CanonicalMapping):

712 """

713 Represent a lazy delimiter and case-insensitive :class:`dict`-like object

714 inheriting from :class:`colour.utilities.CanonicalMapping`.

715

716 This class extends :class:`CanonicalMapping` with lazy evaluation

717 capabilities. When a value is a callable, it is automatically evaluated

718 upon first access and its return value is cached, replacing the original

719 callable for subsequent retrievals.

720

721 Parameters

722 ----------

723 data

724 Data to store into the lazy delimiter and case-insensitive

725 :class:`dict`-like object at initialisation.

726

727 Other Parameters

728 ----------------

729 kwargs

730 Key / value pairs to store into the mapping at initialisation.

731

732 Methods

733 -------

734 - :meth:`~colour.utilities.LazyCanonicalMapping.__getitem__`

735

736 Examples

737 --------

738 >>> def callable_a():

739 ... print(2)

740 ... return 2

741 >>> methods = LazyCanonicalMapping({"McCamy": 1, "Hernandez": callable_a})

742 >>> methods["mccamy"]

743 1

744 >>> methods["hernandez"]

745 2

746 2

747 """

748

749 def __getitem__(self, item: str | Any) -> Any:

750 """

751 Return the value of the specified item from the lazy delimiter and

752 case-insensitive :class:`dict`-like object.

753

754 Parameters

755 ----------

756 item

757 Item to retrieve the value of from the lazy delimiter and

758 case-insensitive :class:`dict`-like object.

759

760 Returns

761 -------

762 :class:`object`

763 Item value.

764 """

765

766 import colour # noqa: PLC0415

767

768 value = super().__getitem__(item)

769

770 if callable(value) and hasattr(colour, "__disable_lazy_load__"):

771 value = value()

772 super().__setitem__(item, value)

773

774 return value