From bdc3ac6b503b51bf504d0c69e40a952e7aa2ec41 Mon Sep 17 00:00:00 2001 From: Thomas Mansencal Date: Sun, 12 Jan 2025 12:27:11 +1300 Subject: [PATCH] Use `colour` checks configuration. --- .gitignore | 34 +- .pre-commit-config.yaml | 22 +- CONTRIBUTING.rst | 4 +- LICENSE | 2 +- README.rst | 2 +- colour_clf_io/__init__.py | 119 +++--- colour_clf_io/elements.py | 260 ++++++++----- colour_clf_io/errors.py | 8 +- colour_clf_io/parsing.py | 242 +++++++----- colour_clf_io/process_list.py | 48 ++- colour_clf_io/process_nodes.py | 355 +++++++++++------- colour_clf_io/tests/conftest.py | 24 +- colour_clf_io/tests/test_clf_common.py | 97 ++++- colour_clf_io/tests/test_clf_parsing.py | 469 +++++++++++++++--------- colour_clf_io/values.py | 54 +-- docs/conf.py | 2 +- docs/index.rst | 2 +- docs/requirements.txt | 21 +- pyproject.toml | 145 +++----- requirements.txt | 178 +++++++++ tasks.py | 57 +-- utilities/export_todo.py | 6 +- utilities/unicode_to_ascii.py | 4 +- 23 files changed, 1375 insertions(+), 780 deletions(-) create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index 360e0ee..9c42ef2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,24 @@ +# Common Files *.egg-info *.pyc *.pyo .DS_Store .coverage* -.fleet -.idea -.ipynb_checkpoints -.vs -.vscode -.sandbox +uv.lock -__pycache__ +# Common Directories +.fleet/ +.idea/ +.ipynb_checkpoints/ +.python-version +.vs/ +.vscode/ +.sandbox/ +build/ +dist/ +docs/_build/ +docs/generated/ +node_modules/ +references/ -build -dist -docs/_build -docs/_static/Basics_*.png -docs/_static/Examples_*.png -docs/_static/Plotting_*.png -docs/_static/Tutorial_*.png -docs/generated -poetry.lock -references +__pycache__ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 354f8a5..5b884e0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,13 +1,12 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.5.0" + rev: "v5.0.0" hooks: - id: check-added-large-files - id: check-case-conflict - id: check-merge-conflict - id: check-symlinks - id: check-yaml - exclude: config-aces-reference.ocio.yaml - id: debug-statements - id: end-of-file-fixer - id: mixed-line-ending @@ -16,35 +15,30 @@ repos: - id: requirements-txt-fixer - id: trailing-whitespace - repo: https://github.com/codespell-project/codespell - rev: v2.2.6 + rev: v2.3.0 hooks: - id: codespell - args: ["--ignore-words-list=co-ordinates,exitance,fro,hart,ist"] - exclude: "BIBLIOGRAPHY.bib|CONTRIBUTORS.rst" - - repo: https://github.com/ikamensh/flynt - rev: "1.0.1" - hooks: - - id: flynt - args: [--verbose] + args: ["--ignore-words-list=socio-economic"] + exclude: "BIBLIOGRAPHY.bib|CONTRIBUTORS.rst|.*.ipynb" - repo: https://github.com/PyCQA/isort rev: "5.13.2" hooks: - id: isort - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.1.14" + rev: "v0.8.2" hooks: - id: ruff-format - id: ruff + args: [--fix] - repo: https://github.com/adamchainz/blacken-docs - rev: 1.16.0 + rev: 1.19.1 hooks: - id: blacken-docs language_version: python3.10 - repo: https://github.com/pre-commit/mirrors-prettier - rev: "v3.1.0" + rev: "v4.0.0-alpha.8" hooks: - id: prettier - exclude: config-aces-reference.ocio.yaml - repo: https://github.com/pre-commit/pygrep-hooks rev: "v1.10.0" hooks: diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index e91f868..35ffccd 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -9,7 +9,7 @@ If you would like to contribute to **Colour**, please refer to the following gui About ----- -| **Colour** by Colour Developers -| Copyright 2013 Colour Developers – `colour-developers@colour-science.org `__ +| **Colour - CLF IO** by Colour Developers +| Copyright 2024 Colour Developers – `colour-developers@colour-science.org `__ | This software is released under terms of BSD-3-Clause: https://opensource.org/licenses/BSD-3-Clause | `https://github.com/colour-science/colour `__ diff --git a/LICENSE b/LICENSE index a70f873..f769d9e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2013 Colour Developers +Copyright 2024 Colour Developers Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/README.rst b/README.rst index c16d80a..7205525 100644 --- a/README.rst +++ b/README.rst @@ -167,6 +167,6 @@ About ----- | **Colour - CLF IO** by Colour Developers -| Copyright 2015 Colour Developers – `colour-developers@colour-science.org `__ +| Copyright 2024 Colour Developers – `colour-developers@colour-science.org `__ | This software is released under terms of BSD-3-Clause: https://opensource.org/licenses/BSD-3-Clause | `https://github.com/colour-science/colour-clf-io `__ diff --git a/colour_clf_io/__init__.py b/colour_clf_io/__init__.py index b4ad410..5e68010 100644 --- a/colour_clf_io/__init__.py +++ b/colour_clf_io/__init__.py @@ -18,55 +18,17 @@ from __future__ import annotations -__application_name__ = "Colour - CLF IO" - -__major_version__ = "0" -__minor_version__ = "0" -__change_version__ = "0" -__version__ = ".".join((__major_version__, __minor_version__, __change_version__)) +import typing +if typing.TYPE_CHECKING: + from pathlib import Path -# Security issues in lxml should be addressed and no longer be a concern: +# NOTE: Security issues in lxml should be addressed and no longer be a concern: # https://discuss.python.org/t/status-of-defusedxml-and-recommendation-in-docs/34762/6 - -__author__ = "Colour Developers" -__copyright__ = "Copyright 2013 Colour Developers" -__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" -__maintainer__ = "Colour Developers" -__email__ = "colour-developers@colour-science.org" -__status__ = "Production" - -__all__ = [ - "read_clf", - "parse_clf", - "LUT1D", - "LUT3D", - "ProcessNode", - "ProcessList", - "Matrix", - "Range", - "Exponent", - "ExponentStyle", - "ExponentParams", - "ASC_CDL", - "ASC_CDL_Style", - "SatNode", - "SOPNode", - "Interpolation1D", - "Interpolation3D", - "BitDepth", - "Channel", - "CalibrationInfo", - "Info", - "LogParams", - "LogStyle", - "RangeStyle", - "Log", -] - import lxml.etree from .elements import ( + Array, CalibrationInfo, ExponentParams, ExponentStyle, @@ -88,10 +50,61 @@ ProcessNode, Range, ) -from .values import ASC_CDL_Style, BitDepth, Channel, Interpolation1D, Interpolation3D +from .values import ( + ASC_CDL_Style, + BitDepth, + Channel, + Interpolation1D, + Interpolation3D, +) +__author__ = "Colour Developers" +__copyright__ = "Copyright 2024 Colour Developers" +__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" +__maintainer__ = "Colour Developers" +__email__ = "colour-developers@colour-science.org" +__status__ = "Production" + +__all__ = [ + "Array", + "CalibrationInfo", + "ExponentParams", + "ExponentStyle", + "Info", + "LogParams", + "LogStyle", + "RangeStyle", + "SatNode", + "SOPNode", +] +__all__ += ["ProcessList"] +__all__ += [ + "ASC_CDL", + "LUT1D", + "LUT3D", + "Exponent", + "Log", + "Matrix", + "ProcessNode", + "Range", +] +__all__ += [ + "ASC_CDL_Style", + "BitDepth", + "Channel", + "Interpolation1D", + "Interpolation3D", +] + +__application_name__ = "Colour - CLF IO" -def read_clf(path) -> ProcessList: +__major_version__ = "0" +__minor_version__ = "1" +__change_version__ = "0" +__version__ = f"{__major_version__}.{__minor_version__}.{__change_version__}" + + +def read_clf(path: str | Path) -> ProcessList | None: """ Read given *CLF* file and return the resulting `ProcessList`. @@ -108,15 +121,15 @@ def read_clf(path) -> ProcessList: ------ :class: ParsingError If the given file does not contain a valid CLF document. - """ - xml = lxml.etree.parse(path) # noqa: S320 + + xml = lxml.etree.parse(str(path)) # noqa: S320 xml_process_list = xml.getroot() - root = ProcessList.from_xml(xml_process_list) - return root + + return ProcessList.from_xml(xml_process_list) -def parse_clf(text): +def parse_clf(text: str | bytes) -> ProcessList | None: """ Read given string as a *CLF* document and return the resulting `ProcessList`. @@ -133,8 +146,8 @@ def parse_clf(text): ------ :class: ParsingError If the given string does not contain a valid CLF document. - """ + xml = lxml.etree.fromstring(text) # noqa: S320 - root = ProcessList.from_xml(xml) - return root + + return ProcessList.from_xml(xml) diff --git a/colour_clf_io/elements.py b/colour_clf_io/elements.py index ab40b4a..4423921 100644 --- a/colour_clf_io/elements.py +++ b/colour_clf_io/elements.py @@ -4,16 +4,19 @@ Defines objects that hold data from elements contained in a CLF document. These typically are child elements of Process Nodes. - """ from __future__ import annotations import enum +import typing from dataclasses import dataclass -import numpy.typing as npt -from typing_extensions import Self +if typing.TYPE_CHECKING: + import numpy.typing as npt + +if typing.TYPE_CHECKING: + import lxml.etree from colour_clf_io.errors import ParsingError from colour_clf_io.parsing import ( @@ -22,6 +25,7 @@ child_element, child_element_or_exception, map_optional, + must_have, retrieve_attributes, retrieve_attributes_as_float, three_floats, @@ -29,7 +33,7 @@ from colour_clf_io.values import Channel __author__ = "Colour Developers" -__copyright__ = "Copyright 2013 Colour Developers" +__copyright__ = "Copyright 2024 Colour Developers" __license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" __maintainer__ = "Colour Developers" __email__ = "colour-developers@colour-science.org" @@ -56,44 +60,61 @@ class Array(XMLParsable): References ---------- - https://docs.acescentral.com/specifications/clf/#array + - https://docs.acescentral.com/specifications/clf/#array """ values: list[float] dim: tuple[int, ...] - @classmethod - def from_xml(cls, xml, config: ParserConfig) -> Self | None: # noqa: ARG003 + @staticmethod + def from_xml( + xml: lxml.etree._Element | None, + config: ParserConfig, # noqa: ARG004 + ) -> Array | None: """ - Parse and return the Array from the given XML node. Returns None if the given - element is None. + Parse and return a :class:`colour_clf_io.Array` class instance from the + given XML node. Returns `None`` if the given XML node is ``None``. Expects the xml element to be a valid element according to the CLF specification. + Returns + ------- + class:`colour_clf_io.Array` or :py:data:`None` + Parsed XML node. + Raises ------ - :class: ParsingError - If the node does not conform to the specification, a `ParsingError` - will be raised. The error message will indicate the details of the issue - that was encountered. - + :class:`ParsingError` + If the node does not conform to the specification, a ``ParsingError`` + exception will be raised. The error message will indicate the + details of the issue that was encountered. """ + if xml is None: return None - dim = tuple(map(int, xml.get("dim").split())) - values = list(map(float, xml.text.split())) - return cls(values=values, dim=dim) - def as_array(self) -> npt.ArrayLike: + dim = xml.get("dim") + must_have( + xml, + 'Array must have a "dim" attribute', + ) + + dimensions = tuple(map(int, dim.split())) # pyright: ignore + values = list(map(float, xml.text.split())) # pyright: ignore + + return Array(values=values, dim=dimensions) + + def as_array(self) -> npt.NDArray: """ Convert the CLF element into a numpy array. Returns ------- - :class:`numpy.ndarray` + :class:`numpy`ndarray`` Array of shape `dim` with the data from `values`. """ + import numpy as np dim = self.dim @@ -110,7 +131,7 @@ class CalibrationInfo(XMLParsable): References ---------- - https://docs.acescentral.com/specifications/clf/#processlist + - https://docs.acescentral.com/specifications/clf/#processlist """ display_device_serial_num: str | None @@ -121,25 +142,34 @@ class CalibrationInfo(XMLParsable): calibration_software_name: str | None calibration_software_version: str | None - @classmethod - def from_xml(cls, xml, config: ParserConfig) -> Self | None: # noqa: ARG003 + @staticmethod + def from_xml( + xml: lxml.etree._Element | None, + config: ParserConfig, # noqa: ARG004 + ) -> CalibrationInfo | None: """ - Parse and return the Calibration Info from the given XML node. Returns None - if the given element is None. + Parse and return a :class:`colour_clf_io.CalibrationInfo` class instance + from the given XML node. Returns `None`` if the given XML node is ``None``. Expects the xml element to be a valid element according to the CLF specification. + Returns + ------- + class:`colour_clf_io.CalibrationInfo` or :py:data:`None` + Parsed XML node. + Raises ------ - :class: ParsingError - If the node does not conform to the specification, a `ParsingError` - will be raised. The error message will indicate the details of the issue - that was encountered. - + :class:`ParsingError` + If the node does not conform to the specification, a ``ParsingError`` + exception will be raised. The error message will indicate the + details of the issue that was encountered. """ + if xml is None: return None + attributes = retrieve_attributes( xml, { @@ -152,7 +182,8 @@ def from_xml(cls, xml, config: ParserConfig) -> Self | None: # noqa: ARG003 "calibration_software_version": "CalibrationSoftwareVersion", }, ) - return cls(**attributes) + + return CalibrationInfo(**attributes) class RangeStyle(enum.Enum): @@ -161,7 +192,7 @@ class RangeStyle(enum.Enum): References ---------- - https://docs.acescentral.com/specifications/clf/#range + - https://docs.acescentral.com/specifications/clf/#range """ CLAMP = "Clamp" @@ -174,7 +205,7 @@ class LogStyle(enum.Enum): References ---------- - https://docs.acescentral.com/specifications/clf/#processList + - https://docs.acescentral.com/specifications/clf/#processList """ LOG_10 = "log10" @@ -193,7 +224,7 @@ class ExponentStyle(enum.Enum): References ---------- - https://docs.acescentral.com/specifications/clf/#exponent + - https://docs.acescentral.com/specifications/clf/#exponent """ BASIC_FWD = "basicFwd" @@ -215,35 +246,45 @@ class SOPNode(XMLParsable): References ---------- - https://docs.acescentral.com/specifications/clf/#asc_cdl + - https://docs.acescentral.com/specifications/clf/#asc_cdl """ slope: tuple[float, float, float] offset: tuple[float, float, float] power: tuple[float, float, float] - @classmethod - def from_xml(cls, xml, config: ParserConfig) -> Self | None: + @staticmethod + def from_xml( + xml: lxml.etree._Element | None, config: ParserConfig + ) -> SOPNode | None: """ - Parse and return the SOPNode from the given XML node. Returns None if the given - element is None. + Parse and return a :class:`colour_clf_io.SOPNode` class instance from the + given XML node. Returns `None`` if the given XML node is ``None``. Expects the xml element to be a valid element according to the CLF specification. + Returns + ------- + class:`colour_clf_io.SOPNode` or :py:data:`None` + Parsed XML node. + Raises ------ - :class: ParsingError - If the node does not conform to the specification, a `ParsingError` - will be raised. The error message will indicate the details of the issue - that was encountered. + :class:`ParsingError` + If the node does not conform to the specification, a ``ParsingError`` + exception will be raised. The error message will indicate the + details of the issue that was encountered. """ + if xml is None: return None + slope = three_floats(child_element_or_exception(xml, "Slope", config).text) offset = three_floats(child_element_or_exception(xml, "Offset", config).text) power = three_floats(child_element_or_exception(xml, "Power", config).text) - return cls(slope=slope, offset=offset, power=power) + + return SOPNode(slope=slope, offset=offset, power=power) @dataclass @@ -253,34 +294,47 @@ class SatNode(XMLParsable): References ---------- - https://docs.acescentral.com/specifications/clf/#asc_cdl + - https://docs.acescentral.com/specifications/clf/#asc_cdl """ saturation: float - @classmethod - def from_xml(cls, xml, config: ParserConfig) -> Self | None: + @staticmethod + def from_xml( + xml: lxml.etree._Element | None, config: ParserConfig + ) -> SatNode | None: """ - Parse and return the SatNode from the given XML node. Returns None if the given - element is None. + Parse and return a :class:`colour_clf_io.SatNode` class instance from the + given XML node. Returns `None`` if the given XML node is ``None``. Expects the xml element to be a valid element according to the CLF specification. + Returns + ------- + class:`colour_clf_io.SatNode` or :py:data:`None` + Parsed XML node. + Raises ------ - :class: ParsingError - If the node does not conform to the specification, a `ParsingError` - will be raised. The error message will indicate the details of the issue - that was encountered. + :class:`ParsingError` + If the node does not conform to the specification, a ``ParsingError`` + exception will be raised. The error message will indicate the + details of the issue that was encountered. """ + if xml is None: return None + saturation = child_element_or_exception(xml, "Saturation", config).text if saturation is None: - raise ParsingError("Saturation node in SatNode contains no value.") + exception = "Saturation node in SatNode contains no value." + + raise ParsingError(exception) + saturation = float(saturation) - return cls(saturation=saturation) + + return SatNode(saturation=saturation) @dataclass @@ -290,7 +344,7 @@ class Info(XMLParsable): References ---------- - https://docs.acescentral.com/specifications/clf/#processList + - https://docs.acescentral.com/specifications/clf/#processList """ app_release: str | None @@ -300,25 +354,31 @@ class Info(XMLParsable): aces_user_name: str | None calibration_info: CalibrationInfo | None - @classmethod - def from_xml(cls, xml, config: ParserConfig) -> Self | None: + @staticmethod + def from_xml(xml: lxml.etree._Element | None, config: ParserConfig) -> Info | None: """ - Parse and return the Info from the given XML node. Returns None if the given - element is None. + Parse and return a :class:`colour_clf_io.Info` class instance from the + given XML node. Returns `None`` if the given XML node is ``None``. Expects the xml element to be a valid element according to the CLF specification. + Returns + ------- + class:`colour_clf_io.Info` or :py:data:`None` + Parsed XML node. + Raises ------ - :class: ParsingError - If the node does not conform to the specification, a `ParsingError` - will be raised. The error message will indicate the details of the issue - that was encountered. - + :class:`ParsingError` + If the node does not conform to the specification, a ``ParsingError`` + exception will be raised. The error message will indicate the + details of the issue that was encountered. """ + if xml is None: return None + attributes = retrieve_attributes( xml, { @@ -330,9 +390,11 @@ def from_xml(cls, xml, config: ParserConfig) -> Self | None: }, ) calibration_info = CalibrationInfo.from_xml( - child_element(xml, "CalibrationInfo", config), config + child_element(xml, "CalibrationInfo", config), # pyright: ignore + config, ) - return cls(calibration_info=calibration_info, **attributes) + + return Info(calibration_info=calibration_info, **attributes) @dataclass @@ -342,7 +404,7 @@ class LogParams(XMLParsable): References ---------- - https://docs.acescentral.com/specifications/clf/#log + - https://docs.acescentral.com/specifications/clf/#log """ base: float | None @@ -354,24 +416,34 @@ class LogParams(XMLParsable): linear_slope: float | None channel: Channel | None - @classmethod - def from_xml(cls, xml, config: ParserConfig) -> Self | None: # noqa: ARG003 + @staticmethod + def from_xml( + xml: lxml.etree._Element | None, + config: ParserConfig, # noqa: ARG004 + ) -> LogParams | None: """ - Parse and return the Log Param from the given XML node. Returns None if the - given element is None. + Parse and return a :class:`colour_clf_io.LogParams` class instance from + the given XML node. Returns `None`` if the given XML node is ``None``. Expects the xml element to be a valid element according to the CLF specification. + Returns + ------- + class:`colour_clf_io.LogParams` or :py:data:`None` + Parsed XML node. + Raises ------ - :class: ParsingError - If the node does not conform to the specification, a `ParsingError` - will be raised. The error message will indicate the details of the issue - that was encountered. + :class:`ParsingError` + If the node does not conform to the specification, a ``ParsingError`` + exception will be raised. The error message will indicate the + details of the issue that was encountered. """ + if xml is None: return None + attributes = retrieve_attributes_as_float( xml, { @@ -387,7 +459,7 @@ def from_xml(cls, xml, config: ParserConfig) -> Self | None: # noqa: ARG003 channel = map_optional(Channel, xml.get("channel")) - return cls(channel=channel, **attributes) + return LogParams(channel=channel, **attributes) @dataclass @@ -397,31 +469,41 @@ class ExponentParams(XMLParsable): References ---------- - https://docs.acescentral.com/specifications/clf/#exponent + - https://docs.acescentral.com/specifications/clf/#exponent """ exponent: float offset: float | None channel: Channel | None - @classmethod - def from_xml(cls, xml, config: ParserConfig) -> Self | None: # noqa: ARG003 + @staticmethod + def from_xml( + xml: lxml.etree._Element | None, + config: ParserConfig, # noqa: ARG004 + ) -> ExponentParams | None: """ - Parse and return the Exponent Params from the given XML node. Returns None if - the given element is None. + Parse and return a :class:`colour_clf_io.ExponentParams` class instance + from the given XML node. Returns `None`` if the given XML node is ``None``. Expects the xml element to be a valid element according to the CLF specification. + Returns + ------- + class:`colour_clf_io.ExponentParams` or :py:data:`None` + Parsed XML node. + Raises ------ - :class: ParsingError - If the node does not conform to the specification, a `ParsingError` - will be raised. The error message will indicate the details of the issue - that was encountered. + :class:`ParsingError` + If the node does not conform to the specification, a ``ParsingError`` + exception will be raised. The error message will indicate the + details of the issue that was encountered. """ + if xml is None: return None + attributes = retrieve_attributes_as_float( xml, { @@ -430,8 +512,12 @@ def from_xml(cls, xml, config: ParserConfig) -> Self | None: # noqa: ARG003 }, ) exponent = attributes.pop("exponent") + if exponent is None: - raise ParsingError("Exponent process node has no `exponent' value.") + exception = "Exponent process node has no `exponent' value." + + raise ParsingError(exception) + channel = map_optional(Channel, xml.get("channel")) - return cls(channel=channel, exponent=exponent, **attributes) + return ExponentParams(channel=channel, exponent=exponent, **attributes) diff --git a/colour_clf_io/errors.py b/colour_clf_io/errors.py index 1eb8c69..62e586c 100644 --- a/colour_clf_io/errors.py +++ b/colour_clf_io/errors.py @@ -3,18 +3,22 @@ ====== Defines errors that are used as part of the parsing and validation of CLF documents. - """ from __future__ import annotations __author__ = "Colour Developers" -__copyright__ = "Copyright 2013 Colour Developers" +__copyright__ = "Copyright 2024 Colour Developers" __license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" __maintainer__ = "Colour Developers" __email__ = "colour-developers@colour-science.org" __status__ = "Production" +__all__ = [ + "ParsingError", + "ValidationError", +] + class ParsingError(Exception): """ diff --git a/colour_clf_io/parsing.py b/colour_clf_io/parsing.py index d4d062a..63c7cd4 100644 --- a/colour_clf_io/parsing.py +++ b/colour_clf_io/parsing.py @@ -3,34 +3,37 @@ ======= Defines utilities that are used to parse CLF documents. - """ from __future__ import annotations import collections -import xml.etree -import xml.etree.ElementTree +import typing from abc import ABC, abstractmethod from dataclasses import dataclass from itertools import islice -from typing import Callable, TypeVar +from typing import TypeGuard, TypeVar + +if typing.TYPE_CHECKING: + from collections.abc import Callable, Iterable + from typing import Any -from typing_extensions import Self, TypeGuard +if typing.TYPE_CHECKING: + import lxml.etree from colour_clf_io.errors import ParsingError __author__ = "Colour Developers" -__copyright__ = "Copyright 2013 Colour Developers" +__copyright__ = "Copyright 2024 Colour Developers" __license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" __maintainer__ = "Colour Developers" __email__ = "colour-developers@colour-science.org" __status__ = "Production" -__ALL__ = [ +__all__ = [ + "NAMESPACE_NAME", "ParserConfig", "XMLParsable", - "fully_qualified_name", "map_optional", "retrieve_attributes", "retrieve_attributes_as_float", @@ -39,20 +42,19 @@ "child_elements", "child_element_or_exception", "element_as_text", + "element_as_float", "elements_as_text_list", "sliding_window", "three_floats", - "element_as_float", ] -_T = TypeVar("_T") - -NAMESPACE_NAME = "urn:AMPAS:CLF:v3.0" +NAMESPACE_NAME: str = "urn:AMPAS:CLF:v3.0" @dataclass class ParserConfig: - """Additional settings for parsing the CLF document. + """ + Additional settings for parsing the CLF document. Parameters ---------- @@ -64,17 +66,19 @@ class ParserConfig: namespace_name: str | None = NAMESPACE_NAME def clf_namespace_prefix_mapping(self) -> dict[str, str] | None: - """Return the namespaces prefix mapping used for CLF documents. + """ + Return the namespaces prefix mapping used for CLF documents. Returns ------- - :class:`dict[str, str]` that contains the namespaces prefix mappings. - + :class:`dict[str, str]` or :py:data:`None` + Dictionary that contain the namespaces prefix mappings. """ + if self.namespace_name: return {"clf": self.namespace_name} - else: - return None + + return None class XMLParsable(ABC): @@ -89,9 +93,11 @@ class XMLParsable(ABC): - :meth:`~colour_lf_io.parsing.XMLParsable.from_xml` """ - @classmethod + @staticmethod @abstractmethod - def from_xml(cls, xml, config: ParserConfig) -> Self | None: + def from_xml( + xml: lxml.etree._Element | None, config: ParserConfig + ) -> XMLParsable | None: """ Parse an object of this class from the given XML object. @@ -104,39 +110,41 @@ def from_xml(cls, xml, config: ParserConfig) -> Self | None: Returns ------- - An instance of the parsed object, or :py:data:`None` if parsing failed. - + :class:`colour_clf_io.parsing.XMLParsable` or :py:data:`None` + Parsed object or ``None`` if parsing failed. """ -def map_optional(f: Callable, value): +def map_optional(function: Callable, value: Any | None) -> Any: """ - Apply `f` to value, if `value` is not :py:data:`None`. + Apply ``function`` to value, if ``value`` is not ``None``. Parameters ---------- - f + function The function to apply. value - The value (that might be :py:data:`None`) + The value to apply the function onto Returns ------- - The result of applying `f` to `value`, or :py:data:`None`. - + :class:`object` or :py:data:`None` + The result of applying ``function`` to ``value``. """ + if value is not None: - return f(value) + return function(value) + return None def retrieve_attributes( - xml, attribute_mapping: dict[str, str] + xml: lxml.etree._Element, attribute_mapping: dict[str, str] ) -> dict[str, str | None]: """ - Take a dictionary of keys and attribute names and map the attribute names to the - corresponding values from the given XML element. Note that the keys of the - attribute mapping are not used in any way. + Take a dictionary of keys and attribute names and map the attribute names + to the corresponding values from the given XML element. Note that the keys + of the attribute mapping are not used in any way. Parameters ---------- @@ -149,15 +157,15 @@ def retrieve_attributes( ------- :class:`dict[str, str | None]` The resulting dictionary of keys and attribute values. - """ + return { k: xml.get(attribute_name) for k, attribute_name in attribute_mapping.items() } def retrieve_attributes_as_float( - xml, attribute_mapping: dict[str, str] + xml: lxml.etree._Element, attribute_mapping: dict[str, str] ) -> dict[str, float | None]: """ Take a dictionary of keys and attribute names and map the attribute names to the @@ -177,13 +185,14 @@ def retrieve_attributes_as_float( ------- :class:`dict[str, float | None]` The resulting dictionary of keys and attribute values. - """ + attributes = retrieve_attributes(xml, attribute_mapping) - def as_float(value): + def as_float(value: Any) -> float | None: if value is None: return None + try: return float(value) except ValueError: @@ -192,7 +201,10 @@ def as_float(value): return {key: as_float(value) for key, value in attributes.items()} -def must_have(value: _T | None, message) -> TypeGuard[_T]: +T = TypeVar("T") + + +def must_have(value: T | None, message: str) -> TypeGuard[T]: """ Assert that `value` is not :py:data:`None`. @@ -210,16 +222,17 @@ def must_have(value: _T | None, message) -> TypeGuard[_T]: Returns ------- :class:`TypeGuard` - """ + if value is None: raise ParsingError(message) + return True def child_element( - xml, name, config: ParserConfig, xpath_function="" -) -> xml.etree.ElementTree.Element | None | str: + xml: lxml.etree._Element, name: str, config: ParserConfig, xpath_function: str = "" +) -> lxml.etree._Element | str | None: """ Return a named child element of the given XML element. @@ -239,25 +252,28 @@ def child_element( :class:`xml.etree.ElementTree.Element` or :class`str` or :py:data:`None` The found child element, or the result of the applied XPath function. :py:data:`None` if the child was not found. - """ elements = child_elements(xml, name, config, xpath_function) element_count = len(elements) + if element_count == 0: return None - elif element_count == 1: + + if element_count == 1: return elements[0] - else: - raise ParsingError( - f"Found multiple elements of type {name} in " - f"element {xml}, but only expected exactly one." - ) + + exception = ( + f"Found multiple elements of type {name} in " + f"element {xml}, but only expected exactly one." + ) + + raise ParsingError(exception) def child_elements( - xml, name, config: ParserConfig, xpath_function="" -) -> list[xml.etree.ElementTree.Element] | list[str]: + xml: lxml.etree._Element, name: str, config: ParserConfig, xpath_function: str = "" +) -> list[lxml.etree._Element] | list[str]: """ Return all child elements with a given name of an XML element. @@ -274,11 +290,11 @@ def child_elements( Returns ------- - :class:`xml.etree.ElementTree.Element` or :class`str` or :py:data:`None` + :class:`xml.etree.ElementTree.Element` or :class`str` The found child element, or the result of the applied XPath function. :py:data:`None` if the child was not found. - """ + if config.clf_namespace_prefix_mapping(): elements = xml.xpath( f"clf:{name}{xpath_function}", @@ -286,15 +302,16 @@ def child_elements( ) else: elements = xml.xpath(f"{name}{xpath_function}") - return elements + + return elements # pyright: ignore def child_element_or_exception( - xml, name, config: ParserConfig -) -> xml.etree.ElementTree.Element: + xml: lxml.etree._Element, name: str, config: ParserConfig +) -> lxml.etree._Element: """ - Return a named child element of the given XML element, or raise an exception if no - such child element is found. + Return a named child element of the given XML element, or raise an exception + if no such child element is found. Parameters ---------- @@ -316,17 +333,22 @@ def child_element_or_exception( :class:`xml.etree.ElementTree.Element` The found child element. """ + element = child_element(xml, name, config) assert not isinstance(element, str) # noqa: S101 + if element is None: - raise ParsingError( + exception = ( f"Tried to retrieve child element '{name}' from '{xml}' but child was " "not present." ) + + raise ParsingError(exception) + return element -def element_as_text(xml, name, config: ParserConfig) -> str: +def element_as_text(xml: lxml.etree._Element, name: str, config: ParserConfig) -> str: """ Convert a named child of the given XML element to its text value. @@ -342,18 +364,21 @@ def element_as_text(xml, name, config: ParserConfig) -> str: Returns ------- :class:`str` - The text value of the child element. If the child element is not present and - empty string is returned. - + The text value of the child element. If the child element is not present + an empty string is returned. """ + text = child_element(xml, name, config, xpath_function="/text()") + if text is None: return "" - else: - return str(text) + + return str(text) -def element_as_float(xml, name, config: ParserConfig) -> float | None: +def element_as_float( + xml: lxml.etree._Element, name: str, config: ParserConfig +) -> float | None: """ Convert a named child of the given XML element to its float value. @@ -368,24 +393,28 @@ def element_as_float(xml, name, config: ParserConfig) -> float | None: Returns ------- - :class:`float` - The value of the child element as float. If the child element is not or an - invalid float representation, :py:data:`None` is returned. - + :class:`float` or :py:data:`None` + The value of the child element as float. If the child element is not or + an invalid float representation, ``None`` is returned. """ + text = child_element(xml, name, config, xpath_function="/text()") + if text is None: return None - else: - try: - return float(str(text)) - except ValueError: - return None + + try: + return float(str(text)) + except ValueError: + return None -def elements_as_text_list(xml, name, config: ParserConfig): +def elements_as_text_list( + xml: lxml.etree._Element, name: str, config: ParserConfig +) -> list[str]: """ - Return one or more child elements of the given XML element as a list of strings. + Return one or more child elements of the given XML element as a list of + strings. Parameters ---------- @@ -399,23 +428,39 @@ def elements_as_text_list(xml, name, config: ParserConfig): Returns ------- :class:`list` of :class:`str` - A list of string, where each string corresponds to the text representation of - a child element. - + A list of string, where each string corresponds to the text + representation of a child element. """ + if config.clf_namespace_prefix_mapping(): - return xml.xpath( + return xml.xpath( # pyright: ignore f"clf:{name}/text()", namespaces=config.clf_namespace_prefix_mapping() ) - else: - return xml.xpath(f"{name}/text()") + + return xml.xpath(f"{name}/text()") # pyright: ignore -def sliding_window(iterable, n): +def sliding_window(iterable: Iterable, n: int) -> Iterable: """ Collect data into overlapping fixed-length chunks or blocks. - Source: https://docs.python.org/3/library/itertools.html + + Parameters + ---------- + iterable + Iterable to collect the data from + n + Chunk size + + Returns + ------- + Generator + Chunk generator. + + References + ---------- + - https://docs.python.org/3/library/itertools.html """ + it = iter(iterable) window = collections.deque(islice(it, n - 1), maxlen=n) for x in it: @@ -423,31 +468,36 @@ def sliding_window(iterable, n): yield tuple(window) -def three_floats(s: str | None) -> tuple[float, float, float]: +def three_floats(text: str | None) -> tuple[float, float, float]: """ - Parse the given value as a comma separated list of floating point values. + Parse the given text as a comma separated list of floating point values. Parameters ---------- - s + text String to parse. Raises ------ :class:`ParsingError` - If `s` is :py:data:`None`, or cannot be parsed as three floats. + If `text` is :py:data:`None`, or cannot be parsed as three floats. Returns ------- :class:`tuple` of :class:`float` Three floating point values. - """ - if s is None: - raise ParsingError(f"Failed to parse three float values from {s}") - parts = s.split() + + if text is None: + exception = f"Failed to parse three float values from {text}" + + raise ParsingError(exception) + + parts = text.split() + if len(parts) != 3: - raise ParsingError(f"Failed to parse three float values from {s}") - values = tuple(map(float, parts)) - # Repacking here to satisfy type check. - return values[0], values[1], values[2] + exception = f"Failed to parse three float values from {text}" + + raise ParsingError(exception) + + return float(parts[0]), float(parts[1]), float(parts[2]) diff --git a/colour_clf_io/process_list.py b/colour_clf_io/process_list.py index 5812ca9..ae7ccb9 100644 --- a/colour_clf_io/process_list.py +++ b/colour_clf_io/process_list.py @@ -3,7 +3,6 @@ ============ Defines the top level Process List object that represents a CLF process. - """ from __future__ import annotations @@ -28,13 +27,13 @@ ) __author__ = "Colour Developers" -__copyright__ = "Copyright 2013 Colour Developers" +__copyright__ = "Copyright 2024 Colour Developers" __license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" __maintainer__ = "Colour Developers" __email__ = "colour-developers@colour-science.org" __status__ = "Production" -__ALL__ = ["ProcessList"] +__all__ = ["ProcessList"] @dataclass @@ -44,7 +43,7 @@ class ProcessList: References ---------- - https://docs.acescentral.com/specifications/clf/#processList + - https://docs.acescentral.com/specifications/clf/#processList """ id: str @@ -61,30 +60,37 @@ class ProcessList: info: Info | None @staticmethod - def from_xml(xml): + def from_xml(xml: lxml.etree._Element | None) -> ProcessList | None: """ - Parse and return the Process List from the given XML node. Returns None if the - given element is None. + Parse and return a :class:`colour_clf_io.ProcessList` class instance + from the given XML node. Returns `None`` if the given XML node is ``None``. Expects the xml element to be a valid element according to the CLF specification. + Returns + ------- + class:`colour_clf_io.ProcessList` or :py:data:`None` + Parsed XML node. + Raises ------ - :class: ParsingError - If the node does not conform to the specification, a `ParsingError` - will be raised. The error message will indicate the details of the issue - that was encountered. - + :class:`ParsingError` + If the node does not conform to the specification, a ``ParsingError`` + exception will be raised. The error message will indicate the + details of the issue that was encountered. """ + if xml is None: return None - id = xml.get("id") # noqa: A001 - must_have(id, "ProcessList must contain an `id` attribute") + + id_ = xml.get("id") + must_have(id_, "ProcessList must contain an `id` attribute") + compatible_clf_version = xml.get("compCLFversion") must_have( compatible_clf_version, - "ProcessList must contain an `compCLFversion` attribute", + 'ProcessList must contain a "compCLFversion" attribute', ) # By default, we would expect the correct namespace as per the specification. @@ -95,9 +101,9 @@ def from_xml(xml): if not namespace: config.namespace_name = None elif namespace != config.namespace_name: - raise ParsingError( - f"Found invalid xmlns attribute in process list: {namespace}" - ) + exception = f"Found invalid xmlns attribute in process list: {namespace}" + + raise ParsingError(exception) name = xml.get("name") inverse_of = xml.get("inverseOf") @@ -111,16 +117,18 @@ def from_xml(xml): process_nodes = filter( lambda node: lxml.etree.QName(node).localname not in ignore_nodes, xml ) + if not process_nodes: warn("Got empty process node.") + process_nodes = [ parse_process_node(xml_node, config) for xml_node in process_nodes ] assert_bit_depth_compatibility(process_nodes) return ProcessList( - id=id, - compatible_CLF_version=compatible_clf_version, + id=id_, # pyright: ignore + compatible_CLF_version=compatible_clf_version, # pyright: ignore process_nodes=process_nodes, name=name, inverse_of=inverse_of, diff --git a/colour_clf_io/process_nodes.py b/colour_clf_io/process_nodes.py index 2ad15d0..74a1f3c 100644 --- a/colour_clf_io/process_nodes.py +++ b/colour_clf_io/process_nodes.py @@ -3,14 +3,17 @@ ============ Defines the available process nodes in a CLF document. - """ from __future__ import annotations +import typing from abc import ABC from dataclasses import dataclass +if typing.TYPE_CHECKING: + from collections.abc import Callable + import lxml.etree from colour_clf_io.elements import ( @@ -42,8 +45,19 @@ Interpolation3D, ) -__ALL__ = [ +__author__ = "Colour Developers" +__copyright__ = "Copyright 2024 Colour Developers" +__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" +__maintainer__ = "Colour Developers" +__email__ = "colour-developers@colour-science.org" +__status__ = "Production" + +__all__ = [ + "PROCESSING_NODE_CONSTRUCTORS", + "register_process_node_xml_constructor", "ProcessNode", + "assert_bit_depth_compatibility", + "parse_process_node", "LUT1D", "LUT3D", "Matrix", @@ -53,23 +67,28 @@ "ASC_CDL", ] -processing_node_constructors = {} +PROCESSING_NODE_CONSTRUCTORS: dict = {} +""" +Hold the processing node constructors. +""" -def register_process_node_xml_constructor(name): +def register_process_node_xml_constructor(name: str) -> Callable: """ - Add the constructor method to the `processing_node_constructors` dictionary. - Adds the wrapped function as value with the given name as key. + Add the constructor method to the :attr:`PROCESSING_NODE_CONSTRUCTORS` + dictionary. Adds the wrapped function as value with the given name as key. Parameters ---------- name Name to use as key for adding. - """ - def register(constructor): - processing_node_constructors[name] = constructor + def register(constructor: Callable) -> Callable: + """Register the given callable.""" + + PROCESSING_NODE_CONSTRUCTORS[name] = constructor + return constructor return register @@ -82,7 +101,7 @@ class ProcessNode(XMLParsable, ABC): References ---------- - https://docs.acescentral.com/specifications/clf/#processNode + - https://docs.acescentral.com/specifications/clf/#processNode """ id: str | None @@ -92,7 +111,7 @@ class ProcessNode(XMLParsable, ABC): description: str | None @staticmethod - def parse_attributes(xml, config: ParserConfig) -> dict: + def parse_attributes(xml: lxml.etree._Element, config: ParserConfig) -> dict: """ Parse the default attributes of a *ProcessNode* and return them as a dictionary of names and their values. @@ -108,8 +127,8 @@ def parse_attributes(xml, config: ParserConfig) -> dict: ------- :class:`dict` *dict* of attribute names and their values. - """ + attributes = retrieve_attributes( xml, { @@ -120,13 +139,13 @@ def parse_attributes(xml, config: ParserConfig) -> dict: in_bit_depth = BitDepth(xml.get("inBitDepth")) out_bit_depth = BitDepth(xml.get("outBitDepth")) description = element_as_text(xml, "Description", config) - args = { + + return { "in_bit_depth": in_bit_depth, "out_bit_depth": out_bit_depth, "description": description, **attributes, } - return args def assert_bit_depth_compatibility(process_nodes: list[ProcessNode]) -> bool: @@ -135,50 +154,55 @@ def assert_bit_depth_compatibility(process_nodes: list[ProcessNode]) -> bool: Examples -------- - ``` >>> from colour_clf_io.process_nodes import assert_bit_depth_compatibility, LUT1D >>> from colour_clf_io.elements import Array - >>> lut = Array(values=[0,1], dim=(2,1)) - >>> node_i8 = LUT1D( \ - id=None, \ - name=None, \ - description=None, \ - half_domain=False, \ - raw_halfs=False, \ - interpolation = None, \ - array=lut, \ - in_bit_depth=BitDepth.i8, \ - out_bit_depth=BitDepth.i8 ) - >>> node_f16 = LUT1D( \ - id=None, \ - name=None, \ - description=None, \ - half_domain=False, \ - raw_halfs=False, \ - interpolation = None, \ - array=lut, \ - in_bit_depth=BitDepth.f16, \ - out_bit_depth=BitDepth.f16 ) + >>> lut = Array(values=[0, 1], dim=(2, 1)) + >>> node_i8 = LUT1D( + ... id=None, + ... name=None, + ... description=None, + ... half_domain=False, + ... raw_halfs=False, + ... interpolation=None, + ... array=lut, + ... in_bit_depth=BitDepth.i8, + ... out_bit_depth=BitDepth.i8, + ... ) + >>> node_f16 = LUT1D( + ... id=None, + ... name=None, + ... description=None, + ... half_domain=False, + ... raw_halfs=False, + ... interpolation=None, + ... array=lut, + ... in_bit_depth=BitDepth.f16, + ... out_bit_depth=BitDepth.f16, + ... ) >>> assert_bit_depth_compatibility([node_i8, node_i8]) True >>> assert_bit_depth_compatibility( - ... [node_i8, node_f16]) # doctest: +IGNORE_EXCEPTION_DETAIL + ... [node_i8, node_f16] + ... ) # doctest: +IGNORE_EXCEPTION_DETAIL Traceback (most recent call last): ... ValidationError: ... - ``` """ + for node_a, node_b in sliding_window(process_nodes, 2): is_compatible = node_a.out_bit_depth == node_b.in_bit_depth if not is_compatible: - raise ValidationError( + exception = ( f"Encountered incompatible bit depth between two processing nodes: " f"{node_a} and {node_b}" ) + + raise ValidationError(exception) + return True -def parse_process_node(xml, config: ParserConfig): +def parse_process_node(xml: lxml.etree._Element, config: ParserConfig) -> ProcessNode: """ Return the correct process node that corresponds to this XML element. @@ -191,14 +215,18 @@ def parse_process_node(xml, config: ParserConfig): ------ :class: ParsingError If the given element does not match any valid process node, or the node does not - correctly correspond to the specification.. - + correctly correspond to the specification. """ + tag = lxml.etree.QName(xml).localname - constructor = processing_node_constructors.get(tag) + constructor = PROCESSING_NODE_CONSTRUCTORS.get(tag) + if constructor is not None: - return processing_node_constructors[tag](xml, config) - raise ParsingError(f"Encountered invalid processing node with tag '{xml.tag}'") + return PROCESSING_NODE_CONSTRUCTORS[tag](xml, config) + + exception = f"Encountered invalid processing node with tag '{xml.tag}'" + + raise ParsingError(exception) @dataclass @@ -208,7 +236,7 @@ class LUT1D(ProcessNode): References ---------- - https://docs.acescentral.com/specifications/clf/#lut1d + - https://docs.acescentral.com/specifications/clf/#lut1d """ array: Array @@ -218,27 +246,37 @@ class LUT1D(ProcessNode): @staticmethod @register_process_node_xml_constructor("LUT1D") - def from_xml(xml, config: ParserConfig): + def from_xml(xml: lxml.etree._Element | None, config: ParserConfig) -> LUT1D | None: """ - Parse and return the LUT1D from the given XML node. Returns None if the given - element is None. + Parse and return a :class:`colour_clf_io.LUT1D` class instance + from the given XML node. Returns `None`` if the given XML node is ``None``. Expects the xml element to be a valid element according to the CLF specification. + Returns + ------- + class:`colour_clf_io.LUT1D` or :py:data:`None` + Parsed XML node. + Raises ------ - :class: ParsingError - If the node does not conform to the specification, a `ParsingError` - will be raised. The error message will indicate the details of the issue - that was encountered. + :class:`ParsingError` + If the node does not conform to the specification, a ``ParsingError`` + exception will be raised. The error message will indicate the + details of the issue that was encountered. """ + if xml is None: return None + super_args = ProcessNode.parse_attributes(xml, config) - array = Array.from_xml(child_element(xml, "Array", config), config) + array = Array.from_xml(child_element(xml, "Array", config), config) # pyright: ignore + if array is None: - raise ParsingError("LUT1D processing node does not have an Array element.") + exception = "LUT1D processing node does not have an Array element." + + raise ParsingError(exception) half_domain = xml.get("halfDomain") == "true" raw_halfs = xml.get("rawHalfs") == "true" @@ -259,7 +297,7 @@ class LUT3D(ProcessNode): References ---------- - https://docs.acescentral.com/specifications/clf/#lut3d + - https://docs.acescentral.com/specifications/clf/#lut3d """ array: Array @@ -269,30 +307,42 @@ class LUT3D(ProcessNode): @staticmethod @register_process_node_xml_constructor("LUT3D") - def from_xml(xml, config: ParserConfig): + def from_xml(xml: lxml.etree._Element | None, config: ParserConfig) -> LUT3D | None: """ - Parse and return the LUT3D from the given XML node. Returns None if the given - element is None. + Parse and return a :class:`colour_clf_io.LUT3D` class instance + from the given XML node. Returns `None`` if the given XML node is ``None``. Expects the xml element to be a valid element according to the CLF specification. + Returns + ------- + class:`colour_clf_io.LUT3D` or :py:data:`None` + Parsed XML node. + Raises ------ - :class: ParsingError - If the node does not conform to the specification, a `ParsingError` - will be raised. The error message will indicate the details of the issue - that was encountered. + :class:`ParsingError` + If the node does not conform to the specification, a ``ParsingError`` + exception will be raised. The error message will indicate the + details of the issue that was encountered. """ + if xml is None: return None + super_args = ProcessNode.parse_attributes(xml, config) - array = Array.from_xml(child_element(xml, "Array", config), config) + array = Array.from_xml(child_element(xml, "Array", config), config) # pyright: ignore + if array is None: - raise ParsingError("LUT3D processing node does not have an Array element.") + exception = "LUT3D processing node does not have an Array element." + + raise ParsingError(exception) + half_domain = xml.get("halfDomain") == "true" raw_halfs = xml.get("rawHalfs") == "true" interpolation = Interpolation3D(xml.get("interpolation")) + return LUT3D( array=array, half_domain=half_domain, @@ -309,34 +359,47 @@ class Matrix(ProcessNode): References ---------- - https://docs.acescentral.com/specifications/clf/#matrix + - https://docs.acescentral.com/specifications/clf/#matrix """ array: Array @staticmethod @register_process_node_xml_constructor("Matrix") - def from_xml(xml, config: ParserConfig): + def from_xml( + xml: lxml.etree._Element | None, config: ParserConfig + ) -> Matrix | None: """ - Parse and return the Matrix from the given XML node. Returns None if the given - element is None. + Parse and return a :class:`colour_clf_io.Matrix` class instance + from the given XML node. Returns `None`` if the given XML node is ``None``. - Expects the xml element to be a valid element according to the CLF - specification. + Expects the xml element to be a valid element according to the CLF + specification. + + Returns + ------- + class:`colour_clf_io.Matrix` or :py:data:`None` + Parsed XML node. Raises ------ - :class: ParsingError - If the node does not conform to the specification, a `ParsingError` - will be raised. The error message will indicate the details of the issue - that was encountered. + :class:`ParsingError` + If the node does not conform to the specification, a ``ParsingError`` + exception will be raised. The error message will indicate the + details of the issue that was encountered. """ + if xml is None: return None + super_args = ProcessNode.parse_attributes(xml, config) - array = Array.from_xml(child_element(xml, "Array", config), config) + array = Array.from_xml(child_element(xml, "Array", config), config) # pyright: ignore + if array is None: - raise ParsingError("Matrix processing node does not have an Array element.") + exception = "Matrix processing node does not have an Array element." + + raise ParsingError(exception) + return Matrix(array=array, **super_args) @@ -347,7 +410,7 @@ class Range(ProcessNode): References ---------- - https://docs.acescentral.com/specifications/clf/#range + - https://docs.acescentral.com/specifications/clf/#range """ min_in_value: float | None @@ -359,27 +422,35 @@ class Range(ProcessNode): @staticmethod @register_process_node_xml_constructor("Range") - def from_xml(xml, config: ParserConfig): + def from_xml(xml: lxml.etree._Element | None, config: ParserConfig) -> Range | None: """ - Parse and return the Range from the given XML node. Returns None if the given - element is None. + Parse and return a :class:`colour_clf_io.Range` class instance + from the given XML node. Returns `None`` if the given XML node is ``None``. - Expects the xml element to be a valid element according to the CLF - specification. + Expects the xml element to be a valid element according to the CLF + specification. + + Returns + ------- + class:`colour_clf_io.Range` or :py:data:`None` + Parsed XML node. Raises ------ - :class: ParsingError - If the node does not conform to the specification, a `ParsingError` - will be raised. The error message will indicate the details of the issue - that was encountered. + :class:`ParsingError` + If the node does not conform to the specification, a ``ParsingError`` + exception will be raised. The error message will indicate the + details of the issue that was encountered. """ + if xml is None: return None super_args = ProcessNode.parse_attributes(xml, config) - def optional_float(name): + def optional_float(name: str) -> float | None: + """Convert given name to float.""" + return element_as_float(xml, name, config) min_in_value = optional_float("minInValue") @@ -406,7 +477,7 @@ class Log(ProcessNode): References ---------- - https://docs.acescentral.com/specifications/clf/#log + - https://docs.acescentral.com/specifications/clf/#log """ style: LogStyle @@ -414,34 +485,42 @@ class Log(ProcessNode): @staticmethod @register_process_node_xml_constructor("Log") - def from_xml(xml, config: ParserConfig): + def from_xml(xml: lxml.etree._Element | None, config: ParserConfig) -> Log | None: """ - Parse and return the Log from the given XML node. Returns None if the given - element is None. + Parse and return a :class:`colour_clf_io.Log` class instance + from the given XML node. Returns `None`` if the given XML node is ``None``. - Expects the xml element to be a valid element according to the CLF - specification. + Expects the xml element to be a valid element according to the CLF + specification. + + Returns + ------- + class:`colour_clf_io.Log` or :py:data:`None` + Parsed XML node. Raises ------ - :class: ParsingError - If the node does not conform to the specification, a `ParsingError` - will be raised. The error message will indicate the details of the issue - that was encountered. + :class:`ParsingError` + If the node does not conform to the specification, a ``ParsingError`` + exception will be raised. The error message will indicate the + details of the issue that was encountered. """ + if xml is None: return None + super_args = ProcessNode.parse_attributes(xml, config) style = LogStyle(xml.get("style")) param_elements = child_elements(xml, "LogParams", config) params = [ param for param in [ - LogParams.from_xml(param_element, config) + LogParams.from_xml(param_element, config) # pyright: ignore for param_element in param_elements ] if param is not None ] + return Log(style=style, log_params=params, **super_args) @@ -452,7 +531,7 @@ class Exponent(ProcessNode): References ---------- - https://docs.acescentral.com/specifications/clf/#exponent + - https://docs.acescentral.com/specifications/clf/#exponent """ style: ExponentStyle @@ -460,38 +539,56 @@ class Exponent(ProcessNode): @staticmethod @register_process_node_xml_constructor("Exponent") - def from_xml(xml, config: ParserConfig): + def from_xml( + xml: lxml.etree._Element | None, config: ParserConfig + ) -> Exponent | None: """ - Parse and return the Exponent from the given XML node. Returns None if the given - element is None. + Parse and return a :class:`colour_clf_io.Exponent` class instance + from the given XML node. Returns `None`` if the given XML node is ``None``. + + Expects the xml element to be a valid element according to the CLF + specification. + + Returns + ------- + class:`colour_clf_io.Exponent` or :py:data:`None` + Parsed XML node. - Expects the xml element to be a valid element according to the CLF - specification. Raises ------ - :class: ParsingError - If the node does not conform to the specification, a `ParsingError` - will be raised. The error message will indicate the details of the issue - that was encountered. + :class:`ParsingError` + If the node does not conform to the specification, a ``ParsingError`` + exception will be raised. The error message will indicate the + details of the issue that was encountered. """ + if xml is None: return None + super_args = ProcessNode.parse_attributes(xml, config) style = map_optional(ExponentStyle, xml.get("style")) + if style is None: - raise ParsingError("Exponent process node has no `style' value.") + exception = "Exponent process node has no `style' value." + + raise ParsingError(exception) + param_elements = child_elements(xml, "ExponentParams", config) params = [ param for param in [ - ExponentParams.from_xml(param_element, config) + ExponentParams.from_xml(param_element, config) # pyright: ignore for param_element in param_elements ] if param is not None ] + if not params: - raise ParsingError("Exponent process node has no `ExponentParams' element.") + exception = "Exponent process node has no `ExponentParams' element." + + raise ParsingError(exception) + return Exponent(style=style, exponent_params=params, **super_args) @@ -502,7 +599,7 @@ class ASC_CDL(ProcessNode): References ---------- - https://docs.acescentral.com/specifications/clf/#asc_cdl + - https://docs.acescentral.com/specifications/clf/#asc_cdl """ style: ASC_CDL_Style @@ -511,25 +608,35 @@ class ASC_CDL(ProcessNode): @staticmethod @register_process_node_xml_constructor("ASC_CDL") - def from_xml(xml, config: ParserConfig): + def from_xml( + xml: lxml.etree._Element | None, config: ParserConfig + ) -> ASC_CDL | None: """ - Parse and return the ASC_CDL from the given XML node. Returns None if the given - element is None. + Parse and return a :class:`colour_clf_io.ASC_CDL` class instance + from the given XML node. Returns `None`` if the given XML node is ``None``. - Expects the xml element to be a valid element according to the CLF - specification. + Expects the xml element to be a valid element according to the CLF + specification. + + Returns + ------- + class:`colour_clf_io.ASC_CDL` or :py:data:`None` + Parsed XML node. Raises ------ - :class: ParsingError - If the node does not conform to the specification, a `ParsingError` - will be raised. The error message will indicate the details of the issue - that was encountered. + :class:`ParsingError` + If the node does not conform to the specification, a ``ParsingError`` + exception will be raised. The error message will indicate the + details of the issue that was encountered. """ + if xml is None: return None + super_args = ProcessNode.parse_attributes(xml, config) style = ASC_CDL_Style(xml.get("style")) - sopnode = SOPNode.from_xml(child_element(xml, "SOPNode", config), config) - sat_node = SatNode.from_xml(child_element(xml, "SatNode", config), config) + sopnode = SOPNode.from_xml(child_element(xml, "SOPNode", config), config) # pyright: ignore + sat_node = SatNode.from_xml(child_element(xml, "SatNode", config), config) # pyright: ignore + return ASC_CDL(style=style, sopnode=sopnode, sat_node=sat_node, **super_args) diff --git a/colour_clf_io/tests/conftest.py b/colour_clf_io/tests/conftest.py index 0689eea..1bc4c55 100644 --- a/colour_clf_io/tests/conftest.py +++ b/colour_clf_io/tests/conftest.py @@ -1,7 +1,18 @@ -import pytest # noqa: D100 +""" +Pytest Configuration +==================== +Configure *pytest* to use with *OpenColorIO* if available. +""" + +from __future__ import annotations + +import pytest + + +def pytest_addoption(parser) -> None: # noqa: ANN001 + """Add a *pytest* option for test requiring *OpenColorIO*.""" -def pytest_addoption(parser): # noqa: D103 parser.addoption( "--with_ocio", action="store_true", @@ -10,15 +21,20 @@ def pytest_addoption(parser): # noqa: D103 ) -def pytest_configure(config): # noqa: D103 +def pytest_configure(config) -> None: # noqa: ANN001 + """Configure *pytest* for *OpenColorIO*.""" + config.addinivalue_line( "markers", "with_ocio: mark test that require the OpenColorIO library" ) -def pytest_collection_modifyitems(config, items): # noqa: D103 +def pytest_collection_modifyitems(config, items) -> None: # noqa: ANN001 + """Modify *pytest* collection for *OpenColorIO*.""" + if config.getoption("--with_ocio"): return + skip_slow = pytest.mark.skip(reason="need --with_ocio option to run") for item in items: if "with_ocio" in item.keywords: diff --git a/colour_clf_io/tests/test_clf_common.py b/colour_clf_io/tests/test_clf_common.py index 85335b4..420b607 100644 --- a/colour_clf_io/tests/test_clf_common.py +++ b/colour_clf_io/tests/test_clf_common.py @@ -2,64 +2,133 @@ Defines helper functionality for CLF tests. """ +from __future__ import annotations + import os import tempfile import numpy as np +import numpy.typing as npt import colour_clf_io.parsing import colour_clf_io.process_list __author__ = "Colour Developers" -__copyright__ = "Copyright 2013 Colour Developers" +__copyright__ = "Copyright 2024 Colour Developers" __license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" __maintainer__ = "Colour Developers" __email__ = "colour-developers@colour-science.org" __status__ = "Production" -__all__ = ["snippet_to_process_list", "wrap_snippet"] +__all__ = [ + "EXAMPLE_WRAPPER", + "wrap_snippet", + "snippet_to_process_list", + "snippet_as_tmp_file", + "result_as_array", +] -EXAMPLE_WRAPPER = """ +EXAMPLE_WRAPPER: str = """ + {0} -""" +""".strip() def wrap_snippet(snippet: str) -> str: - """# noqa: D401 - Takes a string that should contain the text representation of a CLF node, and + """ + Take a string that should contain the text representation of a CLF node, and returns valid CLF document. Essentially the given string is pasted into the `ProcessList` if a CLF document. This is useful to quickly convert example snippets of Process Nodes into valid CLF documents for parsing. + + Parameters + ---------- + snippet + Snippet to wrap as a CLF document. + + Returns + ------- + :class:`str` + CLF document. """ + return EXAMPLE_WRAPPER.format(snippet) -def snippet_to_process_list(snippet: str) -> colour_clf_io.process_list.ProcessList: - """# noqa: D401 - Takes a string that should contain a valid body for a XML Process List and - returns the parsed `ProcessList`. +def snippet_to_process_list( + snippet: str, +) -> colour_clf_io.process_list.ProcessList | None: """ + Take a string that should contain a valid body for an XML Process List and + returns the parsed :class:`colour_clf_io.process_list.ProcessList` class + instance. + + Parameters + ---------- + snippet + Snippet to parse. + + Returns + ------- + :class:`colour_clf_io.process_list.ProcessList` + """ + doc = wrap_snippet(snippet) + return colour_clf_io.parse_clf(doc) -def snippet_as_tmp_file(snippet): +def snippet_as_tmp_file(snippet: str) -> str: + """ + Write given snippet to a temporary file. + + Parameters + ---------- + snippet + Snippet to write + + Returns + ------- + :class:`str` + Temporary filename. + """ + doc = wrap_snippet(snippet) tmp_folder = tempfile.gettempdir() file_name = os.path.join(tmp_folder, "colour_snippet.clf") + with open(file_name, "w") as f: f.write(doc) + return file_name -def result_as_array(result_text): - result_parts = result_text.decode("utf-8").strip().split() +def result_as_array(result: bytes) -> npt.NDArray: + """ + Decode given result and convert them to an array. + + Parameters + ---------- + result + Result to convert to an array. + + Returns + ------- + :class:`np.ndarray` + Converted result array. + """ + + result_parts = result.decode("utf-8").strip().split() if len(result_parts) != 3: - raise RuntimeError(f"Invalid OCIO result: {result_text}") + exception = f"Invalid OCIO result: {result}" + + raise RuntimeError(exception) + result_values = list(map(float, result_parts)) + return np.array(result_values) diff --git a/colour_clf_io/tests/test_clf_parsing.py b/colour_clf_io/tests/test_clf_parsing.py index cacb554..c438855 100644 --- a/colour_clf_io/tests/test_clf_parsing.py +++ b/colour_clf_io/tests/test_clf_parsing.py @@ -1,8 +1,9 @@ # !/usr/bin/env python """Define the unit tests for the :mod:`colour.io.clf` module.""" +from __future__ import annotations + import os -import unittest import numpy as np import pytest @@ -16,45 +17,49 @@ from .test_clf_common import wrap_snippet __author__ = "Colour Developers" -__copyright__ = "Copyright 2013 Colour Developers" +__copyright__ = "Copyright 2024 Colour Developers" __license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" __maintainer__ = "Colour Developers" __email__ = "colour-developers@colour-science.org" __status__ = "Production" __all__ = [ + "ROOT_CLF", + "EXAMPLE_WRAPPER", "TestParseCLF", ] ROOT_CLF: str = os.path.join(os.path.dirname(__file__), "resources") -EXAMPLE_WRAPPER = """ +EXAMPLE_WRAPPER: str = """ {0} """ -class TestParseCLF(unittest.TestCase): +class TestParseCLF: """ Define tests methods for parsing CLF files using the functionality provided in the :mod: `colour.io.clf`module. """ - def test_read_sample_document_1(self): + def test_read_sample_document_1(self) -> None: """ Test parsing of the sample document `ACES2065_1_to_ACEScct.xml`. """ + clf_data = read_clf(os.path.join(ROOT_CLF, "ACES2065_1_to_ACEScct.xml")) - self.assertEqual( - clf_data.description, ["Conversion from linear ACES2065-1 to ACEScct"] - ) - self.assertEqual(clf_data.input_descriptor, "ACES (SMPTE ST 2065-1)") - self.assertEqual(clf_data.output_descriptor, "ACEScct") - self.assertEqual(len(clf_data.process_nodes), 3) + assert clf_data is not None + + assert clf_data.description == ["Conversion from linear ACES2065-1 to ACEScct"] + assert clf_data.input_descriptor == "ACES (SMPTE ST 2065-1)" + assert clf_data.output_descriptor == "ACEScct" + assert len(clf_data.process_nodes) == 3 first_process_node = clf_data.process_nodes[0] - self.assertIsInstance(first_process_node, colour_clf_io.process_nodes.Matrix) + assert isinstance(first_process_node, colour_clf_io.process_nodes.Matrix) + np.testing.assert_array_almost_equal( first_process_node.array.as_array(), np.array( @@ -66,33 +71,40 @@ def test_read_sample_document_1(self): ), ) - def test_read_sample_document_2(self): + def test_read_sample_document_2(self) -> None: """ Test parsing of the sample document `LMT Kodak 2383 Print Emulation.xml`. """ + clf_data = read_clf( os.path.join(ROOT_CLF, "LMT Kodak 2383 Print Emulation.xml") ) - self.assertEqual(clf_data.description, ["Print film emulation (Kodak 2383)"]) - self.assertEqual(clf_data.input_descriptor, "ACES (SMPTE ST 2065-1)") - self.assertEqual(clf_data.output_descriptor, "ACES (SMPTE ST 2065-1)") - self.assertEqual(len(clf_data.process_nodes), 10) - def test_read_sample_document_3(self): + assert clf_data is not None + assert clf_data.description == ["Print film emulation (Kodak 2383)"] + assert clf_data.input_descriptor == "ACES (SMPTE ST 2065-1)" + assert clf_data.output_descriptor == "ACES (SMPTE ST 2065-1)" + assert len(clf_data.process_nodes) == 10 + + def test_read_sample_document_3(self) -> None: """ Test parsing of the sample document `LMT_ARRI_K1S1_709_EI800_v3.xml`. """ + clf_data = read_clf(os.path.join(ROOT_CLF, "LMT_ARRI_K1S1_709_EI800_v3.xml")) - self.assertEqual(clf_data.description, ["An ARRI based look"]) - self.assertEqual(clf_data.input_descriptor, "ACES (SMPTE ST 2065-1)") - self.assertEqual(clf_data.output_descriptor, "ACES (SMPTE ST 2065-1)") - self.assertEqual(len(clf_data.process_nodes), 7) - def test_LUT1D_example(self): + assert clf_data is not None + assert clf_data.description == ["An ARRI based look"] + assert clf_data.input_descriptor == "ACES (SMPTE ST 2065-1)" + assert clf_data.output_descriptor == "ACES (SMPTE ST 2065-1)" + assert len(clf_data.process_nodes) == 7 + + def test_LUT1D_example(self) -> None: """ Test parsing of the example process node from the official CLF specification Example 1. """ + example = """ 1D LUT - Turn 4 grey levels into 4 inverted codes @@ -104,25 +116,29 @@ def test_LUT1D_example(self): """ + doc = parse_clf(wrap_snippet(example)) + + assert doc is not None + node = doc.process_nodes[0] - self.assertIsInstance(node, colour_clf_io.process_nodes.LUT1D) - self.assertEqual(node.id, "lut-23") - self.assertEqual(node.name, "4 Value Lut") - self.assertEqual(node.in_bit_depth, colour_clf_io.values.BitDepth.i12) - self.assertEqual(node.out_bit_depth, colour_clf_io.values.BitDepth.i12) - self.assertEqual( - node.description, "1D LUT - Turn 4 grey levels into 4 inverted codes" - ) + + assert isinstance(node, colour_clf_io.process_nodes.LUT1D) + assert node.id == "lut-23" + assert node.name == "4 Value Lut" + assert node.in_bit_depth == colour_clf_io.values.BitDepth.i12 + assert node.out_bit_depth == colour_clf_io.values.BitDepth.i12 + assert node.description == "1D LUT - Turn 4 grey levels into 4 inverted codes" np.testing.assert_array_almost_equal( node.array.as_array(), np.array([3, 2, 1, 0]) ) - def test_LUT3D_example(self): + def test_LUT3D_example(self) -> None: """ Test parsing of the example process node from the official CLF specification Example 2. """ + example = """ 3D LUT @@ -138,18 +154,20 @@ def test_LUT3D_example(self): """ # noqa: E501 + doc = parse_clf(wrap_snippet(example)) + assert doc is not None + node = doc.process_nodes[0] - self.assertIsInstance(node, colour_clf_io.process_nodes.LUT3D) - self.assertEqual(node.id, "lut-24") - self.assertEqual(node.name, "green look") - self.assertEqual(node.in_bit_depth, colour_clf_io.values.BitDepth.i12) - self.assertEqual(node.out_bit_depth, colour_clf_io.values.BitDepth.f16) - self.assertEqual( - node.interpolation, colour_clf_io.values.Interpolation3D.TRILINEAR - ) - self.assertEqual(node.description, "3D LUT") + + assert isinstance(node, colour_clf_io.process_nodes.LUT3D) + assert node.id == "lut-24" + assert node.name == "green look" + assert node.in_bit_depth == colour_clf_io.values.BitDepth.i12 + assert node.out_bit_depth == colour_clf_io.values.BitDepth.f16 + assert node.interpolation == colour_clf_io.values.Interpolation3D.TRILINEAR + assert node.description == "3D LUT" np.testing.assert_array_almost_equal( node.array.as_array(), np.array( @@ -166,11 +184,12 @@ def test_LUT3D_example(self): ), ) - def test_matrix_example_1(self): + def test_matrix_example_1(self) -> None: """ Test parsing of the example process node from the official CLF specification Example 3. """ + example = """ 3x3 color space conversion from AP0 to AP1 @@ -181,14 +200,19 @@ def test_matrix_example_1(self): """ + doc = parse_clf(wrap_snippet(example)) + + assert doc is not None + node = doc.process_nodes[0] - self.assertIsInstance(node, colour_clf_io.process_nodes.Matrix) - self.assertEqual(node.id, "lut-28") - self.assertEqual(node.name, "AP0 to AP1") - self.assertEqual(node.in_bit_depth, colour_clf_io.values.BitDepth.f16) - self.assertEqual(node.out_bit_depth, colour_clf_io.values.BitDepth.f16) - self.assertEqual(node.description, "3x3 color space conversion from AP0 to AP1") + + assert isinstance(node, colour_clf_io.process_nodes.Matrix) + assert node.id == "lut-28" + assert node.name == "AP0 to AP1" + assert node.in_bit_depth == colour_clf_io.values.BitDepth.f16 + assert node.out_bit_depth == colour_clf_io.values.BitDepth.f16 + assert node.description == "3x3 color space conversion from AP0 to AP1" np.testing.assert_array_almost_equal( node.array.as_array(), np.array( @@ -200,11 +224,12 @@ def test_matrix_example_1(self): ), ) - def test_matrix_example_2(self): + def test_matrix_example_2(self) -> None: """ Test parsing of the example process node from the official CLF specification Example 4. """ + example = """ 3x4 Matrix , 4th column is offset @@ -215,14 +240,19 @@ def test_matrix_example_2(self): """ # noqa: E501 + doc = parse_clf(wrap_snippet(example)) + + assert doc is not None + node = doc.process_nodes[0] - self.assertIsInstance(node, colour_clf_io.process_nodes.Matrix) - self.assertEqual(node.id, "lut-25") - self.assertEqual(node.name, "colorspace conversion") - self.assertEqual(node.in_bit_depth, colour_clf_io.values.BitDepth.i10) - self.assertEqual(node.out_bit_depth, colour_clf_io.values.BitDepth.i10) - self.assertEqual(node.description, " 3x4 Matrix , 4th column is offset ") + + assert isinstance(node, colour_clf_io.process_nodes.Matrix) + assert node.id == "lut-25" + assert node.name == "colorspace conversion" + assert node.in_bit_depth == colour_clf_io.values.BitDepth.i10 + assert node.out_bit_depth == colour_clf_io.values.BitDepth.i10 + assert node.description == " 3x4 Matrix , 4th column is offset " np.testing.assert_array_almost_equal( node.array.as_array(), np.array( @@ -249,11 +279,12 @@ def test_matrix_example_2(self): ), ) - def test_range_example(self): + def test_range_example(self) -> None: """ Test parsing of the example process node from the official CLF specification Example 5. """ + example = """ 10-bit full range to SMPTE range @@ -263,44 +294,56 @@ def test_range_example(self): 940 """ + doc = parse_clf(wrap_snippet(example)) + + assert doc is not None + node = doc.process_nodes[0] - self.assertIsInstance(node, colour_clf_io.process_nodes.Range) - self.assertEqual(node.id, None) - self.assertEqual(node.name, None) - self.assertEqual(node.in_bit_depth, colour_clf_io.values.BitDepth.i10) - self.assertEqual(node.out_bit_depth, colour_clf_io.values.BitDepth.i10) - self.assertEqual(node.description, "10-bit full range to SMPTE range") - self.assertEqual(node.min_in_value, 0.0) - self.assertEqual(node.min_out_value, 64.0) - self.assertEqual(node.max_out_value, 940.0) - - def test_log_example_1(self): + + assert isinstance(node, colour_clf_io.process_nodes.Range) + assert node.id is None + assert node.name is None + assert node.in_bit_depth == colour_clf_io.values.BitDepth.i10 + assert node.out_bit_depth == colour_clf_io.values.BitDepth.i10 + assert node.description == "10-bit full range to SMPTE range" + assert node.min_in_value == 0.0 + assert node.min_out_value == 64.0 + assert node.max_out_value == 940.0 + + def test_log_example_1(self) -> None: """ Test parsing of the example process node from the official CLF specification Example 6. """ + example = """ Base 10 Logarithm """ + doc = parse_clf(wrap_snippet(example)) + + assert doc is not None + node = doc.process_nodes[0] - self.assertIsInstance(node, colour_clf_io.process_nodes.Log) - self.assertEqual(node.id, None) - self.assertEqual(node.name, None) - self.assertEqual(node.in_bit_depth, colour_clf_io.values.BitDepth.f16) - self.assertEqual(node.out_bit_depth, colour_clf_io.values.BitDepth.f16) - self.assertEqual(node.description, "Base 10 Logarithm") - self.assertEqual(node.style, colour_clf_io.elements.LogStyle.LOG_10) - self.assertEqual(node.log_params, []) - def test_log_example_2(self): + assert isinstance(node, colour_clf_io.process_nodes.Log) + assert node.id is None + assert node.name is None + assert node.in_bit_depth == colour_clf_io.values.BitDepth.f16 + assert node.out_bit_depth == colour_clf_io.values.BitDepth.f16 + assert node.description == "Base 10 Logarithm" + assert node.style == colour_clf_io.elements.LogStyle.LOG_10 + assert node.log_params == [] + + def test_log_example_2(self) -> None: """ Test parsing of the example process node from the official CLF specification Example 7. """ + example = """ Linear to DJI D-Log @@ -309,119 +352,162 @@ def test_log_example_2(self): linearSlope="6.025"/> """ + doc = parse_clf(wrap_snippet(example)) + + assert doc is not None + node = doc.process_nodes[0] - self.assertIsInstance(node, colour_clf_io.process_nodes.Log) - self.assertEqual(node.id, None) - self.assertEqual(node.name, None) - self.assertEqual(node.in_bit_depth, colour_clf_io.values.BitDepth.f32) - self.assertEqual(node.out_bit_depth, colour_clf_io.values.BitDepth.f32) - self.assertEqual(node.description, "Linear to DJI D-Log") - self.assertEqual(node.style, colour_clf_io.elements.LogStyle.CAMERA_LIN_TO_LOG) - self.assertAlmostEqual(node.log_params[0].base, 10.0) - self.assertAlmostEqual(node.log_params[0].log_side_slope, 0.256663) - self.assertAlmostEqual(node.log_params[0].log_side_offset, 0.584555) - self.assertAlmostEqual(node.log_params[0].lin_side_slope, 0.9892) - self.assertAlmostEqual(node.log_params[0].lin_side_offset, 0.0108) - self.assertAlmostEqual(node.log_params[0].lin_side_break, 0.0078) - self.assertAlmostEqual(node.log_params[0].linear_slope, 6.025) - - def test_exponent_example_1(self): + + assert isinstance(node, colour_clf_io.process_nodes.Log) + assert node.id is None + assert node.name is None + assert node.in_bit_depth == colour_clf_io.values.BitDepth.f32 + assert node.out_bit_depth == colour_clf_io.values.BitDepth.f32 + assert node.description == "Linear to DJI D-Log" + assert node.style == colour_clf_io.elements.LogStyle.CAMERA_LIN_TO_LOG + assert node.log_params[0].base is not None + np.testing.assert_allclose(node.log_params[0].base, 10.0) + assert node.log_params[0].log_side_slope is not None + np.testing.assert_allclose(node.log_params[0].log_side_slope, 0.256663) + assert node.log_params[0].log_side_offset is not None + np.testing.assert_allclose(node.log_params[0].log_side_offset, 0.584555) + assert node.log_params[0].lin_side_slope is not None + np.testing.assert_allclose(node.log_params[0].lin_side_slope, 0.9892) + assert node.log_params[0].lin_side_offset is not None + np.testing.assert_allclose(node.log_params[0].lin_side_offset, 0.0108) + assert node.log_params[0].lin_side_break is not None + np.testing.assert_allclose(node.log_params[0].lin_side_break, 0.0078) + assert node.log_params[0].linear_slope is not None + np.testing.assert_allclose(node.log_params[0].linear_slope, 6.025) + + def test_exponent_example_1(self) -> None: """ Test parsing of the example process node from the official CLF specification Example 8. """ + example = """ Basic 2.2 Gamma """ + doc = parse_clf(wrap_snippet(example)) + + assert doc is not None + node = doc.process_nodes[0] - self.assertIsInstance(node, colour_clf_io.process_nodes.Exponent) - self.assertEqual(node.id, None) - self.assertEqual(node.name, None) - self.assertEqual(node.in_bit_depth, colour_clf_io.values.BitDepth.f32) - self.assertEqual(node.out_bit_depth, colour_clf_io.values.BitDepth.f32) - self.assertEqual(node.description, "Basic 2.2 Gamma") - self.assertEqual(node.style, colour_clf_io.elements.ExponentStyle.BASIC_FWD) - self.assertAlmostEqual(node.exponent_params[0].exponent, 2.2) - def test_exponent_example_2(self): + assert isinstance(node, colour_clf_io.process_nodes.Exponent) + assert node.id is None + assert node.name is None + assert node.in_bit_depth == colour_clf_io.values.BitDepth.f32 + assert node.out_bit_depth == colour_clf_io.values.BitDepth.f32 + assert node.description == "Basic 2.2 Gamma" + assert node.style == colour_clf_io.elements.ExponentStyle.BASIC_FWD + np.testing.assert_allclose(node.exponent_params[0].exponent, 2.2) + + def test_exponent_example_2(self) -> None: """ Test parsing of the example process node from the official CLF specification Example 9. """ + example = """ EOTF (sRGB) """ + doc = parse_clf(wrap_snippet(example)) + + assert doc is not None + node = doc.process_nodes[0] - self.assertIsInstance(node, colour_clf_io.process_nodes.Exponent) - self.assertEqual(node.id, None) - self.assertEqual(node.name, None) - self.assertEqual(node.in_bit_depth, colour_clf_io.values.BitDepth.f32) - self.assertEqual(node.out_bit_depth, colour_clf_io.values.BitDepth.f32) - self.assertEqual(node.description, "EOTF (sRGB)") - self.assertEqual(node.style, colour_clf_io.elements.ExponentStyle.MON_CURVE_FWD) - self.assertAlmostEqual(node.exponent_params[0].exponent, 2.4) - self.assertAlmostEqual(node.exponent_params[0].offset, 0.055) - - def test_exponent_example_3(self): + + assert isinstance(node, colour_clf_io.process_nodes.Exponent) + assert node.id is None + assert node.name is None + assert node.in_bit_depth == colour_clf_io.values.BitDepth.f32 + assert node.out_bit_depth == colour_clf_io.values.BitDepth.f32 + assert node.description == "EOTF (sRGB)" + assert node.style, colour_clf_io.elements.ExponentStyle.MON_CURVE_FWD + assert node.exponent_params[0].exponent is not None + np.testing.assert_allclose(node.exponent_params[0].exponent, 2.4) + assert node.exponent_params[0].offset is not None + np.testing.assert_allclose(node.exponent_params[0].offset, 0.055) + + def test_exponent_example_3(self) -> None: """ Test parsing of the example process node from the official CLF specification Example 10. """ + example = """ CIE L* """ + doc = parse_clf(wrap_snippet(example)) + + assert doc is not None + node = doc.process_nodes[0] - self.assertIsInstance(node, colour_clf_io.process_nodes.Exponent) - self.assertEqual(node.id, None) - self.assertEqual(node.name, None) - self.assertEqual(node.in_bit_depth, colour_clf_io.values.BitDepth.f32) - self.assertEqual(node.out_bit_depth, colour_clf_io.values.BitDepth.f32) - self.assertEqual(node.description, "CIE L*") - self.assertEqual(node.style, colour_clf_io.elements.ExponentStyle.MON_CURVE_REV) - self.assertAlmostEqual(node.exponent_params[0].exponent, 3.0) - self.assertAlmostEqual(node.exponent_params[0].offset, 0.16) - - def test_exponent_example_4(self): + + assert isinstance(node, colour_clf_io.process_nodes.Exponent) + assert node.id is None + assert node.name is None + assert node.in_bit_depth == colour_clf_io.values.BitDepth.f32 + assert node.out_bit_depth == colour_clf_io.values.BitDepth.f32 + assert node.description == "CIE L*" + assert node.style == colour_clf_io.elements.ExponentStyle.MON_CURVE_REV + assert node.exponent_params[0].exponent is not None + np.testing.assert_allclose(node.exponent_params[0].exponent, 3.0) + assert node.exponent_params[0].offset is not None + np.testing.assert_allclose(node.exponent_params[0].offset, 0.16) + + def test_exponent_example_4(self) -> None: """ Test parsing of the example process node from the official CLF specification Example 11. """ + example = """ Rec. 709 OETF """ + doc = parse_clf(wrap_snippet(example)) + + assert doc is not None + node = doc.process_nodes[0] - self.assertIsInstance(node, colour_clf_io.process_nodes.Exponent) - self.assertEqual(node.id, None) - self.assertEqual(node.name, None) - self.assertEqual(node.in_bit_depth, colour_clf_io.values.BitDepth.f32) - self.assertEqual(node.out_bit_depth, colour_clf_io.values.BitDepth.f32) - self.assertEqual(node.description, "Rec. 709 OETF") - self.assertEqual(node.style, colour_clf_io.elements.ExponentStyle.MON_CURVE_REV) - self.assertAlmostEqual(node.exponent_params[0].exponent, 2.2222222222222222) - self.assertAlmostEqual(node.exponent_params[0].offset, 0.099) - - def test_ASC_CDL_example(self): + + assert isinstance(node, colour_clf_io.process_nodes.Exponent) + assert node.id is None + assert node.name is None + assert node.in_bit_depth == colour_clf_io.values.BitDepth.f32 + assert node.out_bit_depth == colour_clf_io.values.BitDepth.f32 + assert node.description == "Rec. 709 OETF" + assert node.style == colour_clf_io.elements.ExponentStyle.MON_CURVE_REV + assert node.exponent_params[0].exponent is not None + np.testing.assert_allclose(node.exponent_params[0].exponent, 2.2222222222222222) + assert node.exponent_params[0].offset is not None + np.testing.assert_allclose(node.exponent_params[0].offset, 0.099) + + def test_ASC_CDL_example(self) -> None: """ Test parsing of the example process node from the official CLF specification Example 12. """ + example = """ scene 1 exterior look @@ -435,25 +521,34 @@ def test_ASC_CDL_example(self): """ + doc = parse_clf(wrap_snippet(example)) + + assert doc is not None + node = doc.process_nodes[0] - self.assertIsInstance(node, colour_clf_io.process_nodes.ASC_CDL) - self.assertEqual(node.id, "cc01234") - self.assertEqual(node.name, None) - self.assertEqual(node.in_bit_depth, colour_clf_io.values.BitDepth.f16) - self.assertEqual(node.out_bit_depth, colour_clf_io.values.BitDepth.f16) - self.assertEqual(node.description, "scene 1 exterior look") - self.assertEqual(node.style, colour_clf_io.values.ASC_CDL_Style.FWD) - self.assertEqual(node.sopnode.slope, (1.000000, 1.000000, 0.900000)) - self.assertEqual(node.sopnode.offset, (-0.030000, -0.020000, 0.000000)) - self.assertEqual(node.sopnode.power, (1.2500000, 1.000000, 1.000000)) - self.assertAlmostEqual(node.sat_node.saturation, 1.700000) - - def test_ACES2065_1_to_ACEScg_example(self): + + assert isinstance(node, colour_clf_io.process_nodes.ASC_CDL) + assert node.id == "cc01234" + assert node.name is None + assert node.in_bit_depth == colour_clf_io.values.BitDepth.f16 + assert node.out_bit_depth == colour_clf_io.values.BitDepth.f16 + assert node.description == "scene 1 exterior look" + assert node.style == colour_clf_io.values.ASC_CDL_Style.FWD + assert node.sopnode is not None + assert node.sopnode.slope == (1.000000, 1.000000, 0.900000) + assert node.sopnode.offset == (-0.030000, -0.020000, 0.000000) + assert node.sopnode.power == (1.2500000, 1.000000, 1.000000) + assert node.sat_node is not None + assert node.sat_node.saturation is not None + np.testing.assert_allclose(node.sat_node.saturation, 1.700000) + + def test_ACES2065_1_to_ACEScg_example(self) -> None: """ Test parsing of the example process node from the official CLF specification Example 13. """ + # Note that this string uses binary encoding, as the XML document specifies its # own encoding. example = b""" @@ -475,15 +570,20 @@ def test_ACES2065_1_to_ACEScg_example(self): """ + doc = parse_clf(example) - self.assertEqual(len(doc.process_nodes), 1) - self.assertIsInstance(doc.process_nodes[0], colour_clf_io.process_nodes.Matrix) - def test_ACES2065_1_to_ACEScct_example(self): + assert doc is not None + + assert len(doc.process_nodes) == 1 + assert isinstance(doc.process_nodes[0], colour_clf_io.process_nodes.Matrix) + + def test_ACES2065_1_to_ACEScct_example(self) -> None: """ Test parsing of the example process node from the official CLF specification Example 14. """ + # Note that this string uses binary encoding, as the XML document specifies its # own encoding. example = b""" @@ -509,16 +609,21 @@ def test_ACES2065_1_to_ACEScct_example(self): """ # noqa: E501 + doc = parse_clf(example) - self.assertEqual(len(doc.process_nodes), 2) - self.assertIsInstance(doc.process_nodes[0], colour_clf_io.process_nodes.Matrix) - self.assertIsInstance(doc.process_nodes[1], colour_clf_io.process_nodes.Log) - def test_CIE_XYZ_to_CIELAB_example(self): + assert doc is not None + + assert len(doc.process_nodes) == 2 + assert isinstance(doc.process_nodes[0], colour_clf_io.process_nodes.Matrix) + assert isinstance(doc.process_nodes[1], colour_clf_io.process_nodes.Log) + + def test_CIE_XYZ_to_CIELAB_example(self) -> None: """ Test parsing of the example process node from the official CLF specification Example 14. """ + # Note that this string uses binary encoding, as the XML document specifies its # own encoding. example = b""" @@ -547,18 +652,21 @@ def test_CIE_XYZ_to_CIELAB_example(self): """ + doc = parse_clf(example) - self.assertEqual(len(doc.process_nodes), 3) - self.assertIsInstance(doc.process_nodes[0], colour_clf_io.process_nodes.Matrix) - self.assertIsInstance( - doc.process_nodes[1], colour_clf_io.process_nodes.Exponent - ) - self.assertIsInstance(doc.process_nodes[2], colour_clf_io.process_nodes.Matrix) - def test_fail_on_invalid_namespace(self): + assert doc is not None + + assert len(doc.process_nodes) == 3 + assert isinstance(doc.process_nodes[0], colour_clf_io.process_nodes.Matrix) + assert isinstance(doc.process_nodes[1], colour_clf_io.process_nodes.Exponent) + assert isinstance(doc.process_nodes[2], colour_clf_io.process_nodes.Matrix) + + def test_fail_on_invalid_namespace(self) -> None: """ Test parsing oa a process list with an invalid xmlns attribute. """ + example = b""" @@ -567,20 +675,15 @@ def test_fail_on_invalid_namespace(self): """ - try: - parse_clf(example) - except ParsingError: - return - self.fail( - "Parsing should have thrown a validation error due to invalid xmlns " - "attribute." - ) + + pytest.raises(ParsingError, parse_clf, example) @pytest.mark.with_ocio - def test_CLF_from_OCIO(self): + def test_CLF_from_OCIO(self) -> None: """ Test parsing of a CLF file written by OpenColorIO. """ + import PyOpenColorIO as ocio ocio_transform = ( @@ -589,20 +692,22 @@ def test_CLF_from_OCIO(self): .createGroupTransform() ) clf_text = ocio_transform.write("Academy/ASC Common LUT Format").encode() + doc = parse_clf(clf_text) - self.assertEqual(len(doc.process_nodes), 2) - self.assertIsInstance(doc.process_nodes[0], colour_clf_io.process_nodes.Log) - self.assertIsInstance(doc.process_nodes[1], colour_clf_io.process_nodes.Matrix) + + assert doc is not None + + assert len(doc.process_nodes) == 2 + assert isinstance(doc.process_nodes[0], colour_clf_io.process_nodes.Log) + assert isinstance(doc.process_nodes[1], colour_clf_io.process_nodes.Matrix) + node = doc.process_nodes[0] - self.assertIsNotNone( - node.log_params, "Log Params were not parsed successfully." - ) - self.assertAlmostEqual(node.log_params[0].base, ocio_transform[0].getBase()) - self.assertAlmostEqual( + + assert node.log_params is not None + assert node.log_params[0].base is not None + np.testing.assert_allclose(node.log_params[0].base, ocio_transform[0].getBase()) + assert node.log_params[0].log_side_slope is not None + np.testing.assert_allclose( node.log_params[0].log_side_slope, ocio_transform[0].getLogSideSlopeValue()[0], ) - - -if __name__ == "__main__": - unittest.main() diff --git a/colour_clf_io/values.py b/colour_clf_io/values.py index 3bd6c3b..62230f5 100644 --- a/colour_clf_io/values.py +++ b/colour_clf_io/values.py @@ -2,9 +2,8 @@ Values ======= -Defines enums that represent allowed values in some of the fields contained in a CLF -document. - +Defines enums that represent allowed values in some of the fields contained in a +CLF document. """ from __future__ import annotations @@ -13,13 +12,13 @@ from enum import Enum __author__ = "Colour Developers" -__copyright__ = "Copyright 2013 Colour Developers" +__copyright__ = "Copyright 2024 Colour Developers" __license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" __maintainer__ = "Colour Developers" __email__ = "colour-developers@colour-science.org" __status__ = "Production" -__ALL__ = [ +__all__ = [ "BitDepth", "Channel", "Interpolation1D", @@ -34,7 +33,7 @@ class BitDepth(Enum): References ---------- - https://docs.acescentral.com/specifications/clf/#processNode + - https://docs.acescentral.com/specifications/clf/#processNode """ i8 = "8i" @@ -44,13 +43,13 @@ class BitDepth(Enum): f16 = "16f" f32 = "32f" - def scale_factor(self): - """Return the scale factor that is needed to normalise a value of the given - BitDepth to the range 0..1. + def scale_factor(self) -> float: + """ + Return the scale factor that is needed to normalise a value of the given + bit depth to the range 0..1. Examples -------- - ``` >>> from colour_clf_io.values import BitDepth >>> 255 / BitDepth.i8.scale_factor() == 1.0 True @@ -60,34 +59,37 @@ def scale_factor(self): True >>> 1.0 / BitDepth.f16.scale_factor() == 1.0 True - - ``` """ + if self == BitDepth.i8: return 2**8 - 1 - elif self == BitDepth.i10: + + if self == BitDepth.i10: return 2**10 - 1 - elif self == BitDepth.i12: + + if self == BitDepth.i12: return 2**12 - 1 - elif self == BitDepth.i16: + + if self == BitDepth.i16: return 2**16 - 1 - elif self in [BitDepth.f16, BitDepth.f32]: + + if self in [BitDepth.f16, BitDepth.f32]: return 1.0 - raise NotImplementedError() + + raise NotImplementedError @classmethod - def all(cls): - """Return a list of all valid BitDepth values. + def all(cls: type[BitDepth]) -> list: + """ + Return a list of all valid bit depth values. Examples -------- - ``` >>> from colour_clf_io.values import BitDepth >>> BitDepth.all() ['8i', '10i', '12i', '16i', '16f', '32f'] - - ``` """ + return [e.value for e in cls] @@ -97,7 +99,7 @@ class Channel(enum.Enum): References ---------- - https://docs.acescentral.com/specifications/clf/#ranges + - https://docs.acescentral.com/specifications/clf/#ranges """ R = "R" @@ -111,7 +113,7 @@ class Interpolation1D(Enum): References ---------- - https://docs.acescentral.com/specifications/clf/#lut1d + - https://docs.acescentral.com/specifications/clf/#lut1d """ LINEAR = "linear" @@ -123,7 +125,7 @@ class Interpolation3D(Enum): References ---------- - https://docs.acescentral.com/specifications/clf/#lut3d + - https://docs.acescentral.com/specifications/clf/#lut3d """ TRILINEAR = "trilinear" @@ -136,7 +138,7 @@ class ASC_CDL_Style(enum.Enum): References ---------- - https://docs.acescentral.com/specifications/clf/#asc_cdl + - https://docs.acescentral.com/specifications/clf/#asc_cdl """ FWD = "Fwd" diff --git a/docs/conf.py b/docs/conf.py index d823cd3..81fbc30 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -9,7 +9,7 @@ sys.path.append(str(Path(__file__).parent.parent)) -import colour_clf_io as package # noqa: E402 +import colour_clf_io as package basename = re.sub("_(\\w)", lambda x: x.group(1).upper(), package.__name__.title()) diff --git a/docs/index.rst b/docs/index.rst index 46e77e6..53d6180 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -71,6 +71,6 @@ About ----- | **Colour - CLF IO** by Colour Developers -| Copyright 2015 Colour Developers – `colour-developers@colour-science.org `__ +| Copyright 2024 Colour Developers – `colour-developers@colour-science.org `__ | This software is released under terms of BSD-3-Clause: https://opensource.org/licenses/BSD-3-Clause | `https://github.com/colour-science/colour-clf-io `__ diff --git a/docs/requirements.txt b/docs/requirements.txt index 04b885e..cef2a23 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -5,28 +5,27 @@ alabaster==1.0.0 babel==2.16.0 beautifulsoup4==4.12.3 biblib-simple==0.1.2 -certifi==2024.8.30 -charset-normalizer==3.4.0 +certifi==2024.12.14 +charset-normalizer==3.4.1 colorama==0.4.6 ; sys_platform == 'win32' docutils==0.21.2 idna==3.10 imagesize==1.4.1 -jinja2==3.1.4 +jinja2==3.1.5 latexcodec==3.0.0 lxml==5.3.0 -lxml-stubs==0.5.1 markupsafe==3.0.2 -numpy==2.1.2 +numpy==2.2.1 packaging==24.2 pybtex==0.24.0 pybtex-docutils==1.0.3 -pydata-sphinx-theme==0.16.0 -pygments==2.18.0 +pydata-sphinx-theme==0.16.1 +pygments==2.19.1 pyyaml==6.0.2 requests==2.32.3 restructuredtext-lint==1.4.0 -setuptools==75.3.0 ; python_full_version >= '3.12' -six==1.16.0 +setuptools==75.8.0 ; python_full_version >= '3.12' +six==1.17.0 snowballstemmer==2.2.0 soupsieve==2.6 sphinx==8.1.3 @@ -37,6 +36,6 @@ sphinxcontrib-htmlhelp==2.1.0 sphinxcontrib-jsmath==1.0.1 sphinxcontrib-qthelp==2.0.0 sphinxcontrib-serializinghtml==2.0.0 -tomli==2.0.2 ; python_full_version < '3.11' +tomli==2.2.1 ; python_full_version < '3.11' typing-extensions==4.12.2 -urllib3==2.2.3 +urllib3==2.3.0 diff --git a/pyproject.toml b/pyproject.toml index 89c9fb1..8421d68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,10 +45,8 @@ dependencies = [ "numpy>=1.24,<3", "typing-extensions>=4,<5", "lxml>=5.2.1,<6", - "lxml-stubs>=0.5.1,<0.6" ] - [project.optional-dependencies] docs = [ "biblib-simple", @@ -58,7 +56,6 @@ docs = [ "sphinxcontrib-bibtex", ] - [tool.uv] package = true dev-dependencies = [ @@ -74,6 +71,8 @@ dev-dependencies = [ "pytest-xdist", "toml", "twine", + # Package Specific + "lxml-stubs>=0.5.1,<0.6" ] [build-system] @@ -84,11 +83,8 @@ build-backend = "hatchling.build" packages = [ "colour-clf-io" ] [tool.codespell] -ignore-words-list = 'co-ordinates,exitance,hart,ist' -skip = 'BIBLIOGRAPHY.bib,CONTRIBUTORS.rst' - -[tool.flynt] -line_length = 999 +ignore-words-list = "socio-economic" +skip = "BIBLIOGRAPHY.bib,CONTRIBUTORS.rst,*.ipynb" [tool.isort] ensure_newline_before_comments = true @@ -96,7 +92,6 @@ force_grid_wrap = 0 include_trailing_comma = true line_length = 88 multi_line_output = 3 -skip_glob = ["colour/**/__init__.py"] split_on_trailing_comma = true use_parentheses = true @@ -105,105 +100,67 @@ reportMissingImports = false reportMissingModuleSource = false reportUnboundVariable = false reportUnnecessaryCast = true -reportUnnecessaryTypeIgnorComment = true +reportUnnecessaryTypeIgnoreComment = true reportUnsupportedDunderAll = false reportUnusedExpression = false - +[tool.pytest.ini_options] +addopts = "--durations=5" [tool.ruff] -target-version = "py39" +target-version = "py310" line-length = 88 -lint.select = [ - "A", # flake8-builtins - "ARG", # flake8-unused-arguments - # "ANN", # flake8-annotations - "B", # flake8-bugbear - # "BLE", # flake8-blind-except - "C4", # flake8-comprehensions - # "C90", # mccabe - # "COM", # flake8-commas - "DTZ", # flake8-datetimez - "D", # pydocstyle - "E", # pydocstyle - # "ERA", # eradicate - # "EM", # flake8-errmsg - "EXE", # flake8-executable - "F", # flake8 - # "FBT", # flake8-boolean-trap - "G", # flake8-logging-format - "I", # isort - "ICN", # flake8-import-conventions - "INP", # flake8-no-pep420 - "ISC", # flake8-implicit-str-concat - "N", # pep8-naming - # "PD", # pandas-vet - "PIE", # flake8-pie - "PGH", # pygrep-hooks - "PL", # pylint - # "PT", # flake8-pytest-style - # "PTH", # flake8-use-pathlib [Enable] - "Q", # flake8-quotes - "RET", # flake8-return - "RUF", # Ruff - "S", # flake8-bandit - "SIM", # flake8-simplify - "T10", # flake8-debugger - "T20", # flake8-print - # "TCH", # flake8-type-checking - "TID", # flake8-tidy-imports - "TRY", # tryceratops - "UP", # pyupgrade - "W", # pydocstyle - "YTT", # flake8-2020 -] -lint.ignore = [ - "B008", - "B905", - "D104", - "D200", - "D202", - "D205", - "D301", - "D400", - "I001", - "N801", - "N802", - "N803", - "N806", - "N813", - "N815", - "N816", - "PGH003", - "PIE804", - "PLE0605", - "PLR0911", - "PLR0912", - "PLR0913", - "PLR0915", - "PLR2004", - "RET504", - "RET505", - "RET506", - "RET507", - "RET508", - "TRY003", - "TRY300", +select = ["ALL"] +ignore = [ + "C", # Pylint - Convention + "C90", # mccabe + "COM", # flake8-commas + "ERA", # eradicate + "FBT", # flake8-boolean-trap + "FIX", # flake8-fixme + "PT", # flake8-pytest-style + "PTH", # flake8-use-pathlib [Enable] + "TD", # flake8-todos + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in `**kwargs` + "D200", # One-line docstring should fit on one line + "D202", # No blank lines allowed after function docstring + "D205", # 1 blank line required between summary line and description + "D301", # Use `r"""` if any backslashes in a docstring + "D400", # First line should end with a period + "I001", # Import block is un-sorted or un-formatted + "N801", # Class name `.*` should use CapWords convention + "N802", # Function name `.*` should be lowercase + "N803", # Argument name `.*` should be lowercase + "N806", # Variable `.*` in function should be lowercase + "N813", # Camelcase `.*` imported as lowercase `.*` + "N815", # Variable `.*` in class scope should not be mixedCase + "N816", # Variable `.*` in global scope should not be mixedCase + "NPY002", # Replace legacy `np.random.random` call with `np.random.Generator` + "PGH003", # Use specific rule codes when ignoring type issues + "PLR0912", # Too many branches + "PLR0913", # Too many arguments in function definition + "PLR0915", # Too many statements + "PLR2004", # Magic value used in comparison, consider replacing `.*` with a constant variable + "PYI036", # Star-args in `.*` should be annotated with `object` + "PYI051", # `Literal[".*"]` is redundant in a union with `str` + "PYI056", # Calling `.append()` on `__all__` may not be supported by all type checkers (use `+=` instead) + "RUF022", # [*] `__all__` is not sorted + "TRY003", # Avoid specifying long messages outside the exception class + "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` ] -lint.fixable = ["B", "C", "E", "F", "PIE", "RUF", "SIM", "UP", "W"] +typing-modules = [] -[tool.ruff.lint.pydocstyle] +[tool.ruff.pydocstyle] convention = "numpy" -[tool.ruff.lint.per-file-ignores] +[tool.ruff.per-file-ignores] +"__init__.py" = ["D104"] +"colour_hdri/examples/*" = ["INP", "T201", "T203"] "docs/*" = ["INP"] "tasks.py" = ["INP"] +"test_*" = ["S101"] "utilities/*" = ["EXE001", "INP"] "utilities/unicode_to_ascii.py" = ["RUF001"] [tool.ruff.format] docstring-code-format = true - - -[tool.setuptools] -packages = ["colour_clf_io"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7a07226 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,178 @@ +# This file was autogenerated by uv via the following command: +# uv export --no-hashes --all-extras +accessible-pygments==0.0.5 +alabaster==1.0.0 +anyio==4.8.0 +appnope==0.1.4 ; sys_platform == 'darwin' +argon2-cffi==23.1.0 +argon2-cffi-bindings==21.2.0 +arrow==1.3.0 +asttokens==3.0.0 +async-lru==2.0.4 +attrs==24.3.0 +babel==2.16.0 +backports-tarfile==1.2.0 ; python_full_version < '3.12' +beautifulsoup4==4.12.3 +biblib-simple==0.1.2 +bleach==6.2.0 +certifi==2024.12.14 +cffi==1.17.1 +cfgv==3.4.0 +charset-normalizer==3.4.1 +click==8.1.8 +colorama==0.4.6 ; sys_platform == 'win32' +comm==0.2.2 +coverage==7.6.10 +coveralls==4.0.1 +cryptography==44.0.0 ; sys_platform == 'linux' +debugpy==1.8.11 +decorator==5.1.1 +defusedxml==0.7.1 +distlib==0.3.9 +docopt==0.6.2 +docutils==0.21.2 +exceptiongroup==1.2.2 ; python_full_version < '3.11' +execnet==2.1.1 +executing==2.1.0 +fastjsonschema==2.21.1 +filelock==3.16.1 +fqdn==1.5.1 +h11==0.14.0 +hatch==1.14.0 +hatchling==1.27.0 +httpcore==1.0.7 +httpx==0.28.1 +hyperlink==21.0.0 +identify==2.6.5 +idna==3.10 +imagesize==1.4.1 +importlib-metadata==8.5.0 ; python_full_version < '3.12' +iniconfig==2.0.0 +invoke==2.2.0 +ipykernel==6.29.5 +ipython==8.31.0 +ipywidgets==8.1.5 +isoduration==20.11.0 +jaraco-classes==3.4.0 +jaraco-context==6.0.1 +jaraco-functools==4.1.0 +jedi==0.19.2 +jeepney==0.8.0 ; sys_platform == 'linux' +jinja2==3.1.5 +json5==0.10.0 +jsonpointer==3.0.0 +jsonschema==4.23.0 +jsonschema-specifications==2024.10.1 +jupyter==1.1.1 +jupyter-client==8.6.3 +jupyter-console==6.6.3 +jupyter-core==5.7.2 +jupyter-events==0.11.0 +jupyter-lsp==2.2.5 +jupyter-server==2.15.0 +jupyter-server-terminals==0.5.3 +jupyterlab==4.3.4 +jupyterlab-pygments==0.3.0 +jupyterlab-server==2.27.3 +jupyterlab-widgets==3.0.13 +keyring==25.6.0 +latexcodec==3.0.0 +lxml==5.3.0 +lxml-stubs==0.5.1 +markdown-it-py==3.0.0 +markupsafe==3.0.2 +matplotlib-inline==0.1.7 +mdurl==0.1.2 +mistune==3.1.0 +more-itertools==10.5.0 +nbclient==0.10.2 +nbconvert==7.16.5 +nbformat==5.10.4 +nest-asyncio==1.6.0 +nh3==0.2.20 +nodeenv==1.9.1 +notebook==7.3.2 +notebook-shim==0.2.4 +numpy==2.2.1 +overrides==7.7.0 +packaging==24.2 +pandocfilters==1.5.1 +parso==0.8.4 +pathspec==0.12.1 +pexpect==4.9.0 +pkginfo==1.12.0 +platformdirs==4.3.6 +pluggy==1.5.0 +pre-commit==4.0.1 +prometheus-client==0.21.1 +prompt-toolkit==3.0.48 +psutil==6.1.1 +ptyprocess==0.7.0 +pure-eval==0.2.3 +pybtex==0.24.0 +pybtex-docutils==1.0.3 +pycparser==2.22 +pydata-sphinx-theme==0.16.1 +pygments==2.19.1 +pyright==1.1.391 +pytest==8.3.4 +pytest-cov==6.0.0 +pytest-xdist==3.6.1 +python-dateutil==2.9.0.post0 +python-json-logger==3.2.1 +pywin32==308 ; platform_python_implementation != 'PyPy' and sys_platform == 'win32' +pywin32-ctypes==0.2.3 ; sys_platform == 'win32' +pywinpty==2.0.14 ; os_name == 'nt' +pyyaml==6.0.2 +pyzmq==26.2.0 +readme-renderer==44.0 +referencing==0.35.1 +requests==2.32.3 +requests-toolbelt==1.0.0 +restructuredtext-lint==1.4.0 +rfc3339-validator==0.1.4 +rfc3986==2.0.0 +rfc3986-validator==0.1.1 +rich==13.9.4 +rpds-py==0.22.3 +secretstorage==3.3.3 ; sys_platform == 'linux' +send2trash==1.8.3 +setuptools==75.8.0 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +snowballstemmer==2.2.0 +soupsieve==2.6 +sphinx==8.1.3 +sphinxcontrib-applehelp==2.0.0 +sphinxcontrib-bibtex==2.6.3 +sphinxcontrib-devhelp==2.0.0 +sphinxcontrib-htmlhelp==2.1.0 +sphinxcontrib-jsmath==1.0.1 +sphinxcontrib-qthelp==2.0.0 +sphinxcontrib-serializinghtml==2.0.0 +stack-data==0.6.3 +terminado==0.18.1 +tinycss2==1.4.0 +toml==0.10.2 +tomli==2.2.1 ; python_full_version <= '3.11' +tomli-w==1.1.0 +tomlkit==0.13.2 +tornado==6.4.2 +traitlets==5.14.3 +trove-classifiers==2025.1.10.15 +twine==6.0.1 +types-python-dateutil==2.9.0.20241206 +typing-extensions==4.12.2 +uri-template==1.3.0 +urllib3==2.3.0 +userpath==1.9.2 +uv==0.5.16 +virtualenv==20.28.1 +wcwidth==0.2.13 +webcolors==24.11.1 +webencodings==0.5.1 +websocket-client==1.8.0 +widgetsnbextension==4.0.13 +zipp==3.21.0 ; python_full_version < '3.12' +zstandard==0.23.0 diff --git a/tasks.py b/tasks.py index 1978d72..f71677d 100644 --- a/tasks.py +++ b/tasks.py @@ -13,19 +13,23 @@ import uuid from itertools import chain from textwrap import TextWrapper -from typing import Callable +from typing import TYPE_CHECKING import biblib.bib -from invoke.context import Context from invoke.tasks import task import colour_clf_io +if TYPE_CHECKING: + from collections.abc import Callable + + from invoke.context import Context + if not hasattr(inspect, "getargspec"): inspect.getargspec = inspect.getfullargspec # pyright: ignore __author__ = "Colour Developers" -__copyright__ = "Copyright 2013 Colour Developers" +__copyright__ = "Copyright 2024 Colour Developers" __license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" __maintainer__ = "Colour Developers" __email__ = "colour-developers@colour-science.org" @@ -73,7 +77,7 @@ def message_box( width: int = 79, padding: int = 3, print_callable: Callable = print, -): +) -> None: """ Print a message inside a box. @@ -123,7 +127,7 @@ def message_box( ideal_width = width - padding * 2 - 2 - def inner(text): + def inner(text: str) -> str: """Format and pads inner text for the message box.""" return ( @@ -148,7 +152,7 @@ def inner(text): @task -def literalise(ctx: Context): +def literalise(ctx: Context) -> None: """ Write various literals in the `colour.hints` module. @@ -171,7 +175,7 @@ def clean( docs: bool = True, bytecode: bool = False, pytest: bool = True, -): +) -> None: """ Clean the project. @@ -211,7 +215,7 @@ def formatting( ctx: Context, asciify: bool = True, bibtex: bool = True, -): +) -> None: """ Convert unicode characters to ASCII and cleanup the *BibTeX* file. @@ -254,7 +258,7 @@ def quality( ctx: Context, pyright: bool = True, rstlint: bool = True, -): +) -> None: """ Check the codebase with *Pyright* and lints various *restructuredText* files with *rst-lint*. @@ -279,7 +283,7 @@ def quality( @task -def precommit(ctx: Context): +def precommit(ctx: Context) -> None: """ Run the "pre-commit" hooks on the codebase. @@ -294,7 +298,7 @@ def precommit(ctx: Context): @task -def tests(ctx: Context): +def tests(ctx: Context) -> None: """ Run the unit tests with *Pytest*. @@ -315,7 +319,7 @@ def tests(ctx: Context): @task -def examples(ctx: Context, plots: bool = False): +def examples(ctx: Context, plots: bool = False) -> None: """ Run the examples. @@ -346,7 +350,7 @@ def examples(ctx: Context, plots: bool = False): @task(formatting, quality, precommit, tests, examples) -def preflight(ctx: Context): # noqa: ARG001 +def preflight(ctx: Context) -> None: # noqa: ARG001 """ Perform the preflight tasks, i.e., *formatting*, *tests*, *quality*, and *examples*. @@ -365,7 +369,7 @@ def docs( ctx: Context, html: bool = True, pdf: bool = True, -): +) -> None: """ Build the documentation. @@ -391,7 +395,7 @@ def docs( @task -def todo(ctx: Context): +def todo(ctx: Context) -> None: """ Export the TODO items. @@ -408,7 +412,7 @@ def todo(ctx: Context): @task -def requirements(ctx: Context): +def requirements(ctx: Context) -> None: """ Export the *requirements.txt* file. @@ -429,7 +433,7 @@ def requirements(ctx: Context): @task(literalise, clean, preflight, docs, todo, requirements) -def build(ctx: Context): +def build(ctx: Context) -> None: """ Build the project and runs dependency tasks, i.e., *docs*, *todo*, and *preflight*. @@ -442,7 +446,8 @@ def build(ctx: Context): message_box("Building...") if "modified: README.rst" in ctx.run("git status").stdout: # pyright: ignore - raise RuntimeError('Please commit your changes to the "README.rst" file!') + msg = 'Please commit your changes to the "README.rst" file!' + raise RuntimeError(msg) with open("README.rst") as readme_file: readme_content = readme_file.read() @@ -472,7 +477,7 @@ def build(ctx: Context): @task -def virtualise(ctx: Context, tests: bool = True): +def virtualise(ctx: Context, tests: bool = True) -> None: """ Create a virtual environment for the project build. @@ -505,7 +510,7 @@ def virtualise(ctx: Context, tests: bool = True): @task -def tag(ctx: Context): +def tag(ctx: Context) -> None: """ Tag the repository according to defined version using *git-flow*. @@ -519,7 +524,8 @@ def tag(ctx: Context): result = ctx.run("git rev-parse --abbrev-ref HEAD", hide="both") if result.stdout.strip() != "develop": # pyright: ignore - raise RuntimeError("Are you still on a feature or master branch?") + msg = "Are you still on a feature or master branch?" + raise RuntimeError(msg) with open(os.path.join(PYTHON_PACKAGE_NAME, "__init__.py")) as file_handle: file_content = file_handle.read() @@ -539,7 +545,7 @@ def tag(ctx: Context): 1 ) - version = ".".join((major_version, minor_version, change_version)) + version = f"{major_version}.{minor_version}.{change_version}" result = ctx.run("git ls-remote --tags upstream", hide="both") remote_tags = result.stdout.strip().split("\n") # pyright: ignore @@ -548,17 +554,18 @@ def tag(ctx: Context): tags.add(remote_tag.split("refs/tags/")[1].replace("refs/tags/", "^{}")) version_tags = sorted(tags) if f"v{version}" in version_tags: - raise RuntimeError( + msg = ( f'A "{PYTHON_PACKAGE_NAME}" "v{version}" tag already exists in ' f"remote repository!" ) + raise RuntimeError(msg) ctx.run(f"git flow release start v{version}") ctx.run(f"git flow release finish v{version}") @task(build) -def release(ctx: Context): +def release(ctx: Context) -> None: """ Release the project to *Pypi* with *Twine*. @@ -575,7 +582,7 @@ def release(ctx: Context): @task -def sha256(ctx: Context): +def sha256(ctx: Context) -> None: """ Compute the project *Pypi* package *sha256* with *OpenSSL*. diff --git a/utilities/export_todo.py b/utilities/export_todo.py index 8d18eb9..31bfced 100755 --- a/utilities/export_todo.py +++ b/utilities/export_todo.py @@ -9,7 +9,7 @@ import codecs import os -__copyright__ = "Copyright 2013 Colour Developers" +__copyright__ = "Copyright 2024 Colour Developers" __license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" __maintainer__ = "Colour Developers" __email__ = "colour-developers@colour-science.org" @@ -34,7 +34,7 @@ ----- | **Colour** by Colour Developers -| Copyright 2013 Colour Developers - \ +| Copyright 2024 Colour Developers - \ `colour-developers@colour-science.org `__ | This software is released under terms of BSD-3-Clause: \ https://opensource.org/licenses/BSD-3-Clause @@ -93,7 +93,7 @@ def extract_todo_items(root_directory: str) -> dict: return todo_items -def export_todo_items(todo_items: dict, file_path: str): +def export_todo_items(todo_items: dict, file_path: str) -> None: """ Export TODO items to given file. diff --git a/utilities/unicode_to_ascii.py b/utilities/unicode_to_ascii.py index 7bfbf7c..61faa92 100755 --- a/utilities/unicode_to_ascii.py +++ b/utilities/unicode_to_ascii.py @@ -10,7 +10,7 @@ import os import unicodedata -__copyright__ = "Copyright 2013 Colour Developers" +__copyright__ = "Copyright 2024 Colour Developers" __license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" __maintainer__ = "Colour Developers" __email__ = "colour-developers@colour-science.org" @@ -31,7 +31,7 @@ } -def unicode_to_ascii(root_directory: str): +def unicode_to_ascii(root_directory: str) -> None: """ Recursively convert from unicode to ASCII *.py*, *.bib* and *.rst* files in given directory.