#!/usr/bin/env python
"""CDL Convert Base Classes Module
This module provides foundational base classes that implement common functionality
shared across CDL Convert components. The classes listed here should only be used
for inheritance by more specialized classes.
Classes:
AscColorSpaceBase: Base class for ASC XML nodes handling colorspace
metadata with type hints and enhanced validation. Provides standardized
input and viewing colorspace description management.
AscDescBase: Type-safe base class for ASC XML nodes supporting multiple
descriptions with enhanced list management. Handles unlimited
description elements per ASC CDL specifications.
AscXMLBase: Base class for XML element generation.
ColorNodeBase: Base class for color correction nodes (SOP/SAT).
## License
The MIT License (MIT)
cdl_convert
Copyright (c) 2015-2025 Sean Wallitsch
http://github.com/shidarin/cdl_convert/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
# ==============================================================================
# IMPORTS
# ==============================================================================
# Secure XML parsing
import defusedxml
defusedxml.defuse_stdlib()
# Standard Imports
from decimal import Decimal
from xml.dom import minidom
from xml.etree import ElementTree
# cdl_convert Imports
from cdl_convert import config
from cdl_convert.exceptions import ValidationError
from cdl_convert.utils import to_decimal
# ==============================================================================
# EXPORTS
# ==============================================================================
__all__ = [
"AscColorSpaceBase",
"AscDescBase",
"AscXMLBase",
"ColorNodeBase",
]
# ==============================================================================
# CLASSES
# ==============================================================================
[docs]
class AscColorSpaceBase: # pylint: disable=R0903
"""Base class for ASC XML type nodes that deal with colorspace.
This class is meant to be inherited by any node type that uses viewing and
input colorspace descriptions. It provides standardized handling of
colorspace metadata according to ASC CDL specifications.
Attributes:
input_desc (Optional[str]): Description of the color space, format and
properties of the input images. Individual ColorCorrections can
override this. Defaults to None.
viewing_desc (Optional[str]): Viewing device, settings and environment.
Individual ColorCorrections can override this. Defaults to None.
Example:
>>> class MyColorNode(AscColorSpaceBase):
... def __init__(self):
... super().__init__()
... self.input_desc = "Rec.709 Linear"
... self.viewing_desc = "sRGB Display"
"""
[docs]
def __init__(self) -> None:
# For multiple inheritance support.
super().__init__()
self.input_desc: str | None = None
self.viewing_desc: str | None = None
# Public Methods ==========================================================
# =========================================================================
[docs]
def parse_xml_viewing_desc(self, xml_element: ElementTree.Element) -> bool:
"""Parse an ElementTree element to find and add a viewing description.
Args:
xml_element (ElementTree.Element): The XML element to parse for a
ViewingDescription element. If found, sets the viewing_desc
attribute.
Returns:
bool: True if ViewingDescription element was found (even if blank),
False otherwise.
"""
# If the text field is empty, this will return None, which is the
# default value of viewing_desc and input_desc anyway.
viewing_elem = xml_element.find("ViewingDescription")
if viewing_elem is not None:
self.viewing_desc = viewing_elem.text
return True
return False
# ==============================================================================
[docs]
class AscDescBase: # pylint: disable=R0903
"""Base class for ASC XML type nodes that support multiple descriptions.
This class is meant to be inherited by any node type that uses description
fields. It provides standardized handling of multiple description elements
as specified in the ASC CDL schema.
Example:
>>> node = AscDescBase()
>>> node.desc = "First description"
>>> node.desc = "Second description"
>>> print(node.desc)
['First description', 'Second description']
>>> node.desc = ["New list", "of descriptions"]
>>> print(node.desc)
['New list', 'of descriptions']
>>> node.desc = None # Clear descriptions
>>> print(node.desc)
[]
"""
[docs]
def __init__(self) -> None:
super().__init__()
self._desc: list[str] = []
# Properties ==============================================================
@property
def desc(self) -> list[str]:
"""Returns the list of descriptions.
Since ASC nodes can
contain multiple description elements, this attribute stores all
descriptions found during parsing.
Setting desc directly will:
- Append single values to the end of the list
- Replace the list when given a list or tuple
- Empty the list when given None, [], or ()
"""
return self._desc
@desc.setter
def desc(self, value: None | str | list[str] | tuple) -> None:
"""Adds an entry to the descriptions"""
if value is None:
self._desc = []
elif type(value) in [list, tuple]:
self._desc = list(value)
else:
self._desc.append(value) # type: ignore[arg-type]
# Public Methods ==========================================================
[docs]
def parse_xml_descs(self, xml_element: ElementTree.Element) -> None:
"""Parse an ElementTree element to find and add any descriptions.
Args:
xml_element (ElementTree.Element): The XML element to parse for
Description elements. Any found will be appended to the desc
list.
Example:
>>> import xml.etree.ElementTree as ET
>>> node = AscDescBase()
>>> xml = ET.fromstring('''
... <root>
... <Description>First description</Description>
... <Description>Second description</Description>
... </root>
... ''')
>>> node.parse_xml_descs(xml)
>>> print(node.desc)
['First description', 'Second description']
"""
for desc_entry in xml_element.findall("Description"):
if desc_entry.text: # Don't attend if text returns none
self.desc.append(desc_entry.text)
# ==============================================================================
[docs]
class AscXMLBase:
"""Base class for nodes which can be converted to XML Elements.
This class provides convenience attributes and methods for converting
CDL objects to XML representations.
Example:
>>> class MyNode(AscXMLBase):
... def build_element(self):
... import xml.etree.ElementTree as ET
...
... return ET.Element("MyNode")
>>> node = MyNode()
>>> print(node.xml) # Pretty-formatted XML without header
>>> print(node.xml_root) # With XML declaration
Note:
Subclasses must override build_element() to return a valid
ElementTree.Element for the XML-related attributes to work.
"""
[docs]
def __init__(self) -> None:
super().__init__()
# Properties ==============================================================
@property
def element(self) -> ElementTree.Element | None:
"""etree style Element representing the node."""
return self.build_element()
@property
def xml(self) -> str:
"""A nicely formatted XML string representing the node"""
# We'll take the xml_root attrib, which is ready to write, and just
# remove the first line, which is the xml version and encoding.
dom_string = self.xml_root.split("\n")
return "\n".join(dom_string[1:])
@property
def xml_root(self) -> str:
"""A nicely formatted XML string with a root element ready to write"""
element = self.element
if element is None:
raise NotImplementedError(
f"{self.__class__.__name__}.build_element() must be "
f"implemented to return a valid ElementTree.Element, not "
f"None. This is an abstract method that subclasses of "
f"AscXMLBase are required to override."
)
# Use XML standard encoding name for the XML declaration
xml_encoding = config.config.output_encoding_xml
xml_string = ElementTree.tostring(
element, config.config.output_encoding
)
dom_xml = minidom.parseString(xml_string)
dom_string = dom_xml.toprettyxml(indent=" ", encoding=xml_encoding)
return dom_string.decode(config.config.output_encoding)
# Public Methods ==========================================================
[docs]
def build_element(
self,
) -> ElementTree.Element | None: # pragma: no cover pylint: disable=R0201
"""Build an ElementTree Element representing this node.
This is a placeholder method that must be overridden by inheriting
classes to provide actual XML element construction.
Returns:
Optional[ElementTree.Element]: None in base implementation.
Subclasses should return a valid ElementTree.Element.
"""
return None
# ==============================================================================
[docs]
class ColorNodeBase(AscDescBase, AscXMLBase): # pylint: disable=R0903
"""Base class for SOP and SAT color correction nodes.
This class is meant only to be inherited by SopNode and SatNode classes
and should not be used directly. It combines description and XML
functionality while providing value validation methods for color
correction parameters.
Example:
>>> # This class is not used directly, but through SopNode/SatNode
>>> from cdl_convert import ColorCorrection
>>> cc = ColorCorrection("test_id")
>>> cc.slope = [1.2, 1.1, 1.0] # Uses SopNode internally
>>> print(cc.sop_node.xml) # XML representation
"""
[docs]
def __init__(self) -> None:
super().__init__()
# Private Methods =========================================================
@staticmethod
def _check_single_value(
value: Decimal | str | float | int,
name: str,
negative_allow: bool = False,
) -> Decimal:
"""Check and validate a single numeric value for color correction.
Args:
value (Union[Decimal, str, float, int]): The numeric value to
validate. Can be any numeric type or string representation.
name (str): The type of value being checked (e.g., 'slope',
'offset', 'power', 'saturation'). Used in error messages.
negative_allow (bool, optional): Whether to allow negative values.
Defaults to False.
Returns:
Decimal: The validated value converted to Decimal type.
Raises:
ValidationError: If the value fails validation checks. Includes:
- Non-numeric values when numeric is required
- Negative values when negative_allow is False
- Invalid string representations of numbers
Example:
>>> # Valid usage (internal method)
>>> value = ColorNodeBase._check_single_value(1.5, "slope")
>>> print(value) # Decimal('1.5')
>>> # This would raise ValidationError for negative slope
>>> try:
... ColorNodeBase._check_single_value(-0.5, "slope")
... except ValidationError as e:
... print(f"Validation failed: {e}")
"""
value = to_decimal(value, name)
# If given as a single number, that number must be positive
if not negative_allow:
if value < 0:
if config.config.halt_on_error:
raise ValidationError(
f'Invalid {name} value: "{value}". '
f"{name.title()} values must be non-negative (>= 0)."
)
else:
value = Decimal("0.0")
return value