From 934899190f0f1befebdfdb065def7b50e42ec469 Mon Sep 17 00:00:00 2001 From: Carlos Ezequiel Date: Tue, 14 Mar 2023 17:31:14 -0400 Subject: [PATCH] pred2bq: Update schema parsing from prediction results. --- .../predictions_to_bigquery/executor.py | 200 +++++++++++------- .../predictions_to_bigquery/executor_test.py | 190 ++++++++++++----- .../7/prediction_logs-00000-of-00001.gz | Bin 0 -> 27429 bytes 3 files changed, 263 insertions(+), 127 deletions(-) create mode 100644 tfx_addons/predictions_to_bigquery/testdata/sample-tfx-output/BulkInferrer/inference_result/7/prediction_logs-00000-of-00001.gz diff --git a/tfx_addons/predictions_to_bigquery/executor.py b/tfx_addons/predictions_to_bigquery/executor.py index 763e226f..a271d733 100644 --- a/tfx_addons/predictions_to_bigquery/executor.py +++ b/tfx_addons/predictions_to_bigquery/executor.py @@ -19,8 +19,7 @@ import datetime import os import re -from collections.abc import Mapping, Sequence -from typing import Any, List, Optional, Tuple, Union +from typing import Any, Optional, Union import apache_beam as beam import numpy as np @@ -31,7 +30,7 @@ from tensorflow_serving.apis import prediction_log_pb2 from tfx import types from tfx.dsl.components.base import base_beam_executor -from tfx.types import artifact_utils +from tfx.types import Artifact, artifact_utils # TODO(cezequiel): Move relevant functions in utils module here. from tfx_addons.predictions_to_bigquery import utils @@ -41,32 +40,50 @@ _DEFAULT_TIMESTRING_FORMAT = '%Y%m%d_%H%M%S' _REQUIRED_EXEC_PROPERTIES = ( 'bq_table_name', - 'bq_dataset', 'filter_threshold', - 'gcp_project', 'gcs_temp_dir', - 'vocab_label_file', ) _REGEX_CHARS_TO_REPLACE = re.compile(r'[^a-zA-Z0-9_]') +_REGEX_BQ_TABLE_NAME = re.compile(r'^[\w-]*:?[\w_]+\.[\w_]+$') -def _check_exec_properties(exec_properties: Mapping[str, Any]) -> None: +def _check_exec_properties(exec_properties: dict[str, Any]) -> None: for key in _REQUIRED_EXEC_PROPERTIES: if exec_properties[key] is None: raise ValueError(f'{key} must be set in exec_properties') -def _get_labels(transform_output_uri: str, vocab_file: str) -> Sequence[str]: - tf_transform_output = tft.TFTransformOutput(transform_output_uri) - tft_vocab = tf_transform_output.vocabulary_by_name(vocab_filename=vocab_file) +def _get_prediction_log_path(inference_results: list[Artifact]) -> str: + inference_results_uri = artifact_utils.get_single_uri(inference_results) + return f'{inference_results_uri}/*.gz' + + +def _get_tft_output( + transform_graph: Optional[list[Artifact]] = None +) -> Optional[tft.TFTransformOutput]: + if transform_graph is None: + return None + + transform_graph_uri = artifact_utils.get_single_uri(transform_graph) + return tft.TFTransformOutput(transform_graph_uri) + + +def _get_labels(tft_output: tft.TFTransformOutput, + vocab_file: str) -> list[str]: + tft_vocab = tft_output.vocabulary_by_name(vocab_filename=vocab_file) return [label.decode() for label in tft_vocab] -def _get_bq_table_name( - basename: str, - timestamp: Optional[datetime.datetime] = None, - timestring_format: Optional[str] = None, -) -> str: +def _check_bq_table_name(bq_table_name: str) -> None: + if _REGEX_BQ_TABLE_NAME.match(bq_table_name) is None: + raise ValueError('Invalid BigQuery table name.' + ' Specify in either `PROJECT:DATASET.TABLE` or' + ' `DATASET.TABLE` format.') + + +def _add_bq_table_name_suffix(basename: str, + timestamp: Optional[datetime.datetime] = None, + timestring_format: Optional[str] = None) -> str: if timestamp is not None: timestring_format = timestring_format or _DEFAULT_TIMESTRING_FORMAT return basename + '_' + timestamp.strftime(timestring_format) @@ -74,37 +91,67 @@ def _get_bq_table_name( def _get_additional_bq_parameters( - expiration_days: Optional[int] = None, - table_partitioning: bool = False, -) -> Mapping[str, Any]: + table_expiration_days: Optional[int] = None, + table_partitioning: Optional[bool] = False, +) -> dict[str, Any]: output = {} if table_partitioning: time_partitioning = {'type': 'DAY'} logging.info('BigQuery table time partitioning set to DAY') - if expiration_days: - expiration_time_delta = datetime.timedelta(days=expiration_days) + if table_expiration_days: + expiration_time_delta = datetime.timedelta(days=table_expiration_days) expiration_milliseconds = expiration_time_delta.total_seconds() * 1000 logging.info( - f'BigQuery table partition expiration time set to {expiration_days}' - ' days') + f'BigQuery table expiration set to {table_expiration_days} days.') time_partitioning['expirationMs'] = expiration_milliseconds output['timePartitioning'] = time_partitioning return output -def _get_features( - *, - schema_uri: Optional[str] = None, +# TODO(cezequiel): Move to a separate module with called functions. +# pylint: disable=protected-access +def _parse_features_from_prediction_results( + prediction_log_path: str) -> dict[str, Any]: + filepath = tf.io.gfile.glob(prediction_log_path)[0] + compression_type = utils._get_compress_type(filepath) + dataset = tf.data.TFRecordDataset([filepath], + compression_type=compression_type) + + for bytes_record in dataset.take(1): + prediction_log = prediction_log_pb2.PredictionLog.FromString( + bytes_record.numpy()) + + example_bytes = ( + prediction_log.predict_log.request.inputs['examples'].string_val[0]) + example = tf.train.Example.FromString(example_bytes) + features = {} + + for name, feature_proto in example.features.feature.items(): + feature_dtype = utils._get_feature_type(feature=feature_proto) + feature = tf.io.VarLenFeature(dtype=feature_dtype) + features[name] = feature + + return features + + +def _get_schema_features( + schema: Optional[list[Artifact]] = None, + tft_output: Optional[tft.TFTransformOutput] = None, prediction_log_path: Optional[str] = None, -) -> Mapping[str, Any]: - if schema_uri: +) -> dict[str, Any]: + if schema is not None: + schema_uri = artifact_utils.get_single_uri(schema) schema_file = os.path.join(schema_uri, _SCHEMA_FILE_NAME) return utils.load_schema(schema_file) - if not prediction_log_path: - raise ValueError('Specify one of `schema_uri` or `prediction_log_path`.') + if tft_output is not None: + return tft_output.raw_feature_spec() - return utils.parse_schema(prediction_log_path) + if prediction_log_path is None: + raise ValueError( + 'Specify one of `schema`, `tft_output` or `prediction_log_path`.') + + return _parse_features_from_prediction_results(prediction_log_path) def _get_bq_field_name_from_key(key: str) -> str: @@ -112,8 +159,7 @@ def _get_bq_field_name_from_key(key: str) -> str: return re.sub('_+', '_', field_name).strip('_') -def _features_to_bq_schema(features: Mapping[str, Any], - required: bool = False): +def _features_to_bq_schema(features: dict[str, Any], required: bool = False): bq_schema_fields_ = utils.feature_to_bq_schema(features, required=required) bq_schema_fields = [] for field in bq_schema_fields_: @@ -128,8 +174,7 @@ def _features_to_bq_schema(features: Mapping[str, Any], def _tensor_to_native_python_value( - tensor: Union[tf.Tensor, tf.sparse.SparseTensor] -) -> Optional[Union[int, float, str]]: + tensor: Union[tf.Tensor, tf.sparse.SparseTensor]) -> Optional[Any]: """Converts a TF Tensor to a native Python value.""" if isinstance(tensor, tf.sparse.SparseTensor): values = tensor.values.numpy() @@ -139,7 +184,7 @@ def _tensor_to_native_python_value( return None values = np.squeeze(values) # Removes extra dimension, e.g. shape (n, 1). values = values.item() # Converts to native Python type - if isinstance(values, Sequence) and isinstance(values[0], bytes): + if isinstance(values, list) and isinstance(values[0], bytes): return [v.decode('utf-8') for v in values] if isinstance(values, bytes): return values.decode('utf-8') @@ -147,34 +192,35 @@ def _tensor_to_native_python_value( @beam.typehints.with_input_types(str) -@beam.typehints.with_output_types(beam.typehints.Iterable[Tuple[str, str, +@beam.typehints.with_output_types(beam.typehints.Iterable[tuple[str, str, Any]]) class FilterPredictionToDictFn(beam.DoFn): """Converts a PredictionLog proto to a dict.""" def __init__( self, - labels: List, - features: Any, + features: dict[str, tf.io.FixedLenFeature], timestamp: datetime.datetime, filter_threshold: float, + labels: Optional[list[str]] = None, score_multiplier: float = 1., ): super().__init__() - self._labels = labels self._features = features + self._timestamp = timestamp self._filter_threshold = filter_threshold + self._labels = labels self._score_multiplier = score_multiplier - self._timestamp = timestamp - def _parse_prediction(self, predictions: npt.ArrayLike): + def _parse_prediction( + self, predictions: npt.ArrayLike) -> tuple[Optional[str], float]: prediction_id = np.argmax(predictions) logging.debug("Prediction id: %s", prediction_id) logging.debug("Predictions: %s", predictions) - label = self._labels[prediction_id] + label = self._labels[prediction_id] if self._labels is not None else None score = predictions[0][prediction_id] return label, score - def _parse_example(self, serialized: bytes) -> Mapping[str, Any]: + def _parse_example(self, serialized: bytes) -> dict[str, Any]: parsed_example = tf.io.parse_example(serialized, self._features) output = {} for key, tensor in parsed_example.items(): @@ -191,17 +237,18 @@ def process(self, element, *args, **kwargs): # pylint: disable=missing-function del args, kwargs # unused parsed_prediction_scores = tf.make_ndarray( - element.predict_log.response.outputs["outputs"]) + element.predict_log.response.outputs['outputs']) label, score = self._parse_prediction(parsed_prediction_scores) if score >= self._filter_threshold: output = { - "category_label": label, # Workaround to issue with the score value having additional non-zero values # in higher decimal places. # e.g. 0.8 -> 0.800000011920929 - "score": round(score * self._score_multiplier, _DECIMAL_PLACES), - "datetime": self._timestamp, + 'score': round(score * self._score_multiplier, _DECIMAL_PLACES), + 'datetime': self._timestamp, } + if label is not None: + output['category_label'] = label output.update( self._parse_example( element.predict_log.request.inputs['examples'].string_val)) @@ -212,9 +259,9 @@ class Executor(base_beam_executor.BaseBeamExecutor): """Implements predictions-to-bigquery component logic.""" def Do( self, - input_dict: Mapping[str, List[types.Artifact]], - output_dict: Mapping[str, List[types.Artifact]], - exec_properties: Mapping[str, Any], + input_dict: dict[str, list[types.Artifact]], + output_dict: dict[str, list[types.Artifact]], + exec_properties: dict[str, Any], ) -> None: """Do function for predictions_to_bq executor.""" @@ -223,36 +270,41 @@ def Do( # Check required keys set in exec_properties _check_exec_properties(exec_properties) - # get labels from tf transform generated vocab file - labels = _get_labels( - artifact_utils.get_single_uri(input_dict['transform_graph']), - exec_properties['vocab_label_file'], - ) - logging.info(f"found the following labels from TFT vocab: {labels}") - - # set BigQuery table name and timestamp suffix if specified. - bq_table_name = _get_bq_table_name(exec_properties['bq_table_name'], - timestamp, - exec_properties['table_suffix']) - - # set prediction result file path and decoder - inference_results_uri = artifact_utils.get_single_uri( - input_dict["inference_results"]) - prediction_log_path = f"{inference_results_uri}/*.gz" + # Get prediction log file path and decoder + prediction_log_path = _get_prediction_log_path( + input_dict['inference_results']) prediction_log_decoder = beam.coders.ProtoCoder( prediction_log_pb2.PredictionLog) + tft_output = _get_tft_output(input_dict.get('transform_graph')) + # get schema features - features = _get_features(schema_uri=artifact_utils.get_single_uri( - input_dict["schema"]), - prediction_log_path=prediction_log_path) + features = _get_schema_features( + schema=input_dict.get('schema'), + tft_output=tft_output, + prediction_log_path=prediction_log_path, + ) + + # get label names from TFTransformOutput object, if applicable + if tft_output is not None and 'vocab_label_file' in exec_properties: + labels = _get_labels(tft_output, exec_properties['vocab_label_file']) + logging.info(f'Found the following labels from TFT vocab: {labels}.') + else: + labels = None + logging.info('No TFTransform output given; no labels parsed.') + + # set BigQuery table name and timestamp suffix if specified. + _check_bq_table_name(exec_properties['bq_table_name']) + bq_table_name = _add_bq_table_name_suffix( + exec_properties['bq_table_name'], timestamp, + exec_properties['table_time_suffix']) # generate bigquery schema from tf transform features bq_schema = _features_to_bq_schema(features) logging.info(f'generated bq_schema: {bq_schema}.') additional_bq_parameters = _get_additional_bq_parameters( - exec_properties.get('expiration_time_delta'), + exec_properties.get('table_expiration_days'), exec_properties.get('table_partitioning')) # run the Beam pipeline to write the inference data to bigquery @@ -262,14 +314,12 @@ def Do( prediction_log_path, coder=prediction_log_decoder) | 'Filter and Convert to Dict' >> beam.ParDo( FilterPredictionToDictFn( - labels=labels, features=features, timestamp=timestamp, - filter_threshold=exec_properties['filter_threshold'])) - | 'Write Dict to BQ' >> beam.io.gcp.bigquery.WriteToBigQuery( + filter_threshold=exec_properties['filter_threshold'], + labels=labels)) + | 'Write Dict to BQ' >> beam.io.WriteToBigQuery( table=bq_table_name, - dataset=exec_properties['bq_dataset'], - project=exec_properties['gcp_project'], schema=bq_schema, additional_bq_parameters=additional_bq_parameters, create_disposition=beam.io.BigQueryDisposition.CREATE_IF_NEEDED, diff --git a/tfx_addons/predictions_to_bigquery/executor_test.py b/tfx_addons/predictions_to_bigquery/executor_test.py index 38447fb4..d1f01ebc 100644 --- a/tfx_addons/predictions_to_bigquery/executor_test.py +++ b/tfx_addons/predictions_to_bigquery/executor_test.py @@ -15,7 +15,8 @@ """Tests for executor.py.""" import datetime -from typing import Mapping, Sequence, Union +import pathlib +from typing import Union from unittest import mock import apache_beam as beam @@ -35,7 +36,7 @@ def _create_tf_example( - features: Mapping[str, Union[bytes, float, int]]) -> tf.train.Example: + features: dict[str, Union[bytes, float, int]]) -> tf.train.Example: tf_features = {} for key, value in features.items(): if isinstance(value, bytes): @@ -59,8 +60,8 @@ def _create_model_spec() -> model_pb2.ModelSpec: def _create_predict_request( - features: Mapping[str, Union[bytes, float, int]] -) -> predict_pb2.PredictRequest: + features: dict[str, Union[bytes, float, + int]]) -> predict_pb2.PredictRequest: tf_example = _create_tf_example(features) request_tensor_proto = tf.make_tensor_proto( values=tf_example.SerializeToString(), dtype=tf.string, shape=(1, )) @@ -73,7 +74,7 @@ def _create_predict_request( def _create_predict_response( - values: Sequence[float]) -> predict_pb2.PredictResponse: + values: list[float]) -> predict_pb2.PredictResponse: response_tensor_proto = tf.make_tensor_proto(values=values, dtype=tf.float32, shape=(1, len(values))) @@ -103,10 +104,10 @@ def setUp(self): self.filter_threshold = 0.5 self.dofn = executor.FilterPredictionToDictFn( - labels=self.labels, features=self.features, timestamp=self.timestamp, filter_threshold=self.filter_threshold, + labels=self.labels, ) def test_process(self): @@ -138,6 +139,30 @@ def test_process_below_threshold(self): with self.assertRaises(StopIteration): _ = next(self.dofn.process(element)) + def test_process_no_labels(self): + features = { + 'bytes_feature': tf.io.FixedLenFeature([], dtype=tf.string), + } + dofn = executor.FilterPredictionToDictFn( + features=features, + timestamp=self.timestamp, + filter_threshold=self.filter_threshold, + labels=None, + ) + element = _create_prediction_log( + request=_create_predict_request(features={ + 'bytes_feature': b'a', + }), + response=_create_predict_response([0.9]), + ) + expected = { + 'bytes_feature': 'a', + 'score': 0.9, + 'datetime': mock.ANY, + } + output = next(dofn.process(element)) + self.assertEqual(expected, output) + def _make_artifact(uri) -> types.Artifact: artifact = types.Artifact(metadata_store_pb2.ArtifactType()) @@ -146,7 +171,7 @@ def _make_artifact(uri) -> types.Artifact: def _make_artifact_mapping( - data_dict: Mapping[str, str]) -> Mapping[str, Sequence[types.Artifact]]: + data_dict: dict[str, str]) -> dict[str, list[types.Artifact]]: return {k: [_make_artifact(v)] for k, v in data_dict.items()} @@ -168,23 +193,31 @@ def setUp(self): 'gcs_temp_dir': 'gs://bucket/temp-dir', 'expiration_time_delta': 1, 'filter_threshold': 0.5, - 'table_suffix': '%Y%m%d', + 'table_time_suffix': '%Y%m%d', 'table_partitioning': True, 'vocab_label_file': 'vocab_file', } - self.executor = executor.Executor() - + self.enter_context( + mock.patch.object(executor, '_get_prediction_log_path', autospec=True)) + self.enter_context( + mock.patch.object(executor, + '_get_tft_output', + autospec=True, + return_value=object())) + self.enter_context( + mock.patch.object(executor, '_get_schema_features', autospec=True)) self.enter_context( mock.patch.object(executor, '_get_labels', autospec=True)) self.enter_context( - mock.patch.object(executor, '_get_bq_table_name', autospec=True)) + mock.patch.object(executor, '_check_bq_table_name', autospec=True)) + self.enter_context( + mock.patch.object(executor, '_add_bq_table_name_suffix', + autospec=True)) self.enter_context( mock.patch.object(executor, '_get_additional_bq_parameters', autospec=True)) - self.enter_context( - mock.patch.object(executor, '_get_features', autospec=True)) self.enter_context( mock.patch.object(executor, '_features_to_bq_schema', autospec=True)) @@ -193,15 +226,15 @@ def setUp(self): self.mock_pardo = self.enter_context( mock.patch.object(beam, 'ParDo', autospec=True)) self.mock_write_to_bigquery = self.enter_context( - mock.patch.object(beam.io.gcp.bigquery, - 'WriteToBigQuery', - autospec=True)) + mock.patch.object(beam.io, 'WriteToBigQuery', autospec=True)) self.enter_context( mock.patch.object(types.Artifact, 'set_string_custom_property', autospec=True)) + self.executor = executor.Executor() + def test_Do(self): self.executor.Do(self.input_dict, self.output_dict, self.exec_properties) @@ -215,30 +248,67 @@ def test_Do(self): class ExecutorModuleTest(parameterized.TestCase): """Tests for executor module-level functions.""" + def test_get_prediction_log_path(self): + inference_results = [_make_artifact('inference_results')] + expected = 'inference_results/*.gz' + output = executor._get_prediction_log_path(inference_results) + self.assertEqual(expected, output) + + @parameterized.named_parameters([ + ('no_inference_results', False), + ('inference_results', True), + ]) + def test_get_tft_output(self, has_transform_graph): + if has_transform_graph: + transform_graph = [_make_artifact('transform_graph')] + mock_tftransform_output = self.enter_context( + mock.patch.object(tft, 'TFTransformOutput', autospec=True)) + + _ = executor._get_tft_output(transform_graph) + + mock_tftransform_output.assert_called_once() + + else: + output = executor._get_tft_output(None) + self.assertIsNone(output) + def test_get_labels(self): mock_tftransform_output = self.enter_context( mock.patch.object(tft, 'TFTransformOutput', autospec=True)) mock_vocabulary_by_name = ( mock_tftransform_output.return_value.vocabulary_by_name) mock_vocabulary_by_name.return_value = [b'a', b'b'] - - transform_output_uri = '/path/to/transform_output' vocab_file = 'vocab' + tft_output = tft.TFTransformOutput('uri') - output = executor._get_labels(transform_output_uri, vocab_file) + output = executor._get_labels(tft_output, vocab_file) self.assertEqual(['a', 'b'], output) - mock_tftransform_output.assert_called_once_with(transform_output_uri) mock_vocabulary_by_name.assert_called_once_with(vocab_file) + @parameterized.named_parameters([ + ('project_dataset_table', 'gcp_project:bq_dataset.bq_table_name', True), + ('dataset_table', 'bq_dataset.bq_table_name', True), + ('table_only', 'bq_table_name', False) + ]) + def test_check_bq_table_name(self, bq_table_name, is_ok): + if is_ok: + try: + executor._check_bq_table_name(bq_table_name) + except ValueError: + self.fail('ValueError was raised unexpectedly.') + else: + with self.assertRaises(ValueError): + executor._check_bq_table_name(bq_table_name) + @parameterized.named_parameters([('no_timestamp', None, None), ('timestamp_no_format', _TIMESTAMP, None), ('timestamp_format', _TIMESTAMP, '%Y%m%d')]) - def test_get_bq_table_name(self, timestamp, timestring_format): + def test_add_bq_table_name_suffix(self, timestamp, timestring_format): basename = 'bq_table' - output = executor._get_bq_table_name(basename, timestamp, - timestring_format) + output = executor._add_bq_table_name_suffix(basename, timestamp, + timestring_format) if timestamp is None: expected = basename @@ -258,8 +328,8 @@ def test_get_bq_table_name(self, timestamp, timestring_format): ('table_partitioning_only', None, True), ('expiration_table_partitioning', 2, True), ]) - def test_get_additiona_bq_parameters(self, expiration_days, - table_partitioning): + def test_get_additional_bq_parameters(self, expiration_days, + table_partitioning): output = executor._get_additional_bq_parameters(expiration_days, table_partitioning) @@ -278,44 +348,60 @@ def test_get_additiona_bq_parameters(self, expiration_days, } self.assertEqual(expected, output) + def test_parse_features_from_prediction_results(self): + test_data_dir = pathlib.Path( + 'tfx_addons/predictions_to_bigquery/testdata/sample-tfx-output') + prediction_log_path = (test_data_dir / + 'BulkInferrer/inference_result/7/*.gz') + output = executor._parse_features_from_prediction_results( + str(prediction_log_path)) + self.assertIn('Culmen Depth (mm)', output) + self.assertEqual(tf.float32, output['Culmen Depth (mm)'].dtype) + @parameterized.named_parameters([ - ('error_no_input', None, None), - ('schema_uri_only', 'uri', None), - ('prediction_log_path', None, 'path'), - ('schema_uri_prediction_log_path', 'uri', 'path'), + ('error_no_inputs', False, False, False), + ('schema', True, False, False), + ('tft_output', False, True, False), + ('prediction_log_path', False, False, True), ]) - def test_get_features(self, schema_uri, prediction_log_path): - schema = { - 'feature': tf.io.FixedLenFeature([], dtype=tf.int64), - } + def test_get_schema_features(self, has_schema, has_tft_output, + has_prediction_log_path): mock_load_schema = self.enter_context( mock.patch.object(utils, 'load_schema', autospec=True, - return_value=schema)) - mock_parse_schema = self.enter_context( - mock.patch.object(utils, - 'parse_schema', - autopspec=True, - return_value=schema)) + return_value=has_schema)) + mock_raw_feature_spec = self.enter_context( + mock.patch.object(tft.TFTransformOutput, + 'raw_feature_spec', + autospec=True)) + mock_parse_features_from_prediction_results = self.enter_context( + mock.patch.object(executor, + '_parse_features_from_prediction_results', + autospec=True, + return_value=has_schema)) - if schema_uri is None and prediction_log_path is None: + if (has_schema is None and has_tft_output is None + and has_prediction_log_path is None): with self.assertRaises(ValueError): - _ = executor._get_features(schema_uri=schema_uri, - prediction_log_path=prediction_log_path) + _ = executor._get_schema_features(has_schema, has_tft_output, + has_prediction_log_path) + return - else: - output = executor._get_features(schema_uri=schema_uri, - prediction_log_path=prediction_log_path) + if has_schema: + schema = [_make_artifact('schema_uri')] + _ = executor._get_schema_features(schema, None, None) + mock_load_schema.assert_called_once() - if schema_uri: - mock_load_schema.assert_called_once_with(mock.ANY) - mock_parse_schema.assert_not_called() - elif prediction_log_path: - mock_load_schema.assert_not_called() - mock_parse_schema.assert_called_once_with(prediction_log_path) + elif has_tft_output: + tft_output = tft.TFTransformOutput('uri') + _ = executor._get_schema_features(None, tft_output, None) + mock_raw_feature_spec.assert_called_once() - self.assertEqual(schema, output) + else: + prediction_log_path = 'path' + _ = executor._get_schema_features(None, None, prediction_log_path) + mock_parse_features_from_prediction_results.assert_called_once() def test_features_to_bq_schema(self): mock_feature_to_bq_schema = self.enter_context( diff --git a/tfx_addons/predictions_to_bigquery/testdata/sample-tfx-output/BulkInferrer/inference_result/7/prediction_logs-00000-of-00001.gz b/tfx_addons/predictions_to_bigquery/testdata/sample-tfx-output/BulkInferrer/inference_result/7/prediction_logs-00000-of-00001.gz new file mode 100644 index 0000000000000000000000000000000000000000..058b96b6c399491666376283b00ba089ae13a23c GIT binary patch literal 27429 zcmV)5K*_%!iwFP!000001H_$oT$IbN1Q%?(5!vk{gBRyl2jw zna|8b=5n(B&w#Tly^U85%2I<;xRm?gh(Sa94CvLhXGCPTA^oB~el{q1BEIU@e_+3e z!5$+GdE7h<9=Y;3vu7^KV;99!DZrw74)bl+ZSY{QR_WhA^Y7a1tdvv=G#=2i z&(J@;?N}K<6 zzah~*di$zTmj61sPt*VpH^o(P=@c>8{8!~}9ns73j}*oHvxE;A8Zl_FQbNf)IC@CW zVa>bskMPK+F3| zkZ+&CQPEKYBL>$}d=!t&-zWPQwEjYok)>I-^D_CW+0V;eacL9r)w@sHEWGIl9(By8 z<`y+1df*WAUtKDXv-uA(^I-lzmvZ@k`a7952|db69*+mWCf#EEX{rSM2mmX1^)w!3t(0-+ZV9(B2SB)-O_g z&XITyHr|j)TL0sPYW{HKHhNKtWbBhK_OgZkyV!zGwP{tXMLvX_mA;5eHUeLEN|4SuT^C&)=f!U6vs$%A+dBk9|FN!dq zq;FJr%MTyX)A!3hX67P?^y}x_!}>SXBF>m%`R8!rU;E4MoBX#IXqtIB|G!RN$nl98RD>5`mkr6|K}0ir?)hoG$?aG4o79@ z@M+eqd&fn&=|aU<&`D4C!cQ9hYGllthjH#2&unL8CDwdKp_U_};&`z*DbVcXWsEH= zdg2>1Me3Ar#QgFqRkLB`ZMLM6&7(5k74IThGh5N!`V6o{v~&%r}OS3dr_ZswkC|3fO0{KEDP> zd}aP2Yw-_BYp_hXrSY2%({5=z79U+JNnZN+;ciEt-a%%tVY2cOjyK9j375LRO(su< z+A9g*cQv=OYTY-p(B+dM0Iyf?|4I*6hksw*ZzW)d|HcFj)h2}Fu8lhFP0oCdVZff0 z#yK(KP7@`8wif#yxH+yO*bBgD=O;>loP*8gEShkFzfl4FQ`4u4M@;%TcZrJ~cmQZH z5`=5mdPbC_1DOhp9-&b*yXbB#66*?wxw&4@U8TRjuSe<*4~X*o zcqrZ`FGZg$@(n`G?vH%Ym14a2uMCCwS&H0Bg<)fY6J>-kIXTS15 z-g?GKn9Ml5m(9Dhi!Cb+CQL4W)aZP>OvV!H`?WNt8In_R_DtdE7 zB!YavoFv1kC*<%V;O|MxYm|CIF^+#?D*v0~`CSnBkh_Kxw8l~w$82%GhYGjVaWC4Up@I*;4nn{4E zJmpO)J7TbV1-xx>c6K=n`16b@MKnZKu#Mof$*@b_+3xf^v-F`{LHQXZ?C=Z)3FgOTT6 zfFWOrio9oIH>CL-1%lw5m_53>m%^+}?;<>vnv93AH6RQ`z+>WU8YScbJDd^rO5tWr z66Vr7m8I-WWs?hn9_(rQ(lUB@F!+b|BF@rBamVf@DrE^VcfpHJ1eId~AyjhG3?5@R zzqM{2(Ga!6=4RtQ^$pZ8PAzG0ipW3chvduh``Nflds&MGV6n5n(mzJ&8-wNKs9iJX zaN?IUl5T7=@2It$rMQXfn>Zt1&E*hXhLKK@{Qe{nOOcEyi2$rwq*pqpIg*4;Jh++V z8?=sW%&j5rmVzlZ#uEz_ zMDkYNsI`VjDn|hD@EDlVPmvr>*}I-aU0BV|o*4($IuACF7@Emp>#N5U<70!e3FL5o zb`GazarlEl8EpWgXgD}S2pH9?V7M%HJXZo+!2=)SNR4*a-*H=rzi)nj%ccFu(3aHL ziLmZNSejfsXZ$;Z1$m!l3og!wD4f)4_g+2XIZ`l-oW|+v=F;Z2LU+OiJ7-WTLRJ~fO5e6;ifurcaB-mUH92uk?p7mN$vn|8q`PxBO&!)%Oy2BZ)%mDB;8oOa# zC4K0^@F*IWpH7f(wM}x8AyIjTe5P{f(*XPC>DA1Uoy{3zkXhQ1^nFKgE=5V5oY{gv zTR_>Dr>lSO6YN6!4AvzTyc^Ff+P4!mJZGAKWRbUn{b*PSf7%n#9XSbOLe zU-YOfsUSHq>OKwO5l@zn8-ro62bO{n$(O7IQorDl%Vt&bHp~0j7NH_4C#Qv!rUSFE0B#`w z3H)%=+l^&nD{{*ysI8ND`JO+;s;xZDdUad`;kEqst=LxoYa4KW(ZqPvpiBnw`X@VH z_p3Id58MS%1i_C%ScWi z(6t4F^8|tIp$c-gt15_+X{7J4GR!t)z^#JUk%|V6bHbIj*-Z39rJ>S5lZpCrwC7UV zZr=;f+0L>~>h0MZ!2IsE21oV1e{=e*F_thFJ2Oj}PUh)my_R9(k!Kl1*xV9Q_)F?6 zsuM15L75W#@+{fir%5goig-OTzgUPC6J05=fmK|wihb=r0ZgsU%5_PP_k)e)(q9V) zj(W7hn5-`+g$W3vaP|~tsRo(8org;hFeq^Q4G-Q&RpzE87NNuIePrGb{kh|S&yOSOfnPimW&r=o=nU==fAV#o`x|@0ocbaDoEIE-2j0BFN zzJ{o+5{fqXA!wTX!u?6WJ~$Q6-mkKotq+_50XDPj^U8W$nJ_;_;>V^`Yq2rEQf$30 zQjlzhm3J95z>MzWS*?E6!+%ja2if0k$Drf!IE45i90|^Y&36jW zr#41{>Pt7p@5JOxNo8eQQAAV3)>^%`js~-dka&$Bdy8F8y2tw7{}sZk?}=}Wdf=t4 z0*%Qa@xwR-d%4isoP^n2sxP>+2f`2x+ z(Sr{5u#0SD#s%iw5xgG1jvSHV*n;P>4Y}pCV)*AmfzyVwNZfn8IIqn>G=HgRxP@Vm zsq~3eiY9waEd1;^o)b55!uwhISF#HU3)pYv#zKH~3!k^vv4V{Ob<(g@7ss|@V=BrG zZpyOjt(h=3sp2y8;tj%v7i$^E2**b8|eHzkp zL`w}zV^?3MuxAHBIpfr1pUHZ(*4d#Fjw*1B9?v<3=lNl{7Z=z0>9&p)#-DB+p@x%_ zhi(5TOkZ8Of#BE_1k3_)NNsO?lCZurma-*LbJ;&Jqaeashy2!A4-dwx5ZO4O?S=F7}#Y7yb1y4AK(MFF*qDKJrOkp4II?CLF z=0SYT+WK)jJv!zA_#qbiU6ec7B8!>c z=!WTJWg<^`RnntR!qelukw4?OYEgo#bosum`OBpugDD*tHN}xnBSNsU99I=P#gN z91Wv31%LH~gj0o0vl-D3wryBAgEOOkl$VOjcWz4s+e+==*N&G{ajf5~zGRa1_P&Fx zV6g)%<~}&?v**j*-#L23)E?^o(c%Si9@z)U$8D$75+gjG<(13BiOwyGjhTuGzUO$&2B9mAQW%kZ-Q9hj{TX+G#XnjK@wMvabkG0f6-miQaFXUcNTD=!=Fd;1 zDqudyv|C}hv>W=4l@fZ(u3{9AWUU)C0Ou+)pk+s;W$dpBbJ?QLM?-|I9=`mn9v)1i zBK=(!9_dpf79j0T(NNc+F2*V0;=DyhR-!U-3)UCF-Wgc$h`cm<53-re1ZBT)YnDm^ zl6;M*gw7&`TXagUplwP{zHj`Q_iol|`BwIRf$0!nM?90OeVqwd<=KCR8~-*a+d#mU zWS6$i%IZj(Xi&z}6Q;&UkL;0K6sLWVp~;AD&eS(YTY8xrb(h2ZVu92hPjt;N6}Z-1 zXbm?=oh4_UEY}l|C~oV&juk$>g1spT_K4I;xVlM?;r}xGsGOW~Yp4&4%2lk=V)T&; zo?!rehA;D-kZb3azdF%g@kXds1psrsarlLjaVw)WR~Y{#81 z)AShr&)M7{DLLh(q<1^xbi;@AA)SHr(ep*H({@&ZP`b+$@J<_yu;1zuG(tvp5dOn` zMUi0)784{9t1&Wz`OV8<=OvGS$0$BW1 zXZJs@;cbG>58#Xs=d`3@5uK2UHI^CM;Ln3B=DUNedhG-@yb8FD>9hPZ0`y322>z~3 z#akBz276iid1Xs^`lXf06n}LJ<|vbtFCd~TJ!v-u|h%JlS>bs4Mw$G&y9$~w_ zzR5QIcAG`j{0$&_OSs>_$BrovLyI**IP+rA~ubr8eCCKs^L_B*psY|Hbw$$BuR;rW>007Bm4jLRjORN7%|lV z-q3-yJyY3;v8gPgEjR=F&WW}K_2^2H@%L@^b6$;m`R>l9VfQl+tcKlQuerhK$uCAD zr~rSoAJQDF%W2DkWo6+4z48_TC5cyL=s|X^Xae(0n*-t1?!zh3`i@x72~ps|RJ3I; zz{nlc%jwL;v9@6;=U26(MBlhv-f);r!5xQk*D##9k}FAdpy?`1)79pmR90?7DqCC> zRP^uo*mJl(J#WabtZA)+$W}Sov?%;>JBkJBzYZmpuOTZ^Fs=A%FFVSW=0^=~pVa6X zYr7^HsTHxa{=Ms;1a?1l4>NQG6BYk_`^|7Yycc0o=!(;@!rB(vYA@ym@W+;uwpx;w z(wmPNG#41%P9g$3Vm`tflv#LAJhir1Zzb8-6{pycRj1j)*I-q$WP{0nIj-MLx3Pbu z0eWHj^v)d8G}b)>0<^TMNe@%Jam$su<59as6(rI_)4E(wfn;|iUrup{*pOle*!ds9 z*sIqNQ;g#VJ_)Y2y8VS4CwC8M?9hPO5zU21un?vTacn+=!DHhocjW^90G<0l0|Gg3 z$FvoP*r>M&%()o2FPaq_y-ufz7Su2gZuwfWdH-YLTnqemlzy5f<3UXzwvUU~wLxgC zTIZ+yl!V$LAXB=llg<(wLq+0sXZ~I0dhH$?`3&rKi(dG?yB_`ve>}_YF5p!mr3#1v zA*l8hCb+AltJ{HuYPdaA>6HfhIqh9nhA{=HO+1m26Z8+jKJ2!geYtisYcPK@1X$_F z2Q&5YU-+j^!ETk$umO{TgHQrOv_wc!W8H8Z7B2=m?$A+mxB-ex!!w!6^Xy7rh4{cN zoQY!11Macv1@5!k+rbg&4L0}4?dV?0-SE)45KZHh$OjkoCWndcX6N^}nS|TJ$r*20o;JDEF-5mQ zczsu{{W*PfhWK&+LCMDpd{IPtk@4sf#FwN1;%Q`yYt_h#H1_r)j`8zkc%q72D=<7C zjh6!YxD^5=^q~ctj*$nal|@bavRCl&<^|Axk3rdeH$pD*mUI5Wj#EY-d1xEoLA44?xWKry5`3d zAttort1tb0FQsh)f}|hL3Ekg!(ruHv`o{X$xLlI3&)!dE-Nzqg(U-x&)M>|x1?oW< z{=4YS-exT40T2muLzQYy`5n>FMF9a~^uynPvd(DHw$iv^Sen{lByN@v5bs#0L~^pi zBw@WqZDfBmTFW;0f@57{j{Y`Er?IZcnU$KCh&5>p&{$_#QIR+H1V06B5*y@%FBB;T zUZVtGIu)JeC5ij7v7ux%QG?-5g5+!Ji?{5fFI?28CBZz!kPjZ-)Wd@bi9z4XffdW=WjLZ$O4Ey&{l9gu6(!#%*YSYF#PN!T~TkF#Z4 z(^##3pwuw^(yP`wNeyKpcl_6Wu_#Th9R?>0KJS5Y38KJ%cfRH{qHl*m&0yMkA-VPx zL_J=t%_bs2*5Bnh0~$!azCQnqIqi7G?)KOS@%7=XbMtiS=%$uD%E{&e@u1SUTAezd zyN!07SX$pyG*yCw4t!%-;q9?xXoH_Z-{bf*HOR6D7>0Kc0bs?Z>2^-Coy#d$Q~~m^ z;-mFLl-q1ZgS+f$oz-}^{!5HG!c@9KJw%#r(n*85?{31pJ~V!4XI6!-!hqsVRfN6xz<-vo<&kr zOT+PL1Q@I)GpxX{J~FFqS3l&0(P?bUrzvb#l?4!D8+Y#LqR$*nvW*bT9a-OYuq;bt z1h_r69~#Hq9wn*S7!MV@F*9@>;Eo4z0oDWw!kLWieyMxSdqdt`?AGM1tj#A=A;@;c zRoJP={Bv?3Il4Ry{s_Zl$ED*}g{ZGV4^_|GG004hP!{p|xu-G&(@n5a5A@8tE6lV>NE@*saQ2qc>T$CNf8CfX)Xi$DI z$W5i3A5J686M5l>rtXIpUg}pdUhT!po#JMJlW7#LHo~?%+#cXI^fJePa=w7p0j=K8 z*8I7Lt;+?r@^?i9sdCSN0Hy(mQ-PSGn#a>-=UlzeHu?|Ovh zAYPSYJobLVOhAkBorKIKAdc-Qzl(iZ6ufvVuBe-=?*k&|0bTB$NDPYPOraM2SXADA z%IK0B@+X3m?O~F?5t1<;p)w!(dFUlRwBW8H!=a{Uq)$G{3RXYC@JsFOU zQTW}WZ-Z2+K6$98vdw}eBRU?4Gn*jaq1$ZgihHb-2`m>x7hAdZn@q+|sJDWR{}`0* zAY)6jzYJz)y$mK9lphVmkN$9R5?a^`Hjy%8OIsH2`HPNPg}&A6lCtk#!J(qNsp8eW zJ??V4R?=)rVXF;6Gc?ilJuSUHfQ62xi|-AK?_|>sZ(%?F0LE=TT^g?*p2?zn)n#tR z=LTgf$l~JcEY8egae_e^XRyzq?I+*)k&!j<3(1n@Bx%J@0zNq#Z>RUJJDkys3`y;^ zXuZq)#)erML+Q$IdUNt6F?7DfZNPrE>_r@V|1kJWZ6DY6k{%^4tZO{fo~L`?N9l5} zLx~}=!mSEUPGrVruSZ^N{tu4G%W|m61;K>{^8E$l_Jgy~`{p3+=_<|si;b)BoRxgN z9^xxJ{8*eGMSASB1fxr~piBr)3<;mgWeqh5(P=H#035h#b(;Z*Th+k#th*l|G{#Yv z`H8RcaTCYbeqc_sCrQ{RK6_Y1hn?(Ud2spa>w$%a{0B2zC{vrntFh`a@)w!z)J`D&LFI&c}cvkTz8| zRM27@+5P)UJD`Jv!ilKBN2HqgU^>D}~n$Tm=b4{Tf+;^66V(F=L5Q{#I9 zJR`}s-2^Hg|LvDFN^(~8V|R`=(w9ioN;a#EKYf->b3V(;7hMMNHSqk`ONVCi)%xl& z#rW8uYy$b3pZ&EtHS4wcgFzW>5O{R+%xXlLqSUOqnWfAQk$hirPge5%$jQ*~#OKI} zMpBjy&42-*oTPV|0khapbnQhfoqucSeUo+fy2HBWSp`wp^T_=T`ZRrFypflN6I~PB zNk@2JX%E(MtW&ROS(XL3TmXm3_I5MnG|3|SaGiOGKesCZCgzT(X!4mP>~6kwYuEgIS@LmTXn+kT^i_V04nsQVw zi-W`Zhi75_j$@f(=+f`ku&(EqvUaP$;X%EJWVF$PFw#?bP|J}$mPvk0j9(M^io=f} z{;fg{2R50eV{4eFYuKIy^Ed;0=!oRAw(_>1X?65+KJ5D&@oY@jIF_p(n9XR=;PGdA zyal)&j_6WV3=~Vxxo){w9Po9Plmq5s@KZfZg-7T?k3&>( zNAh*a`#c+5{2V*oWI4pwqAd&0w*TK-AlbR0G2Nid0Qq{8ov%k(eBE$Su96ld}1F%8xbA;CR3l?r{thuO=omzlBRJ3S)DNr3&usp?C3CXgqno4!0s@l8H zfWgs&x()Ol2pQOb=x&30MEB{@jY{@<_T9^QN@2w+y7hD5KGwc&99vchtSa@(JL#Mr zq9K8WYP2os@o95nly^l@PN3F|@*LLvMfkz3&UXNAvM~l(n8)o)TqB3EYP3og$j2*; zvh8d!7_z;kNyujXkjP@L9$+Wkz*Tb1?tlECLndTrI&XYwJZ?~Cf{?w<4%uH>kll1q zu2G5EL5BVD_MwBG{Y*NgyVEvAx8uY~;b0)P56U1XISR6Dh|?ZJVStM|^~#+UEa2Qv z`?AGX_OaE6_ptOfv%!@)1@f-d!>YO$$UkEuad{A6!^kcA4(ahE0iV*LX~i9G1DsYH+zt!XEl8_<76+eU9cG**1fTa6Z%fc zji9jPhkVqtuErM!$mqh!={*gk;9)cMy|;nsd`G-*tgGP!mXK&WzT6(BKlZ;`}Y zypFJ!3|x4^oS%p2;mc%x@PedtVhZ9C+mo_WWxA=u;g*od1 zf@zqnJMtg`o_nFAMuN3vmND6bAFNQ*`vP?*O?o^(FHO%B{q`Vh-zR}>YaRn(cI5U~ zef98V_&0=WiSLdF{vd_WxCZ@7bLrd}S_*pi^g`!iTq}(wkj#FE>)Yf(5qpzEbM-_U z7e1mY%qb`K(<_{OdR}DH2cKuB8R*MeyA^+|M=vKCPB&(BQA7STAbw4LbRNUH`bJKH z2pdIVvK1I-cw=i{D){X1**y? zib-|N;-7Krl6@LU6IJ9pIrYXA`Q#SW;>|qZX7hJ;1)G2eZQxpU zNhD?4Mklgf?;m1i>du8I3p{)Bj2_n|nUrk+j7rsVec5PcEAEH$Nbf$fL`NK_86G~i zqNq(7NhP>7!+|5zvtvE*-P#`<( zw_I*|cryIQLvk1)wCsxWtdfx6ZSf_}^Q-*{bkFD<&Q|i4nCz#N+8 zoI@YE`~(%*m`aQ($m@>f@yxfQ=4-@DQ#zTXEU`sA`@Z%zcK;_(B-`z)4WH`a$+T5f zLO2T?4w2JidogNOxXwOoZvZS=eZFqzMI?hyRx&P*L zc7NG1mUq}ZJh&6#n3%4;lSp1?G)Xhp_b}}lNt?P7zEjbQm%JL1K@1{ql4FAOVuM_5`B{hTt z;WuqRHgNDjdE)S36tSH)G$J5xBAZA|rZ#k!(n9U}-FJyP+zgZL=u{yg3-El-f?xm5 zPJZ(T1li@UCSTPDGVDmPv6IMI``V?vPfqG+yM2==IwYC>maz2jFcm2+zQSD&qqpDJ z+Y-07W!gQ+gAd;?v8L2=HoyTnzw-Nb)fC!4OsveFPjmoYU zIK_-zPcV<(pxVD_vtNemftmIPCj9j)^1qhMErbV)WP3tA${{2+*iXGMty9EtYU_Pm zz#jAAP9KM?eS(jW&dsV`7Q}3W#|d^aHjNE=u?WI!(-*~b3I5^VZ%;*$E@1p*R}9lu zpdJ7BV$3yPlNdlbm}Rg7NU{;BYV!syupX?+-9To1EG2Yox>Fkk-DhNX7jh6cGb^5U73Kz{}#$%^QeuQ zM=*a+mi#J-zXr##ae20}{VPEQ`~zJ6+S&Uh*K!y~U%li;Qvn*j)U zyf7Uv?aavVp*gjjU1-$64r&C)?Jp9L(i%23goJFi+g7$WdOfQd4(|T?wR>tA2hU(J znHr&LosbYt@NyWok=@L&4$I?zb&C%;+omT^yzSp%w=?(_Z|hm2r^DdLLxx#e}_Gwm3&j|!FbwtCVQT2gwk$jDiU1l{`D!KgNA8H_(ziqC0} zqXO|ctqy?EoK!Y9G=NmWR(w$sdn0i&)B$uMv;NMl>~{YREbe_U{GY$>mx7L)ji*&# z7ivj$!@qqU-W7=6ESl!;;HQ6l?>B%*Hj zlx6AXiDlPOSPeK+}_&9s>(=pb#))EM^m6NwrboemYAgmm?-|PRw$p4XJJZTZo zUg~;ls_qA`H@lw8zaW7c{zk=7#H|^-EZ);$*3W5|1MJ%EJ*-_mFfOam_Nyi>GBF!? zbKnhQl0o?i#O!%?%foK_XX7$ljD{w(8G(_v&QD#tBU%V zV;qx&6wMah=v|64=eRB*-;ji_o*i?FcA;XcP)i+FyUMM`nFLymQFz-CcCqV0w!|A8 zBm1Uto>a&6mHa!Ane)`@H}taAhhpoLC=RWQC&c&5;9qreBhFNcW4vUGNOmwxc*2Fr z)Dp>`2k0Ot0_~VZ6tv8EF4#_vDqQ~{eCLbeq~)5Otd=2mp3{=RK%h3 zv{58wM_XTIKQ+I^7F1pXQFeC8=*}H8DVu-S{k8FgL74?oHX{4d^2b?}-EvW`V_hY2 z-gfrxVrn4#*@Yk4pN)%SsmKNnNnnJ!?gbyBB7UDM6Oh?`Y!$8$W^a^uUM{Se##$FU z%8q{yb}uDYsC8VAg{#Q5WAf)b3)8`+40hljUP?5Nzxrwy@GKi_MBkd8KS4 zGdWRFAeaNY4IJ{$-DO&a_0MO02KR`|(@HULhu(YAGWZgGC zWpkfAW1CNHf%qDq)L=sMOun{us`Rlj(V$EM`FfU}uiIICU2sv(QltGFRZ8Tn6frw1 z@uPwdJ(uX_oM2*vqRF4s!uI|+7j~KLt0Cm9Za5?4QE#15(i3OJQcWaXt4t--pHfS! z?>9UP(bY4keh)po7k&t>I_E-TYLgLOn!t&^WbO@UdLwQUu^SSxzJ75-AmacgyRGMc z?p6rO*qCmZMO-O|En_A5dh_06*7)#ac6;Auh_9FPrwk-HFs#RQ%gvJL2HU z;J{~=v0b!B88@JE_gtvS7O^pX=#oOZ%ss1d){n_jBe!>bEXh~GK4-PU0~hs_x*Osv zZ?Op<>2W*CZnmLv3DMzl0gk4vdl=QYp=|H_&Zhw+W4qdTvs;NwT~3PEFLa?XJ&Oqg zQmPR2@%2J<(sg4#wMKq-^{=Z(Ai54;9UZ5K_Y#R(*Ow!{Vrs2iGEix?=0M4%kaAOP zQ^A)Dj<#LX?%Gost>@E@U`>E|=^RB(+Iv0N_mGW0@rcbcfo)0K+=evPH@3G?PR`B6 zFSy`17!h;@wHMl)8pX;EhF46qtZnT1GVuyec-+Z&#Gp(B@p_sauUlDonQz83 zBwp5m6?Q4^Pl1Yi1emp}oFzjx_z{L!#i)EqNMA@WJt7`vMPf4i+Uf)pz)}hr>HK8$ zJA%%7MSWRBy*#^w8qqo(g6m0o!;kfdypV;iJhxy+&tVXf*bk-8R`aV9$M@QqzrzUZ zerbp1G*H*U@x2&C!9bs?{+^cPt48(LY{|E8SWGXRwvVOeSuQy{9C?i z1E(_(U9LxC#^~X_?5N@-2PWBuURwK(a+25Wizi{onQQKy-Qp{J28D$SO{8b8B2&fS zq@t;cctZv`}6 zDj9_r5xaqLi+Ft9?2CDGs9;7BCIbAhNq|<~g~=uP`h147I`O27>U?$=#8=;9tB*SD zcI3RTV`IL+N)$KvwG;}!3lx64tC{h$xsL#GI$$vW)mrq4m)?XC_-N>z#CC4#OP6aO zcePxRB5Iol#~`|Hw7O7N54zCGuDF-SKB_-ylQ>O^t6;9Zm@buIfw%7JHLobuLUK}1 zGH+-VFj2;K##u}HO(yFV;O?oK7L-;u{&Nzd>tnxSaeB~&ArZh0HDNCARVE$_BC?rM zQtN$-_UFXRe? z^*HjLalb+N0i^3;cDk-+(RIp2IpLzOionUqNM&JDCni%B^1Sc9DIRW(@zqv;w2~~- zgy}^#VG?(qrDaO;Davtu;*II2=-sDk^J{Ebxy$U6BcNLK(|Tv~=;5`qf6a>=Npw9c=wlk{tBAXNaoav_nYdBhR@`={4ogni-gB5G6#ueEDMG4<2_Ug}lL zG6!2|?uYews|Q^ob?mj(MH+d&8he;`0OGXZ$YN&_j5ezJ(6=rn0|q*9Lz=-%5-Fzbd8`9oopElc=k>rcbc*C69IjjBGfTiomLj{NHQM!g!RBOH<7uS}0^W*eBicThV z{E?9ZWHR8CYs@+~>%Z_f);8hN+H&^H_If$XTHGWh`kYDg7)(GTHwl*#;;Ysb(POv za2H$RjxkL$>AD>d|EKY=L74#3^&~r8H?!z6d#=+O=%PO*a_GFh8!5JaO5w4o9i;Y+T!S(Zd-4dE)!u9p9+ix2W8I&JExE^PR>qZt_<~#8e zC8gg}Hn;8L8$JZ`PHRXnS|5R_%c~I>U)z;IT`(xJfYwuT?#jLzGV)Y?Gr*UfQQttK zwRESax*)N%TK&jr2(6{weLizs_Ea|Oyv2~4t8V9>B?3n9ySj>otRk5d3B+c3C6z@O zudYFFg_ywJG;@rFUJc@)iEN~!JFvUlV;{V@%Us%SfaqGi;&ByydVuf`cN#Vf>)T4$ zYgooskq)jFm9hT4gF$Nwb7>_6LI=D(ajqH$WzZX)KLvApGIh{e5|y7ha}8>*Yq{0# zHNbd2OdYz_(RASpN&WrFWlY$&%ZUfJ0?Gjb1JNMA$O<>AuGsg{ao8zYVrijl;VobK zCgt}e$oQ_l`){(m5jWVg^tBLOD>kejp~q{H{LMrj==e!=_~p2U_1yy3o0HQ=^ah0Q zut3gFWS_S<<1jySsu4zN#2MwpTexL>G)|#25$PujsTH~wRr`FJ2C?-+(${tLNTXtF z%yJ&J*v_EX7!$K&G(cNS;(D(#!SLTmRc$Em(MAXFsZKy@fV=C`*p=mDzlrQ}i%8eP@%-^PLE>-x*@ z=;6C?P@a628?Bf6YueJJv4=PSK}GXYT58Z0ukeSXgdAL0rwsZ6J|{D#0(FGA8IW-) zqi2$gbuXy?x~#C;&hsb;*MX-c_3&MKCX?^9u=oBWTlFF%Be82N5TDXR+J68;Uv~&~ z+Sk0x-Em04RP-J7cg0h*?<)D?A)B_~5lcw|N8ZHTF5g`b-zC@>Bl@mykUNi_qaesP zlZUr?&#SKS11H`L zx&GHUJ$#p>qCJ?Ggq8A%l+(fvAkf;kMg;W8(L7s{VcUd@TS??mw{Y`s%cNp7#q3)VgE!kJ`~KpV~Da*jeV{?6l6YvO#@|ttrmv_DjvP zY@{@3)~Xd*cdSh(WS;o^f6I&B%SLWb9f{XClasowhO>I5I5L;q2nyjM|NHew-WL<&I%DcKnHW-4dMpYS=f?`3Ed#ho#;$ z^u9}o2YrG{s2`pwt+omQ7il3An_g4Jyjt~Mnt2 zZh`8Grcj16hykbMqGXy3TUPk+1a?!dtu7Yce1!FY2>a$h6PoF1$nU%bCy*hnbM%1c z;ziX@hnG+vuL7q=Bz?W{Lw%l%WKvXOl4BiAJ&;mG0v?$UPUxk`E=%~(KOzinQJ1_L z3{HcZ3AYip+Z;_|s0LCljb7a{GM^ge=B~On0~;9|SGst^QSCrMbVbdsIQRzd8q;g; z0?bNNf&Bd033;*10D8BQH6KPjN~-*R0vMr4bzReaFm#vf_odXso64wvPXuqq8oiAX zj!kz(H_gx>D8Po`2eee0WB z{>k{+lYdxht5@ts{sHeQ5Y=s9OHk7ngB&tILa+;8Xu@n@2qN_5cuVRLHp(H=D$$Zw zlZqMC6oACRxp`tBn9Ak|;ty-lZn7z@z-d~|M=klXg8HETB?zvlE$PSf@LhVPJ$XV- zvEqj2f#E+_B!j7s(QHwWpO&R`#8~_~C$K*27I@$%lez!$F=r{-NhCZb@!I>3lR9Hc zF7-S?9)Tj`Qcb4O;RG>vtGHkh z9ZYOXoLJcGcGsdO4Yu@FdzG%BZr^YLf@|OC5I=oV^}vHRq&D7_=ufsC4BqZat*6)Q zlcrV>WCT8xR{CFo!-LW|!=K0zK-_ZYamj7ng1+n0ikGbC*w@VM1UR_s+KlV3^zdCM zL}c57lT&Nz{quW38g_5ACV=LY{p?>GW+7Z4!}tAk1EwWBQcVzlDlI+C5uqbJ6>e@} zb>5_6YM<+1$Jw}L-;Z%z*G3!SbmYfH9(RVx3akxJ6(L*N-lym;`ZzV7o_#{@5whc@ z+^ryb=jm08dRjr-yaN)i9c7)=3+tTKggoFJ|jzHE(Rq z4;?*a6W{(wWEy|`H;&yk+r$i6>~DgMD7NAz`Kqv3 zEtRO5K&MlqcNSBZzAC9!Y!{1;x%3#wOEnj2R*mZA^#3leF88_)dYhog;cLb3eUF7qrUdvyyLbYBq_oHB3zrnNhXq^^eQ~6-{ zcsmjw#Qv3r;)#tj9Ia3@OyEK_Z0gvSNW_S7Tt6yf38%lzMSHJ~7YeBDBMYko-NBZ| zA={E0{wM1g_3Emi-szu#QbPRWrMu;_tux_AIOl3Ark9vliCa^G4Xl&&(+8x*`nI9k z962GP_>iSdQ(V;9$6eJt7x#cNoV3bc>EXTLuh$uuuh=0-|^PT+8XDebE@h% zBxGKzCG~3E`5*rn@uZ(WLvL{(&=K}IF|^NjDDVM&MzD-iNzcN+f?<|Yg`lWpdjpnh=e~3ulDV?Zspn!C_jHPK}2XLv|%ZCa9jEW+4o zm@muO$3~{p>e5oLC*K>?_ujawg{CDye9hnf;cPwl(jJPCzf3r$tLN$-I0;gmlm9ZO zf|#IbMJ~+eQDh`y0}y&Z%wfR)Tep(aR(yRpMWL>5|6#6QzGe}XwnBVeKX5ZqkK0kk z&Y%31CUb8}0wG_$xs0|}8~trJgNbM?(9Mh-@hHLSd(F{WIQ1AGgN24YNxJIxE}%}R zUPxWE3e2lkc(NTDXPr=t>v>A4E|^J&gvgKKt)cV$J_@FdpY_+F}0rI ze8DNXfzSm$pMEkbs&*Q-e;}yDY)-?*-&&BwO~)t^Jf5X`WUcEPq}@KP_nPjZEnnrQVBYfsitkWrRL}e=>wy~&d(^P=bJyB zYPrwwy*6UVA`w3!Dv0S&}7IIhRK}<;5LPS%c?18ettx%!KfuZ_1ZSk@ZRm=E-$c7m|moEUZqd>8ZLuI0nJh&^YCYz78X`+EgqI8pxXs#;q^vJ3ak7B;V(!sVX5K*B3kG zt=8LDUcK7nGDMeCz2FxPRN9*Y(e$2dfycUPg*O*eubWu72V1LdB#?e6aAsJ+$WzHR zHN!w%t)12$!(_O#%aMHDFZqZys`8M9b=?f{rPgbk=BSZz)J|*sOVn!btW5$XT=fxF z51a4_{Lwm(Pp1chMn-;2IbdxuAWqlA!KldlOig;q_yRBWx1W90d4<5yC0)ICTy$*C z%QiAG*7m0hk@J_P+D>4HPx(vXBzfgsZ^KZWLO9B?E})iQa#sF-xM)3tlcsqOOVjMC zMAEhUnnCplcT=CP0P8`stHhOeT*h9T>Ivp%7*Q6{b>NQ4RF{cX1IgWwA9|%~7-J?U zZS9Fgv42&RUST+CCcc@@6w1lik8bFq)~f5Ou6hJ&39o&<{g|V2UK*P_IYKy_TbE2y zHKy(mz15wtVGJkE3TyX$R)gTRRofIQ=Ma-_py!xez$R*F8m&h6|HqV2pA_{`XMcJI zqHFCR*NeB!q^rk<$QQ;`gE9@I>s5BT{>-B5nu~H7R6^Uc$jiyehnLF~9TVR7=lxiC zampLc2%E3)U||09{|>5vVrI8()SzDGzc^xmV$5Q zyUzW|Dd~~^?lex@vDiNsVtT=xU8NK0+{CT&scg%`6!!k2xe#M5eaB4GgE8b|S+pVK z_1i*)XOH)dA3 zi7&cli9$W8dv`wqjHiVW*;`uUs7=IBO!OcrlgXR$k5I;9WSmZN(o#gBYc(p~t}2*( zlCU33C9)&;4zZf)F%V&w4=1L5kxAIX=LZeO2L@#wNLY*^8)1_S|3}zJgYpK8^SR$W z?>k0tv1hP#cpjJ`4@S6EFjDwkYr5Wo#1x$J9^pzrHt4G;m6Qt2qkOyfi;C*$JNVu2 z6xBVtTb}_DJ$)mi2Kf$-_{w}3wRo0{o8;NJL!`vZ$*BtVl8h}&rX&~9E^g|=8|;_k zx7o0teuV&x{rU8Y$V>nSoL%W{ylPOEf&l)U9l()U0Gb~NkCEbcpoJ1o=p1r+@HfLD zEO#}Y&Qj^D6f?b<8q3x;?q1T+BafA)R11i zGjX=y+h=f8bkx9z!L=ZfBWCwx(ykOONPKNdV*>`Ivh+RkAnHbB9mwY3morT4Zgtp{Obl!34}!{=~3$-#cw8!gjNB!G`&hV7gueXq_gM-{BU!h^ov zssS^Kg?w(PwOupVZP{7EO;{iCl+uJr42?xiqBzM{ty>4!ubu~3 zzTq<=zP>VcZ=(la_y@+n68!6w;FBUyD@{(Cf<;|2JA)tikY~G1?&`%DG?KZrU;fHX zJwj?p#MjuqH5WTpRvj7nGiMW>58b-4Q$zMA z=Xa!*AY_!MYf^=o6gk?uaOpgkwWSY#HsA=lob{kQ*GH!gW95$5C1n&!Bw#yV?_^D? z{lVO(P6Kx(57{zZ55Pzd_ zJmIeL=1>msFfzvGuvDSRG!9b?ZS|yYq@r}@^|zqa?Cp`I?CSh+5MZm%sCU231niT| zYYQ7+7?d3#V9T=mv^iP%{+|rW1Oxpenjibkv$4aJFr{7_YX!x3;GjPJ&20Jh?9zAl2lHng7zOi zLnY8JO4&2%pp~x+sOlxx!V7kF?sg^tb2$^w_P5^6dKhLvfITa8C0vgx00~iPtY{L- zIeFb`L4ovEM)>S6c%gO^@UC-4vvSzZ!E@WGa)^`DBns}J=_z*YCcaoHFa1XNWYvD= zRwjWxGtPntJFk4QP>=RrOgrjF^g^S}s$UzNeNyjK1Goh})@jeZGN0<)cYRmFUa%_9a!`woaO|Ixlt-7P#)tD3@ID zBNSgAvtKT{*ElQVBjht~@s%Ejv1m;?Qkt|Lo3_L+y=J-Yt6ZEJ!ogHR)B3)lItJ~^ zeVhrq%063tl|4EDO3^x0?fr=!if;Jao|Hxox{2WONGGR)=wyCV5Fl$I6w{=bP~WF3 zCyr80)<=Tv#UCQO@%#H``5e>iN-~z#GMSYulf;S)iG>*3a(wn|Jst$wB-4Nd8;Pta z6t;4Irs#t z_&S4KEIuD1?AHyi*6Bf*9;HK~7jk1dmJ_(;%R3gFH*tLkOj}z@{%dPO!L`XG^Vg9D zF{KG*ck!IQ_58eMGKBm9^P7$&EceZ8Y_b0>*673vh%oo=E{hzPH4CuJ>Bf(kT-cKn zv(6sraw=Rhgek_v%l>iB1R!PKb4AMKR0;V;xv**dcCo2Fwz3mJ zU|~HbWY$9mEwX{EH?TITrmZ*S1SZY-M63EIAc&U<&VzMd94ZCFW|vNUJm!z|lN!;% zS^9fUyxHA!wU6X$-rU`+!{i-oNj0#oZe#6H!yP+TYV0dF^c@orgyuZJgC^#n=0tUP z&vZD5<|Jsfl>k02>ZNOE5o1cO1YmnPz@{J@Uz%QNr&cmrU2cdB5G|5!^n+{KSyU!lalJS%#G;+x^w%o z?V`)I(LMHZ)t{{7XppWkt$UBwBj2y>0ZB~FYLbH8iam9H5P~O*a2n``&bk=mOTXu8 zQ17{lKez)UK`7++Xh@;Twq97_#m4=Hka%6rdx=#HxXcRwvK+!|aqZ#h|34=6%ceA| zu1uy|opy8PCzZg8r~OdGOr-XH&G3@p$Q*9|_Tat7#c*>OUE5 z{xeYYUu#H2j^DY@MIFDX_h%T&Fd3} zi0OHPOhB?%%P7&D$X2-GLG2CW^x;Z%%N#FG81<*Mj?$~RR##iersQ70n$-INf^1dp zp{w<%6;TZBvYn!eGKHOA0GWYIPMU#*AYbV?;zeFf&#R;3v$^!Y2c{=MF(i(B;cCU` zy7;|YJ6J~dEo}5w@Cn&Mx$CWmAJh5Gl$?|wyU)HA4)G2wI!52E+wB^TW1S0(pHP{? z1<)sSkUdl1aYqN6!W~bcF+@4M0twl{-`2A4(^jyjXU0R2MK#)gS`R-)8frOe#rq%U zlK`{Azo1k;I_TYeI$cMv;LskV#a?r;@&YmpR%YrAY@pu}#^Ccy5iFm(undXV?EU9h z{rAqXwi7^o&&(g^CA7~(taY+;Lu0x@nE@j9COcw}vJkuBqFhCD`N0+itjq~&kSPlX zaMrY^zT@|fUr)A{vHpFW)^izY8zA6zBekQ#s{BNK77o*>d`0NYS)8+Ir(Dsw>)7>A zR$>ioph@A4x$>}~l;!el>Nt^evz?*wm(V?K!`uCsL z`+k*4*nC$OV0>y&wt$2!%&vu=;H)VZ6bAQM<%Dpof`g~>YUF@-w)fI zW%ae3#xlGFDwG%rwmc8y(qTchWl9NS!fA!C;BESB>TagE?qK~Az_+Ke<5=@&^rZgMnB1{_Tz zLekH+PXlVq){mqR(fWdS$hA$``VLxv5#D_-^IW@|&AtKhRV|=zZwGE(3h^g8^(j?K z?^}g1VGPchmQi^C1XJid1ox?`O)H|u^MG{fWcn!nzpL?$A!56pYhK{u6J=<{xhy73l8Ny@-f}AOSV#D95%lf;9DRj z7Q%f}lYAw5A7KUe9Aa*L=0JRPOb$HfxGFhmZ>4$9O1ol-8FCd)O;1@98d}3ZOn2rL z_BA%h(Z$cBdkbT!)pvuh5z?!Daug3sdHd|I&AP$dYu{mA3xTKOjue@bzFqM+*@+LV?36tC59b0O5tr05fPAu1Yofv?{G~fPurEz4hqak zQ7jDDEUE$FNh~05qi5q)Phkz`A7%aDf)UuLNB0iv;la>eN}iQzx{|C%`k#zy!asf6 zCk4?ucZg+$tepB^^c8~}nCn!s-QJpjpr48ek+bO-fBWtvHv35;Yw-uTxn$ClL&x=~ zDGASi4)Uir&Nw-Ji1Y`642IGJF)QvUs3|{0YOl3t8=RcxhASai_}5gbD^=CreFhAU z9@K3hxU#O>faq?6dPMi>(XA>jBos*aRJ$6F^=wIuQOHFy_U(+5%-!<@JN9@9I6~HG zVOobw#?Ex!_|kaXpv(jrdz+oHzp@y+>7rcYjPD7-RtNG&YsnuJ{IT-NCCzLqN$vN- zDLbP-4SV1uylo~Zn6u$^ww-p8fc1-P@zpS3h{Vk6$`$r~>{a$#eK6dwu(b|4w@Ug?J*alE}-Lgdf@_P6(Vtja@J10|J2W_0iINESz9~FF9AD@zXt9+7b zHOur*@rt(g6q_;R-8flfd6PWj_+K{ z7o*f;O1b@LN;TL>hJV)7wQw1Hc2Yc>ID98N<_8LX%6!qqM-Oo<{y~FvkZa4#n&K&OBaRV0i_89|+I(;1t8p|G9#B1ygO7&z?+IL`HsuwCaRVo0#yUZNC2?T>^~PT)7EGM;e1?B+rvwlhl9Vn6P#+ zP^FPYv0BtAZ;~-n|1{S9lVhy*dho)mK4s!;#|HmjbS{i_tCi6*pBMhEN0j8bC6L?z z%g6(v(8{DmF^Dt&dCz_JxHHjN2hy{PVq?nF&u)$@%fDkYJKA_1>*De=gxOU0A*~#X z*}e0{s51vZvttf1rJ5MaO?qS7yof;<^Ab2anyaCcAAmTf{H#i@vVfWp^db}}3W`rk-S0?}L7LSIrL6%!t@ANHyt_}H`n*~g`z)cMu zvLQ1Ls`Qp3oK1aAk<^jA#-~BE?@BOm$v~RGux&(gMLz%*ZATGG=Q+ zLl*wcLAGpQJYx-i0b6{V{Jm43pp5*0T+-yfFpf*ZHu1uOHS4w-TnK?Zql2tsHB3YT zn*_F43`e->>*C}^Xn{d6EQ6UlTUxX$DOc@eHnGJa7BL*0J3MDuyLo!}GGxsY0l_d! z*et$O96RV>;D)5wKaOpl73Altth}gg~VY>LfF#T zlb(AvhbI%pgb3md&<}h_$UfbFhOysIvcq@58F>3TC%L)oVNSj2_Xu=26OexJv zvjXW5j6tKu1Q_~yoIqWwf>r;e0}o)sB?G0#dFv5-*eJ`F6sLVzP19i(b8{biz8hQx zvANKImwI4^j`a~IV1X3(;(R6Txwv@QF`TLM7dT3i2^wl#VpFAzn7$Y@ zLK7nQB{2(GypwJF{0~;7B$zA?$|7#mA8KO_RjJ(lcU>IHC zY={XnGKKGGl7A|tRxmE#uR&hJowXy7e6z+vWGYMdEJkQ3I!`&yTU7BlzK17$WBSxf zW!(!NW#eyv+Opcg`JMHctPcM^`)Y7i{j$?3cLpS4l6EbP9KiBTjjq?aUy(|M3=UWP zvY4r;lvm0&GXJ4bQ6Mk_yA2$IJ}wNT?Vh3D4nD;VSo9bS=bPcnHkhRA%OalY$lpq< zKi)bCPK{mJ#77UhNLN%(Zd~r1YO=9OFlV{fNqP)HZ>%{S0}h%=4D;eDK^``ejS`)W z-s1v}ZpVz3bxDes7<~QWrn`FZtDO1@qN>{OPrnT#iS8JVxKy#MD$YNrIjjyguGkpZe`(R_FZSF zZcF%RrGHrjWO3y1!&`1lqW4gLEx0n|H1@tJgz$W(ra(YTged7YdqGf!3Sp{xYF;Od9Fv~1%Esb@|&NhyjSaP2277R*B>Dk`$nM}winytBD*2R z8rchqpM(D$N9DwQsLP>v8{DK^xaiEA26X+qa=D7?km#D~=A@etU7b!BKcmyI9^kk+ zu{FNq659c%-JoCAc@o9}MErQj*Gm8sQ~B@QmPmO>>+=FOukn>-+xm(VaLaYPt>2zo zZTnMR_4nTUAikXT&W?2C$|UTU!8$|W54ygbbeFk3cV%lAovX*~hy_>>E!!(goVxZY z6Uj;6U_z}DGDTvmJ>Z`o=$h1Zeo8R8KXyogL{2MwC^ZkTW9ipnoLh#Z>$~f2>JL{H z^}F-?A-eL69C%6(?}h*V{mUg{Lm~3( z^iu6Au`#V|eH$lykjxJ2?9b_k2BcdSm?Wb6&x3R;T+&cdm`_%F#B|| zJ;Nc)bd9x=lftNOVvNjSw9=|MgJUzyrBIS>*bme1jhOl0T*r<3<0Li;kWC#`d zE-(isqB-cYBwp#|-PG6%iW=QJ0mAEQ!+jp*|i?;_ALXB#e^Q6_Ybs((dBj-nQdT%74d*RzL26jFZ~T~xg= zJ_XE7q#fSVG!w4d0r7tt4;z#TAY4zf!*w$YF0V(#*PG?2VVWutN0HZ*3v(HxczukLYG1vK3NeuYPENJ(wfw5f#zN|d<%tkm zH{D+D)#KGD{`*MgZ7S36d_QVHMQltbEZZ4^%4gq{)uL%5Hg~jg!iwWGZ^(fM{wqkx zX;HbpJP{AtacFI{ctTls3jJ#Q_M->v`v(u$v4a~Rx;#q_3ev-KQ9t1+>F1A)K||lo z+8(D@`dVAh&CeAHoO;(caHgjPJzQzF#n!5JCqvsx6aK;$5 z6xan+I6F=pmr>!Q^oJdqKPQkxk-0Krq z14SL(WEVohF#jN)FeXUKqJ&4d+2Oe68r{_VGxMtDu7au5HFFGK>j9U*IoI#;&J`mt zPJEP)HrGqg7JJ(>2&&C35@Go{?A%290p3HQvRr)PIHNZK`C@6TA@f0H4Wntey45bK z?i*V|ZE^+lTqDy*_0S{FC8!@van><;fU$s`ZU~xZf1iNf&`Y77rp}h9DJl=+p0KPS zBRT0~j`}8$@PC@3P7<%^$eXNV_Z#e3D5&q6v*?o#^+?l`1`j?_uyfjyah!5(RooTw zds;heu=XOfM6OF(Ulx(b$WLi+oq^3D!VD-Bn^|DeG#f;OuT~o#vHO!Bu@!|jgI#C^ zkJr;99l?>?5xo56MtOy;J2Gsm9Cj|+g6RuG=$lqU_;8IREjNKNi@MgROr}oAMpsVE zCLY#b^6^v^cQ18Rc~C&u=IFDAjWX#v(Bs@a<38)6Wq__fv(t4Yi>{L{%5k}&)2k{F zSUnt@fsOzR^bf~0gn8ec8y1d*YMtzZ*g~(%E*#$(LeG7U6x)(%Idd5i5$OqG>r#cB zR!hIvtY^Qs%ysw<2&);Ra&2|&rHG+nB6EQeqfUUbB)g`V_TUU)t3`=aruR_bv5fCr zfnFi{xe7wLc)MQPFkfPOKYW`xssD6!R$U+Lg81rksL}KPKecO-*HqIWKJ`l;PulD>6^ zGmcGmCC+ulx~T*OU)t;6OMW$fd~xD6)dV=S#l{Sy&+oi5cS8VdV;|ZxrdFzFBal~L ztkl#^UHD^OwN{%05M2GHtsSNhT;%7}*e;JoN!qz;)OocaAco-)z3qz+! z1qPBLqfL)oq&MX>mXR6Kt=O~js-vstS6RSea3ubu3X}D?7||$#XQjZ62Y;F-g8p2( z0yN<_H_X+FLrdw@>=5AK##Lv@Nw|6)q~U%CO`gPS&BeFO!`E4D*%pku?*Eo|hl3OK zHgn4ZT&Sru*18&R`UF11L^QWHt42Cj9%?HmUI^0C-KOsw5U5=U?6D8T=c?VH1vNU&@|LYBq)Aa7Ar^eH%uFXzrs$ai^F!R){#sw=o;`vO!d+55+2=&sXZiw$LLQRKz=>-WXgkltSEw%}6` zT+Jpe{!@=EJvKljlou^&dmIYfy8~x9QTML35e+BG{%Rn?Nd9pth`I$cOrxLK@56-e zs-$mTKQ`tA?48<;q-&a&t9qcSn>uO+sHJ~ZXv+jWvh?_8t$jNcO}85@NgutWF9I#9 zCe!=;nNNtbkaVGacxJ{aQl>gXC^n(NS$ zU;XvC5ydjgXnV$x6p5*TC;|BQ>|fIr`<}0=i7Uy-HbfHdx@bzmY;r~f(a#CUr25T? z_=JQjsji1w_cKqmTBlS9u8(iDa?zs%WY^qn(qgTXJ_NcW{c1~9<79;H+B@V-MzY{l zdw43*Lac4)qlGM(#VEqcIzFaLr4}JD$=9Kj+^TQ+yz0r#pk`3{tlck;D>34^0ezq1 zCwRp)?AQayV-LNO0@gV-4NecH-DlaMyo0T!LGK3x^#v&v;MlQXs>`Fm|Jz@u!!Q0j zJzcsB>l9Wohn!LSzne?*+C*J5d3s(o$)b$BD zbv;F-E?~RsIl||8I*3SI+nBjF<4Ih2&Pl{O zeu*VXG6~_D)}Yq8-4*zclt=2^WA<9HWCf6e#jzzUkc1@m1j!@?sspjbH%JqiyFi7} z^A6A!ys4&YdYf(HgAIGcR z|7d*v{H&SoI0tJ?jPVvu*rU6K;P@DNQtiCNikVcY-Ww(1EJY)Iyi0Px6I>M5gg6f- oAz`PVupNPUL!N#7&J}?o&U0^^!