safe/utilities/keyword_io.py
# coding=utf-8
"""Keyword IO implementation."""
import logging
from ast import literal_eval
from datetime import datetime
from qgis.PyQt.QtCore import QObject
from qgis.PyQt.QtCore import QUrl, QDateTime
from qgis.core import QgsMapLayer
from safe import messaging as m
from safe.definitions.keyword_properties import property_extra_keywords
from safe.definitions.utilities import definition
from safe.messaging import styles
from safe.utilities.i18n import tr
from safe.utilities.metadata import (
write_iso19115_metadata, read_iso19115_metadata)
__copyright__ = "Copyright 2011, The InaSAFE Project"
__license__ = "GPL version 3"
__email__ = "info@inasafe.org"
__revision__ = '$Format:%H$'
LOGGER = logging.getLogger('InaSAFE')
# Notes(IS): This class can be replaced by safe.utilities.metadata
# Some methods for viewing the keywords should be put in the other class
class KeywordIO(QObject):
"""Class for doing keyword read/write operations.
It abstracts away differences between using SAFE to get keywords from a
.keywords file and this plugins implementation of keyword caching in a
local sqlite db used for supporting keywords for remote datasources.
"""
def __init__(self, layer=None):
"""Constructor for the KeywordIO object.
.. versionchanged:: 3.3 added optional layer parameter.
"""
QObject.__init__(self)
self.layer = layer
@staticmethod
def read_keywords(layer, keyword=None):
"""Read keywords for a datasource and return them as a dictionary.
This is a wrapper method that will 'do the right thing' to fetch
keywords for the given datasource. In particular, if the datasource
is remote (e.g. a database connection) it will fetch the keywords from
the keywords store.
:param layer: A QGIS QgsMapLayer instance that you want to obtain
the keywords for.
:type layer: QgsMapLayer, QgsRasterLayer, QgsVectorLayer,
QgsPluginLayer
:param keyword: If set, will extract only the specified keyword
from the keywords dict.
:type keyword: str
:returns: A dict if keyword is omitted, otherwise the value for the
given key if it is present.
:rtype: dict, str
TODO: Don't raise generic exceptions.
:raises: HashNotFoundError, Exception, OperationalError,
NoKeywordsFoundError, KeywordNotFoundError, InvalidParameterError,
UnsupportedProviderError
"""
source = layer.source()
# Try to read from ISO metadata first.
return read_iso19115_metadata(source, keyword)
@staticmethod
def write_keywords(layer, keywords):
"""Write keywords for a datasource.
This is a wrapper method that will 'do the right thing' to store
keywords for the given datasource. In particular, if the datasource
is remote (e.g. a database connection) it will write the keywords from
the keywords store.
:param layer: A QGIS QgsMapLayer instance.
:type layer: qgis.core.QgsMapLayer
:param keywords: A dict containing all the keywords to be written
for the layer.
:type keywords: dict
:raises: UnsupportedProviderError
"""
if not isinstance(layer, QgsMapLayer):
raise Exception(
tr('The layer is not a QgsMapLayer : {type}').format(
type=type(layer)))
source = layer.source()
write_iso19115_metadata(source, keywords)
# methods below here should be considered private
def to_message(self, keywords=None, show_header=True):
"""Format keywords as a message object.
.. versionadded:: 3.2
.. versionchanged:: 3.3 - default keywords to None
The message object can then be rendered to html, plain text etc.
:param keywords: Keywords to be converted to a message. Optional. If
not passed then we will attempt to get keywords from self.layer
if it is not None.
:type keywords: dict
:param show_header: Flag indicating if InaSAFE logo etc. should be
added above the keywords table. Default is True.
:type show_header: bool
:returns: A safe message object containing a table.
:rtype: safe.messaging.Message
"""
if keywords is None and self.layer is not None:
keywords = self.read_keywords(self.layer)
# This order was determined in issue #2313
preferred_order = [
'title',
'layer_purpose',
'exposure',
'hazard',
'hazard_category',
'layer_geometry',
'layer_mode',
'classification',
'exposure_unit',
'continuous_hazard_unit',
'value_map', # attribute values
'thresholds', # attribute values
'value_maps', # attribute values
'inasafe_fields',
'inasafe_default_values',
'resample',
'source',
'url',
'scale',
'license',
'date',
'extra_keywords',
'keyword_version'
] # everything else in arbitrary order
report = m.Message()
if show_header:
logo_element = m.Brand()
report.add(logo_element)
report.add(m.Heading(tr(
'Layer keywords:'), **styles.BLUE_LEVEL_4_STYLE))
report.add(m.Text(tr(
'The following keywords are defined for the active layer:')))
table = m.Table(style_class='table table-condensed table-striped')
# First render out the preferred order keywords
for keyword in preferred_order:
if keyword in keywords:
value = keywords[keyword]
row = self._keyword_to_row(keyword, value)
keywords.pop(keyword)
table.add(row)
# now render out any remaining keywords in arbitrary order
for keyword in keywords:
value = keywords[keyword]
row = self._keyword_to_row(keyword, value)
table.add(row)
# If the keywords class was instantiated with a layer object
# we can add some context info not stored in the keywords themselves
# but that is still useful to see...
if self.layer:
# First the CRS
keyword = tr('Reference system')
value = self.layer.crs().authid()
row = self._keyword_to_row(keyword, value)
table.add(row)
# Next the data source
keyword = tr('Layer source')
value = self.layer.publicSource() # Hide password
row = self._keyword_to_row(keyword, value, wrap_slash=True)
table.add(row)
# Finalise the report
report.add(table)
return report
def _keyword_to_row(self, keyword, value, wrap_slash=False):
"""Helper to make a message row from a keyword.
.. versionadded:: 3.2
Use this when constructing a table from keywords to display as
part of a message object.
:param keyword: The keyword to be rendered.
:type keyword: str
:param value: Value of the keyword to be rendered.
:type value: basestring
:param wrap_slash: Whether to replace slashes with the slash plus the
html <wbr> tag which will help to e.g. wrap html in small cells if
it contains a long filename. Disabled by default as it may cause
side effects if the text contains html markup.
:type wrap_slash: bool
:returns: A row to be added to a messaging table.
:rtype: safe.messaging.items.row.Row
"""
row = m.Row()
# Translate titles explicitly if possible
if keyword == 'title':
value = tr(value)
# # See #2569
if keyword == 'url':
if isinstance(value, QUrl):
value = value.toString()
if keyword == 'date':
if isinstance(value, QDateTime):
value = value.toString('d MMM yyyy')
elif isinstance(value, datetime):
value = value.strftime('%d %b %Y')
# we want to show the user the concept name rather than its key
# if possible. TS
keyword_definition = definition(keyword)
if keyword_definition is None:
keyword_definition = tr(keyword.capitalize().replace(
'_', ' '))
else:
try:
keyword_definition = keyword_definition['name']
except KeyError:
# Handling if name is not exist.
keyword_definition = keyword_definition['key'].capitalize()
keyword_definition = keyword_definition.replace('_', ' ')
# We deal with some special cases first:
# In this case the value contains a DICT that we want to present nicely
if keyword in [
'value_map',
'inasafe_fields',
'inasafe_default_values']:
value = self._dict_to_row(value)
elif keyword == 'extra_keywords':
value = self._dict_to_row(value, property_extra_keywords)
elif keyword == 'value_maps':
value = self._value_maps_row(value)
elif keyword == 'thresholds':
value = self._threshold_to_row(value)
# In these KEYWORD cases we show the DESCRIPTION for
# the VALUE keyword_definition
elif keyword in ['classification']:
# get the keyword_definition for this class from definitions
value = definition(value)
value = value['description']
# In these VALUE cases we show the DESCRIPTION for
# the VALUE keyword_definition
elif value in []:
# get the keyword_definition for this class from definitions
value = definition(value)
value = value['description']
# In these VALUE cases we show the NAME for the VALUE
# keyword_definition
elif value in [
'multiple_event',
'single_event',
'point',
'line',
'polygon'
'field']:
# get the name for this class from definitions
value = definition(value)
value = value['name']
# otherwise just treat the keyword as literal text
else:
# Otherwise just directly read the value
pretty_value = None
value_definition = definition(value)
if value_definition:
pretty_value = value_definition.get('name')
if not pretty_value:
pretty_value = value
value = pretty_value
key = m.ImportantText(keyword_definition)
row.add(m.Cell(key))
row.add(m.Cell(value, wrap_slash=wrap_slash))
return row
@staticmethod
def _threshold_to_row(thresholds_keyword):
"""Helper to make a message row from a threshold
We are expecting something like this:
{
'thresholds': {
'structure': {
'ina_structure_flood_hazard_classification': {
'classes': {
'low': [1, 2],
'medium': [3, 4],
'high': [5, 6]
},
'active': True
},
'ina_structure_flood_hazard_4_class_classification':
{
'classes': {
'low': [1, 2],
'medium': [3, 4],
'high': [5, 6],
'very_high': [7, 8]
},
'active': False
}
},
'population': {
'ina_population_flood_hazard_classification': {
'classes': {
'low': [1, 2.5],
'medium': [2.5, 4.5],
'high': [4.5, 6]
},
'active': False
},
'ina_population_flood_hazard_4_class_classification':
{
'classes': {
'low': [1, 2.5],
'medium': [2.5, 4],
'high': [4, 6],
'very_high': [6, 8]
},
'active': True
}
},
},
Each value is a list with exactly two element [a, b], where a <= b.
:param thresholds_keyword: Value of the keyword to be rendered. This
must be a string representation of a dict, or a dict.
:type thresholds_keyword: basestring, dict
:returns: A table to be added into a cell in the keywords table.
:rtype: safe.messaging.items.table
"""
if isinstance(thresholds_keyword, str):
thresholds_keyword = literal_eval(thresholds_keyword)
for k, v in list(thresholds_keyword.items()):
# If the v is not dictionary, it should be the old value maps.
# To handle thresholds in the Impact Function.
if not isinstance(v, dict):
table = m.Table(style_class='table table-condensed')
for key, value in list(thresholds_keyword.items()):
row = m.Row()
name = definition(key)['name'] if definition(key) else key
row.add(m.Cell(m.ImportantText(name)))
pretty_value = tr('%s to %s' % (value[0], value[1]))
row.add(m.Cell(pretty_value))
table.add(row)
return table
table = m.Table(style_class='table table-condensed table-striped')
i = 0
for exposure_key, classifications in list(thresholds_keyword.items()):
i += 1
exposure = definition(exposure_key)
exposure_row = m.Row()
exposure_row.add(m.Cell(m.ImportantText(tr('Exposure'))))
exposure_row.add(m.Cell(m.Text(exposure['name'])))
exposure_row.add(m.Cell(''))
table.add(exposure_row)
active_classification = None
classification_row = m.Row()
classification_row.add(m.Cell(m.ImportantText(tr(
'Classification'))))
for classification, value in list(classifications.items()):
if value.get('active'):
active_classification = definition(classification)
classification_row.add(
m.Cell(active_classification['name']))
classification_row.add(m.Cell(''))
break
if not active_classification:
classification_row.add(m.Cell(tr('No classifications set.')))
classification_row.add(m.Cell(''))
continue
table.add(classification_row)
header = m.Row()
header.add(m.Cell(tr('Class name')))
header.add(m.Cell(tr('Minimum')))
header.add(m.Cell(tr('Maximum')))
table.add(header)
classes = active_classification.get('classes')
# Sort by value, put the lowest first
classes = sorted(classes, key=lambda the_key: the_key['value'])
for the_class in classes:
threshold = classifications[active_classification['key']][
'classes'][the_class['key']]
row = m.Row()
row.add(m.Cell(the_class['name']))
row.add(m.Cell(threshold[0]))
row.add(m.Cell(threshold[1]))
table.add(row)
if i < len(thresholds_keyword):
# Empty row
empty_row = m.Row()
empty_row.add(m.Cell(''))
empty_row.add(m.Cell(''))
table.add(empty_row)
return table
@staticmethod
def _dict_to_row(keyword_value, keyword_property=None):
"""Helper to make a message row from a keyword where value is a dict.
.. versionadded:: 3.2
Use this when constructing a table from keywords to display as
part of a message object. This variant will unpack the dict and
present it nicely in the keyword value area as a nested table in the
cell.
We are expecting keyword value would be something like this:
"{'high': ['Kawasan Rawan Bencana III'], "
"'medium': ['Kawasan Rawan Bencana II'], "
"'low': ['Kawasan Rawan Bencana I']}"
Or by passing a python dict object with similar layout to above.
i.e. A string representation of a dict where the values are lists.
:param keyword_value: Value of the keyword to be rendered. This must
be a string representation of a dict, or a dict.
:type keyword_value: basestring, dict
:param keyword_property: The definition of the keyword property.
:type keyword_property: dict, None
:returns: A table to be added into a cell in the keywords table.
:rtype: safe.messaging.items.table
"""
if isinstance(keyword_value, str):
keyword_value = literal_eval(keyword_value)
table = m.Table(style_class='table table-condensed')
# Sorting the key
for key in sorted(keyword_value.keys()):
value = keyword_value[key]
row = m.Row()
# First the heading
if keyword_property is None:
if definition(key):
name = definition(key)['name']
else:
name = tr(key.replace('_', ' ').capitalize())
else:
default_name = tr(key.replace('_', ' ').capitalize())
name = keyword_property.get('member_names', {}).get(
key, default_name)
row.add(m.Cell(m.ImportantText(name)))
# Then the value. If it contains more than one element we
# present it as a bullet list, otherwise just as simple text
if isinstance(value, (tuple, list, dict, set)):
if len(value) > 1:
bullets = m.BulletedList()
for item in value:
bullets.add(item)
row.add(m.Cell(bullets))
elif len(value) == 0:
row.add(m.Cell(""))
else:
row.add(m.Cell(value[0]))
else:
if keyword_property == property_extra_keywords:
key_definition = definition(key)
if key_definition and key_definition.get('options'):
value_definition = definition(value)
if value_definition:
value = value_definition.get('name', value)
elif key_definition and key_definition.get(
'type') == datetime:
try:
value = datetime.strptime(value, key_definition[
'store_format'])
value = value.strftime(
key_definition['show_format'])
except ValueError:
try:
value = datetime.strptime(
value, key_definition['store_format2'])
value = value.strftime(
key_definition['show_format'])
except ValueError:
pass
row.add(m.Cell(value))
table.add(row)
return table
@staticmethod
def _value_maps_row(value_maps_keyword):
"""Helper to make a message row from a value maps.
Expected keywords:
'value_maps': {
'structure': {
'ina_structure_flood_hazard_classification': {
'classes': {
'low': [1, 2, 3],
'medium': [4],
'high': [5, 6]
},
'active': True
},
'ina_structure_flood_hazard_4_class_classification':
{
'classes': {
'low': [1],
'medium': [2, 3, 4],
'high': [5, 6, 7],
'very_high': [8]
},
'active': False
}
},
'population': {
'ina_population_flood_hazard_classification': {
'classes': {
'low': [1],
'medium': [2, 3],
'high': [4, 5, 6]
},
'active': False
},
'ina_population_flood_hazard_4_class_classification':
{
'classes': {
'low': [1, 2],
'medium': [3, 4],
'high': [4, 5, 6],
'very_high': [6, 7, 8]
},
'active': True
}
},
}
:param value_maps_keyword: Value of the keyword to be rendered. This
must be a string representation of a dict, or a dict.
:type value_maps_keyword: basestring, dict
:returns: A table to be added into a cell in the keywords table.
:rtype: safe.messaging.items.table
"""
if isinstance(value_maps_keyword, str):
value_maps_keyword = literal_eval(value_maps_keyword)
table = m.Table(style_class='table table-condensed table-striped')
i = 0
for exposure_key, classifications in list(value_maps_keyword.items()):
i += 1
exposure = definition(exposure_key)
exposure_row = m.Row()
exposure_row.add(m.Cell(m.ImportantText(tr('Exposure'))))
exposure_row.add(m.Cell(exposure['name']))
table.add(exposure_row)
classification_row = m.Row()
classification_row.add(m.Cell(m.ImportantText(tr(
'Classification'))))
active_classification = None
for classification, value in list(classifications.items()):
if value.get('active'):
active_classification = definition(classification)
if active_classification.get('name'):
classification_row.add(
m.Cell(active_classification['name']))
break
if not active_classification:
classification_row.add(m.Cell(tr('No classifications set.')))
continue
table.add(classification_row)
header = m.Row()
header.add(m.Cell(tr('Class name')))
header.add(m.Cell(tr('Values')))
table.add(header)
classes = active_classification.get('classes')
# Sort by value, put the lowest first
classes = sorted(classes, key=lambda k: k['value'])
for the_class in classes:
value_map = classifications[active_classification['key']][
'classes'].get(the_class['key'], [])
row = m.Row()
row.add(m.Cell(the_class['name']))
row.add(m.Cell(', '.join([str(v) for v in value_map])))
table.add(row)
if i < len(value_maps_keyword):
# Empty row
empty_row = m.Row()
empty_row.add(m.Cell(''))
empty_row.add(m.Cell(''))
table.add(empty_row)
return table