from typing import List, Dict, ClassVar, Optional, Iterator, Union
from pathlib import Path
import xml.etree.ElementTree as ET
import xml.dom.minidom as minidom
import hashlib
HASH_ALGO = 'sha1'
"""The :mod:`hashlib` algorithm to use for creating file hashes
"""
HASH_FUNC = getattr(hashlib, HASH_ALGO)
[docs]class QRCElement(object):
"""An element within a QRC document tree
Attributes:
parent (QRCElement, optional): The parent element. If this element is
the document root, this is ``None``
element (ET.Element): The :class:`xml.etree.ElementTree.Element`
associated with this element
children (List[QRCElement]): Direct descendants of this element
"""
TAG: ClassVar[Optional[str]] = None
"""The default tag name"""
def __init__(self, **kwargs):
parent = kwargs.get('parent')
element = kwargs.get('element')
tag = kwargs.get('tag', self.TAG)
attrib = kwargs.get('attrib', {})
if element is None:
assert tag is not None
if parent is None:
element = ET.Element(tag, attrib)
else:
element = ET.SubElement(parent.element, tag, attrib)
self.parent = parent
self.element = element
self.children = []
for child_elem in element:
self.add_child(element=child_elem)
[docs] def tostring(self) -> str:
"""Build the XML representation of the tree
"""
ugly = ET.tostring(self.element, encoding='unicode')
dom = minidom.parseString(ugly)
pretty = dom.toprettyxml()
pretty = [line for line in pretty.splitlines() if len(line.strip('\t'))]
pretty[0] = '<!DOCTYPE RCC>'
return '\n'.join(pretty)
[docs] def write(self, filename: Path):
"""Save the contents of :meth:`tostring` as a QRC file
Note:
This may only be called on the root element
"""
assert self.parent is None
filename.write_text(self.tostring())
[docs] @classmethod
def create(cls, **kwargs) -> 'QRCElement':
"""Create an instance of :class:`QRCElement`
The subclass will be chosen using the given element or tag keyword arguments
Keyword Arguments:
element (ET.Element, optional): If provided, an instance of
:class:`xml.etree.ElementTree.Element` to use as the root element
tag (str, optional): If no element is provided, this will be the tag
name of the root element. If both ``element`` and ``tag`` are ``None``,
the :attr:`TAG` attribute of the class will be used.
"""
element = kwargs.get('element')
if element is not None:
tag = element.tag
else:
tag = kwargs.get('tag', cls.TAG)
_cls = cls.cls_for_tag(tag)
return _cls(**kwargs)
[docs] @classmethod
def cls_for_tag(cls, tag: str):
"""Find a subclass of :class:`QRCElement` matching the given tag
"""
def iter_subclass(_cls):
yield _cls
for subcls in _cls.__subclasses__():
if not issubclass(subcls, QRCElement):
continue
yield subcls
for _cls in iter_subclass(QRCElement):
if _cls.TAG == tag:
return _cls
@property
def root_element(self) -> 'QRCElement':
"""The root of the tree
"""
p = self.parent
if p is None:
return self
return p.root_element
@property
def tag(self) -> str:
"""The :attr:`~xml.etree.ElementTree.Element.tag` name of the element
"""
return self.element.tag
@property
def attrib(self) -> Dict:
"""The element :attr:`attributes <xml.etree.ElementTree.Element.attrib>`
"""
return self.element.attrib
@property
def text(self):
"""The element :attr:`~xml.etree.ElementTree.Element.text`
"""
return self.element.text
@text.setter
def text(self, value):
self.element.text = value
[docs] def add_child(self, **kwargs) -> 'QRCElement':
"""Create a child instance using :meth:`create` and add it to this
element's :attr:`children`
"""
kwargs['parent'] = self
child = self.create(**kwargs)
self.children.append(child)
return child
[docs] def remove_child(self, child: 'QRCElement'):
"""Remove an child element the tree
"""
self.element.remove(child.element)
self.children.remove(child)
[docs] def walk(self) -> Iterator['QRCElement']:
"""Iterate over this element and all of its descendants
"""
yield self
for c in self.children:
yield from c.walk()
def __repr__(self):
return f'<{self.__class__.__name__}: "{self}">'
def __str__(self):
return self.tag
[docs]class QRCDocument(QRCElement):
"""A :class:`QRCElement` subclass to be used as the document root
Keyword Arguments:
base_path (pathlib.Path): The filesystem path representing the root
directory for the document (usually the document's directory)
"""
TAG: ClassVar[str] = 'RCC'
"""The default tag name"""
def __init__(self, **kwargs):
self.base_path = kwargs['base_path']
if not self.base_path.is_absolute():
raise ValueError('base_path must be absolute')
super().__init__(**kwargs)
[docs] @classmethod
def from_file(cls, filename: Path) -> 'QRCDocument':
"""Create a tree from an existing qrc file
"""
root = ET.fromstring(filename.read_text())
return cls(element=root, base_path=filename.resolve().parent)
@property
def current_hash(self) -> Optional[str]:
"""The hash of the contents when the qrc file was last saved
"""
return self.attrib.get('content_hash')
@current_hash.setter
def current_hash(self, value: str):
self.attrib['content_hash'] = value
[docs] def hashes_match(self) -> bool:
"""Determine if the contents defined within the document have changed
on the local filesystem
Compares the :attr:`current_hash` against the result of :meth:`hash_contents`
"""
if not self.current_hash:
return False
local_hash = self.hash_contents()
return local_hash == self.current_hash
[docs] def add_file(self, filename: Path, prefix: Optional[str] = None, **kwargs) -> 'QRCFile':
"""Add a :class:`QRCFile` to the document if it does not currently exist
Arguments:
filename (pathlib.Path): The filename to add
prefix (str, optional): The :attr:`~QRCResource.prefix` to use for
the :class:`QRCResource`. If not given, it will default to ``"/"``
**kwargs: Extra keyword arguments to pass to the :class:`QRCFile` creation
"""
if prefix is None:
prefix = '/'
resource_el = self.find_resource(prefix)
if resource_el is None:
resource_el = self.add_child(tag='qresource', prefix=prefix)
return resource_el.add_file(filename, **kwargs)
[docs] def find_resource(self, prefix: str) -> Optional['QRCResource']:
"""Search for a :class:`QRCResource` matching the given prefix
If one is not found, ``None`` will be returned
"""
for el in self.children:
if el.prefix == prefix:
return el
[docs] def search_for_file(self, filename: Path) -> Optional['QRCFile']:
"""Search for the :class:`QRCFile` element matching the given filename
"""
for c in self.children:
if not isinstance(c, QRCResource):
continue
r = c.search_for_file(filename)
if r is not None:
return r
[docs] def remove_missing_files(self) -> List['QRCFile']:
"""Find and remove any :class:`QRCFile` elements whose filenames do not
currently exist in the filesystem.
The elements that were removed (if any) are returned
"""
removed = []
for f in self.iter_files():
if not f.exists():
removed.append(f)
f.parent.remove_child(f)
return removed
[docs] def iter_resources(self) -> Iterator['QRCResource']:
"""Iterate over child :class:`QRCResource` instances
"""
for c in self.children:
if isinstance(c, QRCResource):
yield c
[docs] def iter_files(self, missing_ok: bool = True) -> Iterator['QRCFile']:
"""Iterate through all :class:`QRCFile` instances in the tree
"""
for r in self.iter_resources():
yield from r.iter_files(missing_ok)
[docs] def hash_contents(self) -> str:
"""Create a single hash from all :class:`QRCFile` data on the local
filesystem using :meth:`QRCFile.hash_contents`
"""
hashes = [f.hash_contents() for f in self.iter_files(missing_ok=False)]
m = HASH_FUNC()
for hval in sorted(hashes):
m.update(hval.encode('utf-8'))
return m.hexdigest()
[docs] def tostring(self) -> str:
self.current_hash = self.hash_contents()
return super().tostring()
[docs]class QRCResource(QRCElement):
"""A :class:`QRCElement` subclass representing a qresource element
"""
TAG: ClassVar[str] = 'qresource'
"""The default tag name"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._path = None
if 'prefix' in kwargs:
self.prefix = kwargs['prefix']
@property
def prefix(self):
"""The prefix to be used for all children of this ``qresource``.
This only affects the way the child resources are accessed from within
the Qt Resource System and has no impact on local file paths.
"""
return self.attrib.get('prefix')
@prefix.setter
def prefix(self, value: str):
self.attrib['prefix'] = value
@property
def path(self) -> Path:
"""Alias for :attr:`~QRCDocument.base_path` of the :attr:`root_element`
"""
return self.root_element.base_path
[docs] def add_file(self, filename: Path, **kwargs) -> 'QRCFile':
"""Add a :class:`QRCFile` to the resource if it does not currently exist
Arguments:
filename (pathlib.Path): The filename to add
**kwargs: Extra keyword arguments to pass to the :class:`QRCFile` creation
"""
el = self.search_for_file(filename)
if el is not None:
return el
rel_fn = self.normailize_child_filename(filename)
kw = kwargs.copy()
kw['tag'] = 'file'
kw['filename'] = rel_fn
return self.add_child(**kw)
[docs] def normailize_child_filename(self, filename: Path) -> Path:
"""Translate the given path to be relative to the :attr:`~QRCDocument.base_path`.
If the filename given is not absolute, the :attr:`~QRCDocument.base_path`
will be prepended to it.
"""
base = self.path
if not filename.is_absolute:
filename = base / filename
filename = filename.relative_to(base)
return filename
[docs] def search_for_file(self, filename: Path) -> Optional['QRCFile']:
"""Search within this qresource for the :class:`QRCFile` element
matching the given filename
"""
rel_p = self.normailize_child_filename(filename)
etsearch = self.element.findall(f'.//file[.="{rel_p}"]')
if not len(etsearch):
return None
for c in self.iter_files():
if c.filename == rel_p:
return c
[docs] def iter_files(self, missing_ok: bool = True) -> Iterator['QRCFile']:
"""Iterate over all :class:`QRCFile` instances within this qresource
"""
for c in self.children:
if isinstance(c, QRCFile):
if not missing_ok and not c.exists():
continue
yield c
def __str__(self):
return f'prefix={self.prefix}'
[docs]class QRCFile(QRCElement):
"""A :class:`QRCElement` subclass representing a file resource
Keyword Arguments:
filename: See :attr:`filename`
filename_abs: See :attr:`filename_abs`
alias: See :attr:`alias`
Note:
Only one of the filename arguments may be present as keyword arguments
"""
TAG: ClassVar[str] = 'file'
"""The default tag name"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
if 'filename_abs' in kwargs and 'filename' in kwargs:
raise ValueError('cannot provide both "filename" and "filename_abs"')
for attr in ['filename', 'filename_abs', 'alias']:
if attr in kwargs:
setattr(self, attr, kwargs[attr])
@property
def filename(self) -> Path:
"""The filename as a :class:`pathlib.Path`
(relative to the parent :class:`QRCResource`)
"""
s = self.text
if s is None:
return None
s = s.strip()
if not len(s):
return None
return Path(s)
@filename.setter
def filename(self, value: Optional[Union[Path, str]]):
if value is None:
self.text = None
else:
if not isinstance(value, Path):
value = Path(value)
assert not value.is_absolute()
self.text = str(value)
@property
def filename_abs(self) -> Path:
"""Absolute filename including the parent :attr:`~QRCDocument.base_path`
When set, the :attr:`filename` will be updated using
:meth:`~QRCResource.normailize_child_filename`
"""
base = self.parent.path
return base / self.filename
@filename_abs.setter
def filename_abs(self, value: Optional[Union[Path, str]]):
if value is None:
self.text = None
else:
if not isinstance(value, Path):
value = Path(value)
if not value.is_absolute():
raise ValueError('path must be absolute')
p = self.parent.normailize_child_filename(value)
self.filename = p
@property
def alias(self) -> Optional[str]:
"""The file alias as described in the `qrc documentation`_
.. _qrc documentation: https://doc.qt.io/qt-5/resources.html#resource-collection-files-op-op-qrc
"""
return self.attrib.get('alias')
@alias.setter
def alias(self, value):
if isinstance(value, Path):
value = str(value)
self.attrib['alias'] = value
[docs] def exists(self) -> bool:
"""Returns whether the file exists in the filesystem
"""
return self.filename_abs.exists()
[docs] def hash_contents(self) -> str:
"""Create a hash of the file data using :mod:`hashlib` algorithm defined
by :any:`HASH_ALGO`
"""
m = HASH_FUNC()
block_size = 65536
p = self.filename_abs
if not p.exists():
return m.hexdigest()
with open(p, 'rb') as fp:
while True:
data = fp.read(block_size)
if not data:
break
m.update(data)
return m.hexdigest()
def __str__(self):
return f'filename={self.filename}, alias={self.alias}'