From e1174b7265f00a2af197a327867b8c7eeeef2ecb Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 25 Jul 2022 15:39:59 +0200 Subject: [PATCH 1/8] Add acceptance tests --- features/doc-customprops.feature | 28 ++++++ features/steps/customprops.py | 89 ++++++++++++++++++ .../steps/test_files/doc-customprops.docx | Bin 0 -> 7922 bytes .../steps/test_files/doc-no-customprops.docx | Bin 0 -> 11394 bytes 4 files changed, 117 insertions(+) create mode 100644 features/doc-customprops.feature create mode 100644 features/steps/customprops.py create mode 100644 features/steps/test_files/doc-customprops.docx create mode 100644 features/steps/test_files/doc-no-customprops.docx diff --git a/features/doc-customprops.feature b/features/doc-customprops.feature new file mode 100644 index 000000000..2dcec28a3 --- /dev/null +++ b/features/doc-customprops.feature @@ -0,0 +1,28 @@ +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 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 diff --git a/features/steps/customprops.py b/features/steps/customprops.py new file mode 100644 index 000000000..d3c06c9da --- /dev/null +++ b/features/steps/customprops.py @@ -0,0 +1,89 @@ +# 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')) + + +@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')) + + +# 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 + + +# 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): + document = context.document + custom_properties = document.custom_properties + assert isinstance(custom_properties, CustomProperties) + + +@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) + ) diff --git a/features/steps/test_files/doc-customprops.docx b/features/steps/test_files/doc-customprops.docx new file mode 100644 index 0000000000000000000000000000000000000000..a3dc7a027350607fc79dee5ef7aa29355665a7fc GIT binary patch literal 7922 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

>9!)DBQj43=}|5!4c+&rZJ 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 Date: Mon, 25 Jul 2022 15:44:10 +0200 Subject: [PATCH 2/8] Add support for custom properties --- src/docx/__init__.py | 2 + src/docx/document.py | 8 ++ src/docx/opc/constants.py | 1 + src/docx/opc/customprops.py | 70 +++++++++++++ src/docx/opc/package.py | 22 ++++ src/docx/opc/parts/customprops.py | 74 +++++++++++++ src/docx/oxml/__init__.py | 4 + src/docx/oxml/customprops.py | 155 ++++++++++++++++++++++++++++ src/docx/oxml/ns.py | 2 + src/docx/parts/document.py | 8 ++ tests/opc/parts/test_customprops.py | 51 +++++++++ tests/opc/test_customprops.py | 90 ++++++++++++++++ 12 files changed, 487 insertions(+) create mode 100644 src/docx/opc/customprops.py create mode 100644 src/docx/opc/parts/customprops.py create mode 100644 src/docx/oxml/customprops.py create mode 100644 tests/opc/parts/test_customprops.py create mode 100644 tests/opc/test_customprops.py diff --git a/src/docx/__init__.py b/src/docx/__init__.py index a518501a5..32d0390d7 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 07751f155..a88b06546 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -90,6 +90,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| collectoin 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..54c022538 --- /dev/null +++ b/src/docx/opc/customprops.py @@ -0,0 +1,70 @@ +# 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 + +NS_VT = "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes" + +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 : + # print(etree.tostring(prop, pretty_print=True)) + elm = prop[0] + if elm.tag == f"{{{NS_VT}}}i4": + try: + return int(elm.text) + except: + return elm.text + elif elm.tag == f"{{{NS_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 : + elmType = 'lpwstr' + if isinstance(value, bool): + elmType = 'bool' + value = str(1 if value else 0) + elif isinstance(value, numbers.Number): + elmType = 'i4' + value = str(int(value)) + prop = etree.SubElement( self._element, "property" ) + elm = etree.SubElement(prop, f"{{{NS_VT}}}{elmType}", nsmap = {'vt':NS_VT} ) + elm.text = value + prop.set("name", key) + prop.set("fmtid", "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}") + prop.set("pid", "%s" % str(len(self._element) + 1)) + else: + elm = prop[0] + if elm.tag == f"{{{NS_VT}}}i4": + elm.text = str(int(value)) + elif elm.tag == f"{{{NS_VT}}}bool": + elm.text = str(1 if value else 0) + else: + elm.text = str(value) + + def __len__( self ): + return len(self._element) + + def lookup(self, item): + for child in self._element : + if child.get("name") == item : + return child + return None \ No newline at end of file diff --git a/src/docx/opc/package.py b/src/docx/opc/package.py index b5bdc0e7c..eeacc31fb 100644 --- a/src/docx/opc/package.py +++ b/src/docx/opc/package.py @@ -4,6 +4,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 @@ -35,6 +36,14 @@ def core_properties(self): 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): """Generate exactly one reference to each relationship in the package by performing a depth-first traversal of the rels graph.""" @@ -163,6 +172,19 @@ def _core_properties_part(self): 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..e6ec3616d --- /dev/null +++ b/src/docx/opc/parts/customprops.py @@ -0,0 +1,74 @@ +# 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 datetime import datetime + +from ..constants import CONTENT_TYPE as CT +from ..customprops import CustomProperties +from ...oxml.customprops import CT_CustomProperties +from ..packuri import PackURI +from ..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) + custom_properties = custom_properties_part.custom_properties + 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 621ef279a..a51c3dc4a 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('cup: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..c8a941c59 --- /dev/null +++ b/src/docx/oxml/customprops.py @@ -0,0 +1,155 @@ +# encoding: utf-8 + +""" +lxml custom element classes for core properties-related XML elements. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import re + +from datetime import datetime, timedelta +from lxml import etree +from .ns import nsdecls, qn +from .xmlchemy import BaseOxmlElement, ZeroOrOne +from . 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("cup", "vt") + + @classmethod + def new(cls): + """ + Return a new ```` element + """ + xml = cls._customProperties_tmpl + customProperties = parse_xml(xml) + return customProperties + + 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 + + _offset_pattern = re.compile('([+-])(\d\d):(\d\d)') + + @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: + tmpl = "could not parse W3CDTF datetime string '%s'" + raise ValueError(tmpl % 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): + tmpl = ( + "property requires object, got %s" + ) + raise ValueError(tmpl % 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: + tmpl = ( + "exceeded 255 char limit for property, got:\n\n'%s'" + ) + raise ValueError(tmpl % 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 3238864e9..fcb6e7f16 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", + "cup": "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 a157764b9..6c8852c4e 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -46,6 +46,14 @@ def core_properties(self): 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..bfd42ecc9 --- /dev/null +++ b/tests/opc/parts/test_customprops.py @@ -0,0 +1,51 @@ +# encoding: utf-8 + +""" +Unit test suite for the docx.opc.parts.customprops module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from datetime import datetime, timedelta + +import pytest + +from docx.opc.customprops import CustomProperties +from docx.opc.parts.customprops import CustomPropertiesPart +from docx.oxml.customprops import CT_CustomProperties + +from ...unitutil.mock import class_mock, instance_mock + + +class DescribeCustomPropertiesPart(object): + + def it_provides_access_to_its_custom_props_object(self, customprops_fixture): + custom_properties_part, CustomProperties_ = customprops_fixture + custom_properties = custom_properties_part.custom_properties + CustomProperties_.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 customprops_fixture(self, element_, CustomProperties_): + custom_properties_part = CustomPropertiesPart(None, None, element_, None) + return custom_properties_part, CustomProperties_ + + # fixture components ----------------------------------- + + @pytest.fixture + def CustomProperties_(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..8f3482d01 --- /dev/null +++ b/tests/opc/test_customprops.py @@ -0,0 +1,90 @@ +# 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 datetime import datetime + +from docx.opc.customprops import CustomProperties +from docx.oxml.customprops import CT_CustomProperties +from docx.oxml import parse_xml +from lxml import etree + +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): + pass + + 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 + + # 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.customProperties(prop_name, str_type, str_value) + return custom_properties_blank, prop_name, value, expected_xml + + # fixture components --------------------------------------------- + + def customProperties(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) From 9a7d78dbee23413af5ff480c95a78333e0d1d253 Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 29 Sep 2022 10:12:58 +0200 Subject: [PATCH 3/8] Resolve code smell issues --- src/docx/opc/customprops.py | 34 +++++++++++++------------- src/docx/opc/parts/customprops.py | 13 ++++------ src/docx/oxml/customprops.py | 37 +++++++++++------------------ tests/opc/parts/test_customprops.py | 19 ++++----------- 4 files changed, 42 insertions(+), 61 deletions(-) diff --git a/src/docx/opc/customprops.py b/src/docx/opc/customprops.py index 54c022538..eeacd37ca 100644 --- a/src/docx/opc/customprops.py +++ b/src/docx/opc/customprops.py @@ -13,6 +13,7 @@ NS_VT = "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes" + class CustomProperties(object): """ Corresponds to part named ``/docProps/custom.xml``, containing the custom @@ -21,36 +22,37 @@ class CustomProperties(object): def __init__(self, element): self._element = element - def __getitem__( self, item ): + def __getitem__(self, item): prop = self.lookup(item) - if prop is not None : - # print(etree.tostring(prop, pretty_print=True)) + if prop is not None: elm = prop[0] if elm.tag == f"{{{NS_VT}}}i4": try: return int(elm.text) - except: + except ValueError: return elm.text elif elm.tag == f"{{{NS_VT}}}bool": return True if elm.text == '1' else False return elm.text - def __setitem__( self, key, value ): + def __setitem__(self, key, value): prop = self.lookup(key) - if prop is None : - elmType = 'lpwstr' + if prop is None: + elm_type = 'lpwstr' if isinstance(value, bool): - elmType = 'bool' + elm_type = 'bool' value = str(1 if value else 0) elif isinstance(value, numbers.Number): - elmType = 'i4' + elm_type = 'i4' value = str(int(value)) - prop = etree.SubElement( self._element, "property" ) - elm = etree.SubElement(prop, f"{{{NS_VT}}}{elmType}", nsmap = {'vt':NS_VT} ) + prop = etree.SubElement(self._element, "property") + elm = etree.SubElement(prop, f"{{{NS_VT}}}{elm_type}", nsmap={'vt':NS_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", "%s" % str(len(self._element) + 1)) + prop.set("pid", str(len(self._element) + 1)) else: elm = prop[0] if elm.tag == f"{{{NS_VT}}}i4": @@ -60,11 +62,11 @@ def __setitem__( self, key, value ): else: elm.text = str(value) - def __len__( self ): + def __len__(self): return len(self._element) def lookup(self, item): - for child in self._element : - if child.get("name") == item : + for child in self._element: + if child.get("name") == item: return child - return None \ No newline at end of file + return None diff --git a/src/docx/opc/parts/customprops.py b/src/docx/opc/parts/customprops.py index e6ec3616d..d71cc2979 100644 --- a/src/docx/opc/parts/customprops.py +++ b/src/docx/opc/parts/customprops.py @@ -10,13 +10,11 @@ from lxml import etree -from datetime import datetime - -from ..constants import CONTENT_TYPE as CT -from ..customprops import CustomProperties -from ...oxml.customprops import CT_CustomProperties -from ..packuri import PackURI -from ..part import XmlPart +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) @@ -35,7 +33,6 @@ def ct_parse_xml(xml): return root_element - class CustomPropertiesPart(XmlPart): """ Corresponds to part named ``/docProps/custom.xml``, containing the custom diff --git a/src/docx/oxml/customprops.py b/src/docx/oxml/customprops.py index c8a941c59..506645289 100644 --- a/src/docx/oxml/customprops.py +++ b/src/docx/oxml/customprops.py @@ -8,13 +8,13 @@ absolute_import, division, print_function, unicode_literals ) +from datetime import datetime, timedelta import re -from datetime import datetime, timedelta -from lxml import etree -from .ns import nsdecls, qn -from .xmlchemy import BaseOxmlElement, ZeroOrOne -from . import parse_xml +from docx.oxml.ns import nsdecls, qn +from docx.oxml.xmlchemy import BaseOxmlElement +from docx.oxml import parse_xml + class CT_CustomProperties(BaseOxmlElement): @@ -25,6 +25,7 @@ class CT_CustomProperties(BaseOxmlElement): """ _customProperties_tmpl = "\n" % nsdecls("cup", "vt") + _offset_pattern = re.compile("([+-])(\\d\\d):(\\d\\d)") @classmethod def new(cls): @@ -32,8 +33,8 @@ def new(cls): Return a new ```` element """ xml = cls._customProperties_tmpl - customProperties = parse_xml(xml) - return customProperties + custom_properties = parse_xml(xml) + return custom_properties def _datetime_of_element(self, property_name): element = getattr(self, property_name) @@ -74,8 +75,6 @@ def _offset_dt(cls, dt, offset_str): td = timedelta(hours=hours, minutes=minutes) return dt + td - _offset_pattern = re.compile('([+-])(\d\d):(\d\d)') - @classmethod def _parse_W3CDTF_to_datetime(cls, w3cdtf_str): # valid W3CDTF date cases: @@ -101,8 +100,7 @@ def _parse_W3CDTF_to_datetime(cls, w3cdtf_str): except ValueError: continue if dt is None: - tmpl = "could not parse W3CDTF datetime string '%s'" - raise ValueError(tmpl % w3cdtf_str) + 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 @@ -112,18 +110,15 @@ 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): - tmpl = ( - "property requires object, got %s" - ) - raise ValueError(tmpl % type(value)) + 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 + # 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 + # 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')] @@ -134,10 +129,7 @@ def _set_element_text(self, prop_name, value): """ value = str(value) if len(value) > 255: - tmpl = ( - "exceeded 255 char limit for property, got:\n\n'%s'" - ) - raise ValueError(tmpl % value) + raise ValueError("exceeded 255 char limit for property, got:\n\n'%s'" % value) element = self._get_or_add(prop_name) element.text = value @@ -152,4 +144,3 @@ def _text_of_element(self, property_name): if element.text is None: return '' return element.text - diff --git a/tests/opc/parts/test_customprops.py b/tests/opc/parts/test_customprops.py index bfd42ecc9..5c37dbda1 100644 --- a/tests/opc/parts/test_customprops.py +++ b/tests/opc/parts/test_customprops.py @@ -8,23 +8,21 @@ absolute_import, division, print_function, unicode_literals ) -from datetime import datetime, timedelta - import pytest from docx.opc.customprops import CustomProperties from docx.opc.parts.customprops import CustomPropertiesPart from docx.oxml.customprops import CT_CustomProperties -from ...unitutil.mock import class_mock, instance_mock +from tests.unitutil.mock import class_mock, instance_mock class DescribeCustomPropertiesPart(object): - def it_provides_access_to_its_custom_props_object(self, customprops_fixture): - custom_properties_part, CustomProperties_ = customprops_fixture + 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 - CustomProperties_.assert_called_once_with(custom_properties_part.element) + 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): @@ -36,14 +34,7 @@ def it_can_create_a_default_custom_properties_part(self): # fixtures --------------------------------------------- @pytest.fixture - def customprops_fixture(self, element_, CustomProperties_): - custom_properties_part = CustomPropertiesPart(None, None, element_, None) - return custom_properties_part, CustomProperties_ - - # fixture components ----------------------------------- - - @pytest.fixture - def CustomProperties_(self, request): + def mock_custom_properties_(self, request): return class_mock(request, 'docx.opc.parts.customprops.CustomProperties') @pytest.fixture From 770b8a25a28e2b33366bed5c3e8b62d59c1a43b4 Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 29 Sep 2022 10:23:05 +0200 Subject: [PATCH 4/8] Remove unused variable --- src/docx/opc/parts/customprops.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/docx/opc/parts/customprops.py b/src/docx/opc/parts/customprops.py index d71cc2979..f0ec31669 100644 --- a/src/docx/opc/parts/customprops.py +++ b/src/docx/opc/parts/customprops.py @@ -45,7 +45,6 @@ def default(cls, package): values for its base properties. """ custom_properties_part = cls._new(package) - custom_properties = custom_properties_part.custom_properties return custom_properties_part @property From 96c4b9bc9bf4ce58cd937e8b7b69d665e7f725db Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 29 Sep 2022 10:31:19 +0200 Subject: [PATCH 5/8] Code cleanup in test code --- tests/opc/test_customprops.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/opc/test_customprops.py b/tests/opc/test_customprops.py index 8f3482d01..8bdbf77f9 100644 --- a/tests/opc/test_customprops.py +++ b/tests/opc/test_customprops.py @@ -10,12 +10,9 @@ import pytest -from datetime import datetime - from docx.opc.customprops import CustomProperties -from docx.oxml.customprops import CT_CustomProperties from docx.oxml import parse_xml -from lxml import etree + class DescribeCustomProperties(object): @@ -51,12 +48,12 @@ def prop_get_fixture(self, request, custom_properties_default): ]) def prop_set_fixture(self, request, custom_properties_blank): prop_name, str_type, str_value, value = request.param - expected_xml = self.customProperties(prop_name, str_type, str_value) + 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 customProperties(self, prop_name, str_type, str_value): + def build_custom_properties_xml(self, prop_name, str_type, str_value): tmpl = ( '\n' @@ -65,7 +62,7 @@ def customProperties(self, prop_name, str_type, str_value): ' \n' '' ) - return tmpl %(prop_name, str_type, str_value, str_type) + return tmpl % (prop_name, str_type, str_value, str_type) @pytest.fixture def custom_properties_blank(self): From 52b348b765634d9c4ad5c946bf33fdd8f7f074c1 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 15 Nov 2022 08:19:07 +0100 Subject: [PATCH 6/8] custProps: allow iteration on custom proeprties --- features/doc-customprops.feature | 4 ++++ features/steps/customprops.py | 9 ++++++++- .../test_files/.~lock.doc-customprops.docx# | 1 + src/docx/opc/customprops.py | 4 ++++ tests/opc/test_customprops.py | 18 ++++++++++++++++-- 5 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 features/steps/test_files/.~lock.doc-customprops.docx# diff --git a/features/doc-customprops.feature b/features/doc-customprops.feature index 2dcec28a3..8ffd4026a 100644 --- a/features/doc-customprops.feature +++ b/features/doc-customprops.feature @@ -26,3 +26,7 @@ Feature: Read and write custom document properties 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 diff --git a/features/steps/customprops.py b/features/steps/customprops.py index d3c06c9da..b75978e94 100644 --- a/features/steps/customprops.py +++ b/features/steps/customprops.py @@ -78,7 +78,6 @@ def then_the_custom_property_values_match_the_known_values(context): "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 @@ -87,3 +86,11 @@ def then_the_custom_property_values_match_the_new_values(context): 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): + exp_names = iter(['AppVersion', 'CustomPropBool', 'CustomPropInt', 'CustomPropString', + 'DocSecurity', 'HyperlinksChanged', 'LinksUpToDate', 'ScaleCrop', 'ShareDoc']) + custom_properties = context.document.custom_properties + for prop_name in custom_properties: + assert prop_name == next(exp_names) diff --git a/features/steps/test_files/.~lock.doc-customprops.docx# b/features/steps/test_files/.~lock.doc-customprops.docx# new file mode 100644 index 000000000..fc206971c --- /dev/null +++ b/features/steps/test_files/.~lock.doc-customprops.docx# @@ -0,0 +1 @@ +,mko,haendel.local,15.11.2022 08:06,file:///Users/mko/Library/Application%20Support/LibreOffice/4; \ No newline at end of file diff --git a/src/docx/opc/customprops.py b/src/docx/opc/customprops.py index eeacd37ca..0687feb3c 100644 --- a/src/docx/opc/customprops.py +++ b/src/docx/opc/customprops.py @@ -65,6 +65,10 @@ def __setitem__(self, key, value): 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: diff --git a/tests/opc/test_customprops.py b/tests/opc/test_customprops.py index 8bdbf77f9..90500c00e 100644 --- a/tests/opc/test_customprops.py +++ b/tests/opc/test_customprops.py @@ -21,14 +21,28 @@ def it_can_read_existing_prop_values(self, prop_get_fixture): actual_value = custom_properties[prop_name] assert actual_value == exp_value - def it_can_change_existing_prop_values(self): - pass + 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_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=[ From 22451195dca8227d088ff7ab393a3606bb89e4ea Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 2 Dec 2022 14:28:52 +0100 Subject: [PATCH 7/8] custom_properties: Allow removal of existing properties --- features/doc-customprops.feature | 8 ++++ features/steps/customprops.py | 41 ++++++++++++++++--- .../test_files/.~lock.doc-customprops.docx# | 2 +- src/docx/opc/customprops.py | 5 +++ tests/opc/test_customprops.py | 5 +++ 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/features/doc-customprops.feature b/features/doc-customprops.feature index 8ffd4026a..e2dbfaf10 100644 --- a/features/doc-customprops.feature +++ b/features/doc-customprops.feature @@ -7,6 +7,7 @@ Feature: Read and write custom document properties 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 @@ -27,6 +28,13 @@ Feature: Read and write custom document properties 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 index b75978e94..6b9ff883f 100644 --- a/features/steps/customprops.py +++ b/features/steps/customprops.py @@ -23,11 +23,16 @@ @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 ==================================================== @@ -49,6 +54,13 @@ def when_I_assign_new_values_to_the_custom_properties(context): 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') @@ -59,11 +71,18 @@ def then_a_custom_properties_part_with_no_values_is_added(context): @then('I can access the custom properties object') def then_I_can_access_the_custom_properties_object(context): - document = context.document - custom_properties = document.custom_properties + 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 = ( @@ -78,6 +97,7 @@ def then_the_custom_property_values_match_the_known_values(context): "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 @@ -87,10 +107,19 @@ def then_the_custom_property_values_match_the_new_values(context): "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): - exp_names = iter(['AppVersion', 'CustomPropBool', 'CustomPropInt', 'CustomPropString', - 'DocSecurity', 'HyperlinksChanged', 'LinksUpToDate', 'ScaleCrop', 'ShareDoc']) custom_properties = context.document.custom_properties - for prop_name in custom_properties: - assert prop_name == next(exp_names) + 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/.~lock.doc-customprops.docx# b/features/steps/test_files/.~lock.doc-customprops.docx# index fc206971c..9def5b66a 100644 --- a/features/steps/test_files/.~lock.doc-customprops.docx# +++ b/features/steps/test_files/.~lock.doc-customprops.docx# @@ -1 +1 @@ -,mko,haendel.local,15.11.2022 08:06,file:///Users/mko/Library/Application%20Support/LibreOffice/4; \ No newline at end of file +,mko,haendel.local,02.12.2022 13:37,file:///Users/mko/Library/Application%20Support/LibreOffice/4; \ No newline at end of file diff --git a/src/docx/opc/customprops.py b/src/docx/opc/customprops.py index 0687feb3c..83eea5844 100644 --- a/src/docx/opc/customprops.py +++ b/src/docx/opc/customprops.py @@ -62,6 +62,11 @@ def __setitem__(self, key, value): 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) diff --git a/tests/opc/test_customprops.py b/tests/opc/test_customprops.py index 90500c00e..0fd33f240 100644 --- a/tests/opc/test_customprops.py +++ b/tests/opc/test_customprops.py @@ -32,6 +32,11 @@ def it_can_set_new_prop_values(self, 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'] From 24086982e797c667d64d031753e56f6ad30d765a Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 23 Oct 2023 13:51:16 +0200 Subject: [PATCH 8/8] features/steps/test_files: remove obsolete lock file --- features/steps/test_files/.~lock.doc-customprops.docx# | 1 - 1 file changed, 1 deletion(-) delete mode 100644 features/steps/test_files/.~lock.doc-customprops.docx# diff --git a/features/steps/test_files/.~lock.doc-customprops.docx# b/features/steps/test_files/.~lock.doc-customprops.docx# deleted file mode 100644 index 9def5b66a..000000000 --- a/features/steps/test_files/.~lock.doc-customprops.docx# +++ /dev/null @@ -1 +0,0 @@ -,mko,haendel.local,02.12.2022 13:37,file:///Users/mko/Library/Application%20Support/LibreOffice/4; \ No newline at end of file