diff --git a/.github/workflows/makefile.yml b/.github/workflows/makefile.yml new file mode 100644 index 000000000..284b6b678 --- /dev/null +++ b/.github/workflows/makefile.yml @@ -0,0 +1,34 @@ +name: Makefile CI + +on: + push: + branches: [ "master", "dermatest" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Install reuirements + run: | + sudo apt-get install -y python3 python3-pip + sudo python3 -m pip install --upgrade pip + sudo pip3 install -r requirements.txt + sudo pip3 install -r requirements-dev.txt + sudo pip3 install -r requirements-test.txt + + - name: Build and install package + run: | + make build + make install + + - name: Run tests + run: make test + + - name: Build wheel + run: make wheel diff --git a/features/doc-customprops.feature b/features/doc-customprops.feature new file mode 100644 index 000000000..e2dbfaf10 --- /dev/null +++ b/features/doc-customprops.feature @@ -0,0 +1,40 @@ +Feature: Read and write custom document properties + In order to find documents and make them manageable by digital means + As a developer using python-docx + I need to access and modify the Dublin Core metadata for a document + + + Scenario: read the custom properties of a document + Given a document having known custom properties + Then I can access the custom properties object + And the expected custom properties are visible + And the custom property values match the known values + + + Scenario: change the custom properties of a document + Given a document having known custom properties + When I assign new values to the custom properties + Then the custom property values match the new values + + + Scenario: a default custom properties part is added if doc doesn't have one + Given a document having no custom properties part + When I access the custom properties object + Then a custom properties part with no values is added + + + Scenario: set custom properties on a document that doesn't have one + Given a document having no custom properties part + When I assign new values to the custom properties + Then the custom property values match the new values + + + Scenario: iterate the custom properties of a document + Given a document having known custom properties + Then I can iterate the custom properties object + + + Scenario: delete an existing custom property + Given a document having known custom properties + When I delete an existing custom property + Then the custom property is missing in the remaining list of custom properties diff --git a/features/steps/customprops.py b/features/steps/customprops.py new file mode 100644 index 000000000..6b9ff883f --- /dev/null +++ b/features/steps/customprops.py @@ -0,0 +1,125 @@ +# encoding: utf-8 + +""" +Gherkin step implementations for custom properties-related features. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from datetime import datetime, timedelta + +from behave import given, then, when + +from docx import Document +from docx.opc.customprops import CustomProperties + +from helpers import test_docx + + +# given =================================================== + +@given('a document having known custom properties') +def given_a_document_having_known_custom_properties(context): + context.document = Document(test_docx('doc-customprops')) + context.exp_prop_names = [ + 'AppVersion', 'CustomPropBool', 'CustomPropInt', 'CustomPropString', + 'DocSecurity', 'HyperlinksChanged', 'LinksUpToDate', 'ScaleCrop', 'ShareDoc' + ] + + +@given('a document having no custom properties part') +def given_a_document_having_no_custom_properties_part(context): + context.document = Document(test_docx('doc-no-customprops')) + context.exp_prop_names = [] + + +# when ==================================================== + +@when('I access the custom properties object') +def when_I_access_the_custom_properties_object(context): + context.document.custom_properties + + +@when("I assign new values to the custom properties") +def when_I_assign_new_values_to_the_custom_properties(context): + context.propvals = ( + ('CustomPropBool', False), + ('CustomPropInt', 1), + ('CustomPropString', 'Lorem ipsum'), + ) + custom_properties = context.document.custom_properties + for name, value in context.propvals: + custom_properties[name] = value + + +@when("I delete an existing custom property") +def when_I_delete_an_existing_custom_property(context): + custom_properties = context.document.custom_properties + del custom_properties["CustomPropInt"] + context.prop_name = "CustomPropInt" + + +# then ==================================================== + +@then('a custom properties part with no values is added') +def then_a_custom_properties_part_with_no_values_is_added(context): + custom_properties = context.document.custom_properties + assert len(custom_properties) == 0 + + +@then('I can access the custom properties object') +def then_I_can_access_the_custom_properties_object(context): + custom_properties = context.document.custom_properties + assert isinstance(custom_properties, CustomProperties) + + +@then('the expected custom properties are visible') +def then_the_expected_custom_properties_are_visible(context): + custom_properties = context.document.custom_properties + exp_prop_names = context.exp_prop_names + for name in exp_prop_names: + assert custom_properties.lookup(name) is not None + + +@then('the custom property values match the known values') +def then_the_custom_property_values_match_the_known_values(context): + known_propvals = ( + ('CustomPropBool', True), + ('CustomPropInt', 13), + ('CustomPropString', 'Test String'), + ) + custom_properties = context.document.custom_properties + for name, expected_value in known_propvals: + value = custom_properties[name] + assert value == expected_value, ( + "got '%s' for custom property '%s'" % (value, name) + ) + + +@then('the custom property values match the new values') +def then_the_custom_property_values_match_the_new_values(context): + custom_properties = context.document.custom_properties + for name, expected_value in context.propvals: + value = custom_properties[name] + assert value == expected_value, ( + "got '%s' for custom property '%s'" % (value, name) + ) + + +@then('I can iterate the custom properties object') +def then_I_can_iterate_the_custom_properties_object(context): + custom_properties = context.document.custom_properties + exp_prop_names = context.exp_prop_names + act_prop_names = [name for name in custom_properties] + assert act_prop_names == exp_prop_names + + +@then('the custom property is missing in the remaining list of custom properties') +def then_the_custom_property_is_missing_in_the_remaining_list_of_custom_properties(context): + custom_properties = context.document.custom_properties + prop_name = context.prop_name + assert prop_name is not None + assert custom_properties.lookup(prop_name) is None + assert prop_name not in [name for name in custom_properties] diff --git a/features/steps/test_files/doc-customprops.docx b/features/steps/test_files/doc-customprops.docx new file mode 100644 index 000000000..a3dc7a027 Binary files /dev/null and b/features/steps/test_files/doc-customprops.docx differ diff --git a/features/steps/test_files/doc-no-customprops.docx b/features/steps/test_files/doc-no-customprops.docx new file mode 100644 index 000000000..588bf557f Binary files /dev/null and b/features/steps/test_files/doc-no-customprops.docx differ diff --git a/src/docx/__init__.py b/src/docx/__init__.py index 205221027..3c0ef5db0 100644 --- a/src/docx/__init__.py +++ b/src/docx/__init__.py @@ -25,6 +25,7 @@ from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.part import PartFactory from docx.opc.parts.coreprops import CorePropertiesPart +from docx.opc.parts.customprops import CustomPropertiesPart from docx.parts.document import DocumentPart from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.image import ImagePart @@ -41,6 +42,7 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None: PartFactory.part_class_selector = part_class_selector PartFactory.part_type_for[CT.OPC_CORE_PROPERTIES] = CorePropertiesPart +PartFactory.part_type_for[CT.OPC_CUSTOM_PROPERTIES] = CustomPropertiesPart PartFactory.part_type_for[CT.WML_DOCUMENT_MAIN] = DocumentPart PartFactory.part_type_for[CT.WML_FOOTER] = FooterPart PartFactory.part_type_for[CT.WML_HEADER] = HeaderPart diff --git a/src/docx/document.py b/src/docx/document.py index 8944a0e50..d0b4ba548 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -112,6 +112,14 @@ def core_properties(self): """A |CoreProperties| object providing Dublin Core properties of document.""" return self._part.core_properties + @property + def custom_properties(self): + """ + A |CustomProperties| object providing read/write access to the custom + properties of this document. + """ + return self._part.custom_properties + @property def inline_shapes(self): """The |InlineShapes| collection for this document. diff --git a/src/docx/opc/constants.py b/src/docx/opc/constants.py index 89d3c16cc..892bd9dac 100644 --- a/src/docx/opc/constants.py +++ b/src/docx/opc/constants.py @@ -45,6 +45,7 @@ class CONTENT_TYPE: ) OFC_VML_DRAWING = "application/vnd.openxmlformats-officedocument.vmlDrawing" OPC_CORE_PROPERTIES = "application/vnd.openxmlformats-package.core-properties+xml" + OPC_CUSTOM_PROPERTIES = "application/vnd.openxmlformats-officedocument.custom-properties+xml" OPC_DIGITAL_SIGNATURE_CERTIFICATE = ( "application/vnd.openxmlformats-package.digital-signature-certificate" ) diff --git a/src/docx/opc/customprops.py b/src/docx/opc/customprops.py new file mode 100644 index 000000000..3b67ed82b --- /dev/null +++ b/src/docx/opc/customprops.py @@ -0,0 +1,80 @@ +# encoding: utf-8 + +""" +Support reading and writing custom properties to and from a .docx file. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import numbers +from lxml import etree +from docx.oxml.ns import nspfxmap, qn + + +class CustomProperties(object): + """ + Corresponds to part named ``/docProps/custom.xml``, containing the custom + document properties for this document package. + """ + def __init__(self, element): + self._element = element + + def __getitem__(self, item): + prop = self.lookup(item) + if prop is not None: + elm = prop[0] + if elm.tag == qn("vt:i4"): + try: + return int(elm.text) + except ValueError: + return elm.text + elif elm.tag == qn("vt:bool"): + return True if elm.text == '1' else False + return elm.text + + def __setitem__(self, key, value): + prop = self.lookup(key) + if prop is None: + elm_type = 'lpwstr' + if isinstance(value, bool): + elm_type = 'bool' + value = str(1 if value else 0) + elif isinstance(value, numbers.Number): + elm_type = 'i4' + value = str(int(value)) + prop = etree.SubElement(self._element, qn("op:property"), nsmap=nspfxmap("op")) + elm = etree.SubElement(prop, qn(f"vt:{elm_type}"), nsmap=nspfxmap("vt")) + elm.text = value + prop.set("name", key) + # magic number "FMTID_UserDefinedProperties" + # MS doc ref: https://learn.microsoft.com/de-de/windows/win32/stg/predefined-property-set-format-identifiers + prop.set("fmtid", "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}") + prop.set("pid", str(len(self._element) + 1)) + else: + elm = prop[0] + if elm.tag == qn("vt:i4"): + elm.text = str(int(value)) + elif elm.tag == qn("vt:bool"): + elm.text = str(1 if value else 0) + else: + elm.text = str(value) + + def __delitem__(self, key): + prop = self.lookup(key) + if prop is not None: + self._element.remove(prop) + + def __len__(self): + return len(self._element) + + def __iter__(self): + for child in self._element: + yield child.get("name") + + def lookup(self, item): + for child in self._element: + if child.get("name") == item: + return child + return None diff --git a/src/docx/opc/package.py b/src/docx/opc/package.py index 3b1eef256..047ca6f40 100644 --- a/src/docx/opc/package.py +++ b/src/docx/opc/package.py @@ -8,6 +8,7 @@ from docx.opc.packuri import PACKAGE_URI, PackURI from docx.opc.part import PartFactory from docx.opc.parts.coreprops import CorePropertiesPart +from docx.opc.parts.customprops import CustomPropertiesPart from docx.opc.pkgreader import PackageReader from docx.opc.pkgwriter import PackageWriter from docx.opc.rel import Relationships @@ -44,6 +45,14 @@ def core_properties(self) -> CoreProperties: properties for this document.""" return self._core_properties_part.core_properties + @property + def custom_properties(self): + """ + |CustomProperties| object providing read/write access to the + custom properties for this document. + """ + return self._custom_properties_part.custom_properties + def iter_rels(self) -> Iterator[_Relationship]: """Generate exactly one reference to each relationship in the package by performing a depth-first traversal of the rels graph.""" @@ -179,6 +188,19 @@ def _core_properties_part(self) -> CorePropertiesPart: self.relate_to(core_properties_part, RT.CORE_PROPERTIES) return core_properties_part + @property + def _custom_properties_part(self): + """ + |CustomPropertiesPart| object related to this package. Creates + a default custom properties part if one is not present (not common). + """ + try: + return self.part_related_by(RT.CUSTOM_PROPERTIES) + except KeyError: + custom_properties_part = CustomPropertiesPart.default(self) + self.relate_to(custom_properties_part, RT.CUSTOM_PROPERTIES) + return custom_properties_part + class Unmarshaller: """Hosts static methods for unmarshalling a package from a |PackageReader|.""" diff --git a/src/docx/opc/parts/customprops.py b/src/docx/opc/parts/customprops.py new file mode 100644 index 000000000..f0ec31669 --- /dev/null +++ b/src/docx/opc/parts/customprops.py @@ -0,0 +1,70 @@ +# encoding: utf-8 + +""" +Custom properties part, corresponds to ``/docProps/custom.xml`` part in package. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from lxml import etree + +from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.customprops import CustomProperties +from docx.oxml.customprops import CT_CustomProperties +from docx.opc.packuri import PackURI +from docx.opc.part import XmlPart + +# configure XML parser +parser_lookup = etree.ElementDefaultClassLookup(element=CT_CustomProperties) +ct_parser = etree.XMLParser(remove_blank_text=True) +ct_parser.set_element_class_lookup(parser_lookup) + + +def ct_parse_xml(xml): + """ + Return root lxml element obtained by parsing XML character string in + *xml*, which can be either a Python 2.x string or unicode. The custom + parser is used, so custom element classes are produced for elements in + *xml* that have them. + """ + root_element = etree.fromstring(xml, ct_parser) + return root_element + + +class CustomPropertiesPart(XmlPart): + """ + Corresponds to part named ``/docProps/custom.xml``, containing the custom + document properties for this document package. + """ + @classmethod + def default(cls, package): + """ + Return a new |CustomPropertiesPart| object initialized with default + values for its base properties. + """ + custom_properties_part = cls._new(package) + return custom_properties_part + + @property + def custom_properties(self): + """ + A |CustomProperties| object providing read/write access to the custom + properties contained in this custom properties part. + """ + return CustomProperties(self.element) + + @classmethod + def load(cls, partname, content_type, blob, package): + element = ct_parse_xml(blob) + return cls(partname, content_type, element, package) + + @classmethod + def _new(cls, package): + partname = PackURI('/docProps/custom.xml') + content_type = CT.OPC_CUSTOM_PROPERTIES + customProperties = CT_CustomProperties.new() + return CustomPropertiesPart( + partname, content_type, customProperties, package + ) diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index bf32932f9..5a6b18372 100644 --- a/src/docx/oxml/__init__.py +++ b/src/docx/oxml/__init__.py @@ -88,6 +88,10 @@ register_element_cls("cp:coreProperties", CT_CoreProperties) +from .customprops import CT_CustomProperties # noqa + +register_element_cls('op:Properties', CT_CustomProperties) + from .document import CT_Body, CT_Document # noqa register_element_cls("w:body", CT_Body) diff --git a/src/docx/oxml/customprops.py b/src/docx/oxml/customprops.py new file mode 100644 index 000000000..6f1605a59 --- /dev/null +++ b/src/docx/oxml/customprops.py @@ -0,0 +1,144 @@ +# encoding: utf-8 + +""" +lxml custom element classes for core properties-related XML elements. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from datetime import datetime, timedelta +import re + +from docx.oxml.ns import nsdecls, qn +from docx.oxml.xmlchemy import BaseOxmlElement +from docx.oxml import parse_xml + + +class CT_CustomProperties(BaseOxmlElement): + """ + ```` element, the root element of the Custom Properties + part stored as ``/docProps/custom.xml``. String elements are + limited in length to 255 unicode characters. + """ + _customProperties_tmpl = "\n" % nsdecls("op", "vt") + _offset_pattern = re.compile("([+-])(\\d\\d):(\\d\\d)") + + @classmethod + def new(cls): + """ + Return a new ```` element + """ + xml = cls._customProperties_tmpl + custom_properties = parse_xml(xml) + return custom_properties + + def _datetime_of_element(self, property_name): + element = getattr(self, property_name) + if element is None: + return None + datetime_str = element.text + try: + return self._parse_W3CDTF_to_datetime(datetime_str) + except ValueError: + # invalid datetime strings are ignored + return None + + def _get_or_add(self, prop_name): + """ + Return element returned by 'get_or_add_' method for *prop_name*. + """ + get_or_add_method_name = 'get_or_add_%s' % prop_name + get_or_add_method = getattr(self, get_or_add_method_name) + element = get_or_add_method() + return element + + @classmethod + def _offset_dt(cls, dt, offset_str): + """ + Return a |datetime| instance that is offset from datetime *dt* by + the timezone offset specified in *offset_str*, a string like + ``'-07:00'``. + """ + match = cls._offset_pattern.match(offset_str) + if match is None: + raise ValueError( + "'%s' is not a valid offset string" % offset_str + ) + sign, hours_str, minutes_str = match.groups() + sign_factor = -1 if sign == '+' else 1 + hours = int(hours_str) * sign_factor + minutes = int(minutes_str) * sign_factor + td = timedelta(hours=hours, minutes=minutes) + return dt + td + + @classmethod + def _parse_W3CDTF_to_datetime(cls, w3cdtf_str): + # valid W3CDTF date cases: + # yyyy e.g. '2003' + # yyyy-mm e.g. '2003-12' + # yyyy-mm-dd e.g. '2003-12-31' + # UTC timezone e.g. '2003-12-31T10:14:55Z' + # numeric timezone e.g. '2003-12-31T10:14:55-08:00' + templates = ( + '%Y-%m-%dT%H:%M:%S', + '%Y-%m-%d', + '%Y-%m', + '%Y', + ) + # strptime isn't smart enough to parse literal timezone offsets like + # '-07:30', so we have to do it ourselves + parseable_part = w3cdtf_str[:19] + offset_str = w3cdtf_str[19:] + dt = None + for tmpl in templates: + try: + dt = datetime.strptime(parseable_part, tmpl) + except ValueError: + continue + if dt is None: + raise ValueError("could not parse W3CDTF datetime string '%s'" % {w3cdtf_str}) + if len(offset_str) == 6: + return cls._offset_dt(dt, offset_str) + return dt + + def _set_element_datetime(self, prop_name, value): + """ + Set date/time value of child element having *prop_name* to *value*. + """ + if not isinstance(value, datetime): + raise ValueError("property requires object, got %s" % type(value)) + element = self._get_or_add(prop_name) + dt_str = value.strftime('%Y-%m-%dT%H:%M:%SZ') + element.text = dt_str + if prop_name in ('created', 'modified'): + # These two require an explicit 'xsi:type="dcterms:W3CDTF"' attribute. + # The first and last line are a hack required to add + # the xsi namespace to the root element rather than each child + # element in which it is referenced. + self.set(qn('xsi:foo'), 'bar') + element.set(qn('xsi:type'), 'dcterms:W3CDTF') + del self.attrib[qn('xsi:foo')] + + def _set_element_text(self, prop_name, value): + """ + Set string value of *name* property to *value*. + """ + value = str(value) + if len(value) > 255: + raise ValueError("exceeded 255 char limit for property, got:\n\n'%s'" % value) + element = self._get_or_add(prop_name) + element.text = value + + def _text_of_element(self, property_name): + """ + Return the text in the element matching *property_name*, or an empty + string if the element is not present or contains no text. + """ + element = getattr(self, property_name) + if element is None: + return '' + if element.text is None: + return '' + return element.text diff --git a/src/docx/oxml/ns.py b/src/docx/oxml/ns.py index 5bed1e6a0..89afe8a37 100644 --- a/src/docx/oxml/ns.py +++ b/src/docx/oxml/ns.py @@ -8,6 +8,7 @@ "a": "http://schemas.openxmlformats.org/drawingml/2006/main", "c": "http://schemas.openxmlformats.org/drawingml/2006/chart", "cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties", + "op": "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties", "dc": "http://purl.org/dc/elements/1.1/", "dcmitype": "http://purl.org/dc/dcmitype/", "dcterms": "http://purl.org/dc/terms/", @@ -16,6 +17,7 @@ "pic": "http://schemas.openxmlformats.org/drawingml/2006/picture", "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", "sl": "http://schemas.openxmlformats.org/schemaLibrary/2006/main", + "vt": "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes", "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "w14": "http://schemas.microsoft.com/office/word/2010/wordml", "wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index 416bb1a27..503a46cac 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -48,6 +48,14 @@ def core_properties(self) -> CoreProperties: of this document.""" return self.package.core_properties + @property + def custom_properties(self): + """ + A |CustomProperties| object providing read/write access to the custom + properties of this document. + """ + return self.package.custom_properties + @property def document(self): """A |Document| object providing access to the content of this document.""" diff --git a/tests/opc/parts/test_customprops.py b/tests/opc/parts/test_customprops.py new file mode 100644 index 000000000..5c37dbda1 --- /dev/null +++ b/tests/opc/parts/test_customprops.py @@ -0,0 +1,42 @@ +# encoding: utf-8 + +""" +Unit test suite for the docx.opc.parts.customprops module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from docx.opc.customprops import CustomProperties +from docx.opc.parts.customprops import CustomPropertiesPart +from docx.oxml.customprops import CT_CustomProperties + +from tests.unitutil.mock import class_mock, instance_mock + + +class DescribeCustomPropertiesPart(object): + + def it_provides_access_to_its_custom_props_object(self, element_, mock_custom_properties_): + custom_properties_part = CustomPropertiesPart(None, None, element_, None) + custom_properties = custom_properties_part.custom_properties + mock_custom_properties_.assert_called_once_with(custom_properties_part.element) + assert isinstance(custom_properties, CustomProperties) + + def it_can_create_a_default_custom_properties_part(self): + custom_properties_part = CustomPropertiesPart.default(None) + assert isinstance(custom_properties_part, CustomPropertiesPart) + custom_properties = custom_properties_part.custom_properties + assert len(custom_properties) == 0 + + # fixtures --------------------------------------------- + + @pytest.fixture + def mock_custom_properties_(self, request): + return class_mock(request, 'docx.opc.parts.customprops.CustomProperties') + + @pytest.fixture + def element_(self, request): + return instance_mock(request, CT_CustomProperties) diff --git a/tests/opc/test_customprops.py b/tests/opc/test_customprops.py new file mode 100644 index 000000000..a34dffdfd --- /dev/null +++ b/tests/opc/test_customprops.py @@ -0,0 +1,107 @@ +# encoding: utf-8 + +""" +Unit test suite for the docx.opc.customprops module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from docx.opc.customprops import CustomProperties +from docx.oxml import parse_xml + + +class DescribeCustomProperties(object): + + def it_can_read_existing_prop_values(self, prop_get_fixture): + custom_properties, prop_name, exp_value = prop_get_fixture + actual_value = custom_properties[prop_name] + assert actual_value == exp_value + + def it_can_change_existing_prop_values(self, custom_properties_default, prop_set_fixture): + _, prop_name, value, _ = prop_set_fixture + assert custom_properties_default[prop_name] != value + custom_properties_default[prop_name] = value + assert custom_properties_default[prop_name] == value + + def it_can_set_new_prop_values(self, prop_set_fixture): + custom_properties, prop_name, value, exp_xml = prop_set_fixture + custom_properties[prop_name] = value + assert custom_properties._element.xml == exp_xml + + def it_can_delete_existing_prop(self, prop_get_fixture): + custom_properties, prop_name, _ = prop_get_fixture + del custom_properties[prop_name] + assert custom_properties.lookup(prop_name) is None + + def it_can_iterate_existing_props(self, custom_properties_default): + exp_names = ['CustomPropBool', 'CustomPropInt', 'CustomPropString'] + + # check 1: as list + assert list(custom_properties_default) == ['CustomPropBool', 'CustomPropInt', 'CustomPropString'] + + # check 2: use iterator + exp_names_iter = iter(exp_names) + for prop_name in custom_properties_default: + assert prop_name == next(exp_names_iter) + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + ('CustomPropString', 'Test String'), + ('CustomPropBool', True), + ('CustomPropInt', 13), + ('CustomPropFoo', None), + ]) + def prop_get_fixture(self, request, custom_properties_default): + prop_name, expected_value = request.param + return custom_properties_default, prop_name, expected_value + + @pytest.fixture(params=[ + ('CustomPropString', 'lpwstr', 'Hi there!', 'Hi there!'), + ('CustomPropBool', 'bool', '0', False), + ('CustomPropInt', 'i4', '5', 5), + ]) + def prop_set_fixture(self, request, custom_properties_blank): + prop_name, str_type, str_value, value = request.param + expected_xml = self.build_custom_properties_xml(prop_name, str_type, str_value) + return custom_properties_blank, prop_name, value, expected_xml + + # fixture components --------------------------------------------- + + def build_custom_properties_xml(self, prop_name, str_type, str_value): + tmpl = ( + '\n' + ' \n' + ' %s\n' + ' \n' + '' + ) + return tmpl % (prop_name, str_type, str_value, str_type) + + @pytest.fixture + def custom_properties_blank(self): + element = parse_xml( + '' + '\n' + ) + return CustomProperties(element) + + @pytest.fixture + def custom_properties_default(self): + element = parse_xml( + b'\n' + b'\n' + b' 1\n' + b' 13\n' + b' Test String\n' + b'\n' + ) + return CustomProperties(element)