Skip to content

Commit d4550c3

Browse files
Add support for writing SMPTE ST 2136-1 compatible files.
1 parent 55bbb75 commit d4550c3

File tree

6 files changed

+1110
-38
lines changed

6 files changed

+1110
-38
lines changed

colour_clf_io/__init__.py

+11-2
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222

2323
import typing
2424

25+
from .parsing import Namespaces
26+
2527
if typing.TYPE_CHECKING:
2628
from pathlib import Path
2729

@@ -168,7 +170,11 @@ def read_clf(text: str | bytes) -> ProcessList | None:
168170
return ProcessList.from_xml(xml)
169171

170172

171-
def write_clf(process_list: ProcessList, path: str | Path | None = None) -> None | str:
173+
def write_clf(
174+
process_list: ProcessList,
175+
path: str | Path | None = None,
176+
namespace: Namespaces = Namespaces.AMPAS,
177+
) -> None | str:
172178
"""
173179
Write the given *ProcessList* as a CLF file to the target
174180
location. If no *path* is given the CLF document will be returned as a string.
@@ -180,12 +186,15 @@ def write_clf(process_list: ProcessList, path: str | Path | None = None) -> None
180186
path
181187
Location of the file, or *None* to return a string representation of the
182188
CLF document.
189+
namespace
190+
:class:`colour_clf_io.Namespaces` instance to be used for the namespace
191+
of the document.
183192
184193
Returns
185194
-------
186195
:class:`colour_clf_io.ProcessList`
187196
"""
188-
xml = process_list.to_xml()
197+
xml = process_list.to_xml(namespace)
189198
serialised = lxml.etree.tostring(xml)
190199
if path is None:
191200
return serialised.decode("utf-8")

colour_clf_io/parsing.py

+49-8
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import typing
1212
from abc import ABC, abstractmethod
1313
from dataclasses import dataclass
14+
from enum import Enum
1415
from itertools import islice
1516
from typing import TypeGuard, TypeVar
1617

@@ -30,7 +31,7 @@
3031
__status__ = "Production"
3132

3233
__all__ = [
33-
"NAMESPACE_NAME",
34+
"Namespaces",
3435
"ParserConfig",
3536
"XMLParsable",
3637
"XMLWritable",
@@ -46,9 +47,22 @@
4647
"elements_as_text_list",
4748
"sliding_window",
4849
"three_floats",
50+
"detect_namespace",
4951
]
5052

51-
NAMESPACE_NAME: str = "urn:AMPAS:CLF:v3.0"
53+
54+
class Namespaces(Enum):
55+
"""
56+
Valid namespaces for parsing and serialising CLF documents.
57+
"""
58+
59+
AMPAS = "urn:AMPAS:CLF:v3.0"
60+
SMTP = "http://www.smpte-ra.org/ns/2136-1/2024"
61+
62+
63+
@dataclass
64+
class UnknownNamespace:
65+
namespace: str
5266

5367

5468
@dataclass
@@ -58,17 +72,17 @@ class ParserConfig:
5872
5973
Attributes
6074
----------
61-
- :attr:`~colour_clf_io.ParserConfig.namespace_name`
75+
- :attr:`~colour_clf_io.ParserConfig.name_space`
6276
6377
Methods
6478
-------
6579
- :meth:`~colour_clf_io.ParserConfig.clf_namespace_prefix_mapping`
6680
"""
6781

68-
namespace_name: str | None = NAMESPACE_NAME
82+
namespace: Namespaces | None
83+
"""
84+
The namespace name used for parsing the *CLF* file.
6985
"""
70-
The namespace name used for parsing the *CLF* file. Usually this should
71-
be the `CLF_NAMESPACE`, but it can be omitted."""
7286

7387
def clf_namespace_prefix_mapping(self) -> dict[str, str] | None:
7488
"""
@@ -80,8 +94,8 @@ def clf_namespace_prefix_mapping(self) -> dict[str, str] | None:
8094
Dictionary that contain the namespaces prefix mappings.
8195
"""
8296

83-
if self.namespace_name:
84-
return {"clf": self.namespace_name}
97+
if self.namespace:
98+
return {"clf": self.namespace.value}
8599

86100
return None
87101

@@ -524,3 +538,30 @@ def set_element_if_not_none(node: lxml.etree._Element, name: str, value: Any) ->
524538
if value is not None and value != "":
525539
child = lxml.etree.SubElement(node, name)
526540
child.text = str(value)
541+
542+
543+
def detect_namespace(node: lxml.etree._Element) -> Namespaces | UnknownNamespace | None:
544+
"""
545+
Detect the namespace of the given CLF document.
546+
547+
Parameters
548+
----------
549+
node
550+
XML element to check for namespace.
551+
552+
Returns
553+
-------
554+
:class:`NameSpace` or :class:`UnknownNamespace` or None depending on whether a valid
555+
namespace was detected, an invalid namespace was detected, or no namespace is
556+
present.
557+
"""
558+
document_namespace = node.xpath("namespace-uri(.)")
559+
if not document_namespace:
560+
return None
561+
document_namespace = str(document_namespace)
562+
match document_namespace:
563+
case Namespaces.SMTP.value:
564+
return Namespaces.SMTP
565+
case Namespaces.AMPAS.value:
566+
return Namespaces.AMPAS
567+
return UnknownNamespace(document_namespace)

colour_clf_io/process_list.py

+43-26
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
from colour_clf_io.elements import Info
1616
from colour_clf_io.errors import ParsingError
1717
from colour_clf_io.parsing import (
18-
NAMESPACE_NAME,
18+
Namespaces,
1919
ParserConfig,
20+
UnknownNamespace,
2021
check_none,
22+
detect_namespace,
2123
element_as_text,
2224
elements_as_text_list,
2325
set_attr_if_not_none,
@@ -66,10 +68,10 @@ class ProcessList:
6668
- https://docs.acescentral.com/specifications/clf/#processList
6769
"""
6870

69-
id: str
71+
id: str | None
7072
"""A string to serve as a unique identifier of the *ProcessList*."""
7173

72-
compatible_CLF_version: str
74+
compatible_CLF_version: str | None
7375
"""
7476
A string indicating the minimum compatible CLF specification version
7577
required to read this file. The compCLFversion corresponding to this
@@ -123,10 +125,10 @@ class ProcessList:
123125
def from_xml(xml: lxml.etree._Element | None) -> ProcessList | None:
124126
"""
125127
Parse and return a :class:`colour_clf_io.ProcessList` class instance
126-
from the given XML element. Returns `None`` if the given XML element is
128+
from the given XML element. Returns ``None`` if the given XML element is
127129
``None``.
128130
129-
Expects the XML element to be a valid element according to the *CLF*
131+
Expects the XML element to be a valid element, according to the *CLF*
130132
specification.
131133
132134
Parameters
@@ -150,6 +152,27 @@ def from_xml(xml: lxml.etree._Element | None) -> ProcessList | None:
150152
if xml is None:
151153
return None
152154

155+
detected_namespace = detect_namespace(xml)
156+
document_namespace: Namespaces | None = None
157+
match detected_namespace:
158+
case None:
159+
document_namespace = None
160+
case UnknownNamespace(value):
161+
exception = f"Found invalid xmlns attribute in *ProcessList*: {value}"
162+
raise ParsingError(exception)
163+
case Namespaces():
164+
document_namespace = detected_namespace
165+
166+
if document_namespace == Namespaces.SMTP:
167+
error = (
168+
"SMPTE ST 2136-1 files are not fully supported. See "
169+
"https://github.com/colour-science/colour-clf-io/issues/6 "
170+
"for more information. "
171+
)
172+
raise ParsingError(error)
173+
174+
config = ParserConfig(namespace=document_namespace)
175+
153176
id_ = xml.get("id")
154177
check_none(id_, "ProcessList must contain an `id` attribute")
155178

@@ -159,18 +182,6 @@ def from_xml(xml: lxml.etree._Element | None) -> ProcessList | None:
159182
'ProcessList must contain a "compCLFversion" attribute',
160183
)
161184

162-
# By default, we would expect the correct namespace as per the specification.
163-
# But if it is not present, we will still try to parse the document anyway.
164-
# We won't accept a wrong namespace through.
165-
config = ParserConfig()
166-
namespace = xml.xpath("namespace-uri(.)")
167-
if not namespace:
168-
config.namespace_name = None
169-
elif namespace != config.namespace_name:
170-
exception = f"Found invalid xmlns attribute in *ProcessList*: {namespace}"
171-
172-
raise ParsingError(exception)
173-
174185
name = xml.get("name")
175186
inverse_of = xml.get("inverseOf")
176187
info = Info.from_xml(xml, config)
@@ -193,8 +204,8 @@ def from_xml(xml: lxml.etree._Element | None) -> ProcessList | None:
193204
assert_bit_depth_compatibility(process_nodes)
194205

195206
return ProcessList(
196-
id=id_, # pyright: ignore
197-
compatible_CLF_version=compatible_clf_version, # pyright: ignore
207+
id=id_,
208+
compatible_CLF_version=compatible_clf_version,
198209
process_nodes=process_nodes,
199210
name=name,
200211
inverse_of=inverse_of,
@@ -204,17 +215,27 @@ def from_xml(xml: lxml.etree._Element | None) -> ProcessList | None:
204215
description=description,
205216
)
206217

207-
def to_xml(self) -> lxml.etree._Element:
218+
def to_xml(self, name_space: Namespaces = Namespaces.AMPAS) -> lxml.etree._Element:
208219
"""
209220
Serialise this object as an XML object.
210221
222+
Parameters
223+
----------
224+
name_space
225+
:class:`colour_clf_io.Namespaces` instance to be used for the namespace
226+
of the document.
227+
211228
Returns
212229
-------
213230
:class:`lxml.etree._Element`
214231
"""
215232
xml = lxml.etree.Element("ProcessList")
216233

217-
xml.set("xmlns", NAMESPACE_NAME)
234+
xml.set("xmlns", name_space.value)
235+
236+
for description_text in self.description:
237+
description_element = lxml.etree.SubElement(xml, "Description")
238+
description_element.text = description_text
218239

219240
set_attr_if_not_none(xml, "id", self.id)
220241
set_attr_if_not_none(xml, "compCLFversion", self.compatible_CLF_version)
@@ -225,11 +246,7 @@ def to_xml(self) -> lxml.etree._Element:
225246

226247
if self.info:
227248
xml.append(self.info.to_xml())
228-
for description_text in self.description:
229-
description_element = lxml.etree.SubElement(xml, "Description")
230-
description_element.text = description_text
231-
# TODO: we might have to store a single list of children in order to preserve
232-
# ordering of description and process nodes
249+
233250
for process_node in self.process_nodes:
234251
xml.append(process_node.to_xml())
235252
return xml

colour_clf_io/process_nodes.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -854,8 +854,8 @@ def to_xml(self) -> lxml.etree._Element:
854854
xml = lxml.etree.Element("ASC_CDL")
855855
self.write_process_node_attributes(xml)
856856
xml.set("style", self.style.value)
857-
if self.sat_node is not None:
858-
xml.append(self.sat_node.to_xml())
859857
if self.sopnode is not None:
860858
xml.append(self.sopnode.to_xml())
859+
if self.sat_node is not None:
860+
xml.append(self.sat_node.to_xml())
861861
return xml

0 commit comments

Comments
 (0)