pax_global_header00006660000000000000000000000064147116652230014521gustar00rootroot0000000000000052 comment=654f07ae08c1c5e9bfe700abcdaed258d1fcc059 python-didl-lite-1.4.1/000077500000000000000000000000001471166522300147125ustar00rootroot00000000000000python-didl-lite-1.4.1/.coveragerc000066400000000000000000000000241471166522300170270ustar00rootroot00000000000000[run] branch = True python-didl-lite-1.4.1/.github/000077500000000000000000000000001471166522300162525ustar00rootroot00000000000000python-didl-lite-1.4.1/.github/workflows/000077500000000000000000000000001471166522300203075ustar00rootroot00000000000000python-didl-lite-1.4.1/.github/workflows/ci-cd.yml000066400000000000000000000072261471166522300220200ustar00rootroot00000000000000name: Build on: - push - pull_request env: publish-python-version: 3.12 jobs: markdown_lint: name: Markdown Lint runs-on: ubuntu-latest steps: - uses: DavidAnson/markdownlint-cli2-action@v16 with: globs: "**/*.md" lint_test_build: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install --upgrade build python -m pip install --upgrade tox tox-gh-actions - name: Test with tox run: tox - name: Build package run: python -m build - name: Upload build artifacts uses: actions/upload-artifact@v4 with: name: python-package-distributions-${{ matrix.python-version }} path: dist/ - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v3 if: ${{ hashFiles('coverage-py312.xml') != '' }} with: files: coverage-py312.xml env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} publish-to-pypi: name: Publish to PyPI runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') needs: - markdown_lint - lint_test_build environment: name: pypi url: https://pypi.org/p/python-didl-lite permissions: id-token: write steps: - name: Download all the dists uses: actions/download-artifact@v4 with: name: python-package-distributions-${{ env.publish-python-version }} path: dist/ - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 github-release: needs: - publish-to-pypi runs-on: ubuntu-latest permissions: contents: write id-token: write steps: - name: Download all the dists uses: actions/download-artifact@v4 with: name: python-package-distributions-${{ env.publish-python-version }} path: dist/ - name: Sign the dists with Sigstore uses: sigstore/gh-action-sigstore-python@v2.1.1 with: inputs: >- ./dist/*.tar.gz ./dist/*.whl - name: Create GitHub Release env: GITHUB_TOKEN: ${{ github.token }} run: >- gh release create '${{ github.ref_name }}' --repo '${{ github.repository }}' --notes "" - name: Upload artifact signatures to GitHub Release env: GITHUB_TOKEN: ${{ github.token }} run: >- gh release upload '${{ github.ref_name }}' dist/** --repo '${{ github.repository }}' publish-to-testpypi: name: Publish to TestPyPI runs-on: ubuntu-latest if: github.repository == 'StevenLooman/python-didl-lite' needs: - markdown_lint - lint_test_build environment: name: testpypi url: https://test.pypi.org/p/python-didl-lite permissions: id-token: write steps: - name: Download all the dists uses: actions/download-artifact@v4 with: name: python-package-distributions-${{ env.publish-python-version }} path: dist/ - name: Publish to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ continue-on-error: true python-didl-lite-1.4.1/.gitignore000066400000000000000000000001761471166522300167060ustar00rootroot00000000000000*.pyc *.egg* .cache/ .coverage .idea/ .mypy_cache/ .pytest_cache/ .tox/ coverage*.xml .venv/ __pycache__ build/ dist/ cov.xml python-didl-lite-1.4.1/.pre-commit-config.yaml000066400000000000000000000023711471166522300211760ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: 'v2.3.0' hooks: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/psf/black rev: '21.7b0' hooks: - id: black args: - --safe - --quiet files: ^(async_upnp_client|tests)/.+\.py$ - repo: https://github.com/codespell-project/codespell rev: 'v2.1.0' hooks: - id: codespell args: - --skip="./.*,*.csv,*.json" - --quiet-level=2 exclude_types: [csv, json] exclude: ^tests/fixtures/ - repo: https://gitlab.com/pycqa/flake8 rev: '3.9.2' hooks: - id: flake8 additional_dependencies: - flake8-docstrings==1.6.0 - flake8-noqa==1.1.0 - pydocstyle==6.1.1 files: ^(async_upnp_client|tests)/.+\.py$ - repo: https://github.com/PyCQA/isort rev: '5.9.2' hooks: - id: isort args: - --profile=black - repo: https://github.com/pre-commit/mirrors-mypy rev: 'v0.910' hooks: - id: mypy args: [--ignore-missing-imports] additional_dependencies: - aiohttp>=3.7.4 - pytest~=6.2.4 files: ^(async_upnp_client|tests)/.+\.py$ python-didl-lite-1.4.1/.travis.yml000066400000000000000000000007501471166522300170250ustar00rootroot00000000000000language: python os: linux dist: focal jobs: fast_finish: true include: - python: "3.6" env: TOXENV=py36 - python: "3.7" env: TOXENV=py37 - python: "3.8" env: TOXENV=py38 - python: "3.9" env: TOXENV=py39 - python: "3.9" env: TOXENV=flake8 - python: "3.9" env: TOXENV=pylint - python: "3.9" env: TOXENV=typing - python: "3.9" env: TOXENV=black install: pip install -U tox script: tox services: - docker python-didl-lite-1.4.1/CHANGES.rst000066400000000000000000000034541471166522300165220ustar00rootroot00000000000000Changes ======= 1.4.1 (2024-11-03) - Cater for malformed XML from WiiM pro in non-strict mode (#14, @pp81381) - Add Python 3.13 support 1.4.0 (2023-12-16) - Treat upnp:class as case-insensitive when in non-strict mode (@chishm) - Raise a `DidlLiteException` when upnp:class is invalid when in strict mode (@chishm) - Drop Python3.6 and Python3.7 support, add Python3.10, Python3.11, Python3.12 support - Bump development dependencies 1.3.2 (2021-11-29) - Annotate DidlObject properties that mypy doesn't see in __init__ (@chishm) 1.3.1 (2021-10-25) - Class properties from ContentDirectory:4 (@chishm) 1.3.0 (2021-10-07) - `DidlObject.resources` is now deprecated, use `DidlObject.res` instead - Rename `utils.ns_tag` to `utils.expand_namespace_tag` - Rename `utils.namespace_tag` to `utils.split_namespace_tag` - Add `__repr__` methods to DIDL classes for easier debugging - Allow camelCase to get and set DIDL object properties (@chishm) 1.2.6 (2021-03-04) - Add non-strict option misbehaving devices 1.2.5 (2020-09-12) - Save xml element for addition information 1.2.4 (2019-03-20) - Better namespace naming 1.2.3 (2019-01-27) - Fix infinite recursion error in DidlObject.__getattr__/Resource.__getattr__ 1.2.2 (2019-01-26) - Add `py.typed` to support PEP 561 - MyPy understands DidlObject/Descriptor have dynamic properties - Move utility methods to utils 1.2.1 (2019-01-20) - Typing fixes (@scop) - Skip unknown object types on parse (@scop) - Use defusedxml to parse XML (@scop) 1.2.0 (2018-11-03) - Typing fixes (@scop) - Allow unknown properties to be parsed and stored, such as albumArtURI on Items, as used by Kodi 1.1.0 (2018-08-17) - Always set properties, even if no value was given 1.0.1 (2018-06-29) - Use default ("") for id and parent_id 1.0.0 (2018-06-29) - Initial release python-didl-lite-1.4.1/LICENSE.md000066400000000000000000000243601471166522300163230ustar00rootroot00000000000000Apache License ============== _Version 2.0, January 2004_ _<>_ ### Terms and Conditions for use, reproduction, and distribution #### 1. Definitions “License” shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. “Licensor” shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. “Legal Entity” shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, “control” means **(i)** the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the outstanding shares, or **(iii)** beneficial ownership of such entity. “You” (or “Your”) shall mean an individual or Legal Entity exercising permissions granted by this License. “Source” form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. “Object” form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. “Work” shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). “Derivative Works” shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. “Contribution” shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, “submitted” means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as “Not a Contribution.” “Contributor” shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. #### 2. Grant of Copyright License Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. #### 3. Grant of Patent License Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. #### 4. Redistribution You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: * **(a)** You must give any other recipients of the Work or Derivative Works a copy of this License; and * **(b)** You must cause any modified files to carry prominent notices stating that You changed the files; and * **(c)** You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and * **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. #### 5. Submission of Contributions Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. #### 6. Trademarks This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. #### 7. Disclaimer of Warranty Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. #### 8. Limitation of Liability In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. #### 9. Accepting Warranty or Additional Liability While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. _END OF TERMS AND CONDITIONS_ ### APPENDIX: How to apply the Apache License to your work To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets `[]` replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same “printed page” as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. python-didl-lite-1.4.1/README.rst000066400000000000000000000014721471166522300164050ustar00rootroot00000000000000DIDL-Lite (Digital Item Declaration Language) tools for Python ============================================================== DIDL-Lite tools for Python to read and write DIDL-Lite-xml. Usage ----- See ``tests/`` for examples on how to use this. Note that this package does **NOT** do any type checking. Coercion of data types from and to strings must be done by the user of this library. Resources / documents --------------------- DIDL-Lite resources and documents: * `UPnP-av-ContentDirectory-v1-Service `_ * `UPnP-av-ContentDirectory-v2-Service `_ * `didl-lite-v2.xsd `_ * `mpeg21-didl `_ python-didl-lite-1.4.1/didl_lite/000077500000000000000000000000001471166522300166435ustar00rootroot00000000000000python-didl-lite-1.4.1/didl_lite/__init__.py000066400000000000000000000002151471166522300207520ustar00rootroot00000000000000# -*- coding: utf-8 -*- """DIDL-Lite (Digital Item Declaration Language) tools for Python.""" from didl_lite import didl_lite # noqa: F401 python-didl-lite-1.4.1/didl_lite/didl_lite.py000066400000000000000000001036401471166522300211520ustar00rootroot00000000000000# -*- coding: utf-8 -*- """DIDL-Lite (Digital Item Declaration Language) tools for Python.""" # pylint: disable=too-many-lines from typing import ( Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Type, TypeVar, Union, ) from xml.etree import ElementTree as ET import defusedxml.ElementTree from .utils import ( NAMESPACES, didl_property_def_key, didl_property_key, expand_namespace_tag, split_namespace_tag, to_camel_case, ) TDO = TypeVar("TDO", bound="DidlObject") # pylint: disable=invalid-name TC = TypeVar("TC", bound="Container") # pylint: disable=invalid-name TD = TypeVar("TD", bound="Descriptor") # pylint: disable=invalid-name TR = TypeVar("TR", bound="Resource") # pylint: disable=invalid-name class DidlLiteException(Exception): """DIDL Lite Exception.""" # region: DidlObjects # upnp_class to python type mapping _upnp_class_map: Dict[str, Type["DidlObject"]] = {} _upnp_class_map_lowercase: Dict[str, Type["DidlObject"]] = {} class DidlObject: """DIDL Object.""" tag: Optional[str] = None upnp_class: str = "object" didl_properties_defs: List[Tuple[str, str, str]] = [ ("didl_lite", "@id", "R"), ("didl_lite", "@parentID", "R"), ("didl_lite", "@restricted", "R"), ("dc", "title", "R"), ("upnp", "class", "R"), ("dc", "creator", "O"), ("didl_lite", "res", "O"), ("upnp", "writeStatus", "O"), ] id: str parent_id: str res: List["Resource"] xml_el: Optional[ET.Element] descriptors: Sequence["Descriptor"] @classmethod def __init_subclass__(cls: Type["DidlObject"], **kwargs: Any) -> None: """Create mapping of upnp_class to Python type for fast lookup.""" super().__init_subclass__(**kwargs) assert cls.upnp_class not in _upnp_class_map assert cls.upnp_class.lower() not in _upnp_class_map_lowercase _upnp_class_map[cls.upnp_class] = cls _upnp_class_map_lowercase[cls.upnp_class.lower()] = cls def __init__( self, id: str = "", parent_id: str = "", descriptors: Optional[Sequence["Descriptor"]] = None, xml_el: Optional[ET.Element] = None, strict: bool = True, **properties: Any, ) -> None: """Initialize.""" # pylint: disable=invalid-name,redefined-builtin,too-many-arguments properties["id"] = id properties["parent_id"] = parent_id properties["class"] = self.upnp_class properties["res"] = properties.get("res") or properties.get("resources") or [] if "resources" in properties: del properties["resources"] self._ensure_required_properties(strict, properties) self._set_property_defaults() self._set_properties(properties) self.xml_el = xml_el self.descriptors = descriptors if descriptors else [] def _ensure_required_properties( self, strict: bool, properties: Mapping[str, Any] ) -> None: """Check if all required properties are given.""" if not strict: return python_property_keys = {didl_property_key(key) for key in properties} for property_def in self.didl_properties_defs: key = didl_property_def_key(property_def) if property_def[2] == "R" and key not in python_property_keys: raise DidlLiteException(key + " is mandatory") def _set_property_defaults(self) -> None: """Ensure we have default/known slots, and set them all to None.""" for property_def in self.didl_properties_defs: key = didl_property_def_key(property_def) setattr(self, key, None) def _set_properties(self, properties: Mapping[str, Any]) -> None: """Set attributes from properties.""" for key, value in properties.items(): setattr(self, key, value) @classmethod def from_xml(cls: Type[TDO], xml_el: ET.Element, strict: bool = True) -> TDO: """ Initialize from an XML node. I.e., parse XML and return instance. """ # pylint: disable=too-many-locals properties = {} # type: Dict[str, Any] # attributes for attr_key, attr_value in xml_el.attrib.items(): key = to_camel_case(attr_key) properties[key] = attr_value # child-nodes for xml_child_node in xml_el: if xml_child_node.tag == expand_namespace_tag("didl_lite:res"): continue _, tag = split_namespace_tag(xml_child_node.tag) key = to_camel_case(tag) value = xml_child_node.text properties[key] = value # attributes of child nodes parent_key = key for attr_key, attr_value in xml_child_node.attrib.items(): key = parent_key + "_" + to_camel_case(attr_key) properties[key] = attr_value # resources resources = [] for res_el in xml_el.findall("./didl_lite:res", NAMESPACES): resource = Resource.from_xml(res_el) resources.append(resource) properties["res"] = properties["resources"] = resources # descriptors descriptors = [] for desc_el in xml_el.findall("./didl_lite:desc", NAMESPACES): descriptor = Descriptor.from_xml(desc_el) descriptors.append(descriptor) return cls(xml_el=xml_el, descriptors=descriptors, strict=strict, **properties) def to_xml(self) -> ET.Element: """Convert self to XML Element.""" assert self.tag is not None item_el = ET.Element(self.tag) elements = {"": item_el} # properties for property_def in self.didl_properties_defs: if "@" in property_def[1]: continue key = didl_property_def_key(property_def) if ( getattr(self, key) is None or key == "res" ): # no resources, handled later on continue tag = property_def[0] + ":" + property_def[1] property_el = ET.Element(tag, {}) property_el.text = getattr(self, key) item_el.append(property_el) elements[property_def[1]] = property_el # attributes and property@attributes for property_def in self.didl_properties_defs: if "@" not in property_def[1]: continue key = didl_property_def_key(property_def) value = getattr(self, key) if value is None: continue el_name, attr_name = property_def[1].split("@") property_el = elements[el_name] property_el.attrib[attr_name] = value # resource for resource in self.resources: res_el = resource.to_xml() item_el.append(res_el) # descriptor for descriptor in self.descriptors: desc_el = descriptor.to_xml() item_el.append(desc_el) return item_el def __getattr__(self, name: str) -> Any: """Get attribute, modifying case as needed.""" if name == "resources": return getattr(self, "res") if name in self.__dict__: return self.__dict__[name] cleaned_name = didl_property_key(name) if cleaned_name not in self.__dict__: raise AttributeError(name) return self.__dict__[cleaned_name] def __setattr__(self, name: str, value: Any) -> None: """Set attribute, modifying case as needed.""" if name not in self.__dict__: # Redirect to the lower_camel_case version if it's already set, # which is the case for all defined didl properties. cleaned_name = didl_property_key(name) if cleaned_name in self.__dict__: name = cleaned_name self.__dict__[name] = value def __repr__(self) -> str: """Evaluatable string representation of this object.""" class_name = type(self).__name__ attr = ", ".join( f"{key}={val!r}" for key, val in self.__dict__.items() if key not in ("class", "xml_el") ) return f"{class_name}({attr})" # region: items class Item(DidlObject): """DIDL Item.""" # pylint: disable=too-few-public-methods tag = "item" upnp_class = "object.item" didl_properties_defs = DidlObject.didl_properties_defs + [ ("didl_lite", "@refID", "O"), # actually, R, but ignore for now ("upnp", "bookmarkID", "O"), ] class ImageItem(Item): """DIDL Image Item.""" # pylint: disable=too-few-public-methods upnp_class = "object.item.imageItem" didl_properties_defs = Item.didl_properties_defs + [ ("upnp", "longDescription", "O"), ("upnp", "storageMedium", "O"), ("upnp", "rating", "O"), ("dc", "description", "O"), ("dc", "publisher", "O"), ("dc", "date", "O"), ("dc", "rights", "O"), ] class Photo(ImageItem): """DIDL Photo.""" # pylint: disable=too-few-public-methods upnp_class = "object.item.imageItem.photo" didl_properties_defs = ImageItem.didl_properties_defs + [ ("upnp", "album", "O"), ] class AudioItem(Item): """DIDL Audio Item.""" # pylint: disable=too-few-public-methods upnp_class = "object.item.audioItem" didl_properties_defs = Item.didl_properties_defs + [ ("upnp", "genre", "O"), ("dc", "description", "O"), ("upnp", "longDescription", "O"), ("dc", "publisher", "O"), ("dc", "language", "O"), ("dc", "relation", "O"), ("dc", "rights", "O"), ] class MusicTrack(AudioItem): """DIDL Music Track.""" # pylint: disable=too-few-public-methods upnp_class = "object.item.audioItem.musicTrack" didl_properties_defs = AudioItem.didl_properties_defs + [ ("upnp", "artist", "O"), ("upnp", "album", "O"), ("upnp", "originalTrackNumber", "O"), ("upnp", "playlist", "O"), ("upnp", "storageMedium", "O"), ("dc", "contributor", "O"), ("dc", "date", "O"), ] class AudioBroadcast(AudioItem): """DIDL Audio Broadcast.""" # pylint: disable=too-few-public-methods upnp_class = "object.item.audioItem.audioBroadcast" didl_properties_defs = AudioItem.didl_properties_defs + [ ("upnp", "region", "O"), ("upnp", "radioCallSign", "O"), ("upnp", "radioStationID", "O"), ("upnp", "radioBand", "O"), ("upnp", "channelNr", "O"), ("upnp", "signalStrength", "O"), ("upnp", "signalLocked", "O"), ("upnp", "tuned", "O"), ("upnp", "recordable", "O"), ] class AudioBook(AudioItem): """DIDL Audio Book.""" # pylint: disable=too-few-public-methods upnp_class = "object.item.audioItem.audioBook" didl_properties_defs = AudioItem.didl_properties_defs + [ ("upnp", "storageMedium", "O"), ("upnp", "producer", "O"), ("dc", "contributor", "O"), ("dc", "date", "O"), ] class VideoItem(Item): """DIDL Video Item.""" # pylint: disable=too-few-public-methods upnp_class = "object.item.videoItem" didl_properties_defs = Item.didl_properties_defs + [ ("upnp", "genre", "O"), ("upnp", "genre@id", "O"), ("upnp", "genre@type", "O"), ("upnp", "longDescription", "O"), ("upnp", "producer", "O"), ("upnp", "rating", "O"), ("upnp", "actor", "O"), ("upnp", "director", "O"), ("dc", "description", "O"), ("dc", "publisher", "O"), ("dc", "language", "O"), ("dc", "relation", "O"), ("upnp", "playbackCount", "O"), ("upnp", "lastPlaybackTime", "O"), ("upnp", "lastPlaybackPosition", "O"), ("upnp", "recordedDayOfWeek", "O"), ("upnp", "srsRecordScheduleID", "O"), ] class Movie(VideoItem): """DIDL Movie.""" # pylint: disable=too-few-public-methods upnp_class = "object.item.videoItem.movie" didl_properties_defs = VideoItem.didl_properties_defs + [ ("upnp", "storageMedium", "O"), ("upnp", "DVDRegionCode", "O"), ("upnp", "channelName", "O"), ("upnp", "scheduledStartTime", "O"), ("upnp", "scheduledEndTime", "O"), ("upnp", "programTitle", "O"), ("upnp", "seriesTitle", "O"), ("upnp", "episodeCount", "O"), ("upnp", "episodeNumber", "O"), ("upnp", "episodeSeason", "O"), ] class VideoBroadcast(VideoItem): """DIDL Video Broadcast.""" # pylint: disable=too-few-public-methods upnp_class = "object.item.videoItem.videoBroadcast" didl_properties_defs = VideoItem.didl_properties_defs + [ ("upnp", "icon", "O"), ("upnp", "region", "O"), ("upnp", "channelNr", "O"), ("upnp", "signalStrength", "O"), ("upnp", "signalLocked", "O"), ("upnp", "tuned", "O"), ("upnp", "recordable", "O"), ("upnp", "callSign", "O"), ("upnp", "price", "O"), ("upnp", "payPerView", "O"), ] class MusicVideoClip(VideoItem): """DIDL Music Video Clip.""" # pylint: disable=too-few-public-methods upnp_class = "object.item.videoItem.musicVideoClip" didl_properties_defs = VideoItem.didl_properties_defs + [ ("upnp", "artist", "O"), ("upnp", "storageMedium", "O"), ("upnp", "album", "O"), ("upnp", "scheduledStartTime", "O"), ("upnp", "scheduledStopTime", "O"), # ('upnp', 'director', 'O'), # duplicate in standard ("dc", "contributor", "O"), ("dc", "date", "O"), ] class PlaylistItem(Item): """DIDL Playlist Item.""" # pylint: disable=too-few-public-methods upnp_class = "object.item.playlistItem" didl_properties_defs = Item.didl_properties_defs + [ ("upnp", "artist", "O"), ("upnp", "genre", "O"), ("upnp", "longDescription", "O"), ("upnp", "storageMedium", "O"), ("dc", "description", "O"), ("dc", "date", "O"), ("dc", "language", "O"), ] class TextItem(Item): """DIDL Text Item.""" # pylint: disable=too-few-public-methods upnp_class = "object.item.textItem" didl_properties_defs = Item.didl_properties_defs + [ ("upnp", "author", "O"), ("upnp", "res@protection", "O"), ("upnp", "longDescription", "O"), ("upnp", "storageMedium", "O"), ("upnp", "rating", "O"), ("dc", "description", "O"), ("dc", "publisher", "O"), ("dc", "contributor", "O"), ("dc", "date", "O"), ("dc", "relation", "O"), ("dc", "language", "O"), ("dc", "rights", "O"), ] class BookmarkItem(Item): """DIDL Bookmark Item.""" # pylint: disable=too-few-public-methods upnp_class = "object.item.bookmarkItem" didl_properties_defs = Item.didl_properties_defs + [ ("upnp", "bookmarkedObjectID", "R"), ("upnp", "neverPlayable", "O"), ("upnp", "deviceUDN", "R"), ("upnp", "serviceType", "R"), ("upnp", "serviceId", "R"), ("dc", "date", "O"), ("dc", "stateVariableCollection", "R"), ] class EpgItem(Item): """DIDL EPG Item.""" # pylint: disable=too-few-public-methods upnp_class = "object.item.epgItem" didl_properties_defs = Item.didl_properties_defs + [ ("upnp", "channelGroupName", "O"), ("upnp", "channelGroupName@id", "O"), ("upnp", "epgProviderName", "O"), ("upnp", "serviceProvider", "O"), ("upnp", "channelName", "O"), ("upnp", "channelNr", "O"), ("upnp", "programTitle", "O"), ("upnp", "seriesTitle", "O"), ("upnp", "programID", "O"), ("upnp", "programID@type", "O"), ("upnp", "seriesID", "O"), ("upnp", "seriesID@type", "O"), ("upnp", "channelID", "O"), ("upnp", "channelID@type", "O"), ("upnp", "channelID@distriNetworkName", "O"), ("upnp", "channelID@distriNetworkID", "O"), ("upnp", "episodeType", "O"), ("upnp", "episodeCount", "O"), ("upnp", "episodeNumber", "O"), ("upnp", "episodeSeason", "O"), ("upnp", "programCode", "O"), ("upnp", "programCode@type", "O"), ("upnp", "rating", "O"), ("upnp", "rating@type", "O"), ("upnp", "rating@advice", "O"), ("upnp", "rating@equivalentAge", "O"), ("upnp", "recommendationID", "O"), ("upnp", "recommendationID@type", "O"), ("upnp", "genre", "O"), ("upnp", "genre@id", "O"), ("upnp", "genre@extended", "O"), ("upnp", "artist", "O"), ("upnp", "artist@role", "O"), ("upnp", "actor", "O"), ("upnp", "actor@role", "O"), ("upnp", "author", "O"), ("upnp", "author@role", "O"), ("upnp", "producer", "O"), ("upnp", "director", "O"), ("dc", "publisher", "O"), ("dc", "contributor", "O"), ("upnp", "callSign", "O"), ("upnp", "networkAffiliation", "O"), # ('upnp', 'serviceProvider', 'O'), # duplicate in standard ("upnp", "price", "O"), ("upnp", "price@currency", "O"), ("upnp", "payPerView", "O"), # ('upnp', 'epgProviderName', 'O'), # duplicate in standard ("dc", "description", "O"), ("upnp", "longDescription", "O"), ("upnp", "icon", "O"), ("upnp", "region", "O"), ("upnp", "rights", "O"), ("dc", "language", "O"), ("dc", "relation", "O"), ("upnp", "scheduledStartTime", "O"), ("upnp", "scheduledEndTime", "O"), ("upnp", "recordable", "O"), ("upnp", "foreignMetadata", "O"), ] class AudioProgram(EpgItem): """DIDL Audio Program.""" # pylint: disable=too-few-public-methods upnp_class = "object.item.epgItem.audioProgram" didl_properties_defs = Item.didl_properties_defs + [ ("upnp", "radioCallSign", "O"), ("upnp", "radioStationID", "O"), ("upnp", "radioBand", "O"), ] class VideoProgram(EpgItem): """DIDL Video Program.""" # pylint: disable=too-few-public-methods upnp_class = "object.item.epgItem.videoProgram" didl_properties_defs = Item.didl_properties_defs + [ ("upnp", "price", "O"), ("upnp", "price@currency", "O"), ("upnp", "payPerView", "O"), ] # endregion # region: containers class Container(DidlObject, list): """DIDL Container.""" # pylint: disable=too-few-public-methods tag = "container" upnp_class = "object.container" didl_properties_defs = DidlObject.didl_properties_defs + [ ("didl_lite", "@childCount", "O"), ("upnp", "createClass", "O"), ("upnp", "searchClass", "O"), ("didl_lite", "@searchable", "O"), ("didl_lite", "@neverPlayable", "O"), ] def __init__( self, id: str = "", parent_id: str = "", descriptors: Optional[Sequence["Descriptor"]] = None, xml_el: Optional[ET.Element] = None, strict: bool = True, children: Iterable[DidlObject] = (), **properties: Any, ) -> None: """Initialize.""" # pylint: disable=redefined-builtin,too-many-arguments super().__init__(id, parent_id, descriptors, xml_el, strict, **properties) self.extend(children) @classmethod def from_xml(cls: Type[TC], xml_el: ET.Element, strict: bool = True) -> TC: """ Initialize from an XML node. I.e., parse XML and return instance. """ instance = super().from_xml(xml_el, strict) # add all children didl_objects = from_xml_el(xml_el, strict) instance.extend(didl_objects) # pylint: disable=no-member return instance def to_xml(self) -> ET.Element: """Convert self to XML Element.""" container_el = super().to_xml() for didl_object in self: didl_object_el = didl_object.to_xml() container_el.append(didl_object_el) return container_el def __repr__(self) -> str: """Evaluatable string representation of this object.""" class_name = type(self).__name__ attr = ", ".join( f"{key}={val!r}" for key, val in self.__dict__.items() if key not in ("class", "xml_el") ) children_repr = ", ".join(repr(child) for child in self) return f"{class_name}({attr}, children=[{children_repr}])" class Person(Container): """DIDL Person.""" # pylint: disable=too-few-public-methods upnp_class = "object.container.person" didl_properties_defs = Container.didl_properties_defs + [ ("dc", "language", "O"), ] class MusicArtist(Person): """DIDL Music Artist.""" # pylint: disable=too-few-public-methods upnp_class = "object.container.person.musicArtist" didl_properties_defs = Container.didl_properties_defs + [ ("upnp", "genre", "O"), ("upnp", "artistDiscographyURI", "O"), ] class PlaylistContainer(Container): """DIDL Playlist Container.""" # pylint: disable=too-few-public-methods upnp_class = "object.container.playlistContainer" didl_properties_defs = Container.didl_properties_defs + [ ("upnp", "artist", "O"), ("upnp", "genre", "O"), ("upnp", "longDescription", "O"), ("upnp", "producer", "O"), ("upnp", "storageMedium", "O"), ("dc", "description", "O"), ("dc", "contributor", "O"), ("dc", "date", "O"), ("dc", "language", "O"), ("dc", "rights", "O"), ] class Album(Container): """DIDL Album.""" # pylint: disable=too-few-public-methods upnp_class = "object.container.album" didl_properties_defs = Container.didl_properties_defs + [ ("upnp", "storageMedium", "O"), ("dc", "longDescription", "O"), ("dc", "description", "O"), ("dc", "publisher", "O"), ("dc", "contributor", "O"), ("dc", "date", "O"), ("dc", "relation", "O"), ("dc", "rights", "O"), ] class MusicAlbum(Album): """DIDL Music Album.""" # pylint: disable=too-few-public-methods upnp_class = "object.container.album.musicAlbum" didl_properties_defs = Container.didl_properties_defs + [ ("upnp", "artist", "O"), ("upnp", "genre", "O"), ("upnp", "producer", "O"), ("upnp", "albumArtURI", "O"), ("upnp", "toc", "O"), ] class PhotoAlbum(Album): """DIDL Photo Album.""" # pylint: disable=too-few-public-methods upnp_class = "object.container.album.photoAlbum" didl_properties_defs = Container.didl_properties_defs + [] class Genre(Container): """DIDL Genre.""" # pylint: disable=too-few-public-methods upnp_class = "object.container.genre" didl_properties_defs = Container.didl_properties_defs + [ ("upnp", "genre", "O"), ("upnp", "longDescription", "O"), ("dc", "description", "O"), ] class MusicGenre(Genre): """DIDL Music Genre.""" # pylint: disable=too-few-public-methods upnp_class = "object.container.genre.musicGenre" didl_properties_defs = Container.didl_properties_defs + [] class MovieGenre(Genre): """DIDL Movie Genre.""" # pylint: disable=too-few-public-methods upnp_class = "object.container.genre.movieGenre" didl_properties_defs = Container.didl_properties_defs + [] class ChannelGroup(Container): """DIDL Channel Group.""" # pylint: disable=too-few-public-methods upnp_class = "object.container.channelGroup" didl_properties_defs = Container.didl_properties_defs + [ ("upnp", "channelGroupName", "O"), ("upnp", "channelGroupName@id", "O"), ("upnp", "epgProviderName", "O"), ("upnp", "serviceProvider", "O"), ("upnp", "icon", "O"), ("upnp", "region", "O"), ] class AudioChannelGroup(ChannelGroup): """DIDL Audio Channel Group.""" # pylint: disable=too-few-public-methods upnp_class = "object.container.channelGroup.audioChannelGroup" didl_properties_defs = Container.didl_properties_defs + [] class VideoChannelGroup(ChannelGroup): """DIDL Video Channel Group.""" # pylint: disable=too-few-public-methods upnp_class = "object.container.channelGroup.videoChannelGroup" didl_properties_defs = Container.didl_properties_defs + [] class EpgContainer(Container): """DIDL EPG Container.""" # pylint: disable=too-few-public-methods upnp_class = "object.container.epgContainer" didl_properties_defs = Container.didl_properties_defs + [ ("upnp", "channelGroupName", "O"), ("upnp", "channelGroupName@id", "O"), ("upnp", "epgProviderName", "O"), ("upnp", "serviceProvider", "O"), ("upnp", "channelName", "O"), ("upnp", "channelNr", "O"), ("upnp", "channelID", "O"), ("upnp", "channelID@type", "O"), ("upnp", "radioCallSign", "O"), ("upnp", "radioStationID", "O"), ("upnp", "radioBand", "O"), ("upnp", "callSign", "O"), ("upnp", "networkAffiliation", "O"), # ('upnp', 'serviceProvider', 'O'), # duplicate in standard ("upnp", "price", "O"), ("upnp", "price@currency", "O"), ("upnp", "payPerView", "O"), # ('upnp', 'epgProviderName', 'O'), # duplicate in standard ("upnp", "icon", "O"), ("upnp", "region", "O"), ("dc", "language", "O"), ("dc", "relation", "O"), ("upnp", "dateTimeRange", "O"), ] class StorageSystem(Container): """DIDL Storage System.""" # pylint: disable=too-few-public-methods upnp_class = "object.container.storageSystem" didl_properties_defs = Container.didl_properties_defs + [ ("upnp", "storageTotal", "R"), ("upnp", "storageUsed", "R"), ("upnp", "storageFree", "R"), ("upnp", "storageMaxPartition", "R"), ("upnp", "storageMedium", "R"), ] class StorageVolume(Container): """DIDL Storage Volume.""" # pylint: disable=too-few-public-methods upnp_class = "object.container.storageVolume" didl_properties_defs = Container.didl_properties_defs + [ ("upnp", "storageTotal", "R"), ("upnp", "storageUsed", "R"), ("upnp", "storageFree", "R"), ("upnp", "storageMedium", "R"), ] class StorageFolder(Container): """DIDL Storage Folder.""" # pylint: disable=too-few-public-methods upnp_class = "object.container.storageFolder" didl_properties_defs = Container.didl_properties_defs + [ ("upnp", "storageUsed", "R"), ] class BookmarkFolder(Container): """DIDL Bookmark Folder.""" # pylint: disable=too-few-public-methods upnp_class = "object.container.bookmarkFolder" didl_properties_defs = Container.didl_properties_defs + [ ("upnp", "genre", "O"), ("upnp", "longDescription", "O"), ("dc", "description", "O"), ] # endregion class Resource: """DIDL Resource.""" # pylint: disable=too-few-public-methods,too-many-instance-attributes def __init__( self, uri: Optional[str], protocol_info: Optional[str], import_uri: Optional[str] = None, size: Optional[str] = None, duration: Optional[str] = None, bitrate: Optional[str] = None, sample_frequency: Optional[str] = None, bits_per_sample: Optional[str] = None, nr_audio_channels: Optional[str] = None, resolution: Optional[str] = None, color_depth: Optional[str] = None, protection: Optional[str] = None, xml_el: Optional[ET.Element] = None, ) -> None: """Initialize.""" # pylint: disable=too-many-arguments self.uri = uri self.protocol_info = protocol_info self.import_uri = import_uri self.size = size self.duration = duration self.bitrate = bitrate self.sample_frequency = sample_frequency self.bits_per_sample = bits_per_sample self.nr_audio_channels = nr_audio_channels self.resolution = resolution self.color_depth = color_depth self.protection = protection self.xml_el = xml_el @classmethod def from_xml(cls: Type[TR], xml_el: ET.Element) -> TR: """Initialize from an XML node.""" uri = xml_el.text protocol_info = xml_el.attrib.get("protocolInfo") import_uri = xml_el.attrib.get("importUri") size = xml_el.attrib.get("size") duration = xml_el.attrib.get("duration") bitrate = xml_el.attrib.get("bitrate") sample_frequency = xml_el.attrib.get("sampleFrequency") bits_per_sample = xml_el.attrib.get("bitsPerSample") nr_audio_channels = xml_el.attrib.get("nrAudioChannels") resolution = xml_el.attrib.get("resolution") color_depth = xml_el.attrib.get("colorDepth") protection = xml_el.attrib.get("protection") return cls( uri, protocol_info=protocol_info, import_uri=import_uri, size=size, duration=duration, bitrate=bitrate, sample_frequency=sample_frequency, bits_per_sample=bits_per_sample, nr_audio_channels=nr_audio_channels, resolution=resolution, color_depth=color_depth, protection=protection, xml_el=xml_el, ) def to_xml(self) -> ET.Element: """Convert self to XML.""" attribs = { "protocolInfo": self.protocol_info or "", } res_el = ET.Element("res", attribs) res_el.text = self.uri return res_el def __repr__(self) -> str: """Evaluatable string representation of this object.""" class_name = type(self).__name__ attr = ", ".join( f"{key}={val!r}" for key, val in self.__dict__.items() if val is not None and key != "xml_el" ) return f"{class_name}({attr})" class Descriptor: """DIDL Descriptor.""" def __init__( self, id: str, name_space: str, type: Optional[str] = None, text: Optional[str] = None, xml_el: Optional[ET.Element] = None, ) -> None: """Initialize.""" # pylint: disable=invalid-name,redefined-builtin,too-many-arguments self.id = id self.name_space = name_space self.type = type self.text = text self.xml_el = xml_el @classmethod def from_xml(cls: Type[TD], xml_el: ET.Element) -> TD: """Initialize from an XML node.""" id_ = xml_el.attrib["id"] name_space = xml_el.attrib["nameSpace"] type_ = xml_el.attrib.get("type") text = xml_el.text return cls(id_, name_space, type=type_, text=text, xml_el=xml_el) def to_xml(self) -> ET.Element: """Convert self to XML.""" attribs = { "id": self.id, "nameSpace": self.name_space, } if self.type is not None: attribs["type"] = self.type desc_el = ET.Element("desc", attribs) desc_el.text = self.text return desc_el def __getattr__(self, name: str) -> Any: """Get attribute.""" if name not in self.__dict__: raise AttributeError(name) return self.__dict__[name] def __repr__(self) -> str: """Evaluatable string representation of this object.""" class_name = type(self).__name__ attr = ", ".join( f"{key}={val!r}" for key, val in self.__dict__.items() if val is not None and key != "xml_el" ) return f"{class_name}({attr})" # endregion def to_xml_string(*objects: DidlObject) -> bytes: """Convert items to DIDL-Lite XML string.""" root_el = ET.Element("DIDL-Lite", {}) root_el.attrib["xmlns"] = NAMESPACES["didl_lite"] root_el.attrib["xmlns:dc"] = NAMESPACES["dc"] root_el.attrib["xmlns:upnp"] = NAMESPACES["upnp"] root_el.attrib["xmlns:sec"] = NAMESPACES["sec"] for didl_object in objects: didl_object_el = didl_object.to_xml() root_el.append(didl_object_el) return ET.tostring(root_el) def from_xml_string( xml_string: str, strict: bool = True ) -> List[Union[DidlObject, Descriptor]]: """Convert XML string to DIDL Objects.""" xml_el = defusedxml.ElementTree.fromstring(xml_string) return from_xml_el(xml_el, strict) def from_xml_el( xml_el: ET.Element, strict: bool = True ) -> List[Union[DidlObject, Descriptor]]: """Convert XML Element to DIDL Objects.""" didl_objects = [] # type: List[Union[DidlObject, Descriptor]] # items and containers, in order for child_el in xml_el: if child_el.tag != expand_namespace_tag( "didl_lite:item" ) and child_el.tag != expand_namespace_tag("didl_lite:container"): continue # construct item upnp_class = child_el.find("./upnp:class", NAMESPACES) if upnp_class is None or not upnp_class.text: if strict: continue # WiiM Pro and possibly other Linkplay devices emit # upnp_class above the item element instead of inside it upnp_class = xml_el.find("./upnp:class", NAMESPACES) if upnp_class is None or not upnp_class.text: continue didl_object_type = type_by_upnp_class(upnp_class.text, strict) if didl_object_type is None: if strict: raise DidlLiteException(f"upnp:class {upnp_class.text} is unknown") continue didl_object = didl_object_type.from_xml(child_el, strict) didl_objects.append(didl_object) # descriptors for desc_el in xml_el.findall("./didl_lite:desc", NAMESPACES): desc = Descriptor.from_xml(desc_el) didl_objects.append(desc) return didl_objects # upnp_class to python type mapping def type_by_upnp_class( upnp_class: str, strict: bool = True ) -> Optional[Type[DidlObject]]: """Get DidlObject-type by upnp_class. When strict is False, the upnp_class lookup will be done ignoring string case. """ if strict: return _upnp_class_map.get(upnp_class) return _upnp_class_map_lowercase.get(upnp_class.lower()) python-didl-lite-1.4.1/didl_lite/py.typed000066400000000000000000000000001471166522300203300ustar00rootroot00000000000000python-didl-lite-1.4.1/didl_lite/utils.py000066400000000000000000000041361471166522300203610ustar00rootroot00000000000000"""Utilities.""" import re from typing import Optional, Tuple NAMESPACES = { "didl_lite": "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/", "dc": "http://purl.org/dc/elements/1.1/", "sec": "http://www.sec.co.kr/", "upnp": "urn:schemas-upnp-org:metadata-1-0/upnp/", "xsi": "http://www.w3.org/2001/XMLSchema-instance", } def expand_namespace_tag(tag: str) -> str: """ Expand namespace-alias to url. E.g., _ns_tag('didl_lite:item') -> '{urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/}item' """ if ":" not in tag: return tag namespace, tag = tag.split(":") namespace_uri = NAMESPACES[namespace] return f"{{{namespace_uri}}}{tag}" def split_namespace_tag(namespaced_tag: str) -> Tuple[Optional[str], str]: """ Extract namespace and tag from namespaced-tag. E.g., _namespace_tag('{urn:schemas-upnp-org:metadata-1-0/upnp/}class') -> 'urn:schemas-upnp-org:metadata-1-0/upnp/', 'class' """ if "}" not in namespaced_tag: return None, namespaced_tag idx = namespaced_tag.index("}") namespace = namespaced_tag[1:idx] tag = namespaced_tag[idx + 1 :] # noqa: E203 return namespace, tag def to_camel_case(name: str) -> str: """Get camel case of name.""" sub1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) return re.sub("([a-z0-9])([A-Z])", r"\1_\2", sub1).lower() def didl_property_key(didl_property_name: str) -> str: """Get Python property key for a DIDL property name.""" if ":" in didl_property_name: # Remove the namespace from the property name didl_property_name = didl_property_name.partition(":")[2] if didl_property_name.startswith("@"): return to_camel_case(didl_property_name.replace("@", "")) return to_camel_case(didl_property_name.replace("@", "_")) def didl_property_def_key(didl_property_def: Tuple[str, ...]) -> str: """Get Python property key for didl_property_def.""" if didl_property_def[1].startswith("@"): return to_camel_case(didl_property_def[1].replace("@", "")) return to_camel_case(didl_property_def[1].replace("@", "_")) python-didl-lite-1.4.1/pylintrc000066400000000000000000000000551471166522300165010ustar00rootroot00000000000000[BASIC] good-names=otherItem, storageMedium python-didl-lite-1.4.1/setup.cfg000066400000000000000000000006431471166522300165360ustar00rootroot00000000000000[bdist_wheel] python-tag=py3 [metadata] license_file = LICENSE.md [flake8] max-line-length = 99 [mypy] check_untyped_defs = true disallow_untyped_calls = true disallow_untyped_defs = true disallow_incomplete_defs = true disallow_untyped_decorators = true no_implicit_optional = true warn_incomplete_stub = true warn_redundant_casts = true warn_return_any = true warn_unused_configs = true warn_unused_ignores = true python-didl-lite-1.4.1/setup.py000077500000000000000000000026321471166522300164320ustar00rootroot00000000000000"""Setup.""" import os.path from setuptools import setup here = os.path.abspath(os.path.dirname(__file__)) with open(os.path.join(here, "README.rst"), encoding="utf-8") as f: LONG_DESCRIPTION = f.read() with open(os.path.join(here, "README.rst"), encoding="utf-8") as f: DESCRIPTION = f.readline().strip() INSTALL_REQUIRES = [ "defusedxml>=0.6.0", ] TEST_REQUIRES = [ "pytest~=7.4.3", ] setup( name="python-didl-lite", version="1.4.1", description=DESCRIPTION, long_description=LONG_DESCRIPTION, long_description_content_type="text/x-rst", url="https://github.com/StevenLooman/python-didl-lite", author="Steven Looman", author_email="steven.looman@gmail.com", classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ], packages=["didl_lite"], package_data={ "didl_lite": ["py.typed"], }, python_requires=">=3.8", install_requires=INSTALL_REQUIRES, tests_require=TEST_REQUIRES, ) python-didl-lite-1.4.1/tests/000077500000000000000000000000001471166522300160545ustar00rootroot00000000000000python-didl-lite-1.4.1/tests/test_didl_lite.py000066400000000000000000000632111471166522300214210ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Unit tests for didl_lite.""" import pytest from defusedxml import ElementTree as ET from didl_lite import didl_lite NAMESPACES = { "didl_lite": "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/", "dc": "http://purl.org/dc/elements/1.1/", "upnp": "urn:schemas-upnp-org:metadata-1-0/upnp/", "xsi": "http://www.w3.org/2001/XMLSchema-instance", } class TestDidlLite: """Tests for didl_lite.""" # pylint: disable=too-many-public-methods def test_item_from_xml(self) -> None: """Test item from XML.""" didl_string = """ Audio Item Title object.item.audioItem English url Long description """ items = didl_lite.from_xml_string(didl_string) assert len(items) == 1 item = items[0] assert item.xml_el is not None assert getattr(item, "title") == "Audio Item Title" assert getattr(item, "upnp_class") == "object.item.audioItem" assert getattr(item, "language") == "English" assert getattr(item, "longDescription") == "Long description" assert getattr(item, "long_description") == "Long description" assert isinstance(item, didl_lite.AudioItem) assert not hasattr(item, "non_existing") resources = item.res assert len(resources) == 1 resource = resources[0] assert resource.xml_el is not None assert resource.protocol_info == "protocol_info" assert resource.uri == "url" assert not hasattr(item, "non_existing") assert item.res == item.resources def test_item_bad_class(self) -> None: """Test item from XML that has a badly-cased upnp:class.""" didl_string = """ Audio Item Title Object.Item.AudioItem English url Long description """ with pytest.raises( didl_lite.DidlLiteException, match="upnp:class Object.Item.AudioItem is unknown", ): didl_lite.from_xml_string(didl_string) items = didl_lite.from_xml_string(didl_string, strict=False) assert len(items) == 1 item = items[0] assert isinstance(item, didl_lite.AudioItem) def test_item_from_xml_not_strict(self) -> None: """Test item from XML.""" didl_string = """ Audio Item Title object.item.audioItem English url """ items = didl_lite.from_xml_string(didl_string, strict=False) assert len(items) == 1 item = items[0] assert item.xml_el is not None assert getattr(item, "title") == "Audio Item Title" assert getattr(item, "upnp_class") == "object.item.audioItem" assert getattr(item, "language") == "English" assert isinstance(item, didl_lite.AudioItem) assert not hasattr(item, "non_existing") resources = item.res assert len(resources) == 1 resource = resources[0] assert resource.xml_el is not None assert resource.protocol_info is None # This is now allowed with strict=False assert resource.uri == "url" assert not hasattr(item, "non_existing") assert item.res == item.resources def test_item_to_xml(self) -> None: """Test item to XML.""" resource = didl_lite.Resource("url", "protocol_info") items = [ didl_lite.AudioItem( id="0", parent_id="0", title="Audio Item Title", restricted="1", resources=[resource], language="English", longDescription="Long description", ), ] didl_string = didl_lite.to_xml_string(*items).decode("utf-8") assert 'xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"' in didl_string assert 'xmlns:dc="http://purl.org/dc/elements/1.1/"' in didl_string assert 'xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"' in didl_string assert 'xmlns:sec="http://www.sec.co.kr/"' in didl_string assert 'xmlns:ns1="urn:schemas-upnp-org:metadata-1-0/upnp/"' not in didl_string didl_el = ET.fromstring(didl_string) item_el = didl_el.find("./didl_lite:item", NAMESPACES) assert item_el is not None assert item_el.attrib["id"] == "0" assert item_el.attrib["parentID"] == "0" assert item_el.attrib["restricted"] == "1" title_el = item_el.find("./dc:title", NAMESPACES) assert title_el is not None assert title_el.text == "Audio Item Title" class_el = item_el.find("./upnp:class", NAMESPACES) assert class_el is not None assert class_el.text == "object.item.audioItem" language_el = item_el.find("./dc:language", NAMESPACES) assert language_el is not None assert language_el.text == "English" long_description_el = item_el.find("./upnp:longDescription", NAMESPACES) assert long_description_el is not None assert long_description_el.text == "Long description" res_el = item_el.find("./didl_lite:res", NAMESPACES) assert res_el is not None assert res_el.attrib["protocolInfo"] == "protocol_info" assert res_el.text == "url" def test_item_repr(self) -> None: """Test item's repr can convert back to an equivalent item.""" # pylint: disable=import-outside-toplevel # repr method doesn't know how package was imported, so only uses class names from didl_lite.didl_lite import AudioItem, Resource item = AudioItem( id="0", parent_id="0", title="Audio Item Title", restricted="1", res=[ Resource("url", "protocol_info"), Resource("url2", "protocol_info2"), ], language="English", ) item_repr = repr(item) item_remade = eval(item_repr) # pylint: disable=eval-used assert ET.tostring(item.to_xml()) == ET.tostring(item_remade.to_xml()) def test_container_from_xml(self) -> None: """Test container from XML.""" didl_string = """ Album Container Title object.container.album Audio Item Title object.item.audioItem English url """ items = didl_lite.from_xml_string(didl_string) assert len(items) == 1 container = items[0] assert container.xml_el is not None assert isinstance(container, didl_lite.Container) assert getattr(container, "title") == "Album Container Title" assert getattr(container, "upnp_class") == "object.container.album" item = container[0] assert item.xml_el is not None assert item.title == "Audio Item Title" assert item.upnp_class == "object.item.audioItem" assert item.language == "English" resources = item.res assert len(resources) == 1 resource = resources[0] assert resource.xml_el is not None assert resource.protocol_info == "protocol_info" assert resource.uri == "url" assert item.res == item.resources def test_container_to_xml(self) -> None: """Test container to XML.""" container = didl_lite.Album( id="0", parent_id="0", title="Audio Item Title", restricted="1" ) resource = didl_lite.Resource("url", "protocol_info") item = didl_lite.AudioItem( id="0", parent_id="0", title="Audio Item Title", restricted="1", resources=[resource], language="English", ) container.append(item) didl_string = didl_lite.to_xml_string(container).decode("utf-8") didl_el = ET.fromstring(didl_string) container_el = didl_el.find("./didl_lite:container", NAMESPACES) assert container_el is not None assert container_el.attrib["id"] == "0" assert container_el.attrib["parentID"] == "0" assert container_el.attrib["restricted"] == "1" item_el = container_el.find("./didl_lite:item", NAMESPACES) assert item_el is not None assert item_el.attrib["id"] == "0" assert item_el.attrib["parentID"] == "0" assert item_el.attrib["restricted"] == "1" title_el = item_el.find("./dc:title", NAMESPACES) assert title_el is not None assert title_el.text == "Audio Item Title" class_el = item_el.find("./upnp:class", NAMESPACES) assert class_el is not None assert class_el.text == "object.item.audioItem" language_el = item_el.find("./dc:language", NAMESPACES) assert language_el is not None assert language_el.text == "English" res_el = item_el.find("./didl_lite:res", NAMESPACES) assert res_el is not None assert res_el.attrib["protocolInfo"] == "protocol_info" assert res_el.text == "url" def test_container_repr(self) -> None: """Test containers's repr can convert back to an equivalent container.""" # pylint: disable=import-outside-toplevel from didl_lite.didl_lite import Album, AudioItem, Resource container = Album( id="0", parent_id="0", title="Audio Item Title", restricted="1" ) resource = Resource("url", "protocol_info") item = AudioItem( id="0", parent_id="0", title="Audio Item Title", restricted="1", resources=[resource], language="English", ) container.append(item) container_repr = repr(container) container_remade = eval(container_repr) # pylint: disable=eval-used assert ET.tostring(container.to_xml()) == ET.tostring(container_remade.to_xml()) def test_descriptor_from_xml_root(self) -> None: """Test root descriptor from XML.""" didl_string = """ Text """ items = didl_lite.from_xml_string(didl_string) assert len(items) == 1 descriptor = items[0] assert descriptor is not None assert descriptor.xml_el is not None assert getattr(descriptor, "id") == "1" assert getattr(descriptor, "name_space") == "ns" assert getattr(descriptor, "type") == "type" assert getattr(descriptor, "text") == "Text" def test_descriptor_from_xml_item(self) -> None: """Test item descriptor from XML.""" didl_string = """ Audio Item Title object.item.audioItem English url Text """ items = didl_lite.from_xml_string(didl_string) assert len(items) == 1 item = items[0] assert item is not None assert isinstance(item, didl_lite.AudioItem) descriptor = item.descriptors[0] assert descriptor is not None assert descriptor.xml_el is not None assert descriptor.id == "1" assert descriptor.name_space == "ns" assert descriptor.type == "type" assert descriptor.text == "Text" def test_descriptor_from_xml_container(self) -> None: """Test container descriptor from XML.""" didl_string = """ Album Container Title object.container.album Text """ items = didl_lite.from_xml_string(didl_string) assert len(items) == 1 container = items[0] assert container is not None assert container.xml_el is not None assert isinstance(container, didl_lite.Container) descriptor = container.descriptors[0] assert descriptor is not None assert descriptor.xml_el is not None assert descriptor.id == "1" assert descriptor.name_space == "ns" assert descriptor.type == "type" assert descriptor.text == "Text" def test_descriptor_from_xml_container_item(self) -> None: """Test item descriptor in container from XML.""" didl_string = """ Album Container Title object.container.album Audio Item Title object.item.audioItem English url Text """ items = didl_lite.from_xml_string(didl_string) assert len(items) == 1 container = items[0] assert container is not None assert isinstance(container, didl_lite.Container) item = container[0] assert item is not None descriptor = item.descriptors[0] assert descriptor is not None assert descriptor.xml_el is not None assert descriptor.id == "1" assert descriptor.name_space == "ns" assert descriptor.type == "type" assert descriptor.text == "Text" def test_descriptor_to_xml(self) -> None: """Test descriptor to XML.""" descriptor = didl_lite.Descriptor( id="1", name_space="ns", type="type", text="Text" ) item = didl_lite.AudioItem( id="0", parent_id="0", title="Audio Item Title", restricted="1", language="English", descriptors=[descriptor], ) didl_string = didl_lite.to_xml_string(item).decode("utf-8") didl_el = ET.fromstring(didl_string) item_el = didl_el.find("./didl_lite:item", NAMESPACES) assert item_el is not None descriptor_el = item_el.find("./didl_lite:desc", NAMESPACES) assert descriptor_el is not None assert len(descriptor_el.attrib) == 3 assert descriptor_el.attrib["id"] == "1" assert descriptor_el.attrib["nameSpace"] == "ns" assert descriptor_el.attrib["type"] == "type" assert descriptor_el.text == "Text" descriptor = didl_lite.Descriptor(id="2", name_space="ns2") descriptor_el = descriptor.to_xml() assert descriptor_el is not None assert len(descriptor_el.attrib) == 2 assert descriptor_el.attrib["id"] == "2" assert descriptor_el.attrib["nameSpace"] == "ns2" def test_descriptor_repr(self) -> None: """Test descriptor's repr can convert back to an equivalent descriptorb.""" # pylint: disable=import-outside-toplevel from didl_lite.didl_lite import Descriptor descriptor = Descriptor(id="1", name_space="ns", type="type", text="Text") descriptor_repr = repr(descriptor) descriptor_remade = eval(descriptor_repr) # pylint: disable=eval-used assert ET.tostring(descriptor.to_xml()) == ET.tostring( descriptor_remade.to_xml() ) def test_item_order(self) -> None: """Test item ordering.""" didl_string = """ Audio Item Title 1 object.item.audioItem English Album Container Title object.container.album Audio Item Title 1 object.item.audioItem English """ items = didl_lite.from_xml_string(didl_string) assert len(items) == 3 assert isinstance(items[0], didl_lite.AudioItem) assert isinstance(items[1], didl_lite.Album) assert isinstance(items[2], didl_lite.AudioItem) def test_item_property_attribute_from_xml(self) -> None: """Test item property from XML attribute.""" didl_string = """ Video Item Title object.item.videoItem Action """ items = didl_lite.from_xml_string(didl_string) assert len(items) == 1 item = items[0] assert item is not None assert getattr(item, "genre") == "Action" assert getattr(item, "genre_id") == "genreId" def test_item_property_attribute_to_xml(self) -> None: """Test item property to XML.""" item = didl_lite.VideoItem( id="0", parent_id="0", title="Video Item Title", restricted="1", genre="Action", genre_id="genreId", ) didl_string = didl_lite.to_xml_string(item).decode("utf-8") didl_el = ET.fromstring(didl_string) item_el = didl_el.find("./didl_lite:item", NAMESPACES) assert item_el is not None genre_el = item_el.find("./upnp:genre", NAMESPACES) assert genre_el is not None assert genre_el.text == "Action" assert genre_el.attrib["id"] == "genreId" def test_item_missing_id(self) -> None: """Test item missing ID from XML.""" didl_string = """ Video Item Title object.item """ items = didl_lite.from_xml_string(didl_string) assert len(items) == 1 def test_item_set_attributes(self) -> None: """Test item attribute from XML.""" didl_string = """ Video Item Title object.item.videoItem """ items = didl_lite.from_xml_string(didl_string) assert len(items) == 1 item = items[0] assert getattr(item, "title") == "Video Item Title" assert hasattr(item, "rating") assert getattr(item, "rating") is None assert isinstance(item, didl_lite.VideoItem) assert len(item.res) == 0 assert item.res == item.resources def test_extra_properties(self) -> None: """Test extra item properties from XML.""" didl_string = """ Video Item Title object.item.videoItem extra_property """ items = didl_lite.from_xml_string(didl_string) assert len(items) == 1 item = items[0] assert hasattr(item, "album_art_uri") assert getattr(item, "album_art_uri") == "extra_property" assert getattr(item, "albumArtURI") == "extra_property" def test_default_properties_set(self) -> None: """Test defaults for item properties.""" item = didl_lite.VideoItem( id="0", parent_id="0", title="Video Item Title", restricted="1" ) assert hasattr(item, "genre_type") # property is set def test_property_case(self) -> None: """Test item properties can be accessed using snake_case or camelCase.""" item = didl_lite.MusicTrack( id="0", parent_id="0", title="Audio Item Title", restricted="1", language="English", originalTrackNumber="1", storage_medium="HDD", ) assert hasattr(item, "original_track_number") assert hasattr(item, "originalTrackNumber") assert item.original_track_number is item.originalTrackNumber assert item.original_track_number == "1" assert hasattr(item, "storage_medium") assert hasattr(item, "storageMedium") assert item.storage_medium is item.storageMedium assert item.storage_medium == "HDD" assert hasattr(item, "long_description") assert hasattr(item, "longDescription") assert item.long_description is item.longDescription assert item.long_description is None assert not hasattr(item, "otherItem") assert not hasattr(item, "other_item") item.storageMedium = "CD" assert item.storage_medium is item.storageMedium assert item.storage_medium == "CD" item.long_description = "Long description" assert item.long_description is item.longDescription assert item.long_description == "Long description" item.otherItem = "otherItem" assert hasattr(item, "otherItem") assert not hasattr(item, "other_item") assert item.otherItem == "otherItem" def test_item_improper_class_nesting(self) -> None: """ Test item from XML that has upnp_class element above item. Cater for WiiM Pro and possibly other Linkplay devices that emit upnp_class above the item element instead of inside it """ didl_string = """ object.item.audioItem.musicTrack Music Track Title Music Track Creator Artist Album """ items = didl_lite.from_xml_string(didl_string, strict=False) assert len(items) == 1 item = items[0] assert isinstance(item, didl_lite.MusicTrack) python-didl-lite-1.4.1/tox.ini000066400000000000000000000023411471166522300162250ustar00rootroot00000000000000[tox] envlist = py38, py39, py310, py311, py312, flake8, pylint, codespell, typing, black [gh-actions] python = 3.8: py38 3.9: py39 3.10: py310 3.11: py311 3.12: py312, flake8, pylint, codespell, typing, black 3.13: py313 [testenv] commands = py.test --cov=didl_lite --cov-report=term --cov-report=xml:coverage-{env_name}.xml {posargs} ignore_errors = True deps = pytest == 7.4.3 pytest-asyncio ~= 0.23.2 pytest-cov ~= 4.1.0 coverage ~= 7.3.3 asyncmock ~= 0.4.2 [testenv:flake8] basepython = python3 ignore_errors = True deps = flake8 ~= 6.1.0 flake8-docstrings == 1.7.0 flake8-noqa == 1.3.2 pydocstyle == 6.3.0 commands = flake8 didl_lite tests [testenv:pylint] basepython = python3 ignore_errors = True deps = pylint ~= 3.0.3 pytest == 7.4.3 commands = pylint didl_lite tests [testenv:codespell] basepython = python3 ignore_errors = True deps = codespell ~= 2.2.6 commands = codespell didl_lite tests [testenv:typing] basepython = python3 ignore_errors = True deps = mypy ~= 1.7.1 pytest == 7.4.3 commands = mypy --ignore-missing-imports didl_lite tests [testenv:black] basepython = python3 deps = black >= 23.12.0 commands = black --diff didl_lite tests