From b87af346cb5f8f2785298799a80d284a08fc7657 Mon Sep 17 00:00:00 2001 From: "L. R. Couto" <57910428+lrcouto@users.noreply.github.com> Date: Wed, 13 Sep 2023 13:03:28 -0300 Subject: [PATCH 1/4] Add ConfigLoader and TemplatedConfigLoader deprecation notices on documentation. (#2991) * Add configloader deprecation notices for configuration pages Signed-off-by: lrcouto * Some more changes to configuration docs Signed-off-by: Ankita Katiyar * Some more changes to configuration docs Signed-off-by: Ankita Katiyar * Update automated testing example with OCL Signed-off-by: Ankita Katiyar * Change text as per Vale suggestions Signed-off-by: lrcouto * Changes to text structure and tables of content Signed-off-by: lrcouto * Changes to style and formatting Signed-off-by: lrcouto * Revert tables of content Signed-off-by: lrcouto * Changes to the list of headings Signed-off-by: Ankita Katiyar --------- Signed-off-by: lrcouto Signed-off-by: Ankita Katiyar Co-authored-by: Ankita Katiyar <110245118+ankatiyar@users.noreply.github.com> Co-authored-by: Ankita Katiyar --- .../configuration/advanced_configuration.md | 51 +++++++++++-------- .../configuration/configuration_basics.md | 29 ++++++++--- docs/source/configuration/credentials.md | 9 ++-- docs/source/configuration/parameters.md | 10 ++-- docs/source/development/automated_testing.md | 4 +- 5 files changed, 63 insertions(+), 40 deletions(-) diff --git a/docs/source/configuration/advanced_configuration.md b/docs/source/configuration/advanced_configuration.md index 6bf78d487e..72003b678e 100644 --- a/docs/source/configuration/advanced_configuration.md +++ b/docs/source/configuration/advanced_configuration.md @@ -4,8 +4,38 @@ The documentation on [configuration](./configuration_basics.md) describes how to By default, Kedro is set up to use the [ConfigLoader](/kedro.config.ConfigLoader) class. Kedro also provides two additional configuration loaders with more advanced functionality: the [TemplatedConfigLoader](/kedro.config.TemplatedConfigLoader) and the [OmegaConfigLoader](/kedro.config.OmegaConfigLoader). Each of these classes are alternatives for the default `ConfigLoader` and have different features. The following sections describe each of these classes and their specific functionality in more detail. +## OmegaConfigLoader + +[OmegaConf](https://omegaconf.readthedocs.io/) is a Python library designed to handle and manage settings. It serves as a YAML-based hierarchical system to organise configurations, which can be structured to accommodate various sources, allowing you to merge settings from multiple locations. + +From Kedro 0.18.5 you can use the [`OmegaConfigLoader`](/kedro.config.OmegaConfigLoader) which uses `OmegaConf` to load data. + +```{note} +`OmegaConfigLoader` is under active development. It is available from Kedro version 0.18.5 with additional features due in later releases. Let us know if you have any feedback about the `OmegaConfigLoader` by joining the [Kedro community on Slack](https://slack.kedro.org/). +``` + +`OmegaConfigLoader` can load `YAML` and `JSON` files. Acceptable file extensions are `.yml`, `.yaml`, and `.json`. By default, any configuration files used by the config loaders in Kedro are `.yml` files. + +To use `OmegaConfigLoader` in your project, set the `CONFIG_LOADER_CLASS` constant in your [`src//settings.py`](../kedro_project_setup/settings.md): + +```python +from kedro.config import OmegaConfigLoader # new import + +CONFIG_LOADER_CLASS = OmegaConfigLoader +``` +### Advanced `OmegaConfigLoader` features +Some advanced use cases of `OmegaConfigLoader` are listed below: +- [How to do templating with the `OmegaConfigLoader`](#how-to-do-templating-with-the-omegaconfigloader) +- [How to use global variables with the `OmegaConfigLoader`](#how-to-use-global-variables-with-the-omegaconfigloader) +- [How to use resolvers in the `OmegaConfigLoader`](#how-to-use-resolvers-in-the-omegaconfigloader) +- [How to load credentials through environment variables](#how-to-load-credentials-through-environment-variables) + ## TemplatedConfigLoader +```{warning} +`ConfigLoader` and `TemplatedConfigLoader` have been deprecated since Kedro `0.18.12` and will be removed in Kedro `0.19.0`. Refer to the [migration guide for config loaders](./config_loader_migration.md) for instructions on how to update your code to use `OmegaConfigLoader`. +``` + Kedro provides an extension [TemplatedConfigLoader](/kedro.config.TemplatedConfigLoader) class that allows you to template values in configuration files. To apply templating in your project, set the `CONFIG_LOADER_CLASS` constant in your [`src//settings.py`](../kedro_project_setup/settings.md): ```python @@ -95,30 +125,9 @@ CONFIG_LOADER_ARGS = { If you specify both `globals_pattern` and `globals_dict` in `CONFIG_LOADER_ARGS`, the contents of the dictionary resulting from `globals_pattern` are merged with the `globals_dict` dictionary. In case of conflicts, the keys from the `globals_dict` dictionary take precedence. -## OmegaConfigLoader - -[OmegaConf](https://omegaconf.readthedocs.io/) is a Python library designed for configuration. It is a YAML-based hierarchical configuration system with support for merging configurations from multiple sources. - -From Kedro 0.18.5 you can use the [`OmegaConfigLoader`](/kedro.config.OmegaConfigLoader) which uses `OmegaConf` under the hood to load data. - -```{note} -`OmegaConfigLoader` is under active development. It was first available from Kedro 0.18.5 with additional features due in later releases. Let us know if you have any feedback about the `OmegaConfigLoader`. -``` - -`OmegaConfigLoader` can load `YAML` and `JSON` files. Acceptable file extensions are `.yml`, `.yaml`, and `.json`. By default, any configuration files used by the config loaders in Kedro are `.yml` files. - -To use `OmegaConfigLoader` in your project, set the `CONFIG_LOADER_CLASS` constant in your [`src//settings.py`](../kedro_project_setup/settings.md): - -```python -from kedro.config import OmegaConfigLoader # new import - -CONFIG_LOADER_CLASS = OmegaConfigLoader -``` - ## Advanced Kedro configuration This section contains a set of guidance for advanced configuration requirements of standard Kedro projects: - * [How to change which configuration files are loaded](#how-to-change-which-configuration-files-are-loaded) * [How to ensure non default configuration files get loaded](#how-to-ensure-non-default-configuration-files-get-loaded) * [How to bypass the configuration loading rules](#how-to-bypass-the-configuration-loading-rules) diff --git a/docs/source/configuration/configuration_basics.md b/docs/source/configuration/configuration_basics.md index 9e133f0e5e..9d53d49e2f 100644 --- a/docs/source/configuration/configuration_basics.md +++ b/docs/source/configuration/configuration_basics.md @@ -4,11 +4,24 @@ This section contains detailed information about Kedro project configuration, wh Kedro makes use of a configuration loader to load any project configuration files, and the available configuration loader classes are: +```{warning} +`ConfigLoader` and `TemplatedConfigLoader` have been deprecated since Kedro `0.18.12` and will be removed in Kedro `0.19.0`. Refer to the [migration guide for config loaders](./config_loader_migration.md) for instructions on how to update your code base to use `OmegaConfigLoader`. +``` + * [`ConfigLoader`](/kedro.config.ConfigLoader) * [`TemplatedConfigLoader`](/kedro.config.TemplatedConfigLoader) * [`OmegaConfigLoader`](/kedro.config.OmegaConfigLoader). -By default, Kedro uses the `ConfigLoader` and, in the following sections and examples, you can assume the default `ConfigLoader` is used, unless otherwise specified. The [advanced configuration documentation](./advanced_configuration.md) covers use of the [`TemplatedConfigLoader`](/kedro.config.TemplatedConfigLoader) and [`OmegaConfigLoader`](/kedro.config.OmegaConfigLoader) in more detail. +By default, Kedro uses the `ConfigLoader`. However, in projects created with Kedro `0.18.13` onwards, `OmegaConfigLoader` has been set as the config loader as the default in the project's `src//settings.py` file. +You can select which config loader you want to use in your project by modifying the `src//settings.py` like this: +```python +from kedro.config import OmegaConfigLoader + +CONFIG_LOADER_CLASS = OmegaConfigLoader +``` +The following sections and examples are valid for both, the `ConfigLoader` and the `OmegaConfigLoader`. The [advanced configuration documentation](./advanced_configuration.md) covers use of the [`TemplatedConfigLoader`](/kedro.config.TemplatedConfigLoader) +and the advanced use cases of the [`OmegaConfigLoader`](/kedro.config.OmegaConfigLoader) in more detail. + ## Configuration source The configuration source folder is [`conf`](../get_started/kedro_concepts.md#conf) by default. We recommend that you keep all configuration files in the default `conf` folder of a Kedro project. @@ -35,7 +48,8 @@ Do not add any local configuration to version control. ``` ## Configuration loading -Kedro-specific configuration (e.g., `DataCatalog` configuration for I/O) is loaded using a configuration loader class, by default, this is [`ConfigLoader`](/kedro.config.ConfigLoader). +Kedro-specific configuration (e.g., `DataCatalog` configuration for I/O) is loaded using a configuration loader class, by default, this is [`ConfigLoader`](/kedro.config.ConfigLoader) for +projects created with Kedro `0.18.13` or older and has been set to `OmegaConfigLoader` for projects created with Kedro `0.18.13` onwards. When you interact with Kedro through the command line, e.g. by running `kedro run`, Kedro loads all project configuration in the configuration source through this configuration loader. The loader recursively scans for configuration files inside the `conf` folder, firstly in `conf/base` (`base` being the default environment) and then in `conf/local` (`local` being the designated overriding environment). @@ -61,7 +75,7 @@ Configuration files will be matched according to file name and type rules. Suppo ### Configuration patterns Under the hood, the Kedro configuration loader loads files based on regex patterns that specify the naming convention for configuration files. These patterns are specified by `config_patterns` in the configuration loader classes. -By default those patterns are set as follows for the configuration of catalog, parameters, logging, credentials, and globals: +By default, those patterns are set as follows for the configuration of catalog, parameters, logging, credentials: ```python config_patterns = { @@ -69,7 +83,6 @@ config_patterns = { "parameters": ["parameters*", "parameters*/**", "**/parameters*"], "credentials": ["credentials*", "credentials*/**", "**/credentials*"], "logging": ["logging*", "logging*/**", "**/logging*"], - "globals": ["globals*", "globals*/**", "**/globals*"], } ``` @@ -80,10 +93,10 @@ If you want to change the way configuration is loaded, you can either [customise This section contains a set of guidance for the most common configuration requirements of standard Kedro projects: * [How to change the setting for a configuration source folder](#how-to-change-the-setting-for-a-configuration-source-folder) -* [How to change the configuration source folder at run time](#how-to-change-the-configuration-source-folder-at-runtime) +* [How to change the configuration source folder at runtime](#how-to-change-the-configuration-source-folder-at-runtime) * [How to read configuration from a compressed file](#how-to-read-configuration-from-a-compressed-file) * [How to access configuration in code](#how-to-access-configuration-in-code) -* [How to specify additional configuration environments ](#how-to-specify-additional-configuration-environments) +* [How to specify additional configuration environments](#how-to-specify-additional-configuration-environments) * [How to change the default overriding environment](#how-to-change-the-default-overriding-environment) * [How to use only one configuration environment](#how-to-use-only-one-configuration-environment) @@ -145,12 +158,12 @@ Note that for both the `tar.gz` and `zip` file the following structure is expect To directly access configuration in code, for example to debug, you can do so as follows: ```python -from kedro.config import ConfigLoader +from kedro.config import OmegaConfigLoader from kedro.framework.project import settings # Instantiate a ConfigLoader with the location of your project configuration. conf_path = str(project_path / settings.CONF_SOURCE) -conf_loader = ConfigLoader(conf_source=conf_path) +conf_loader = OmegaConfigLoader(conf_source=conf_path) # This line shows how to access the catalog configuration. You can access other configuration in the same way. conf_catalog = conf_loader["catalog"] diff --git a/docs/source/configuration/credentials.md b/docs/source/configuration/credentials.md index 8252c9d76f..7bb2af73cc 100644 --- a/docs/source/configuration/credentials.md +++ b/docs/source/configuration/credentials.md @@ -7,14 +7,15 @@ Credentials configuration can be used on its own directly in code or [fed into t If you would rather store your credentials in environment variables instead of a file, you can use the `OmegaConfigLoader` [to load credentials from environment variables](advanced_configuration.md#how-to-load-credentials-through-environment-variables) as described in the advanced configuration chapter. ## How to load credentials in code + Credentials configuration can be loaded the same way as any other project configuration using any of the configuration loader classes: `ConfigLoader`, `TemplatedConfigLoader`, and `OmegaConfigLoader`. -The following examples all use the default `ConfigLoader` class. +The following examples are valid for both, the `ConfigLoader` and the `OmegaConfigLoader`. ```python from pathlib import Path -from kedro.config import ConfigLoader +from kedro.config import OmegaConfigLoader from kedro.framework.project import settings # Substitute with the [root folder for your project](https://docs.kedro.org/en/stable/tutorial/spaceflights_tutorial.html#terminology) @@ -30,11 +31,11 @@ Calling `conf_loader[key]` in the example above throws a `MissingConfigException ```python from pathlib import Path -from kedro.config import ConfigLoader, MissingConfigException +from kedro.config import OmegaConfigLoader, MissingConfigException from kedro.framework.project import settings conf_path = str(Path() / settings.CONF_SOURCE) -conf_loader = ConfigLoader(conf_source=conf_path) +conf_loader = OmegaConfigLoader(conf_source=conf_path) try: credentials = conf_loader["credentials"] diff --git a/docs/source/configuration/parameters.md b/docs/source/configuration/parameters.md index 61c6ff0e9c..8f50636088 100644 --- a/docs/source/configuration/parameters.md +++ b/docs/source/configuration/parameters.md @@ -76,14 +76,14 @@ You can use `add_feed_dict()` to inject any other entries into your `DataCatalog Parameters project configuration can be loaded by any of the configuration loader classes: `ConfigLoader`, `TemplatedConfigLoader`, and `OmegaConfigLoader`. -The following examples all make use of the default `ConfigLoader` class. +The following examples all make use of the `OmegaConfigLoader` class. ```python -from kedro.config import ConfigLoader +from kedro.config import OmegaConfigLoader from kedro.framework.project import settings conf_path = str(project_path / settings.CONF_SOURCE) -conf_loader = ConfigLoader(conf_source=conf_path) +conf_loader = OmegaConfigLoader(conf_source=conf_path) parameters = conf_loader["parameters"] ``` @@ -92,11 +92,11 @@ This loads configuration files from any subdirectories in `conf` that have a fil Calling `conf_loader[key]` in the example above will throw a `MissingConfigException` error if no configuration files match the given key. But if this is a valid workflow for your application, you can handle it as follows: ```python -from kedro.config import ConfigLoader, MissingConfigException +from kedro.config import OmegaConfigLoader, MissingConfigException from kedro.framework.project import settings conf_path = str(project_path / settings.CONF_SOURCE) -conf_loader = ConfigLoader(conf_source=conf_path) +conf_loader = OmegaConfigLoader(conf_source=conf_path) try: parameters = conf_loader["parameters"] diff --git a/docs/source/development/automated_testing.md b/docs/source/development/automated_testing.md index 6efcfa73b9..5d23609d30 100644 --- a/docs/source/development/automated_testing.md +++ b/docs/source/development/automated_testing.md @@ -63,14 +63,14 @@ Now that you have a place to put your tests, you can create an example test in t ``` import pytest -from kedro.config import ConfigLoader +from kedro.config import OmegaConfigLoader from kedro.framework.context import KedroContext from kedro.framework.hooks import _create_hook_manager @pytest.fixture def config_loader(): - return ConfigLoader(conf_source=str(Path.cwd())) + return OmegaConfigLoader(conf_source=str(Path.cwd())) @pytest.fixture From 0fd1cac9e9211960a893ffc3602bee3fd8df9241 Mon Sep 17 00:00:00 2001 From: Tynan DeBold Date: Wed, 13 Sep 2023 19:25:22 +0200 Subject: [PATCH 2/4] Update robots.txt to hide older versions of the docs (#3030) * Update robots.txt to hide older versions of the docs Signed-off-by: Tynan DeBold * Update robots.txt Signed-off-by: Tynan DeBold --------- Signed-off-by: Tynan DeBold Co-authored-by: Jo Stichbury --- docs/source/robots.txt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/source/robots.txt b/docs/source/robots.txt index 9bd9ee90da..8d56f55070 100644 --- a/docs/source/robots.txt +++ b/docs/source/robots.txt @@ -1,5 +1,14 @@ User-agent: * -Disallow: * -Allow: /en/stable -Allow: /en/latest -Allow: /en/0.18.* +Disallow: / +Allow: /en/stable/ +Allow: /en/latest/ +Allow: /en/0.18.5/ +Allow: /en/0.18.6/ +Allow: /en/0.18.7/ +Allow: /en/0.18.8/ +Allow: /en/0.18.9/ +Allow: /en/0.18.10/ +Allow: /en/0.18.11/ +Allow: /en/0.18.12/ +Allow: /en/0.18.13/ +Allow: /en/0.17.7/ From 2f76e7fdf47b0b8a6840b9c9ad19d61897daf0da Mon Sep 17 00:00:00 2001 From: qheuristics Date: Mon, 18 Sep 2023 11:05:13 +0200 Subject: [PATCH 3/4] Update faq.md fixing label formatting (#3005) Remove unneeded :code: word Signed-off-by: Guillermo Lozano Branger --- docs/source/faq/faq.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/faq/faq.md b/docs/source/faq/faq.md index 30bd2a1929..69115f5e30 100644 --- a/docs/source/faq/faq.md +++ b/docs/source/faq/faq.md @@ -61,10 +61,10 @@ Refer to the following table below for a high level guide to each layer's purpos | Folder in data | Description | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Raw | Initial start of the pipeline, containing the sourced data model(s) that should never be changed, it forms your single source of truth to work from. These data models are typically un-typed in most cases e.g. csv, but this will vary from case to case | -| Intermediate | Optional data model(s), which are introduced to type your :code:`raw` data model(s), e.g. converting string based values into their current typed representation | +| Intermediate | Optional data model(s), which are introduced to type your `raw` data model(s), e.g. converting string based values into their current typed representation | | Primary | Domain specific data model(s) containing cleansed, transformed and wrangled data from either `raw` or `intermediate`, which forms your layer that you input into your feature engineering | | Feature | Analytics specific data model(s) containing a set of features defined against the `primary` data, which are grouped by feature area of analysis and stored against a common dimension | -| Model input | Analytics specific data model(s) containing all :code:`feature` data against a common dimension and in the case of live projects against an analytics run date to ensure that you track the historical changes of the features over time | +| Model input | Analytics specific data model(s) containing all `feature` data against a common dimension and in the case of live projects against an analytics run date to ensure that you track the historical changes of the features over time | | Models | Stored, serialised pre-trained machine learning models | | Model output | Analytics specific data model(s) containing the results generated by the model based on the `model input` data | | Reporting | Reporting data model(s) that are used to combine a set of `primary`, `feature`, `model input` and `model output` data used to drive the dashboard and the views constructed. It encapsulates and removes the need to define any blending or joining of data, improve performance and replacement of presentation layer without having to redefine the data models | From 9abc5601bf38d35ff7363e63ce6d361888056197 Mon Sep 17 00:00:00 2001 From: Nok Date: Mon, 18 Sep 2023 14:17:22 +0000 Subject: [PATCH 4/4] Delete `kedro.extras.datasets` and related tests Signed-off-by: Nok --- kedro/extras/datasets/README.md | 22 - kedro/extras/datasets/__init__.py | 19 - kedro/extras/datasets/api/__init__.py | 11 - kedro/extras/datasets/api/api_dataset.py | 141 --- kedro/extras/datasets/biosequence/__init__.py | 8 - .../biosequence/biosequence_dataset.py | 136 --- kedro/extras/datasets/dask/__init__.py | 8 - kedro/extras/datasets/dask/parquet_dataset.py | 210 ---- kedro/extras/datasets/email/__init__.py | 8 - .../extras/datasets/email/message_dataset.py | 187 ---- kedro/extras/datasets/geopandas/README.md | 31 - kedro/extras/datasets/geopandas/__init__.py | 8 - .../datasets/geopandas/geojson_dataset.py | 155 --- kedro/extras/datasets/holoviews/__init__.py | 8 - .../datasets/holoviews/holoviews_writer.py | 136 --- kedro/extras/datasets/json/__init__.py | 8 - kedro/extras/datasets/json/json_dataset.py | 159 --- kedro/extras/datasets/matplotlib/__init__.py | 8 - .../datasets/matplotlib/matplotlib_writer.py | 238 ----- kedro/extras/datasets/networkx/__init__.py | 15 - kedro/extras/datasets/networkx/gml_dataset.py | 143 --- .../datasets/networkx/graphml_dataset.py | 141 --- .../extras/datasets/networkx/json_dataset.py | 148 --- kedro/extras/datasets/pandas/__init__.py | 39 - kedro/extras/datasets/pandas/csv_dataset.py | 193 ---- kedro/extras/datasets/pandas/excel_dataset.py | 263 ----- .../extras/datasets/pandas/feather_dataset.py | 189 ---- kedro/extras/datasets/pandas/gbq_dataset.py | 313 ------ .../extras/datasets/pandas/generic_dataset.py | 247 ----- kedro/extras/datasets/pandas/hdf_dataset.py | 206 ---- kedro/extras/datasets/pandas/json_dataset.py | 188 ---- .../extras/datasets/pandas/parquet_dataset.py | 231 ---- kedro/extras/datasets/pandas/sql_dataset.py | 467 -------- kedro/extras/datasets/pandas/xml_dataset.py | 171 --- kedro/extras/datasets/pickle/__init__.py | 8 - .../extras/datasets/pickle/pickle_dataset.py | 245 ----- kedro/extras/datasets/pillow/__init__.py | 8 - kedro/extras/datasets/pillow/image_dataset.py | 143 --- kedro/extras/datasets/plotly/__init__.py | 11 - kedro/extras/datasets/plotly/json_dataset.py | 167 --- .../extras/datasets/plotly/plotly_dataset.py | 142 --- kedro/extras/datasets/redis/__init__.py | 8 - kedro/extras/datasets/redis/redis_dataset.py | 191 ---- kedro/extras/datasets/spark/__init__.py | 14 - .../datasets/spark/deltatable_dataset.py | 116 -- kedro/extras/datasets/spark/spark_dataset.py | 427 -------- .../datasets/spark/spark_hive_dataset.py | 224 ---- .../datasets/spark/spark_jdbc_dataset.py | 179 ---- kedro/extras/datasets/svmlight/__init__.py | 8 - .../datasets/svmlight/svmlight_dataset.py | 169 --- kedro/extras/datasets/tensorflow/README.md | 34 - kedro/extras/datasets/tensorflow/__init__.py | 8 - .../tensorflow/tensorflow_model_dataset.py | 195 ---- kedro/extras/datasets/text/__init__.py | 8 - kedro/extras/datasets/text/text_dataset.py | 144 --- kedro/extras/datasets/tracking/__init__.py | 11 - .../extras/datasets/tracking/json_dataset.py | 49 - .../datasets/tracking/metrics_dataset.py | 70 -- kedro/extras/datasets/video/__init__.py | 5 - kedro/extras/datasets/video/video_dataset.py | 357 ------- kedro/extras/datasets/yaml/__init__.py | 8 - kedro/extras/datasets/yaml/yaml_dataset.py | 156 --- tests/extras/datasets/__init__.py | 0 tests/extras/datasets/api/__init__.py | 0 tests/extras/datasets/api/test_api_dataset.py | 170 --- .../datasets/bioinformatics/__init__.py | 0 .../test_biosequence_dataset.py | 107 -- tests/extras/datasets/conftest.py | 35 - tests/extras/datasets/dask/__init__.py | 0 .../datasets/dask/test_parquet_dataset.py | 223 ---- tests/extras/datasets/email/__init__.py | 0 .../datasets/email/test_message_dataset.py | 226 ---- tests/extras/datasets/geojson/__init__.py | 0 .../datasets/geojson/test_geojson_dataset.py | 232 ---- tests/extras/datasets/holoviews/__init__.py | 0 .../holoviews/test_holoviews_writer.py | 220 ---- tests/extras/datasets/json/__init__.py | 0 .../extras/datasets/json/test_json_dataset.py | 200 ---- tests/extras/datasets/libsvm/__init__.py | 0 .../datasets/libsvm/test_svmlight_dataset.py | 214 ---- tests/extras/datasets/matplotlib/__init__.py | 0 .../matplotlib/test_matplotlib_writer.py | 436 -------- tests/extras/datasets/networkx/__init__.py | 0 .../datasets/networkx/test_gml_dataset.py | 188 ---- .../datasets/networkx/test_graphml_dataset.py | 188 ---- .../datasets/networkx/test_json_dataset.py | 226 ---- tests/extras/datasets/pandas/__init__.py | 0 .../datasets/pandas/test_csv_dataset.py | 300 ------ .../datasets/pandas/test_excel_dataset.py | 281 ----- .../datasets/pandas/test_feather_dataset.py | 220 ---- .../datasets/pandas/test_gbq_dataset.py | 315 ------ .../datasets/pandas/test_generic_dataset.py | 383 ------- .../datasets/pandas/test_hdf_dataset.py | 245 ----- .../datasets/pandas/test_json_dataset.py | 241 ----- .../datasets/pandas/test_parquet_dataset.py | 344 ------ .../datasets/pandas/test_sql_dataset.py | 425 -------- .../datasets/pandas/test_xml_dataset.py | 241 ----- tests/extras/datasets/pickle/__init__.py | 0 .../datasets/pickle/test_pickle_dataset.py | 269 ----- tests/extras/datasets/pillow/__init__.py | 0 tests/extras/datasets/pillow/data/image.png | Bin 1554 -> 0 bytes .../datasets/pillow/test_image_dataset.py | 231 ---- tests/extras/datasets/plotly/__init__.py | 0 .../datasets/plotly/test_json_dataset.py | 101 -- .../datasets/plotly/test_plotly_dataset.py | 108 -- tests/extras/datasets/redis/__init__.py | 0 .../datasets/redis/test_redis_dataset.py | 165 --- tests/extras/datasets/spark/__init__.py | 0 tests/extras/datasets/spark/conftest.py | 41 - tests/extras/datasets/spark/data/test.parquet | Bin 2828 -> 0 bytes .../datasets/spark/test_deltatable_dataset.py | 100 -- .../datasets/spark/test_memory_dataset.py | 67 -- .../datasets/spark/test_spark_dataset.py | 996 ------------------ .../datasets/spark/test_spark_hive_dataset.py | 311 ------ .../datasets/spark/test_spark_jdbc_dataset.py | 113 -- tests/extras/datasets/tensorflow/__init__.py | 0 .../test_tensorflow_model_dataset.py | 441 -------- tests/extras/datasets/text/__init__.py | 0 .../extras/datasets/text/test_text_dataset.py | 187 ---- tests/extras/datasets/tracking/__init__.py | 0 .../datasets/tracking/test_json_dataset.py | 185 ---- .../datasets/tracking/test_metrics_dataset.py | 194 ---- tests/extras/datasets/video/conftest.py | 107 -- .../datasets/video/data/color_video.mp4 | Bin 17452 -> 0 bytes tests/extras/datasets/video/data/video.mjpeg | Bin 430080 -> 0 bytes tests/extras/datasets/video/data/video.mkv | Bin 55145 -> 0 bytes tests/extras/datasets/video/data/video.mp4 | Bin 55132 -> 0 bytes .../datasets/video/test_sliced_video.py | 56 - .../datasets/video/test_video_dataset.py | 186 ---- .../datasets/video/test_video_objects.py | 170 --- tests/extras/datasets/video/utils.py | 49 - tests/extras/datasets/yaml/__init__.py | 0 .../extras/datasets/yaml/test_yaml_dataset.py | 210 ---- 133 files changed, 17507 deletions(-) delete mode 100644 kedro/extras/datasets/README.md delete mode 100644 kedro/extras/datasets/__init__.py delete mode 100644 kedro/extras/datasets/api/__init__.py delete mode 100644 kedro/extras/datasets/api/api_dataset.py delete mode 100644 kedro/extras/datasets/biosequence/__init__.py delete mode 100644 kedro/extras/datasets/biosequence/biosequence_dataset.py delete mode 100644 kedro/extras/datasets/dask/__init__.py delete mode 100644 kedro/extras/datasets/dask/parquet_dataset.py delete mode 100644 kedro/extras/datasets/email/__init__.py delete mode 100644 kedro/extras/datasets/email/message_dataset.py delete mode 100644 kedro/extras/datasets/geopandas/README.md delete mode 100644 kedro/extras/datasets/geopandas/__init__.py delete mode 100644 kedro/extras/datasets/geopandas/geojson_dataset.py delete mode 100644 kedro/extras/datasets/holoviews/__init__.py delete mode 100644 kedro/extras/datasets/holoviews/holoviews_writer.py delete mode 100644 kedro/extras/datasets/json/__init__.py delete mode 100644 kedro/extras/datasets/json/json_dataset.py delete mode 100644 kedro/extras/datasets/matplotlib/__init__.py delete mode 100644 kedro/extras/datasets/matplotlib/matplotlib_writer.py delete mode 100644 kedro/extras/datasets/networkx/__init__.py delete mode 100644 kedro/extras/datasets/networkx/gml_dataset.py delete mode 100644 kedro/extras/datasets/networkx/graphml_dataset.py delete mode 100644 kedro/extras/datasets/networkx/json_dataset.py delete mode 100644 kedro/extras/datasets/pandas/__init__.py delete mode 100644 kedro/extras/datasets/pandas/csv_dataset.py delete mode 100644 kedro/extras/datasets/pandas/excel_dataset.py delete mode 100644 kedro/extras/datasets/pandas/feather_dataset.py delete mode 100644 kedro/extras/datasets/pandas/gbq_dataset.py delete mode 100644 kedro/extras/datasets/pandas/generic_dataset.py delete mode 100644 kedro/extras/datasets/pandas/hdf_dataset.py delete mode 100644 kedro/extras/datasets/pandas/json_dataset.py delete mode 100644 kedro/extras/datasets/pandas/parquet_dataset.py delete mode 100644 kedro/extras/datasets/pandas/sql_dataset.py delete mode 100644 kedro/extras/datasets/pandas/xml_dataset.py delete mode 100644 kedro/extras/datasets/pickle/__init__.py delete mode 100644 kedro/extras/datasets/pickle/pickle_dataset.py delete mode 100644 kedro/extras/datasets/pillow/__init__.py delete mode 100644 kedro/extras/datasets/pillow/image_dataset.py delete mode 100644 kedro/extras/datasets/plotly/__init__.py delete mode 100644 kedro/extras/datasets/plotly/json_dataset.py delete mode 100644 kedro/extras/datasets/plotly/plotly_dataset.py delete mode 100644 kedro/extras/datasets/redis/__init__.py delete mode 100644 kedro/extras/datasets/redis/redis_dataset.py delete mode 100644 kedro/extras/datasets/spark/__init__.py delete mode 100644 kedro/extras/datasets/spark/deltatable_dataset.py delete mode 100644 kedro/extras/datasets/spark/spark_dataset.py delete mode 100644 kedro/extras/datasets/spark/spark_hive_dataset.py delete mode 100644 kedro/extras/datasets/spark/spark_jdbc_dataset.py delete mode 100644 kedro/extras/datasets/svmlight/__init__.py delete mode 100644 kedro/extras/datasets/svmlight/svmlight_dataset.py delete mode 100644 kedro/extras/datasets/tensorflow/README.md delete mode 100644 kedro/extras/datasets/tensorflow/__init__.py delete mode 100644 kedro/extras/datasets/tensorflow/tensorflow_model_dataset.py delete mode 100644 kedro/extras/datasets/text/__init__.py delete mode 100644 kedro/extras/datasets/text/text_dataset.py delete mode 100644 kedro/extras/datasets/tracking/__init__.py delete mode 100644 kedro/extras/datasets/tracking/json_dataset.py delete mode 100644 kedro/extras/datasets/tracking/metrics_dataset.py delete mode 100644 kedro/extras/datasets/video/__init__.py delete mode 100644 kedro/extras/datasets/video/video_dataset.py delete mode 100644 kedro/extras/datasets/yaml/__init__.py delete mode 100644 kedro/extras/datasets/yaml/yaml_dataset.py delete mode 100644 tests/extras/datasets/__init__.py delete mode 100644 tests/extras/datasets/api/__init__.py delete mode 100644 tests/extras/datasets/api/test_api_dataset.py delete mode 100644 tests/extras/datasets/bioinformatics/__init__.py delete mode 100644 tests/extras/datasets/bioinformatics/test_biosequence_dataset.py delete mode 100644 tests/extras/datasets/conftest.py delete mode 100644 tests/extras/datasets/dask/__init__.py delete mode 100644 tests/extras/datasets/dask/test_parquet_dataset.py delete mode 100644 tests/extras/datasets/email/__init__.py delete mode 100644 tests/extras/datasets/email/test_message_dataset.py delete mode 100644 tests/extras/datasets/geojson/__init__.py delete mode 100644 tests/extras/datasets/geojson/test_geojson_dataset.py delete mode 100644 tests/extras/datasets/holoviews/__init__.py delete mode 100644 tests/extras/datasets/holoviews/test_holoviews_writer.py delete mode 100644 tests/extras/datasets/json/__init__.py delete mode 100644 tests/extras/datasets/json/test_json_dataset.py delete mode 100644 tests/extras/datasets/libsvm/__init__.py delete mode 100644 tests/extras/datasets/libsvm/test_svmlight_dataset.py delete mode 100644 tests/extras/datasets/matplotlib/__init__.py delete mode 100644 tests/extras/datasets/matplotlib/test_matplotlib_writer.py delete mode 100644 tests/extras/datasets/networkx/__init__.py delete mode 100644 tests/extras/datasets/networkx/test_gml_dataset.py delete mode 100644 tests/extras/datasets/networkx/test_graphml_dataset.py delete mode 100644 tests/extras/datasets/networkx/test_json_dataset.py delete mode 100644 tests/extras/datasets/pandas/__init__.py delete mode 100644 tests/extras/datasets/pandas/test_csv_dataset.py delete mode 100644 tests/extras/datasets/pandas/test_excel_dataset.py delete mode 100644 tests/extras/datasets/pandas/test_feather_dataset.py delete mode 100644 tests/extras/datasets/pandas/test_gbq_dataset.py delete mode 100644 tests/extras/datasets/pandas/test_generic_dataset.py delete mode 100644 tests/extras/datasets/pandas/test_hdf_dataset.py delete mode 100644 tests/extras/datasets/pandas/test_json_dataset.py delete mode 100644 tests/extras/datasets/pandas/test_parquet_dataset.py delete mode 100644 tests/extras/datasets/pandas/test_sql_dataset.py delete mode 100644 tests/extras/datasets/pandas/test_xml_dataset.py delete mode 100644 tests/extras/datasets/pickle/__init__.py delete mode 100644 tests/extras/datasets/pickle/test_pickle_dataset.py delete mode 100644 tests/extras/datasets/pillow/__init__.py delete mode 100644 tests/extras/datasets/pillow/data/image.png delete mode 100644 tests/extras/datasets/pillow/test_image_dataset.py delete mode 100644 tests/extras/datasets/plotly/__init__.py delete mode 100644 tests/extras/datasets/plotly/test_json_dataset.py delete mode 100644 tests/extras/datasets/plotly/test_plotly_dataset.py delete mode 100644 tests/extras/datasets/redis/__init__.py delete mode 100644 tests/extras/datasets/redis/test_redis_dataset.py delete mode 100644 tests/extras/datasets/spark/__init__.py delete mode 100644 tests/extras/datasets/spark/conftest.py delete mode 100644 tests/extras/datasets/spark/data/test.parquet delete mode 100644 tests/extras/datasets/spark/test_deltatable_dataset.py delete mode 100644 tests/extras/datasets/spark/test_memory_dataset.py delete mode 100644 tests/extras/datasets/spark/test_spark_dataset.py delete mode 100644 tests/extras/datasets/spark/test_spark_hive_dataset.py delete mode 100644 tests/extras/datasets/spark/test_spark_jdbc_dataset.py delete mode 100644 tests/extras/datasets/tensorflow/__init__.py delete mode 100644 tests/extras/datasets/tensorflow/test_tensorflow_model_dataset.py delete mode 100644 tests/extras/datasets/text/__init__.py delete mode 100644 tests/extras/datasets/text/test_text_dataset.py delete mode 100644 tests/extras/datasets/tracking/__init__.py delete mode 100644 tests/extras/datasets/tracking/test_json_dataset.py delete mode 100644 tests/extras/datasets/tracking/test_metrics_dataset.py delete mode 100644 tests/extras/datasets/video/conftest.py delete mode 100644 tests/extras/datasets/video/data/color_video.mp4 delete mode 100644 tests/extras/datasets/video/data/video.mjpeg delete mode 100644 tests/extras/datasets/video/data/video.mkv delete mode 100644 tests/extras/datasets/video/data/video.mp4 delete mode 100644 tests/extras/datasets/video/test_sliced_video.py delete mode 100644 tests/extras/datasets/video/test_video_dataset.py delete mode 100644 tests/extras/datasets/video/test_video_objects.py delete mode 100644 tests/extras/datasets/video/utils.py delete mode 100644 tests/extras/datasets/yaml/__init__.py delete mode 100644 tests/extras/datasets/yaml/test_yaml_dataset.py diff --git a/kedro/extras/datasets/README.md b/kedro/extras/datasets/README.md deleted file mode 100644 index bd93acd6be..0000000000 --- a/kedro/extras/datasets/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Datasets - -> **Warning** -> `kedro.extras.datasets` is deprecated and will be removed in Kedro 0.19, -> install `kedro-datasets` instead by running `pip install kedro-datasets`. - -Welcome to `kedro.extras.datasets`, the home of Kedro's data connectors. Here you will find `AbstractDataset` implementations created by QuantumBlack and external contributors. - -## What `AbstractDataset` implementations are supported? - -We support a range of data descriptions, including CSV, Excel, Parquet, Feather, HDF5, JSON, Pickle, SQL Tables, SQL Queries, Spark DataFrames and more. We even allow support for working with images. - -These data descriptions are supported with the APIs of `pandas`, `spark`, `networkx`, `matplotlib`, `yaml` and more. - -[The Data Catalog](https://kedro.readthedocs.io/en/stable/data/data_catalog.html) allows you to work with a range of file formats on local file systems, network file systems, cloud object stores, and Hadoop. - -Here is a full list of [supported data descriptions and APIs](https://kedro.readthedocs.io/en/stable/kedro.extras.datasets.html). - -## How can I create my own `AbstractDataset` implementation? - - -Take a look at our [instructions on how to create your own `AbstractDataset` implementation](https://kedro.readthedocs.io/en/stable/extend_kedro/custom_datasets.html). diff --git a/kedro/extras/datasets/__init__.py b/kedro/extras/datasets/__init__.py deleted file mode 100644 index 3eec3e3fe1..0000000000 --- a/kedro/extras/datasets/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""``kedro.extras.datasets`` is where you can find all of Kedro's data connectors. -These data connectors are implementations of the ``AbstractDataset``. - -.. warning:: - - ``kedro.extras.datasets`` is deprecated and will be removed in Kedro 0.19. - Refer to :py:mod:`kedro_datasets` for the documentation, and - install ``kedro-datasets`` to avoid breakage by running ``pip install kedro-datasets``. - -""" - -from warnings import warn as _warn - -_warn( - "`kedro.extras.datasets` is deprecated and will be removed in Kedro 0.19, " - "install `kedro-datasets` instead by running `pip install kedro-datasets`.", - DeprecationWarning, - stacklevel=2, -) diff --git a/kedro/extras/datasets/api/__init__.py b/kedro/extras/datasets/api/__init__.py deleted file mode 100644 index ccd799b2c9..0000000000 --- a/kedro/extras/datasets/api/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""``APIDataSet`` loads the data from HTTP(S) APIs -and returns them into either as string or json Dict. -It uses the python requests library: https://requests.readthedocs.io/en/latest/ -""" - -__all__ = ["APIDataSet"] - -from contextlib import suppress - -with suppress(ImportError): - from .api_dataset import APIDataSet diff --git a/kedro/extras/datasets/api/api_dataset.py b/kedro/extras/datasets/api/api_dataset.py deleted file mode 100644 index 0e79f9aad2..0000000000 --- a/kedro/extras/datasets/api/api_dataset.py +++ /dev/null @@ -1,141 +0,0 @@ -"""``APIDataSet`` loads the data from HTTP(S) APIs. -It uses the python requests library: https://requests.readthedocs.io/en/latest/ -""" -from typing import Any, Dict, Iterable, List, NoReturn, Union - -import requests -from requests.auth import AuthBase - -from kedro.io.core import AbstractDataset, DatasetError - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class APIDataSet(AbstractDataset[None, requests.Response]): - """``APIDataSet`` loads the data from HTTP(S) APIs. - It uses the python requests library: https://requests.readthedocs.io/en/latest/ - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - usda: - type: api.APIDataSet - url: https://quickstats.nass.usda.gov - params: - key: SOME_TOKEN, - format: JSON, - commodity_desc: CORN, - statisticcat_des: YIELD, - agg_level_desc: STATE, - year: 2000 - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.api import APIDataSet - >>> - >>> - >>> data_set = APIDataSet( - >>> url="https://quickstats.nass.usda.gov", - >>> params={ - >>> "key": "SOME_TOKEN", - >>> "format": "JSON", - >>> "commodity_desc": "CORN", - >>> "statisticcat_des": "YIELD", - >>> "agg_level_desc": "STATE", - >>> "year": 2000 - >>> } - >>> ) - >>> data = data_set.load() - """ - - def __init__( # noqa: too-many-arguments - self, - url: str, - method: str = "GET", - data: Any = None, - params: Dict[str, Any] = None, - headers: Dict[str, Any] = None, - auth: Union[Iterable[str], AuthBase] = None, - json: Union[List, Dict[str, Any]] = None, - timeout: int = 60, - credentials: Union[Iterable[str], AuthBase] = None, - ) -> None: - """Creates a new instance of ``APIDataSet`` to fetch data from an API endpoint. - - Args: - url: The API URL endpoint. - method: The Method of the request, GET, POST, PUT, DELETE, HEAD, etc... - data: The request payload, used for POST, PUT, etc requests - https://requests.readthedocs.io/en/latest/user/quickstart/#more-complicated-post-requests - params: The url parameters of the API. - https://requests.readthedocs.io/en/latest/user/quickstart/#passing-parameters-in-urls - headers: The HTTP headers. - https://requests.readthedocs.io/en/latest/user/quickstart/#custom-headers - auth: Anything ``requests`` accepts. Normally it's either ``('login', 'password')``, - or ``AuthBase``, ``HTTPBasicAuth`` instance for more complex cases. Any - iterable will be cast to a tuple. - json: The request payload, used for POST, PUT, etc requests, passed in - to the json kwarg in the requests object. - https://requests.readthedocs.io/en/latest/user/quickstart/#more-complicated-post-requests - timeout: The wait time in seconds for a response, defaults to 1 minute. - https://requests.readthedocs.io/en/latest/user/quickstart/#timeouts - credentials: same as ``auth``. Allows specifying ``auth`` secrets in - credentials.yml. - - Raises: - ValueError: if both ``credentials`` and ``auth`` are specified. - """ - super().__init__() - - if credentials is not None and auth is not None: - raise ValueError("Cannot specify both auth and credentials.") - - auth = credentials or auth - - if isinstance(auth, Iterable): - auth = tuple(auth) - - self._request_args: Dict[str, Any] = { - "url": url, - "method": method, - "data": data, - "params": params, - "headers": headers, - "auth": auth, - "json": json, - "timeout": timeout, - } - - def _describe(self) -> Dict[str, Any]: - return {**self._request_args} - - def _execute_request(self) -> requests.Response: - try: - response = requests.request(**self._request_args) - response.raise_for_status() - except requests.exceptions.HTTPError as exc: - raise DatasetError("Failed to fetch data", exc) from exc - except OSError as exc: - raise DatasetError("Failed to connect to the remote server") from exc - - return response - - def _load(self) -> requests.Response: - return self._execute_request() - - def _save(self, data: None) -> NoReturn: - raise DatasetError(f"{self.__class__.__name__} is a read only data set type") - - def _exists(self) -> bool: - response = self._execute_request() - - return response.ok diff --git a/kedro/extras/datasets/biosequence/__init__.py b/kedro/extras/datasets/biosequence/__init__.py deleted file mode 100644 index d806e3ca33..0000000000 --- a/kedro/extras/datasets/biosequence/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""``AbstractDataset`` implementation to read/write from/to a sequence file.""" - -__all__ = ["BioSequenceDataSet"] - -from contextlib import suppress - -with suppress(ImportError): - from .biosequence_dataset import BioSequenceDataSet diff --git a/kedro/extras/datasets/biosequence/biosequence_dataset.py b/kedro/extras/datasets/biosequence/biosequence_dataset.py deleted file mode 100644 index ac0770aa68..0000000000 --- a/kedro/extras/datasets/biosequence/biosequence_dataset.py +++ /dev/null @@ -1,136 +0,0 @@ -"""BioSequenceDataSet loads and saves data to/from bio-sequence objects to -file. -""" -from copy import deepcopy -from pathlib import PurePosixPath -from typing import Any, Dict, List - -import fsspec -from Bio import SeqIO - -from kedro.io.core import AbstractDataset, get_filepath_str, get_protocol_and_path - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class BioSequenceDataSet(AbstractDataset[List, List]): - r"""``BioSequenceDataSet`` loads and saves data to a sequence file. - - Example: - :: - - >>> from kedro.extras.datasets.biosequence import BioSequenceDataSet - >>> from io import StringIO - >>> from Bio import SeqIO - >>> - >>> data = ">Alpha\nACCGGATGTA\n>Beta\nAGGCTCGGTTA\n" - >>> raw_data = [] - >>> for record in SeqIO.parse(StringIO(data), "fasta"): - >>> raw_data.append(record) - >>> - >>> data_set = BioSequenceDataSet(filepath="ls_orchid.fasta", - >>> load_args={"format": "fasta"}, - >>> save_args={"format": "fasta"}) - >>> data_set.save(raw_data) - >>> sequence_list = data_set.load() - >>> - >>> assert raw_data[0].id == sequence_list[0].id - >>> assert raw_data[0].seq == sequence_list[0].seq - - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """ - Creates a new instance of ``BioSequenceDataSet`` pointing - to a concrete filepath. - - Args: - filepath: Filepath in POSIX format to sequence file prefixed with a protocol like - `s3://`. If prefix is not provided, `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - load_args: Options for parsing sequence files by Biopython ``SeqIO.parse()``. - save_args: file format supported by Biopython ``SeqIO.write()``. - E.g. `{"format": "fasta"}`. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``), as well as - to pass to the filesystem's `open` method through nested keys - `open_args_load` and `open_args_save`. - Here you can find all available arguments for `open`: - https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem.open - All defaults are preserved, except `mode`, which is set to `r` when loading - and to `w` when saving. - - Note: Here you can find all supported file formats: https://biopython.org/wiki/SeqIO - """ - - _fs_args = deepcopy(fs_args) or {} - _fs_open_args_load = _fs_args.pop("open_args_load", {}) - _fs_open_args_save = _fs_args.pop("open_args_save", {}) - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath) - - self._filepath = PurePosixPath(path) - self._protocol = protocol - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._fs = fsspec.filesystem(self._protocol, **_credentials, **_fs_args) - - # Handle default load and save arguments - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - _fs_open_args_load.setdefault("mode", "r") - _fs_open_args_save.setdefault("mode", "w") - self._fs_open_args_load = _fs_open_args_load - self._fs_open_args_save = _fs_open_args_save - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._protocol, - "load_args": self._load_args, - "save_args": self._save_args, - } - - def _load(self) -> List: - load_path = get_filepath_str(self._filepath, self._protocol) - with self._fs.open(load_path, **self._fs_open_args_load) as fs_file: - return list(SeqIO.parse(handle=fs_file, **self._load_args)) - - def _save(self, data: List) -> None: - save_path = get_filepath_str(self._filepath, self._protocol) - - with self._fs.open(save_path, **self._fs_open_args_save) as fs_file: - SeqIO.write(data, handle=fs_file, **self._save_args) - - def _exists(self) -> bool: - load_path = get_filepath_str(self._filepath, self._protocol) - return self._fs.exists(load_path) - - def _release(self) -> None: - self.invalidate_cache() - - def invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/dask/__init__.py b/kedro/extras/datasets/dask/__init__.py deleted file mode 100644 index d93bf4c63f..0000000000 --- a/kedro/extras/datasets/dask/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Provides I/O modules using dask dataframe.""" - -__all__ = ["ParquetDataSet"] - -from contextlib import suppress - -with suppress(ImportError): - from .parquet_dataset import ParquetDataSet diff --git a/kedro/extras/datasets/dask/parquet_dataset.py b/kedro/extras/datasets/dask/parquet_dataset.py deleted file mode 100644 index 21fcfe25b0..0000000000 --- a/kedro/extras/datasets/dask/parquet_dataset.py +++ /dev/null @@ -1,210 +0,0 @@ -"""``ParquetDataSet`` is a data set used to load and save data to parquet files using Dask -dataframe""" - -from copy import deepcopy -from typing import Any, Dict - -import dask.dataframe as dd -import fsspec -import triad - -from kedro.io.core import AbstractDataset, get_protocol_and_path - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class ParquetDataSet(AbstractDataset[dd.DataFrame, dd.DataFrame]): - """``ParquetDataSet`` loads and saves data to parquet file(s). It uses Dask - remote data services to handle the corresponding load and save operations: - https://docs.dask.org/en/latest/how-to/connect-to-remote-data.html - - Example usage for the - `YAML API `_: - - .. code-block:: yaml - - cars: - type: dask.ParquetDataSet - filepath: s3://bucket_name/path/to/folder - save_args: - compression: GZIP - credentials: - client_kwargs: - aws_access_key_id: YOUR_KEY - aws_secret_access_key: YOUR_SECRET - - Example usage for the - `Python API `_: - :: - - - >>> from kedro.extras.datasets.dask import ParquetDataSet - >>> import pandas as pd - >>> import dask.dataframe as dd - >>> - >>> data = pd.DataFrame({'col1': [1, 2], 'col2': [4, 5], - >>> 'col3': [[5, 6], [7, 8]]}) - >>> ddf = dd.from_pandas(data, npartitions=2) - >>> - >>> data_set = ParquetDataSet( - >>> filepath="s3://bucket_name/path/to/folder", - >>> credentials={ - >>> 'client_kwargs':{ - >>> 'aws_access_key_id': 'YOUR_KEY', - >>> 'aws_secret_access_key': 'YOUR SECRET', - >>> } - >>> }, - >>> save_args={"compression": "GZIP"} - >>> ) - >>> data_set.save(ddf) - >>> reloaded = data_set.load() - >>> - >>> assert ddf.compute().equals(reloaded.compute()) - - The output schema can also be explicitly specified using - `Triad `_. - This is processed to map specific columns to - `PyArrow field types `_ or schema. For instance: - - .. code-block:: yaml - - parquet_dataset: - type: dask.ParquetDataSet - filepath: "s3://bucket_name/path/to/folder" - credentials: - client_kwargs: - aws_access_key_id: YOUR_KEY - aws_secret_access_key: "YOUR SECRET" - save_args: - compression: GZIP - schema: - col1: [int32] - col2: [int32] - col3: [[int32]] - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {"write_index": False} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``ParquetDataSet`` pointing to concrete - parquet files. - - Args: - filepath: Filepath in POSIX format to a parquet file - parquet collection or the directory of a multipart parquet. - load_args: Additional loading options `dask.dataframe.read_parquet`: - https://docs.dask.org/en/latest/generated/dask.dataframe.read_parquet.html - save_args: Additional saving options for `dask.dataframe.to_parquet`: - https://docs.dask.org/en/latest/generated/dask.dataframe.to_parquet.html - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Optional parameters to the backend file system driver: - https://docs.dask.org/en/latest/how-to/connect-to-remote-data.html#optional-parameters - """ - self._filepath = filepath - self._fs_args = deepcopy(fs_args) or {} - self._credentials = deepcopy(credentials) or {} - - # Handle default load and save arguments - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - @property - def fs_args(self) -> Dict[str, Any]: - """Property of optional file system parameters. - - Returns: - A dictionary of backend file system parameters, including credentials. - """ - fs_args = deepcopy(self._fs_args) - fs_args.update(self._credentials) - return fs_args - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "load_args": self._load_args, - "save_args": self._save_args, - } - - def _load(self) -> dd.DataFrame: - return dd.read_parquet( - self._filepath, storage_options=self.fs_args, **self._load_args - ) - - def _save(self, data: dd.DataFrame) -> None: - self._process_schema() - data.to_parquet(self._filepath, storage_options=self.fs_args, **self._save_args) - - def _process_schema(self) -> None: - """This method processes the schema in the catalog.yml or the API, if provided. - This assumes that the schema is specified using Triad's grammar for - schema definition. - - When the value of the `schema` variable is a string, it is assumed that - it corresponds to the full schema specification for the data. - - Alternatively, if the `schema` is specified as a dictionary, then only the - columns that are specified will be strictly mapped to a field type. The other - unspecified columns, if present, will be inferred from the data. - - This method converts the Triad-parsed schema into a pyarrow schema. - The output directly supports Dask's specifications for providing a schema - when saving to a parquet file. - - Note that if a `pa.Schema` object is passed directly in the `schema` argument, no - processing will be done. Additionally, the behavior when passing a `pa.Schema` - object is assumed to be consistent with how Dask sees it. That is, it should fully - define the schema for all fields. - """ - schema = self._save_args.get("schema") - - if isinstance(schema, dict): - # The schema may contain values of different types, e.g., pa.DataType, Python types, - # strings, etc. The latter requires a transformation, then we use triad handle all - # other value types. - - # Create a schema from values that triad can handle directly - triad_schema = triad.Schema( - {k: v for k, v in schema.items() if not isinstance(v, str)} - ) - - # Handle the schema keys that are represented as string and add them to the triad schema - triad_schema.update( - triad.Schema( - ",".join( - [f"{k}:{v}" for k, v in schema.items() if isinstance(v, str)] - ) - ) - ) - - # Update the schema argument with the normalized schema - self._save_args["schema"].update( - {col: field.type for col, field in triad_schema.items()} - ) - - elif isinstance(schema, str): - self._save_args["schema"] = triad.Schema(schema).pyarrow_schema - - def _exists(self) -> bool: - protocol = get_protocol_and_path(self._filepath)[0] - file_system = fsspec.filesystem(protocol=protocol, **self.fs_args) - return file_system.exists(self._filepath) diff --git a/kedro/extras/datasets/email/__init__.py b/kedro/extras/datasets/email/__init__.py deleted file mode 100644 index ba7873cbf2..0000000000 --- a/kedro/extras/datasets/email/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""``AbstractDataset`` implementations for managing email messages.""" - -__all__ = ["EmailMessageDataSet"] - -from contextlib import suppress - -with suppress(ImportError): - from .message_dataset import EmailMessageDataSet diff --git a/kedro/extras/datasets/email/message_dataset.py b/kedro/extras/datasets/email/message_dataset.py deleted file mode 100644 index 695d93cbbe..0000000000 --- a/kedro/extras/datasets/email/message_dataset.py +++ /dev/null @@ -1,187 +0,0 @@ -"""``EmailMessageDataSet`` loads/saves an email message from/to a file -using an underlying filesystem (e.g.: local, S3, GCS). It uses the -``email`` package in the standard library to manage email messages. -""" -from copy import deepcopy -from email.generator import Generator -from email.message import Message -from email.parser import Parser -from email.policy import default -from pathlib import PurePosixPath -from typing import Any, Dict - -import fsspec - -from kedro.io.core import ( - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class EmailMessageDataSet( - AbstractVersionedDataset[Message, Message] -): # pylint: disable=too-many-instance-attributes - """``EmailMessageDataSet`` loads/saves an email message from/to a file - using an underlying filesystem (e.g.: local, S3, GCS). It uses the - ``email`` package in the standard library to manage email messages. - - Note that ``EmailMessageDataSet`` doesn't handle sending email messages. - - Example: - :: - - >>> from email.message import EmailMessage - >>> - >>> from kedro.extras.datasets.email import EmailMessageDataSet - >>> - >>> string_to_write = "what would you do if you were invisable for one day????" - >>> - >>> # Create a text/plain message - >>> msg = EmailMessage() - >>> msg.set_content(string_to_write) - >>> msg["Subject"] = "invisibility" - >>> msg["From"] = '"sin studly17"' - >>> msg["To"] = '"strong bad"' - >>> - >>> data_set = EmailMessageDataSet(filepath="test") - >>> data_set.save(msg) - >>> reloaded = data_set.load() - >>> assert msg.__dict__ == reloaded.__dict__ - - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``EmailMessageDataSet`` pointing to a concrete text file - on a specific filesystem. - - Args: - filepath: Filepath in POSIX format to a text file prefixed with a protocol like `s3://`. - If prefix is not provided, `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - Note: `http(s)` doesn't support versioning. - load_args: ``email`` options for parsing email messages (arguments passed - into ``email.parser.Parser.parse``). Here you can find all available arguments: - https://docs.python.org/3/library/email.parser.html#email.parser.Parser.parse - If you would like to specify options for the `Parser`, - you can include them under the "parser" key. Here you can - find all available arguments: - https://docs.python.org/3/library/email.parser.html#email.parser.Parser - All defaults are preserved, but "policy", which is set to ``email.policy.default``. - save_args: ``email`` options for generating MIME documents (arguments passed into - ``email.generator.Generator.flatten``). Here you can find all available arguments: - https://docs.python.org/3/library/email.generator.html#email.generator.Generator.flatten - If you would like to specify options for the `Generator`, - you can include them under the "generator" key. Here you can - find all available arguments: - https://docs.python.org/3/library/email.generator.html#email.generator.Generator - All defaults are preserved. - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``), as well as - to pass to the filesystem's `open` method through nested keys - `open_args_load` and `open_args_save`. - Here you can find all available arguments for `open`: - https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem.open - All defaults are preserved, except `mode`, which is set to `r` when loading - and to `w` when saving. - """ - _fs_args = deepcopy(fs_args) or {} - _fs_open_args_load = _fs_args.pop("open_args_load", {}) - _fs_open_args_save = _fs_args.pop("open_args_save", {}) - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath, version) - - self._protocol = protocol - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - self._fs = fsspec.filesystem(self._protocol, **_credentials, **_fs_args) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - # Handle default load arguments - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._parser_args = self._load_args.pop("parser", {"policy": default}) - - # Handle default save arguments - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - self._generator_args = self._save_args.pop("generator", {}) - - _fs_open_args_load.setdefault("mode", "r") - _fs_open_args_save.setdefault("mode", "w") - self._fs_open_args_load = _fs_open_args_load - self._fs_open_args_save = _fs_open_args_save - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._protocol, - "load_args": self._load_args, - "parser_args": self._parser_args, - "save_args": self._save_args, - "generator_args": self._generator_args, - "version": self._version, - } - - def _load(self) -> Message: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - - with self._fs.open(load_path, **self._fs_open_args_load) as fs_file: - return Parser(**self._parser_args).parse(fs_file, **self._load_args) - - def _save(self, data: Message) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - - with self._fs.open(save_path, **self._fs_open_args_save) as fs_file: - Generator(fs_file, **self._generator_args).flatten(data, **self._save_args) - - self._invalidate_cache() - - def _exists(self) -> bool: - try: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - except DatasetError: - return False - - return self._fs.exists(load_path) - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/geopandas/README.md b/kedro/extras/datasets/geopandas/README.md deleted file mode 100644 index d7c1a3c96a..0000000000 --- a/kedro/extras/datasets/geopandas/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# GeoJSON - -``GeoJSONDataSet`` loads and saves data to a local yaml file using ``geopandas``. -See [geopandas.GeoDataFrame](http://geopandas.org/reference/geopandas.GeoDataFrame.html) for details. - -#### Example use: - -```python -import geopandas as gpd -from shapely.geometry import Point -from kedro.extras.datasets.geopandas import GeoJSONDataSet - -data = gpd.GeoDataFrame( - {"col1": [1, 2], "col2": [4, 5], "col3": [5, 6]}, - geometry=[Point(1, 1), Point(2, 4)], -) -data_set = GeoJSONDataSet(filepath="test.geojson") -data_set.save(data) -reloaded = data_set.load() -assert data.equals(reloaded) -``` - -#### Example catalog.yml: - -```yaml -example_geojson_data: - type: geopandas.GeoJSONDataSet - filepath: data/08_reporting/test.geojson -``` - -Contributed by (Luis Blanche)[https://github.com/lblanche]. diff --git a/kedro/extras/datasets/geopandas/__init__.py b/kedro/extras/datasets/geopandas/__init__.py deleted file mode 100644 index bee7462a83..0000000000 --- a/kedro/extras/datasets/geopandas/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""``GeoJSONDataSet`` is an ``AbstractVersionedDataset`` to save and load GeoJSON files. -""" -__all__ = ["GeoJSONDataSet"] - -from contextlib import suppress - -with suppress(ImportError): - from .geojson_dataset import GeoJSONDataSet diff --git a/kedro/extras/datasets/geopandas/geojson_dataset.py b/kedro/extras/datasets/geopandas/geojson_dataset.py deleted file mode 100644 index 5beba29d57..0000000000 --- a/kedro/extras/datasets/geopandas/geojson_dataset.py +++ /dev/null @@ -1,155 +0,0 @@ -"""GeoJSONDataSet loads and saves data to a local geojson file. The -underlying functionality is supported by geopandas, so it supports all -allowed geopandas (pandas) options for loading and saving geosjon files. -""" -import copy -from pathlib import PurePosixPath -from typing import Any, Dict, Union - -import fsspec -import geopandas as gpd - -from kedro.io.core import ( - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class GeoJSONDataSet( - AbstractVersionedDataset[ - gpd.GeoDataFrame, Union[gpd.GeoDataFrame, Dict[str, gpd.GeoDataFrame]] - ] -): - """``GeoJSONDataSet`` loads/saves data to a GeoJSON file using an underlying filesystem - (eg: local, S3, GCS). - The underlying functionality is supported by geopandas, so it supports all - allowed geopandas (pandas) options for loading and saving GeoJSON files. - - Example: - :: - - >>> import geopandas as gpd - >>> from shapely.geometry import Point - >>> from kedro.extras.datasets.geopandas import GeoJSONDataSet - >>> - >>> data = gpd.GeoDataFrame({'col1': [1, 2], 'col2': [4, 5], - >>> 'col3': [5, 6]}, geometry=[Point(1,1), Point(2,4)]) - >>> data_set = GeoJSONDataSet(filepath="test.geojson", save_args=None) - >>> data_set.save(data) - >>> reloaded = data_set.load() - >>> - >>> assert data.equals(reloaded) - - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {"driver": "GeoJSON"} - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``GeoJSONDataSet`` pointing to a concrete GeoJSON file - on a specific filesystem fsspec. - - Args: - - filepath: Filepath in POSIX format to a GeoJSON file prefixed with a protocol like - `s3://`. If prefix is not provided `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - Note: `http(s)` doesn't support versioning. - load_args: GeoPandas options for loading GeoJSON files. - Here you can find all available arguments: - https://geopandas.org/en/stable/docs/reference/api/geopandas.read_file.html - save_args: GeoPandas options for saving geojson files. - Here you can find all available arguments: - https://geopandas.org/en/stable/docs/reference/api/geopandas.GeoDataFrame.to_file.html - The default_save_arg driver is 'GeoJSON', all others preserved. - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - credentials: credentials required to access the underlying filesystem. - Eg. for ``GCFileSystem`` it would look like `{'token': None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``), as well as - to pass to the filesystem's `open` method through nested keys - `open_args_load` and `open_args_save`. - Here you can find all available arguments for `open`: - https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem.open - All defaults are preserved, except `mode`, which is set to `wb` when saving. - """ - _fs_args = copy.deepcopy(fs_args) or {} - _fs_open_args_load = _fs_args.pop("open_args_load", {}) - _fs_open_args_save = _fs_args.pop("open_args_save", {}) - _credentials = copy.deepcopy(credentials) or {} - protocol, path = get_protocol_and_path(filepath, version) - self._protocol = protocol - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._fs = fsspec.filesystem(self._protocol, **_credentials, **_fs_args) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - self._load_args = copy.deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - - self._save_args = copy.deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - _fs_open_args_save.setdefault("mode", "wb") - self._fs_open_args_load = _fs_open_args_load - self._fs_open_args_save = _fs_open_args_save - - def _load(self) -> Union[gpd.GeoDataFrame, Dict[str, gpd.GeoDataFrame]]: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - with self._fs.open(load_path, **self._fs_open_args_load) as fs_file: - return gpd.read_file(fs_file, **self._load_args) - - def _save(self, data: gpd.GeoDataFrame) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - with self._fs.open(save_path, **self._fs_open_args_save) as fs_file: - data.to_file(fs_file, **self._save_args) - self.invalidate_cache() - - def _exists(self) -> bool: - try: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - except DatasetError: - return False - return self._fs.exists(load_path) - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._load_args, - "save_args": self._save_args, - "version": self._version, - } - - def _release(self) -> None: - self.invalidate_cache() - - def invalidate_cache(self) -> None: - """Invalidate underlying filesystem cache.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/holoviews/__init__.py b/kedro/extras/datasets/holoviews/__init__.py deleted file mode 100644 index f50db9b823..0000000000 --- a/kedro/extras/datasets/holoviews/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""``AbstractDataset`` implementation to save Holoviews objects as image files.""" - -__all__ = ["HoloviewsWriter"] - -from contextlib import suppress - -with suppress(ImportError): - from .holoviews_writer import HoloviewsWriter diff --git a/kedro/extras/datasets/holoviews/holoviews_writer.py b/kedro/extras/datasets/holoviews/holoviews_writer.py deleted file mode 100644 index 34daeb1769..0000000000 --- a/kedro/extras/datasets/holoviews/holoviews_writer.py +++ /dev/null @@ -1,136 +0,0 @@ -"""``HoloviewsWriter`` saves Holoviews objects as image file(s) to an underlying -filesystem (e.g. local, S3, GCS).""" - -import io -from copy import deepcopy -from pathlib import PurePosixPath -from typing import Any, Dict, NoReturn, TypeVar - -import fsspec -import holoviews as hv - -from kedro.io.core import ( - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - -# HoloViews to be passed in `hv.save()` -HoloViews = TypeVar("HoloViews") - - -class HoloviewsWriter(AbstractVersionedDataset[HoloViews, NoReturn]): - """``HoloviewsWriter`` saves Holoviews objects to image file(s) in an underlying - filesystem (e.g. local, S3, GCS). - - Example: - :: - - >>> import holoviews as hv - >>> from kedro.extras.datasets.holoviews import HoloviewsWriter - >>> - >>> curve = hv.Curve(range(10)) - >>> holoviews_writer = HoloviewsWriter("/tmp/holoviews") - >>> - >>> holoviews_writer.save(curve) - - """ - - DEFAULT_SAVE_ARGS = {"fmt": "png"} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - fs_args: Dict[str, Any] = None, - credentials: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - ) -> None: - """Creates a new instance of ``HoloviewsWriter``. - - Args: - filepath: Filepath in POSIX format to a text file prefixed with a protocol like `s3://`. - If prefix is not provided, `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - Note: `http(s)` doesn't support versioning. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``), as well as - to pass to the filesystem's `open` method through nested key `open_args_save`. - Here you can find all available arguments for `open`: - https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem.open - All defaults are preserved, except `mode`, which is set to `wb` when saving. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``S3FileSystem`` it should look like: - `{'key': '', 'secret': ''}}` - save_args: Extra save args passed to `holoviews.save()`. See - https://holoviews.org/reference_manual/holoviews.util.html#holoviews.util.save - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - """ - _credentials = deepcopy(credentials) or {} - _fs_args = deepcopy(fs_args) or {} - _fs_open_args_save = _fs_args.pop("open_args_save", {}) - _fs_open_args_save.setdefault("mode", "wb") - - protocol, path = get_protocol_and_path(filepath, version) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._fs = fsspec.filesystem(self._protocol, **_credentials, **_fs_args) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - self._fs_open_args_save = _fs_open_args_save - - # Handle default save arguments - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._protocol, - "save_args": self._save_args, - "version": self._version, - } - - def _load(self) -> NoReturn: - raise DatasetError(f"Loading not supported for '{self.__class__.__name__}'") - - def _save(self, data: HoloViews) -> None: - bytes_buffer = io.BytesIO() - hv.save(data, bytes_buffer, **self._save_args) - - save_path = get_filepath_str(self._get_save_path(), self._protocol) - with self._fs.open(save_path, **self._fs_open_args_save) as fs_file: - fs_file.write(bytes_buffer.getvalue()) - - self._invalidate_cache() - - def _exists(self) -> bool: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - return self._fs.exists(load_path) - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/json/__init__.py b/kedro/extras/datasets/json/__init__.py deleted file mode 100644 index 887f7cd72f..0000000000 --- a/kedro/extras/datasets/json/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""``AbstractDataset`` implementation to load/save data from/to a JSON file.""" - -__all__ = ["JSONDataSet"] - -from contextlib import suppress - -with suppress(ImportError): - from .json_dataset import JSONDataSet diff --git a/kedro/extras/datasets/json/json_dataset.py b/kedro/extras/datasets/json/json_dataset.py deleted file mode 100644 index f5907cc162..0000000000 --- a/kedro/extras/datasets/json/json_dataset.py +++ /dev/null @@ -1,159 +0,0 @@ -"""``JSONDataSet`` loads/saves data from/to a JSON file using an underlying -filesystem (e.g.: local, S3, GCS). It uses native json to handle the JSON file. -""" -import json -from copy import deepcopy -from pathlib import PurePosixPath -from typing import Any, Dict - -import fsspec - -from kedro.io.core import ( - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class JSONDataSet(AbstractVersionedDataset[Any, Any]): - """``JSONDataSet`` loads/saves data from/to a JSON file using an underlying - filesystem (e.g.: local, S3, GCS). It uses native json to handle the JSON file. - - Example usage for the - `YAML API `_: - - .. code-block:: yaml - - cars: - type: json.JSONDataSet - filepath: gcs://your_bucket/cars.json - fs_args: - project: my-project - credentials: my_gcp_credentials - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.json import JSONDataSet - >>> - >>> data = {'col1': [1, 2], 'col2': [4, 5], 'col3': [5, 6]} - >>> - >>> data_set = JSONDataSet(filepath="test.json") - >>> data_set.save(data) - >>> reloaded = data_set.load() - >>> assert data == reloaded - - """ - - DEFAULT_SAVE_ARGS = {"indent": 2} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``JSONDataSet`` pointing to a concrete JSON file - on a specific filesystem. - - Args: - filepath: Filepath in POSIX format to a JSON file prefixed with a protocol like `s3://`. - If prefix is not provided, `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - Note: `http(s)` doesn't support versioning. - save_args: json options for saving JSON files (arguments passed - into ```json.dump``). Here you can find all available arguments: - https://docs.python.org/3/library/json.html - All defaults are preserved, but "default_flow_style", which is set to False. - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``), as well as - to pass to the filesystem's `open` method through nested keys - `open_args_load` and `open_args_save`. - Here you can find all available arguments for `open`: - https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem.open - All defaults are preserved, except `mode`, which is set to `r` when loading - and to `w` when saving. - """ - _fs_args = deepcopy(fs_args) or {} - _fs_open_args_load = _fs_args.pop("open_args_load", {}) - _fs_open_args_save = _fs_args.pop("open_args_save", {}) - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath, version) - - self._protocol = protocol - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - self._fs = fsspec.filesystem(self._protocol, **_credentials, **_fs_args) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - # Handle default save arguments - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - _fs_open_args_save.setdefault("mode", "w") - self._fs_open_args_load = _fs_open_args_load - self._fs_open_args_save = _fs_open_args_save - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._protocol, - "save_args": self._save_args, - "version": self._version, - } - - def _load(self) -> Any: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - - with self._fs.open(load_path, **self._fs_open_args_load) as fs_file: - return json.load(fs_file) - - def _save(self, data: Any) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - - with self._fs.open(save_path, **self._fs_open_args_save) as fs_file: - json.dump(data, fs_file, **self._save_args) - - self._invalidate_cache() - - def _exists(self) -> bool: - try: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - except DatasetError: - return False - - return self._fs.exists(load_path) - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/matplotlib/__init__.py b/kedro/extras/datasets/matplotlib/__init__.py deleted file mode 100644 index eabd8fc517..0000000000 --- a/kedro/extras/datasets/matplotlib/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""``AbstractDataset`` implementation to save matplotlib objects as image files.""" - -__all__ = ["MatplotlibWriter"] - -from contextlib import suppress - -with suppress(ImportError): - from .matplotlib_writer import MatplotlibWriter diff --git a/kedro/extras/datasets/matplotlib/matplotlib_writer.py b/kedro/extras/datasets/matplotlib/matplotlib_writer.py deleted file mode 100644 index 6c29b4d5ba..0000000000 --- a/kedro/extras/datasets/matplotlib/matplotlib_writer.py +++ /dev/null @@ -1,238 +0,0 @@ -"""``MatplotlibWriter`` saves one or more Matplotlib objects as image -files to an underlying filesystem (e.g. local, S3, GCS).""" - -import io -from copy import deepcopy -from pathlib import PurePosixPath -from typing import Any, Dict, List, NoReturn, Union -from warnings import warn - -import fsspec -import matplotlib.pyplot as plt - -from kedro.io.core import ( - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class MatplotlibWriter( - AbstractVersionedDataset[ - Union[plt.figure, List[plt.figure], Dict[str, plt.figure]], NoReturn - ] -): - """``MatplotlibWriter`` saves one or more Matplotlib objects as - image files to an underlying filesystem (e.g. local, S3, GCS). - - Example usage for the - `YAML API `_: - - .. code-block:: yaml - - output_plot: - type: matplotlib.MatplotlibWriter - filepath: data/08_reporting/output_plot.png - save_args: - format: png - - Example usage for the - `Python API `_: - :: - - >>> import matplotlib.pyplot as plt - >>> from kedro.extras.datasets.matplotlib import MatplotlibWriter - >>> - >>> fig = plt.figure() - >>> plt.plot([1, 2, 3]) - >>> plot_writer = MatplotlibWriter( - >>> filepath="data/08_reporting/output_plot.png" - >>> ) - >>> plt.close() - >>> plot_writer.save(fig) - - Example saving a plot as a PDF file: - :: - - >>> import matplotlib.pyplot as plt - >>> from kedro.extras.datasets.matplotlib import MatplotlibWriter - >>> - >>> fig = plt.figure() - >>> plt.plot([1, 2, 3]) - >>> pdf_plot_writer = MatplotlibWriter( - >>> filepath="data/08_reporting/output_plot.pdf", - >>> save_args={"format": "pdf"}, - >>> ) - >>> plt.close() - >>> pdf_plot_writer.save(fig) - - Example saving multiple plots in a folder, using a dictionary: - :: - - >>> import matplotlib.pyplot as plt - >>> from kedro.extras.datasets.matplotlib import MatplotlibWriter - >>> - >>> plots_dict = {} - >>> for colour in ["blue", "green", "red"]: - >>> plots_dict[f"{colour}.png"] = plt.figure() - >>> plt.plot([1, 2, 3], color=colour) - >>> - >>> plt.close("all") - >>> dict_plot_writer = MatplotlibWriter( - >>> filepath="data/08_reporting/plots" - >>> ) - >>> dict_plot_writer.save(plots_dict) - - Example saving multiple plots in a folder, using a list: - :: - - >>> import matplotlib.pyplot as plt - >>> from kedro.extras.datasets.matplotlib import MatplotlibWriter - >>> - >>> plots_list = [] - >>> for i in range(5): - >>> plots_list.append(plt.figure()) - >>> plt.plot([i, i + 1, i + 2]) - >>> plt.close("all") - >>> list_plot_writer = MatplotlibWriter( - >>> filepath="data/08_reporting/plots" - >>> ) - >>> list_plot_writer.save(plots_list) - - """ - - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - fs_args: Dict[str, Any] = None, - credentials: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - overwrite: bool = False, - ) -> None: - """Creates a new instance of ``MatplotlibWriter``. - - Args: - filepath: Filepath in POSIX format to save Matplotlib objects to, prefixed with a - protocol like `s3://`. If prefix is not provided, `file` protocol (local filesystem) - will be used. The prefix should be any protocol supported by ``fsspec``. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``), as well as - to pass to the filesystem's `open` method through nested key `open_args_save`. - Here you can find all available arguments for `open`: - https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem.open - All defaults are preserved, except `mode`, which is set to `wb` when saving. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``S3FileSystem`` it should look like: - `{'key': '', 'secret': ''}}` - save_args: Save args passed to `plt.savefig`. See - https://matplotlib.org/api/_as_gen/matplotlib.pyplot.savefig.html - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - overwrite: If True, any existing image files will be removed. - Only relevant when saving multiple Matplotlib objects at - once. - """ - _credentials = deepcopy(credentials) or {} - _fs_args = deepcopy(fs_args) or {} - _fs_open_args_save = _fs_args.pop("open_args_save", {}) - _fs_open_args_save.setdefault("mode", "wb") - - protocol, path = get_protocol_and_path(filepath, version) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._fs = fsspec.filesystem(self._protocol, **_credentials, **_fs_args) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - self._fs_open_args_save = _fs_open_args_save - - # Handle default save arguments - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - if overwrite and version is not None: - warn( - "Setting 'overwrite=True' is ineffective if versioning " - "is enabled, since the versioned path must not already " - "exist; overriding flag with 'overwrite=False' instead." - ) - overwrite = False - self._overwrite = overwrite - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._protocol, - "save_args": self._save_args, - "version": self._version, - } - - def _load(self) -> NoReturn: - raise DatasetError(f"Loading not supported for '{self.__class__.__name__}'") - - def _save( - self, data: Union[plt.figure, List[plt.figure], Dict[str, plt.figure]] - ) -> None: - save_path = self._get_save_path() - - if isinstance(data, (list, dict)) and self._overwrite and self._exists(): - self._fs.rm(get_filepath_str(save_path, self._protocol), recursive=True) - - if isinstance(data, list): - for index, plot in enumerate(data): - full_key_path = get_filepath_str( - save_path / f"{index}.png", self._protocol - ) - self._save_to_fs(full_key_path=full_key_path, plot=plot) - elif isinstance(data, dict): - for plot_name, plot in data.items(): - full_key_path = get_filepath_str(save_path / plot_name, self._protocol) - self._save_to_fs(full_key_path=full_key_path, plot=plot) - else: - full_key_path = get_filepath_str(save_path, self._protocol) - self._save_to_fs(full_key_path=full_key_path, plot=data) - - plt.close("all") - - self._invalidate_cache() - - def _save_to_fs(self, full_key_path: str, plot: plt.figure): - bytes_buffer = io.BytesIO() - plot.savefig(bytes_buffer, **self._save_args) - - with self._fs.open(full_key_path, **self._fs_open_args_save) as fs_file: - fs_file.write(bytes_buffer.getvalue()) - - def _exists(self) -> bool: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - return self._fs.exists(load_path) - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/networkx/__init__.py b/kedro/extras/datasets/networkx/__init__.py deleted file mode 100644 index ece1b98f9c..0000000000 --- a/kedro/extras/datasets/networkx/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""``AbstractDataset`` implementation to save and load NetworkX graphs in JSON -, GraphML and GML formats using ``NetworkX``.""" - -__all__ = ["GMLDataSet", "GraphMLDataSet", "JSONDataSet"] - -from contextlib import suppress - -with suppress(ImportError): - from .gml_dataset import GMLDataSet - -with suppress(ImportError): - from .graphml_dataset import GraphMLDataSet - -with suppress(ImportError): - from .json_dataset import JSONDataSet diff --git a/kedro/extras/datasets/networkx/gml_dataset.py b/kedro/extras/datasets/networkx/gml_dataset.py deleted file mode 100644 index a56ddbe7ba..0000000000 --- a/kedro/extras/datasets/networkx/gml_dataset.py +++ /dev/null @@ -1,143 +0,0 @@ -"""NetworkX ``GMLDataSet`` loads and saves graphs to a graph modelling language (GML) -file using an underlying filesystem (e.g.: local, S3, GCS). ``NetworkX`` is used to -create GML data. -""" - -from copy import deepcopy -from pathlib import PurePosixPath -from typing import Any, Dict - -import fsspec -import networkx - -from kedro.io.core import ( - AbstractVersionedDataset, - Version, - get_filepath_str, - get_protocol_and_path, -) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class GMLDataSet(AbstractVersionedDataset[networkx.Graph, networkx.Graph]): - """``GMLDataSet`` loads and saves graphs to a GML file using an - underlying filesystem (e.g.: local, S3, GCS). ``NetworkX`` is used to - create GML data. - See https://networkx.org/documentation/stable/tutorial.html for details. - - Example: - :: - - >>> from kedro.extras.datasets.networkx import GMLDataSet - >>> import networkx as nx - >>> graph = nx.complete_graph(100) - >>> graph_dataset = GMLDataSet(filepath="test.gml") - >>> graph_dataset.save(graph) - >>> reloaded = graph_dataset.load() - >>> assert nx.is_isomorphic(graph, reloaded) - - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``GMLDataSet``. - - Args: - filepath: Filepath in POSIX format to the NetworkX GML file. - load_args: Arguments passed on to ``networkx.read_gml``. - See the details in - https://networkx.org/documentation/stable/reference/readwrite/generated/networkx.readwrite.gml.read_gml.html - save_args: Arguments passed on to ``networkx.write_gml``. - See the details in - https://networkx.org/documentation/stable/reference/readwrite/generated/networkx.readwrite.gml.write_gml.html - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``), as well as - to pass to the filesystem's `open` method through nested keys - `open_args_load` and `open_args_save`. - Here you can find all available arguments for `open`: - https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem.open - All defaults are preserved, except `mode`, which is set to `r` when loading - and to `w` when saving. - """ - _fs_args = deepcopy(fs_args) or {} - _fs_open_args_load = _fs_args.pop("open_args_load", {}) - _fs_open_args_save = _fs_args.pop("open_args_save", {}) - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath, version) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._fs = fsspec.filesystem(self._protocol, **_credentials, **_fs_args) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - # Handle default load and save arguments - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - _fs_open_args_load.setdefault("mode", "rb") - _fs_open_args_save.setdefault("mode", "wb") - self._fs_open_args_load = _fs_open_args_load - self._fs_open_args_save = _fs_open_args_save - - def _load(self) -> networkx.Graph: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - with self._fs.open(load_path, **self._fs_open_args_load) as fs_file: - data = networkx.read_gml(fs_file, **self._load_args) - return data - - def _save(self, data: networkx.Graph) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - with self._fs.open(save_path, **self._fs_open_args_save) as fs_file: - networkx.write_gml(data, fs_file, **self._save_args) - self._invalidate_cache() - - def _exists(self) -> bool: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - return self._fs.exists(load_path) - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._load_args, - "save_args": self._save_args, - "version": self._version, - } - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/networkx/graphml_dataset.py b/kedro/extras/datasets/networkx/graphml_dataset.py deleted file mode 100644 index 368459958f..0000000000 --- a/kedro/extras/datasets/networkx/graphml_dataset.py +++ /dev/null @@ -1,141 +0,0 @@ -"""NetworkX ``GraphMLDataSet`` loads and saves graphs to a GraphML file using an underlying -filesystem (e.g.: local, S3, GCS). ``NetworkX`` is used to create GraphML data. -""" - -from copy import deepcopy -from pathlib import PurePosixPath -from typing import Any, Dict - -import fsspec -import networkx - -from kedro.io.core import ( - AbstractVersionedDataset, - Version, - get_filepath_str, - get_protocol_and_path, -) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class GraphMLDataSet(AbstractVersionedDataset[networkx.Graph, networkx.Graph]): - """``GraphMLDataSet`` loads and saves graphs to a GraphML file using an - underlying filesystem (e.g.: local, S3, GCS). ``NetworkX`` is used to - create GraphML data. - See https://networkx.org/documentation/stable/tutorial.html for details. - - Example: - :: - - >>> from kedro.extras.datasets.networkx import GraphMLDataSet - >>> import networkx as nx - >>> graph = nx.complete_graph(100) - >>> graph_dataset = GraphMLDataSet(filepath="test.graphml") - >>> graph_dataset.save(graph) - >>> reloaded = graph_dataset.load() - >>> assert nx.is_isomorphic(graph, reloaded) - - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``GraphMLDataSet``. - - Args: - filepath: Filepath in POSIX format to the NetworkX GraphML file. - load_args: Arguments passed on to ``networkx.read_graphml``. - See the details in - https://networkx.org/documentation/stable/reference/readwrite/generated/networkx.readwrite.graphml.read_graphml.html - save_args: Arguments passed on to ``networkx.write_graphml``. - See the details in - https://networkx.org/documentation/stable/reference/readwrite/generated/networkx.readwrite.graphml.write_graphml.html - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``), as well as - to pass to the filesystem's `open` method through nested keys - `open_args_load` and `open_args_save`. - Here you can find all available arguments for `open`: - https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem.open - All defaults are preserved, except `mode`, which is set to `r` when loading - and to `w` when saving. - """ - _fs_args = deepcopy(fs_args) or {} - _fs_open_args_load = _fs_args.pop("open_args_load", {}) - _fs_open_args_save = _fs_args.pop("open_args_save", {}) - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath, version) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._fs = fsspec.filesystem(self._protocol, **_credentials, **_fs_args) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - # Handle default load and save arguments - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - _fs_open_args_load.setdefault("mode", "rb") - _fs_open_args_save.setdefault("mode", "wb") - self._fs_open_args_load = _fs_open_args_load - self._fs_open_args_save = _fs_open_args_save - - def _load(self) -> networkx.Graph: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - with self._fs.open(load_path, **self._fs_open_args_load) as fs_file: - return networkx.read_graphml(fs_file, **self._load_args) - - def _save(self, data: networkx.Graph) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - with self._fs.open(save_path, **self._fs_open_args_save) as fs_file: - networkx.write_graphml(data, fs_file, **self._save_args) - self._invalidate_cache() - - def _exists(self) -> bool: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - return self._fs.exists(load_path) - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._load_args, - "save_args": self._save_args, - "version": self._version, - } - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/networkx/json_dataset.py b/kedro/extras/datasets/networkx/json_dataset.py deleted file mode 100644 index 60db837a91..0000000000 --- a/kedro/extras/datasets/networkx/json_dataset.py +++ /dev/null @@ -1,148 +0,0 @@ -"""``JSONDataSet`` loads and saves graphs to a JSON file using an underlying -filesystem (e.g.: local, S3, GCS). ``NetworkX`` is used to create JSON data. -""" - -import json -from copy import deepcopy -from pathlib import PurePosixPath -from typing import Any, Dict - -import fsspec -import networkx - -from kedro.io.core import ( - AbstractVersionedDataset, - Version, - get_filepath_str, - get_protocol_and_path, -) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class JSONDataSet(AbstractVersionedDataset[networkx.Graph, networkx.Graph]): - """NetworkX ``JSONDataSet`` loads and saves graphs to a JSON file using an - underlying filesystem (e.g.: local, S3, GCS). ``NetworkX`` is used to - create JSON data. - See https://networkx.org/documentation/stable/tutorial.html for details. - - Example: - :: - - >>> from kedro.extras.datasets.networkx import JSONDataSet - >>> import networkx as nx - >>> graph = nx.complete_graph(100) - >>> graph_dataset = JSONDataSet(filepath="test.json") - >>> graph_dataset.save(graph) - >>> reloaded = graph_dataset.load() - >>> assert nx.is_isomorphic(graph, reloaded) - - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``JSONDataSet``. - - Args: - filepath: Filepath in POSIX format to the NetworkX graph JSON file. - load_args: Arguments passed on to ``networkx.node_link_graph``. - See the details in - https://networkx.org/documentation/networkx-1.9.1/reference/generated/networkx.readwrite.json_graph.node_link_graph.html - save_args: Arguments passed on to ``networkx.node_link_data``. - See the details in - https://networkx.org/documentation/networkx-1.9.1/reference/generated/networkx.readwrite.json_graph.node_link_data.html - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``), as well as - to pass to the filesystem's `open` method through nested keys - `open_args_load` and `open_args_save`. - Here you can find all available arguments for `open`: - https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem.open - All defaults are preserved, except `mode`, which is set to `r` when loading - and to `w` when saving. - """ - _fs_args = deepcopy(fs_args) or {} - _fs_open_args_load = _fs_args.pop("open_args_load", {}) - _fs_open_args_save = _fs_args.pop("open_args_save", {}) - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath, version) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._fs = fsspec.filesystem(self._protocol, **_credentials, **_fs_args) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - # Handle default load and save arguments - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - _fs_open_args_save.setdefault("mode", "w") - self._fs_open_args_load = _fs_open_args_load - self._fs_open_args_save = _fs_open_args_save - - def _load(self) -> networkx.Graph: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - with self._fs.open(load_path, **self._fs_open_args_load) as fs_file: - json_payload = json.load(fs_file) - - return networkx.node_link_graph(json_payload, **self._load_args) - - def _save(self, data: networkx.Graph) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - - json_graph = networkx.node_link_data(data, **self._save_args) - with self._fs.open(save_path, **self._fs_open_args_save) as fs_file: - json.dump(json_graph, fs_file) - - self._invalidate_cache() - - def _exists(self) -> bool: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - - return self._fs.exists(load_path) - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._load_args, - "save_args": self._save_args, - "version": self._version, - } - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/pandas/__init__.py b/kedro/extras/datasets/pandas/__init__.py deleted file mode 100644 index 2a8ba76371..0000000000 --- a/kedro/extras/datasets/pandas/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -"""``AbstractDataset`` implementations that produce pandas DataFrames.""" - -__all__ = [ - "CSVDataSet", - "ExcelDataSet", - "FeatherDataSet", - "GBQTableDataSet", - "GBQQueryDataSet", - "HDFDataSet", - "JSONDataSet", - "ParquetDataSet", - "SQLQueryDataSet", - "SQLTableDataSet", - "XMLDataSet", - "GenericDataSet", -] - -from contextlib import suppress - -with suppress(ImportError): - from .csv_dataset import CSVDataSet -with suppress(ImportError): - from .excel_dataset import ExcelDataSet -with suppress(ImportError): - from .feather_dataset import FeatherDataSet -with suppress(ImportError): - from .gbq_dataset import GBQQueryDataSet, GBQTableDataSet -with suppress(ImportError): - from .hdf_dataset import HDFDataSet -with suppress(ImportError): - from .json_dataset import JSONDataSet -with suppress(ImportError): - from .parquet_dataset import ParquetDataSet -with suppress(ImportError): - from .sql_dataset import SQLQueryDataSet, SQLTableDataSet -with suppress(ImportError): - from .xml_dataset import XMLDataSet -with suppress(ImportError): - from .generic_dataset import GenericDataSet diff --git a/kedro/extras/datasets/pandas/csv_dataset.py b/kedro/extras/datasets/pandas/csv_dataset.py deleted file mode 100644 index 26816da5d4..0000000000 --- a/kedro/extras/datasets/pandas/csv_dataset.py +++ /dev/null @@ -1,193 +0,0 @@ -"""``CSVDataSet`` loads/saves data from/to a CSV file using an underlying -filesystem (e.g.: local, S3, GCS). It uses pandas to handle the CSV file. -""" -import logging -from copy import deepcopy -from io import BytesIO -from pathlib import PurePosixPath -from typing import Any, Dict - -import fsspec -import pandas as pd - -from kedro.io.core import ( - PROTOCOL_DELIMITER, - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -logger = logging.getLogger(__name__) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class CSVDataSet(AbstractVersionedDataset[pd.DataFrame, pd.DataFrame]): - """``CSVDataSet`` loads/saves data from/to a CSV file using an underlying - filesystem (e.g.: local, S3, GCS). It uses pandas to handle the CSV file. - - Example usage for the - `YAML API `_: - - .. code-block:: yaml - - cars: - type: pandas.CSVDataSet - filepath: data/01_raw/company/cars.csv - load_args: - sep: "," - na_values: ["#NA", NA] - save_args: - index: False - date_format: "%Y-%m-%d %H:%M" - decimal: . - - motorbikes: - type: pandas.CSVDataSet - filepath: s3://your_bucket/data/02_intermediate/company/motorbikes.csv - credentials: dev_s3 - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.pandas import CSVDataSet - >>> import pandas as pd - >>> - >>> data = pd.DataFrame({'col1': [1, 2], 'col2': [4, 5], - >>> 'col3': [5, 6]}) - >>> - >>> data_set = CSVDataSet(filepath="test.csv") - >>> data_set.save(data) - >>> reloaded = data_set.load() - >>> assert data.equals(reloaded) - - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {"index": False} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``CSVDataSet`` pointing to a concrete CSV file - on a specific filesystem. - - Args: - filepath: Filepath in POSIX format to a CSV file prefixed with a protocol like `s3://`. - If prefix is not provided, `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - Note: `http(s)` doesn't support versioning. - load_args: Pandas options for loading CSV files. - Here you can find all available arguments: - https://pandas.pydata.org/pandas-docs/stable/generated/pandas.read_csv.html - All defaults are preserved. - save_args: Pandas options for saving CSV files. - Here you can find all available arguments: - https://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.to_csv.html - All defaults are preserved, but "index", which is set to False. - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``). - """ - _fs_args = deepcopy(fs_args) or {} - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath, version) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._storage_options = {**_credentials, **_fs_args} - self._fs = fsspec.filesystem(self._protocol, **self._storage_options) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - # Handle default load and save arguments - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - if "storage_options" in self._save_args or "storage_options" in self._load_args: - logger.warning( - "Dropping 'storage_options' for %s, " - "please specify them under 'fs_args' or 'credentials'.", - self._filepath, - ) - self._save_args.pop("storage_options", None) - self._load_args.pop("storage_options", None) - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._load_args, - "save_args": self._save_args, - "version": self._version, - } - - def _load(self) -> pd.DataFrame: - load_path = str(self._get_load_path()) - if self._protocol == "file": - # file:// protocol seems to misbehave on Windows - # (), - # so we don't join that back to the filepath; - # storage_options also don't work with local paths - return pd.read_csv(load_path, **self._load_args) - - load_path = f"{self._protocol}{PROTOCOL_DELIMITER}{load_path}" - return pd.read_csv( - load_path, storage_options=self._storage_options, **self._load_args - ) - - def _save(self, data: pd.DataFrame) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - - buf = BytesIO() - data.to_csv(path_or_buf=buf, **self._save_args) - - with self._fs.open(save_path, mode="wb") as fs_file: - fs_file.write(buf.getvalue()) - - self._invalidate_cache() - - def _exists(self) -> bool: - try: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - except DatasetError: - return False - - return self._fs.exists(load_path) - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/pandas/excel_dataset.py b/kedro/extras/datasets/pandas/excel_dataset.py deleted file mode 100644 index ebf5015b72..0000000000 --- a/kedro/extras/datasets/pandas/excel_dataset.py +++ /dev/null @@ -1,263 +0,0 @@ -"""``ExcelDataSet`` loads/saves data from/to a Excel file using an underlying -filesystem (e.g.: local, S3, GCS). It uses pandas to handle the Excel file. -""" -import logging -from copy import deepcopy -from io import BytesIO -from pathlib import PurePosixPath -from typing import Any, Dict, Union - -import fsspec -import pandas as pd - -from kedro.io.core import ( - PROTOCOL_DELIMITER, - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -logger = logging.getLogger(__name__) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class ExcelDataSet( - AbstractVersionedDataset[ - Union[pd.DataFrame, Dict[str, pd.DataFrame]], - Union[pd.DataFrame, Dict[str, pd.DataFrame]], - ] -): - """``ExcelDataSet`` loads/saves data from/to a Excel file using an underlying - filesystem (e.g.: local, S3, GCS). It uses pandas to handle the Excel file. - - Example usage for the - `YAML API `_: - - .. code-block:: yaml - - rockets: - type: pandas.ExcelDataSet - filepath: gcs://your_bucket/rockets.xlsx - fs_args: - project: my-project - credentials: my_gcp_credentials - save_args: - sheet_name: Sheet1 - load_args: - sheet_name: Sheet1 - - shuttles: - type: pandas.ExcelDataSet - filepath: data/01_raw/shuttles.xlsx - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.pandas import ExcelDataSet - >>> import pandas as pd - >>> - >>> data = pd.DataFrame({'col1': [1, 2], 'col2': [4, 5], - >>> 'col3': [5, 6]}) - >>> - >>> data_set = ExcelDataSet(filepath="test.xlsx") - >>> data_set.save(data) - >>> reloaded = data_set.load() - >>> assert data.equals(reloaded) - - To save a multi-sheet Excel file, no special ``save_args`` are required. - Instead, return a dictionary of ``Dict[str, pd.DataFrame]`` where the string - keys are your sheet names. - - Example usage for the - `YAML API `_ - for a multi-sheet Excel file: - - .. code-block:: yaml - - trains: - type: pandas.ExcelDataSet - filepath: data/02_intermediate/company/trains.xlsx - load_args: - sheet_name: [Sheet1, Sheet2, Sheet3] - - Example usage for the - `Python API `_ - for a multi-sheet Excel file: - :: - - >>> from kedro.extras.datasets.pandas import ExcelDataSet - >>> import pandas as pd - >>> - >>> dataframe = pd.DataFrame({'col1': [1, 2], 'col2': [4, 5], - >>> 'col3': [5, 6]}) - >>> another_dataframe = pd.DataFrame({"x": [10, 20], "y": ["hello", "world"]}) - >>> multiframe = {"Sheet1": dataframe, "Sheet2": another_dataframe} - >>> data_set = ExcelDataSet(filepath="test.xlsx", load_args = {"sheet_name": None}) - >>> data_set.save(multiframe) - >>> reloaded = data_set.load() - >>> assert multiframe["Sheet1"].equals(reloaded["Sheet1"]) - >>> assert multiframe["Sheet2"].equals(reloaded["Sheet2"]) - - """ - - DEFAULT_LOAD_ARGS = {"engine": "openpyxl"} - DEFAULT_SAVE_ARGS = {"index": False} - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - engine: str = "openpyxl", - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``ExcelDataSet`` pointing to a concrete Excel file - on a specific filesystem. - - Args: - filepath: Filepath in POSIX format to a Excel file prefixed with a protocol like - `s3://`. If prefix is not provided, `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - Note: `http(s)` doesn't support versioning. - engine: The engine used to write to Excel files. The default - engine is 'openpyxl'. - load_args: Pandas options for loading Excel files. - Here you can find all available arguments: - https://pandas.pydata.org/pandas-docs/stable/generated/pandas.read_excel.html - All defaults are preserved, but "engine", which is set to "openpyxl". - Supports multi-sheet Excel files (include `sheet_name = None` in `load_args`). - save_args: Pandas options for saving Excel files. - Here you can find all available arguments: - https://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.to_excel.html - All defaults are preserved, but "index", which is set to False. - If you would like to specify options for the `ExcelWriter`, - you can include them under the "writer" key. Here you can - find all available arguments: - https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.ExcelWriter.html - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``). - - Raises: - DatasetError: If versioning is enabled while in append mode. - """ - _fs_args = deepcopy(fs_args) or {} - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath, version) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._storage_options = {**_credentials, **_fs_args} - self._fs = fsspec.filesystem(self._protocol, **self._storage_options) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - # Handle default load arguments - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - - # Handle default save arguments - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - self._writer_args = self._save_args.pop("writer", {}) # type: ignore - self._writer_args.setdefault("engine", engine or "openpyxl") # type: ignore - - if version and self._writer_args.get("mode") == "a": # type: ignore - raise DatasetError( - "'ExcelDataSet' doesn't support versioning in append mode." - ) - - if "storage_options" in self._save_args or "storage_options" in self._load_args: - logger.warning( - "Dropping 'storage_options' for %s, " - "please specify them under 'fs_args' or 'credentials'.", - self._filepath, - ) - self._save_args.pop("storage_options", None) - self._load_args.pop("storage_options", None) - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._protocol, - "load_args": self._load_args, - "save_args": self._save_args, - "writer_args": self._writer_args, - "version": self._version, - } - - def _load(self) -> Union[pd.DataFrame, Dict[str, pd.DataFrame]]: - load_path = str(self._get_load_path()) - if self._protocol == "file": - # file:// protocol seems to misbehave on Windows - # (), - # so we don't join that back to the filepath; - # storage_options also don't work with local paths - return pd.read_excel(load_path, **self._load_args) - - load_path = f"{self._protocol}{PROTOCOL_DELIMITER}{load_path}" - return pd.read_excel( - load_path, storage_options=self._storage_options, **self._load_args - ) - - def _save(self, data: Union[pd.DataFrame, Dict[str, pd.DataFrame]]) -> None: - output = BytesIO() - save_path = get_filepath_str(self._get_save_path(), self._protocol) - - # pylint: disable=abstract-class-instantiated - with pd.ExcelWriter(output, **self._writer_args) as writer: - if isinstance(data, dict): - for sheet_name, sheet_data in data.items(): - sheet_data.to_excel( - writer, sheet_name=sheet_name, **self._save_args - ) - else: - data.to_excel(writer, **self._save_args) - - with self._fs.open(save_path, mode="wb") as fs_file: - fs_file.write(output.getvalue()) - - self._invalidate_cache() - - def _exists(self) -> bool: - try: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - except DatasetError: - return False - - return self._fs.exists(load_path) - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/pandas/feather_dataset.py b/kedro/extras/datasets/pandas/feather_dataset.py deleted file mode 100644 index 445cd9758a..0000000000 --- a/kedro/extras/datasets/pandas/feather_dataset.py +++ /dev/null @@ -1,189 +0,0 @@ -"""``FeatherDataSet`` is a data set used to load and save data to feather files -using an underlying filesystem (e.g.: local, S3, GCS). The underlying functionality -is supported by pandas, so it supports all operations the pandas supports. -""" -import logging -from copy import deepcopy -from io import BytesIO -from pathlib import PurePosixPath -from typing import Any, Dict - -import fsspec -import pandas as pd - -from kedro.io.core import ( - PROTOCOL_DELIMITER, - AbstractVersionedDataset, - Version, - get_filepath_str, - get_protocol_and_path, -) - -logger = logging.getLogger(__name__) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class FeatherDataSet(AbstractVersionedDataset[pd.DataFrame, pd.DataFrame]): - """``FeatherDataSet`` loads and saves data to a feather file using an - underlying filesystem (e.g.: local, S3, GCS). The underlying functionality - is supported by pandas, so it supports all allowed pandas options - for loading and saving csv files. - - Example usage for the - `YAML API `_: - - .. code-block:: yaml - - cars: - type: pandas.FeatherDataSet - filepath: data/01_raw/company/cars.feather - load_args: - columns: ['col1', 'col2', 'col3'] - use_threads: True - - motorbikes: - type: pandas.FeatherDataSet - filepath: s3://your_bucket/data/02_intermediate/company/motorbikes.feather - credentials: dev_s3 - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.pandas import FeatherDataSet - >>> import pandas as pd - >>> - >>> data = pd.DataFrame({'col1': [1, 2], 'col2': [4, 5], - >>> 'col3': [5, 6]}) - >>> - >>> data_set = FeatherDataSet(filepath="test.feather") - >>> - >>> data_set.save(data) - >>> reloaded = data_set.load() - >>> - >>> assert data.equals(reloaded) - - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``FeatherDataSet`` pointing to a concrete - filepath. - - Args: - filepath: Filepath in POSIX format to a feather file prefixed with a protocol like - `s3://`. If prefix is not provided, `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - Note: `http(s)` doesn't support versioning. - load_args: Pandas options for loading feather files. - Here you can find all available arguments: - https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.read_feather.html - All defaults are preserved. - save_args: Pandas options for saving feather files. - Here you can find all available arguments: - https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.to_feather.html - All defaults are preserved. - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``). - """ - _fs_args = deepcopy(fs_args) or {} - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath, version) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._storage_options = {**_credentials, **_fs_args} - self._fs = fsspec.filesystem(self._protocol, **self._storage_options) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - # Handle default load argument - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - if "storage_options" in self._save_args or "storage_options" in self._load_args: - logger.warning( - "Dropping 'storage_options' for %s, " - "please specify them under 'fs_args' or 'credentials'.", - self._filepath, - ) - self._save_args.pop("storage_options", None) - self._load_args.pop("storage_options", None) - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._protocol, - "load_args": self._load_args, - "version": self._version, - } - - def _load(self) -> pd.DataFrame: - load_path = str(self._get_load_path()) - if self._protocol == "file": - # file:// protocol seems to misbehave on Windows - # (), - # so we don't join that back to the filepath; - # storage_options also don't work with local paths - return pd.read_feather(load_path, **self._load_args) - - load_path = f"{self._protocol}{PROTOCOL_DELIMITER}{load_path}" - return pd.read_feather( - load_path, storage_options=self._storage_options, **self._load_args - ) - - def _save(self, data: pd.DataFrame) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - - buf = BytesIO() - data.to_feather(buf, **self._save_args) - - with self._fs.open(save_path, mode="wb") as fs_file: - fs_file.write(buf.getvalue()) - - self._invalidate_cache() - - def _exists(self) -> bool: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - return self._fs.exists(load_path) - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/pandas/gbq_dataset.py b/kedro/extras/datasets/pandas/gbq_dataset.py deleted file mode 100644 index 5a7c460c7c..0000000000 --- a/kedro/extras/datasets/pandas/gbq_dataset.py +++ /dev/null @@ -1,313 +0,0 @@ -"""``GBQTableDataSet`` loads and saves data from/to Google BigQuery. It uses pandas-gbq -to read and write from/to BigQuery table. -""" - -import copy -from pathlib import PurePosixPath -from typing import Any, Dict, NoReturn, Union - -import fsspec -import pandas as pd -from google.cloud import bigquery -from google.cloud.exceptions import NotFound -from google.oauth2.credentials import Credentials - -from kedro.io.core import ( - AbstractDataset, - DatasetError, - get_filepath_str, - get_protocol_and_path, - validate_on_forbidden_chars, -) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class GBQTableDataSet(AbstractDataset[None, pd.DataFrame]): - """``GBQTableDataSet`` loads and saves data from/to Google BigQuery. - It uses pandas-gbq to read and write from/to BigQuery table. - - Example usage for the - `YAML API `_: - - .. code-block:: yaml - - vehicles: - type: pandas.GBQTableDataSet - dataset: big_query_dataset - table_name: big_query_table - project: my-project - credentials: gbq-creds - load_args: - reauth: True - save_args: - chunk_size: 100 - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.pandas import GBQTableDataSet - >>> import pandas as pd - >>> - >>> data = pd.DataFrame({'col1': [1, 2], 'col2': [4, 5], - >>> 'col3': [5, 6]}) - >>> - >>> data_set = GBQTableDataSet('dataset', - >>> 'table_name', - >>> project='my-project') - >>> data_set.save(data) - >>> reloaded = data_set.load() - >>> - >>> assert data.equals(reloaded) - - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {"progress_bar": False} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - dataset: str, - table_name: str, - project: str = None, - credentials: Union[Dict[str, Any], Credentials] = None, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``GBQTableDataSet``. - - Args: - dataset: Google BigQuery dataset. - table_name: Google BigQuery table name. - project: Google BigQuery Account project ID. - Optional when available from the environment. - https://cloud.google.com/resource-manager/docs/creating-managing-projects - credentials: Credentials for accessing Google APIs. - Either ``google.auth.credentials.Credentials`` object or dictionary with - parameters required to instantiate ``google.oauth2.credentials.Credentials``. - Here you can find all the arguments: - https://google-auth.readthedocs.io/en/latest/reference/google.oauth2.credentials.html - load_args: Pandas options for loading BigQuery table into DataFrame. - Here you can find all available arguments: - https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.read_gbq.html - All defaults are preserved. - save_args: Pandas options for saving DataFrame to BigQuery table. - Here you can find all available arguments: - https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.to_gbq.html - All defaults are preserved, but "progress_bar", which is set to False. - - Raises: - DatasetError: When ``load_args['location']`` and ``save_args['location']`` - are different. - """ - # Handle default load and save arguments - self._load_args = copy.deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = copy.deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - self._validate_location() - validate_on_forbidden_chars(dataset=dataset, table_name=table_name) - - if isinstance(credentials, dict): - credentials = Credentials(**credentials) - - self._dataset = dataset - self._table_name = table_name - self._project_id = project - self._credentials = credentials - self._client = bigquery.Client( - project=self._project_id, - credentials=self._credentials, - location=self._save_args.get("location"), - ) - - def _describe(self) -> Dict[str, Any]: - return { - "dataset": self._dataset, - "table_name": self._table_name, - "load_args": self._load_args, - "save_args": self._save_args, - } - - def _load(self) -> pd.DataFrame: - sql = f"select * from {self._dataset}.{self._table_name}" # nosec - self._load_args.setdefault("query", sql) - return pd.read_gbq( - project_id=self._project_id, - credentials=self._credentials, - **self._load_args, - ) - - def _save(self, data: pd.DataFrame) -> None: - data.to_gbq( - f"{self._dataset}.{self._table_name}", - project_id=self._project_id, - credentials=self._credentials, - **self._save_args, - ) - - def _exists(self) -> bool: - table_ref = self._client.dataset(self._dataset).table(self._table_name) - try: - self._client.get_table(table_ref) - return True - except NotFound: - return False - - def _validate_location(self): - save_location = self._save_args.get("location") - load_location = self._load_args.get("location") - - if save_location != load_location: - raise DatasetError( - """"load_args['location']" is different from "save_args['location']". """ - "The 'location' defines where BigQuery data is stored, therefore has " - "to be the same for save and load args. " - "Details: https://cloud.google.com/bigquery/docs/locations" - ) - - -class GBQQueryDataSet(AbstractDataset[None, pd.DataFrame]): - """``GBQQueryDataSet`` loads data from a provided SQL query from Google - BigQuery. It uses ``pandas.read_gbq`` which itself uses ``pandas-gbq`` - internally to read from BigQuery table. Therefore it supports all allowed - pandas options on ``read_gbq``. - - Example adding a catalog entry with the ``YAML API``: - - .. code-block:: yaml - - >>> vehicles: - >>> type: pandas.GBQQueryDataSet - >>> sql: "select shuttle, shuttle_id from spaceflights.shuttles;" - >>> project: my-project - >>> credentials: gbq-creds - >>> load_args: - >>> reauth: True - - - Example using Python API: - :: - - >>> from kedro.extras.datasets.pandas import GBQQueryDataSet - >>> - >>> sql = "SELECT * FROM dataset_1.table_a" - >>> - >>> data_set = GBQQueryDataSet(sql, project='my-project') - >>> - >>> sql_data = data_set.load() - >>> - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - sql: str = None, - project: str = None, - credentials: Union[Dict[str, Any], Credentials] = None, - load_args: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - filepath: str = None, - ) -> None: - """Creates a new instance of ``GBQQueryDataSet``. - - Args: - sql: The sql query statement. - project: Google BigQuery Account project ID. - Optional when available from the environment. - https://cloud.google.com/resource-manager/docs/creating-managing-projects - credentials: Credentials for accessing Google APIs. - Either ``google.auth.credentials.Credentials`` object or dictionary with - parameters required to instantiate ``google.oauth2.credentials.Credentials``. - Here you can find all the arguments: - https://google-auth.readthedocs.io/en/latest/reference/google.oauth2.credentials.html - load_args: Pandas options for loading BigQuery table into DataFrame. - Here you can find all available arguments: - https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.read_gbq.html - All defaults are preserved. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``) used for reading the - SQL query from filepath. - filepath: A path to a file with a sql query statement. - - Raises: - DatasetError: When ``sql`` and ``filepath`` parameters are either both empty - or both provided, as well as when the `save()` method is invoked. - """ - if sql and filepath: - raise DatasetError( - "'sql' and 'filepath' arguments cannot both be provided." - "Please only provide one." - ) - - if not (sql or filepath): - raise DatasetError( - "'sql' and 'filepath' arguments cannot both be empty." - "Please provide a sql query or path to a sql query file." - ) - - # Handle default load arguments - self._load_args = copy.deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - - self._project_id = project - - if isinstance(credentials, dict): - credentials = Credentials(**credentials) - - self._credentials = credentials - self._client = bigquery.Client( - project=self._project_id, - credentials=self._credentials, - location=self._load_args.get("location"), - ) - - # load sql query from arg or from file - if sql: - self._load_args["query"] = sql - self._filepath = None - else: - # filesystem for loading sql file - _fs_args = copy.deepcopy(fs_args) or {} - _fs_credentials = _fs_args.pop("credentials", {}) - protocol, path = get_protocol_and_path(str(filepath)) - - self._protocol = protocol - self._fs = fsspec.filesystem(self._protocol, **_fs_credentials, **_fs_args) - self._filepath = path - - def _describe(self) -> Dict[str, Any]: - load_args = copy.deepcopy(self._load_args) - desc = {} - desc["sql"] = str(load_args.pop("query", None)) - desc["filepath"] = str(self._filepath) - desc["load_args"] = str(load_args) - - return desc - - def _load(self) -> pd.DataFrame: - load_args = copy.deepcopy(self._load_args) - - if self._filepath: - load_path = get_filepath_str(PurePosixPath(self._filepath), self._protocol) - with self._fs.open(load_path, mode="r") as fs_file: - load_args["query"] = fs_file.read() - - return pd.read_gbq( - project_id=self._project_id, - credentials=self._credentials, - **load_args, - ) - - def _save(self, data: None) -> NoReturn: - raise DatasetError("'save' is not supported on GBQQueryDataSet") diff --git a/kedro/extras/datasets/pandas/generic_dataset.py b/kedro/extras/datasets/pandas/generic_dataset.py deleted file mode 100644 index 9d173d6524..0000000000 --- a/kedro/extras/datasets/pandas/generic_dataset.py +++ /dev/null @@ -1,247 +0,0 @@ -"""``GenericDataSet`` loads/saves data from/to a data file using an underlying -filesystem (e.g.: local, S3, GCS). It uses pandas to handle the -type of read/write target. -""" -from copy import deepcopy -from pathlib import PurePosixPath -from typing import Any, Dict - -import fsspec -import pandas as pd - -from kedro.io.core import ( - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -NON_FILE_SYSTEM_TARGETS = [ - "clipboard", - "numpy", - "sql", - "period", - "records", - "timestamp", - "xarray", - "sql_table", -] - - -class GenericDataSet(AbstractVersionedDataset[pd.DataFrame, pd.DataFrame]): - """`pandas.GenericDataSet` loads/saves data from/to a data file using an underlying - filesystem (e.g.: local, S3, GCS). It uses pandas to dynamically select the - appropriate type of read/write target on a best effort basis. - - Example usage for the - `YAML API `_: - - .. code-block:: yaml - - cars: - type: pandas.GenericDataSet - file_format: csv - filepath: s3://data/01_raw/company/cars.csv - load_args: - sep: "," - na_values: ["#NA", NA] - save_args: - index: False - date_format: "%Y-%m-%d" - - This second example is able to load a SAS7BDAT file via the ``pd.read_sas`` method. - Trying to save this dataset will raise a ``DatasetError`` since pandas does not provide an - equivalent ``pd.DataFrame.to_sas`` write method. - - .. code-block:: yaml - - flights: - type: pandas.GenericDataSet - file_format: sas - filepath: data/01_raw/airplanes.sas7bdat - load_args: - format: sas7bdat - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.pandas import GenericDataSet - >>> import pandas as pd - >>> - >>> data = pd.DataFrame({'col1': [1, 2], 'col2': [4, 5], - >>> 'col3': [5, 6]}) - >>> - >>> data_set = GenericDataSet(filepath="test.csv", file_format='csv') - >>> data_set.save(data) - >>> reloaded = data_set.load() - >>> assert data.equals(reloaded) - - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - file_format: str, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ): - """Creates a new instance of ``GenericDataSet`` pointing to a concrete data file - on a specific filesystem. The appropriate pandas load/save methods are - dynamically identified by string matching on a best effort basis. - - Args: - filepath: Filepath in POSIX format to a file prefixed with a protocol like `s3://`. - If prefix is not provided, `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - Key assumption: The first argument of either load/save method points to a - filepath/buffer/io type location. There are some read/write targets such - as 'clipboard' or 'records' that will fail since they do not take a - filepath like argument. - file_format: String which is used to match the appropriate load/save method on a best - effort basis. For example if 'csv' is passed in the `pandas.read_csv` and - `pandas.DataFrame.to_csv` will be identified. An error will be raised unless - at least one matching `read_{file_format}` or `to_{file_format}` method is - identified. - load_args: Pandas options for loading files. - Here you can find all available arguments: - https://pandas.pydata.org/pandas-docs/stable/reference/io.html - All defaults are preserved. - save_args: Pandas options for saving files. - Here you can find all available arguments: - https://pandas.pydata.org/pandas-docs/stable/reference/io.html - All defaults are preserved, but "index", which is set to False. - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``), as well as - to pass to the filesystem's `open` method through nested keys - `open_args_load` and `open_args_save`. - Here you can find all available arguments for `open`: - https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem.open - All defaults are preserved, except `mode`, which is set to `r` when loading - and to `w` when saving. - - Raises: - DatasetError: Will be raised if at least less than one appropriate - read or write methods are identified. - """ - - self._file_format = file_format.lower() - - _fs_args = deepcopy(fs_args) or {} - _fs_open_args_load = _fs_args.pop("open_args_load", {}) - _fs_open_args_save = _fs_args.pop("open_args_save", {}) - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._fs = fsspec.filesystem(self._protocol, **_credentials, **_fs_args) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - _fs_open_args_save.setdefault("mode", "w") - self._fs_open_args_load = _fs_open_args_load - self._fs_open_args_save = _fs_open_args_save - - def _ensure_file_system_target(self) -> None: - # Fail fast if provided a known non-filesystem target - if self._file_format in NON_FILE_SYSTEM_TARGETS: - raise DatasetError( - f"Cannot create a dataset of file_format '{self._file_format}' as it " - f"does not support a filepath target/source." - ) - - def _load(self) -> pd.DataFrame: - - self._ensure_file_system_target() - - load_path = get_filepath_str(self._get_load_path(), self._protocol) - load_method = getattr(pd, f"read_{self._file_format}", None) - if load_method: - with self._fs.open(load_path, **self._fs_open_args_load) as fs_file: - return load_method(fs_file, **self._load_args) - raise DatasetError( - f"Unable to retrieve 'pandas.read_{self._file_format}' method, please ensure that your " - "'file_format' parameter has been defined correctly as per the Pandas API " - "https://pandas.pydata.org/docs/reference/io.html" - ) - - def _save(self, data: pd.DataFrame) -> None: - - self._ensure_file_system_target() - - save_path = get_filepath_str(self._get_save_path(), self._protocol) - save_method = getattr(data, f"to_{self._file_format}", None) - if save_method: - with self._fs.open(save_path, **self._fs_open_args_save) as fs_file: - # KEY ASSUMPTION - first argument is path/buffer/io - save_method(fs_file, **self._save_args) - self._invalidate_cache() - else: - raise DatasetError( - f"Unable to retrieve 'pandas.DataFrame.to_{self._file_format}' method, please " - "ensure that your 'file_format' parameter has been defined correctly as " - "per the Pandas API " - "https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html" - ) - - def _exists(self) -> bool: - try: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - except DatasetError: - return False - - return self._fs.exists(load_path) - - def _describe(self) -> Dict[str, Any]: - return { - "file_format": self._file_format, - "filepath": self._filepath, - "protocol": self._protocol, - "load_args": self._load_args, - "save_args": self._save_args, - "version": self._version, - } - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/pandas/hdf_dataset.py b/kedro/extras/datasets/pandas/hdf_dataset.py deleted file mode 100644 index aa02434776..0000000000 --- a/kedro/extras/datasets/pandas/hdf_dataset.py +++ /dev/null @@ -1,206 +0,0 @@ -"""``HDFDataSet`` loads/saves data from/to a hdf file using an underlying -filesystem (e.g.: local, S3, GCS). It uses pandas.HDFStore to handle the hdf file. -""" -from copy import deepcopy -from pathlib import PurePosixPath -from threading import Lock -from typing import Any, Dict - -import fsspec -import pandas as pd - -from kedro.io.core import ( - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -HDFSTORE_DRIVER = "H5FD_CORE" - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class HDFDataSet(AbstractVersionedDataset[pd.DataFrame, pd.DataFrame]): - """``HDFDataSet`` loads/saves data from/to a hdf file using an underlying - filesystem (e.g. local, S3, GCS). It uses pandas.HDFStore to handle the hdf file. - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - hdf_dataset: - type: pandas.HDFDataSet - filepath: s3://my_bucket/raw/sensor_reading.h5 - credentials: aws_s3_creds - key: data - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.pandas import HDFDataSet - >>> import pandas as pd - >>> - >>> data = pd.DataFrame({'col1': [1, 2], 'col2': [4, 5], - >>> 'col3': [5, 6]}) - >>> - >>> data_set = HDFDataSet(filepath="test.h5", key='data') - >>> data_set.save(data) - >>> reloaded = data_set.load() - >>> assert data.equals(reloaded) - - """ - - # _lock is a class attribute that will be shared across all the instances. - # It is used to make dataset safe for threads. - _lock = Lock() - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - key: str, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``HDFDataSet`` pointing to a concrete hdf file - on a specific filesystem. - - Args: - filepath: Filepath in POSIX format to a hdf file prefixed with a protocol like `s3://`. - If prefix is not provided, `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - Note: `http(s)` doesn't support versioning. - key: Identifier to the group in the HDF store. - load_args: PyTables options for loading hdf files. - You can find all available arguments at: - https://www.pytables.org/usersguide/libref/top_level.html#tables.open_file - All defaults are preserved. - save_args: PyTables options for saving hdf files. - You can find all available arguments at: - https://www.pytables.org/usersguide/libref/top_level.html#tables.open_file - All defaults are preserved. - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``), as well as - to pass to the filesystem's `open` method through nested keys - `open_args_load` and `open_args_save`. - Here you can find all available arguments for `open`: - https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem.open - All defaults are preserved, except `mode`, which is set `wb` when saving. - """ - _fs_args = deepcopy(fs_args) or {} - _fs_open_args_load = _fs_args.pop("open_args_load", {}) - _fs_open_args_save = _fs_args.pop("open_args_save", {}) - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath, version) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._fs = fsspec.filesystem(self._protocol, **_credentials, **_fs_args) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - self._key = key - - # Handle default load and save arguments - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - _fs_open_args_save.setdefault("mode", "wb") - self._fs_open_args_load = _fs_open_args_load - self._fs_open_args_save = _fs_open_args_save - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "key": self._key, - "protocol": self._protocol, - "load_args": self._load_args, - "save_args": self._save_args, - "version": self._version, - } - - def _load(self) -> pd.DataFrame: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - - with self._fs.open(load_path, **self._fs_open_args_load) as fs_file: - binary_data = fs_file.read() - - with HDFDataSet._lock: - # Set driver_core_backing_store to False to disable saving - # contents of the in-memory h5file to disk - with pd.HDFStore( - "in-memory-load-file", - mode="r", - driver=HDFSTORE_DRIVER, - driver_core_backing_store=0, - driver_core_image=binary_data, - **self._load_args, - ) as store: - return store[self._key] - - def _save(self, data: pd.DataFrame) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - - with HDFDataSet._lock: - with pd.HDFStore( - "in-memory-save-file", - mode="w", - driver=HDFSTORE_DRIVER, - driver_core_backing_store=0, - **self._save_args, - ) as store: - store.put(self._key, data, format="table") - # pylint: disable=protected-access - binary_data = store._handle.get_file_image() - - with self._fs.open(save_path, **self._fs_open_args_save) as fs_file: - fs_file.write(binary_data) - - self._invalidate_cache() - - def _exists(self) -> bool: - try: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - except DatasetError: - return False - - return self._fs.exists(load_path) - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/pandas/json_dataset.py b/kedro/extras/datasets/pandas/json_dataset.py deleted file mode 100644 index c2cf971bb9..0000000000 --- a/kedro/extras/datasets/pandas/json_dataset.py +++ /dev/null @@ -1,188 +0,0 @@ -"""``JSONDataSet`` loads/saves data from/to a JSON file using an underlying -filesystem (e.g.: local, S3, GCS). It uses pandas to handle the JSON file. -""" -import logging -from copy import deepcopy -from io import BytesIO -from pathlib import PurePosixPath -from typing import Any, Dict - -import fsspec -import pandas as pd - -from kedro.io.core import ( - PROTOCOL_DELIMITER, - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -logger = logging.getLogger(__name__) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class JSONDataSet(AbstractVersionedDataset[pd.DataFrame, pd.DataFrame]): - """``JSONDataSet`` loads/saves data from/to a JSON file using an underlying - filesystem (e.g.: local, S3, GCS). It uses pandas to handle the json file. - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - clickstream_dataset: - type: pandas.JSONDataSet - filepath: abfs://landing_area/primary/click_stream.json - credentials: abfs_creds - - json_dataset: - type: pandas.JSONDataSet - filepath: data/01_raw/Video_Games.json - load_args: - lines: True - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.pandas import JSONDataSet - >>> import pandas as pd - >>> - >>> data = pd.DataFrame({'col1': [1, 2], 'col2': [4, 5], - >>> 'col3': [5, 6]}) - >>> - >>> data_set = JSONDataSet(filepath="test.json") - >>> data_set.save(data) - >>> reloaded = data_set.load() - >>> assert data.equals(reloaded) - - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``JSONDataSet`` pointing to a concrete JSON file - on a specific filesystem. - - Args: - filepath: Filepath in POSIX format to a JSON file prefixed with a protocol like `s3://`. - If prefix is not provided `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - Note: `http(s)` doesn't support versioning. - load_args: Pandas options for loading JSON files. - Here you can find all available arguments: - https://pandas.pydata.org/pandas-docs/stable/generated/pandas.read_json.html - All defaults are preserved. - save_args: Pandas options for saving JSON files. - Here you can find all available arguments: - https://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.to_json.html - All defaults are preserved, but "index", which is set to False. - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{'token': None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``). - """ - _fs_args = deepcopy(fs_args) or {} - _credentials = deepcopy(credentials) or {} - protocol, path = get_protocol_and_path(filepath, version) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._storage_options = {**_credentials, **_fs_args} - self._fs = fsspec.filesystem(self._protocol, **self._storage_options) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - # Handle default load and save arguments - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - if "storage_options" in self._save_args or "storage_options" in self._load_args: - logger.warning( - "Dropping 'storage_options' for %s, " - "please specify them under 'fs_args' or 'credentials'.", - self._filepath, - ) - self._save_args.pop("storage_options", None) - self._load_args.pop("storage_options", None) - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._load_args, - "save_args": self._save_args, - "version": self._version, - } - - def _load(self) -> pd.DataFrame: - load_path = str(self._get_load_path()) - if self._protocol == "file": - # file:// protocol seems to misbehave on Windows - # (), - # so we don't join that back to the filepath; - # storage_options also don't work with local paths - return pd.read_json(load_path, **self._load_args) - - load_path = f"{self._protocol}{PROTOCOL_DELIMITER}{load_path}" - return pd.read_json( - load_path, storage_options=self._storage_options, **self._load_args - ) - - def _save(self, data: pd.DataFrame) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - - buf = BytesIO() - data.to_json(path_or_buf=buf, **self._save_args) - - with self._fs.open(save_path, mode="wb") as fs_file: - fs_file.write(buf.getvalue()) - - self._invalidate_cache() - - def _exists(self) -> bool: - try: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - except DatasetError: - return False - - return self._fs.exists(load_path) - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/pandas/parquet_dataset.py b/kedro/extras/datasets/pandas/parquet_dataset.py deleted file mode 100644 index 43c603f2ae..0000000000 --- a/kedro/extras/datasets/pandas/parquet_dataset.py +++ /dev/null @@ -1,231 +0,0 @@ -"""``ParquetDataSet`` loads/saves data from/to a Parquet file using an underlying -filesystem (e.g.: local, S3, GCS). It uses pandas to handle the Parquet file. -""" -import logging -from copy import deepcopy -from io import BytesIO -from pathlib import Path, PurePosixPath -from typing import Any, Dict - -import fsspec -import pandas as pd -import pyarrow.parquet as pq - -from kedro.io.core import ( - PROTOCOL_DELIMITER, - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -logger = logging.getLogger(__name__) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class ParquetDataSet(AbstractVersionedDataset[pd.DataFrame, pd.DataFrame]): - """``ParquetDataSet`` loads/saves data from/to a Parquet file using an underlying - filesystem (e.g.: local, S3, GCS). It uses pandas to handle the Parquet file. - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - boats: - type: pandas.ParquetDataSet - filepath: data/01_raw/boats.parquet - load_args: - engine: pyarrow - use_nullable_dtypes: True - save_args: - file_scheme: hive - has_nulls: False - engine: pyarrow - - trucks: - type: pandas.ParquetDataSet - filepath: abfs://container/02_intermediate/trucks.parquet - credentials: dev_abs - load_args: - columns: [name, gear, disp, wt] - index: name - save_args: - compression: GZIP - partition_on: [name] - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.pandas import ParquetDataSet - >>> import pandas as pd - >>> - >>> data = pd.DataFrame({'col1': [1, 2], 'col2': [4, 5], - >>> 'col3': [5, 6]}) - >>> - >>> data_set = ParquetDataSet(filepath="test.parquet") - >>> data_set.save(data) - >>> reloaded = data_set.load() - >>> assert data.equals(reloaded) - - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``ParquetDataSet`` pointing to a concrete Parquet file - on a specific filesystem. - - Args: - filepath: Filepath in POSIX format to a Parquet file prefixed with a protocol like - `s3://`. If prefix is not provided, `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - It can also be a path to a directory. If the directory is - provided then it can be used for reading partitioned parquet files. - Note: `http(s)` doesn't support versioning. - load_args: Additional options for loading Parquet file(s). - Here you can find all available arguments when reading single file: - https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.read_parquet.html - Here you can find all available arguments when reading partitioned datasets: - https://arrow.apache.org/docs/python/generated/pyarrow.parquet.ParquetDataset.html#pyarrow.parquet.ParquetDataset.read - All defaults are preserved. - save_args: Additional saving options for saving Parquet file(s). - Here you can find all available arguments: - https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.to_parquet.html - All defaults are preserved. ``partition_cols`` is not supported. - version: If specified, should be an instance of ``kedro.io.core.Version``. - If its ``load`` attribute is None, the latest version will be loaded. If - its ``save`` attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``). - """ - _fs_args = deepcopy(fs_args) or {} - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath, version) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._storage_options = {**_credentials, **_fs_args} - self._fs = fsspec.filesystem(self._protocol, **self._storage_options) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - # Handle default load and save arguments - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - if "storage_options" in self._save_args or "storage_options" in self._load_args: - logger.warning( - "Dropping 'storage_options' for %s, " - "please specify them under 'fs_args' or 'credentials'.", - self._filepath, - ) - self._save_args.pop("storage_options", None) - self._load_args.pop("storage_options", None) - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._load_args, - "save_args": self._save_args, - "version": self._version, - } - - def _load(self) -> pd.DataFrame: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - - if self._fs.isdir(load_path): - # It doesn't work at least on S3 if root folder was created manually - # https://issues.apache.org/jira/browse/ARROW-7867 - data = ( - pq.ParquetDataset(load_path, filesystem=self._fs) - .read(**self._load_args) - .to_pandas() - ) - else: - data = self._load_from_pandas() - - return data - - def _load_from_pandas(self): - load_path = str(self._get_load_path()) - if self._protocol == "file": - # file:// protocol seems to misbehave on Windows - # (), - # so we don't join that back to the filepath; - # storage_options also don't work with local paths - return pd.read_parquet(load_path, **self._load_args) - - load_path = f"{self._protocol}{PROTOCOL_DELIMITER}{load_path}" - return pd.read_parquet( - load_path, storage_options=self._storage_options, **self._load_args - ) - - def _save(self, data: pd.DataFrame) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - - if Path(save_path).is_dir(): - raise DatasetError( - f"Saving {self.__class__.__name__} to a directory is not supported." - ) - - if "partition_cols" in self._save_args: - raise DatasetError( - f"{self.__class__.__name__} does not support save argument " - f"'partition_cols'. Please use 'kedro.io.PartitionedDataSet' instead." - ) - - bytes_buffer = BytesIO() - data.to_parquet(bytes_buffer, **self._save_args) - - with self._fs.open(save_path, mode="wb") as fs_file: - fs_file.write(bytes_buffer.getvalue()) - - self._invalidate_cache() - - def _exists(self) -> bool: - try: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - except DatasetError: - return False - - return self._fs.exists(load_path) - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/pandas/sql_dataset.py b/kedro/extras/datasets/pandas/sql_dataset.py deleted file mode 100644 index 03b3c43aee..0000000000 --- a/kedro/extras/datasets/pandas/sql_dataset.py +++ /dev/null @@ -1,467 +0,0 @@ -"""``SQLDataSet`` to load and save data to a SQL backend.""" - -import copy -import re -from pathlib import PurePosixPath -from typing import Any, Dict, NoReturn, Optional - -import fsspec -import pandas as pd -from sqlalchemy import create_engine -from sqlalchemy.exc import NoSuchModuleError - -from kedro.io.core import ( - AbstractDataset, - DatasetError, - get_filepath_str, - get_protocol_and_path, -) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -__all__ = ["SQLTableDataSet", "SQLQueryDataSet"] - -KNOWN_PIP_INSTALL = { - "psycopg2": "psycopg2", - "mysqldb": "mysqlclient", - "cx_Oracle": "cx_Oracle", -} - -DRIVER_ERROR_MESSAGE = """ -A module/driver is missing when connecting to your SQL server. SQLDataSet - supports SQLAlchemy drivers. Please refer to - https://docs.sqlalchemy.org/en/13/core/engines.html#supported-databases - for more information. -\n\n -""" - - -def _find_known_drivers(module_import_error: ImportError) -> Optional[str]: - """Looks up known keywords in a ``ModuleNotFoundError`` so that it can - provide better guideline for the user. - - Args: - module_import_error: Error raised while connecting to a SQL server. - - Returns: - Instructions for installing missing driver. An empty string is - returned in case error is related to an unknown driver. - - """ - - # module errors contain string "No module name 'module_name'" - # we are trying to extract module_name surrounded by quotes here - res = re.findall(r"'(.*?)'", str(module_import_error.args[0]).lower()) - - # in case module import error does not match our expected pattern - # we have no recommendation - if not res: - return None - - missing_module = res[0] - - if KNOWN_PIP_INSTALL.get(missing_module): - return ( - f"You can also try installing missing driver with\n" - f"\npip install {KNOWN_PIP_INSTALL.get(missing_module)}" - ) - - return None - - -def _get_missing_module_error(import_error: ImportError) -> DatasetError: - missing_module_instruction = _find_known_drivers(import_error) - - if missing_module_instruction is None: - return DatasetError( - f"{DRIVER_ERROR_MESSAGE}Loading failed with error:\n\n{str(import_error)}" - ) - - return DatasetError(f"{DRIVER_ERROR_MESSAGE}{missing_module_instruction}") - - -def _get_sql_alchemy_missing_error() -> DatasetError: - return DatasetError( - "The SQL dialect in your connection is not supported by " - "SQLAlchemy. Please refer to " - "https://docs.sqlalchemy.org/en/13/core/engines.html#supported-databases " - "for more information." - ) - - -class SQLTableDataSet(AbstractDataset[pd.DataFrame, pd.DataFrame]): - """``SQLTableDataSet`` loads data from a SQL table and saves a pandas - dataframe to a table. It uses ``pandas.DataFrame`` internally, - so it supports all allowed pandas options on ``read_sql_table`` and - ``to_sql`` methods. Since Pandas uses SQLAlchemy behind the scenes, when - instantiating ``SQLTableDataSet`` one needs to pass a compatible connection - string either in ``credentials`` (see the example code snippet below) or in - ``load_args`` and ``save_args``. Connection string formats supported by - SQLAlchemy can be found here: - https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls - - ``SQLTableDataSet`` modifies the save parameters and stores - the data with no index. This is designed to make load and save methods - symmetric. - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - shuttles_table_dataset: - type: pandas.SQLTableDataSet - credentials: db_credentials - table_name: shuttles - load_args: - schema: dwschema - save_args: - schema: dwschema - if_exists: replace - - Sample database credentials entry in ``credentials.yml``: - - .. code-block:: yaml - - db_credentials: - con: postgresql://scott:tiger@localhost/test - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.pandas import SQLTableDataSet - >>> import pandas as pd - >>> - >>> data = pd.DataFrame({"col1": [1, 2], "col2": [4, 5], - >>> "col3": [5, 6]}) - >>> table_name = "table_a" - >>> credentials = { - >>> "con": "postgresql://scott:tiger@localhost/test" - >>> } - >>> data_set = SQLTableDataSet(table_name=table_name, - >>> credentials=credentials) - >>> - >>> data_set.save(data) - >>> reloaded = data_set.load() - >>> - >>> assert data.equals(reloaded) - - """ - - DEFAULT_LOAD_ARGS: Dict[str, Any] = {} - DEFAULT_SAVE_ARGS: Dict[str, Any] = {"index": False} - # using Any because of Sphinx but it should be - # sqlalchemy.engine.Engine or sqlalchemy.engine.base.Engine - engines: Dict[str, Any] = {} - - def __init__( - self, - table_name: str, - credentials: Dict[str, Any], - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - ) -> None: - """Creates a new ``SQLTableDataSet``. - - Args: - table_name: The table name to load or save data to. It - overwrites name in ``save_args`` and ``table_name`` - parameters in ``load_args``. - credentials: A dictionary with a ``SQLAlchemy`` connection string. - Users are supposed to provide the connection string 'con' - through credentials. It overwrites `con` parameter in - ``load_args`` and ``save_args`` in case it is provided. To find - all supported connection string formats, see here: - https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls - load_args: Provided to underlying pandas ``read_sql_table`` - function along with the connection string. - To find all supported arguments, see here: - https://pandas.pydata.org/pandas-docs/stable/generated/pandas.read_sql_table.html - To find all supported connection string formats, see here: - https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls - save_args: Provided to underlying pandas ``to_sql`` function along - with the connection string. - To find all supported arguments, see here: - https://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.to_sql.html - To find all supported connection string formats, see here: - https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls - It has ``index=False`` in the default parameters. - - Raises: - DatasetError: When either ``table_name`` or ``con`` is empty. - """ - - if not table_name: - raise DatasetError("'table_name' argument cannot be empty.") - - if not (credentials and "con" in credentials and credentials["con"]): - raise DatasetError( - "'con' argument cannot be empty. Please " - "provide a SQLAlchemy connection string." - ) - - # Handle default load and save arguments - self._load_args = copy.deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = copy.deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - self._load_args["table_name"] = table_name - self._save_args["name"] = table_name - - self._connection_str = credentials["con"] - self.create_connection(self._connection_str) - - @classmethod - def create_connection(cls, connection_str: str) -> None: - """Given a connection string, create singleton connection - to be used across all instances of `SQLTableDataSet` that - need to connect to the same source. - """ - if connection_str in cls.engines: - return - - try: - engine = create_engine(connection_str) - except ImportError as import_error: - raise _get_missing_module_error(import_error) from import_error - except NoSuchModuleError as exc: - raise _get_sql_alchemy_missing_error() from exc - - cls.engines[connection_str] = engine - - def _describe(self) -> Dict[str, Any]: - load_args = copy.deepcopy(self._load_args) - save_args = copy.deepcopy(self._save_args) - del load_args["table_name"] - del save_args["name"] - return { - "table_name": self._load_args["table_name"], - "load_args": load_args, - "save_args": save_args, - } - - def _load(self) -> pd.DataFrame: - engine = self.engines[self._connection_str] # type:ignore - return pd.read_sql_table(con=engine, **self._load_args) - - def _save(self, data: pd.DataFrame) -> None: - engine = self.engines[self._connection_str] # type: ignore - data.to_sql(con=engine, **self._save_args) - - def _exists(self) -> bool: - eng = self.engines[self._connection_str] # type: ignore - schema = self._load_args.get("schema", None) - exists = self._load_args["table_name"] in eng.table_names(schema) - return exists - - -class SQLQueryDataSet(AbstractDataset[None, pd.DataFrame]): - """``SQLQueryDataSet`` loads data from a provided SQL query. It - uses ``pandas.DataFrame`` internally, so it supports all allowed - pandas options on ``read_sql_query``. Since Pandas uses SQLAlchemy behind - the scenes, when instantiating ``SQLQueryDataSet`` one needs to pass - a compatible connection string either in ``credentials`` (see the example - code snippet below) or in ``load_args``. Connection string formats supported - by SQLAlchemy can be found here: - https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls - - It does not support save method so it is a read only data set. - To save data to a SQL server use ``SQLTableDataSet``. - - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - shuttle_id_dataset: - type: pandas.SQLQueryDataSet - sql: "select shuttle, shuttle_id from spaceflights.shuttles;" - credentials: db_credentials - - Advanced example using the ``stream_results`` and ``chunksize`` options to reduce memory usage: - - .. code-block:: yaml - - shuttle_id_dataset: - type: pandas.SQLQueryDataSet - sql: "select shuttle, shuttle_id from spaceflights.shuttles;" - credentials: db_credentials - execution_options: - stream_results: true - load_args: - chunksize: 1000 - - Sample database credentials entry in ``credentials.yml``: - - .. code-block:: yaml - - db_credentials: - con: postgresql://scott:tiger@localhost/test - - Example usage for the - `Python API `_: - :: - - - >>> from kedro.extras.datasets.pandas import SQLQueryDataSet - >>> import pandas as pd - >>> - >>> data = pd.DataFrame({"col1": [1, 2], "col2": [4, 5], - >>> "col3": [5, 6]}) - >>> sql = "SELECT * FROM table_a" - >>> credentials = { - >>> "con": "postgresql://scott:tiger@localhost/test" - >>> } - >>> data_set = SQLQueryDataSet(sql=sql, - >>> credentials=credentials) - >>> - >>> sql_data = data_set.load() - - """ - - # using Any because of Sphinx but it should be - # sqlalchemy.engine.Engine or sqlalchemy.engine.base.Engine - engines: Dict[str, Any] = {} - - def __init__( # noqa: too-many-arguments - self, - sql: str = None, - credentials: Dict[str, Any] = None, - load_args: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - filepath: str = None, - execution_options: Optional[Dict[str, Any]] = None, - ) -> None: - """Creates a new ``SQLQueryDataSet``. - - Args: - sql: The sql query statement. - credentials: A dictionary with a ``SQLAlchemy`` connection string. - Users are supposed to provide the connection string 'con' - through credentials. It overwrites `con` parameter in - ``load_args`` and ``save_args`` in case it is provided. To find - all supported connection string formats, see here: - https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls - load_args: Provided to underlying pandas ``read_sql_query`` - function along with the connection string. - To find all supported arguments, see here: - https://pandas.pydata.org/pandas-docs/stable/generated/pandas.read_sql_query.html - To find all supported connection string formats, see here: - https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``), as well as - to pass to the filesystem's `open` method through nested keys - `open_args_load` and `open_args_save`. - Here you can find all available arguments for `open`: - https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem.open - All defaults are preserved, except `mode`, which is set to `r` when loading. - filepath: A path to a file with a sql query statement. - execution_options: A dictionary with non-SQL advanced options for the connection to - be applied to the underlying engine. To find all supported execution - options, see here: - https://docs.sqlalchemy.org/en/12/core/connections.html#sqlalchemy.engine.Connection.execution_options - Note that this is not a standard argument supported by pandas API, but could be - useful for handling large datasets. - - Raises: - DatasetError: When either ``sql`` or ``con`` parameters is empty. - """ - if sql and filepath: - raise DatasetError( - "'sql' and 'filepath' arguments cannot both be provided." - "Please only provide one." - ) - - if not (sql or filepath): - raise DatasetError( - "'sql' and 'filepath' arguments cannot both be empty." - "Please provide a sql query or path to a sql query file." - ) - - if not (credentials and "con" in credentials and credentials["con"]): - raise DatasetError( - "'con' argument cannot be empty. Please " - "provide a SQLAlchemy connection string." - ) - - default_load_args = {} # type: Dict[str, Any] - - self._load_args = ( - {**default_load_args, **load_args} - if load_args is not None - else default_load_args - ) - - # load sql query from file - if sql: - self._load_args["sql"] = sql - self._filepath = None - else: - # filesystem for loading sql file - _fs_args = copy.deepcopy(fs_args) or {} - _fs_credentials = _fs_args.pop("credentials", {}) - protocol, path = get_protocol_and_path(str(filepath)) - - self._protocol = protocol - self._fs = fsspec.filesystem(self._protocol, **_fs_credentials, **_fs_args) - self._filepath = path - self._connection_str = credentials["con"] - self._execution_options = execution_options or {} - self.create_connection(self._connection_str) - - @classmethod - def create_connection(cls, connection_str: str) -> None: - """Given a connection string, create singleton connection - to be used across all instances of `SQLQueryDataSet` that - need to connect to the same source. - """ - if connection_str in cls.engines: - return - - try: - engine = create_engine(connection_str) - except ImportError as import_error: - raise _get_missing_module_error(import_error) from import_error - except NoSuchModuleError as exc: - raise _get_sql_alchemy_missing_error() from exc - - cls.engines[connection_str] = engine - - def _describe(self) -> Dict[str, Any]: - load_args = copy.deepcopy(self._load_args) - return { - "sql": str(load_args.pop("sql", None)), - "filepath": str(self._filepath), - "load_args": str(load_args), - "execution_options": str(self._execution_options), - } - - def _load(self) -> pd.DataFrame: - load_args = copy.deepcopy(self._load_args) - engine = self.engines[self._connection_str].execution_options( - **self._execution_options - ) # type: ignore - - if self._filepath: - load_path = get_filepath_str(PurePosixPath(self._filepath), self._protocol) - with self._fs.open(load_path, mode="r") as fs_file: - load_args["sql"] = fs_file.read() - - return pd.read_sql_query(con=engine, **load_args) - - def _save(self, data: None) -> NoReturn: - raise DatasetError("'save' is not supported on SQLQueryDataSet") diff --git a/kedro/extras/datasets/pandas/xml_dataset.py b/kedro/extras/datasets/pandas/xml_dataset.py deleted file mode 100644 index 30bd777252..0000000000 --- a/kedro/extras/datasets/pandas/xml_dataset.py +++ /dev/null @@ -1,171 +0,0 @@ -"""``XMLDataSet`` loads/saves data from/to a XML file using an underlying -filesystem (e.g.: local, S3, GCS). It uses pandas to handle the XML file. -""" -import logging -from copy import deepcopy -from io import BytesIO -from pathlib import PurePosixPath -from typing import Any, Dict - -import fsspec -import pandas as pd - -from kedro.io.core import ( - PROTOCOL_DELIMITER, - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -logger = logging.getLogger(__name__) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class XMLDataSet(AbstractVersionedDataset[pd.DataFrame, pd.DataFrame]): - """``XMLDataSet`` loads/saves data from/to a XML file using an underlying - filesystem (e.g.: local, S3, GCS). It uses pandas to handle the XML file. - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.pandas import XMLDataSet - >>> import pandas as pd - >>> - >>> data = pd.DataFrame({'col1': [1, 2], 'col2': [4, 5], - >>> 'col3': [5, 6]}) - >>> - >>> data_set = XMLDataSet(filepath="test.xml") - >>> data_set.save(data) - >>> reloaded = data_set.load() - >>> assert data.equals(reloaded) - - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {"index": False} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``XMLDataSet`` pointing to a concrete XML file - on a specific filesystem. - - Args: - filepath: Filepath in POSIX format to a XML file prefixed with a protocol like `s3://`. - If prefix is not provided, `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - Note: `http(s)` doesn't support versioning. - load_args: Pandas options for loading XML files. - Here you can find all available arguments: - https://pandas.pydata.org/docs/reference/api/pandas.read_xml.html - All defaults are preserved. - save_args: Pandas options for saving XML files. - Here you can find all available arguments: - https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.to_xml.html - All defaults are preserved, but "index", which is set to False. - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``). - """ - _fs_args = deepcopy(fs_args) or {} - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath, version) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._storage_options = {**_credentials, **_fs_args} - self._fs = fsspec.filesystem(self._protocol, **self._storage_options) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - # Handle default load and save arguments - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - if "storage_options" in self._save_args or "storage_options" in self._load_args: - logger.warning( - "Dropping 'storage_options' for %s, " - "please specify them under 'fs_args' or 'credentials'.", - self._filepath, - ) - self._save_args.pop("storage_options", None) - self._load_args.pop("storage_options", None) - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._load_args, - "save_args": self._save_args, - "version": self._version, - } - - def _load(self) -> pd.DataFrame: - load_path = str(self._get_load_path()) - if self._protocol == "file": - # file:// protocol seems to misbehave on Windows - # (), - # so we don't join that back to the filepath; - # storage_options also don't work with local paths - return pd.read_xml(load_path, **self._load_args) - - load_path = f"{self._protocol}{PROTOCOL_DELIMITER}{load_path}" - return pd.read_xml( - load_path, storage_options=self._storage_options, **self._load_args - ) - - def _save(self, data: pd.DataFrame) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - - buf = BytesIO() - data.to_xml(path_or_buffer=buf, **self._save_args) - - with self._fs.open(save_path, mode="wb") as fs_file: - fs_file.write(buf.getvalue()) - - self._invalidate_cache() - - def _exists(self) -> bool: - try: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - except DatasetError: - return False - - return self._fs.exists(load_path) - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/pickle/__init__.py b/kedro/extras/datasets/pickle/__init__.py deleted file mode 100644 index 40b898eb07..0000000000 --- a/kedro/extras/datasets/pickle/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""``AbstractDataset`` implementation to load/save data from/to a Pickle file.""" - -__all__ = ["PickleDataSet"] - -from contextlib import suppress - -with suppress(ImportError): - from .pickle_dataset import PickleDataSet diff --git a/kedro/extras/datasets/pickle/pickle_dataset.py b/kedro/extras/datasets/pickle/pickle_dataset.py deleted file mode 100644 index 93bbbc2dbc..0000000000 --- a/kedro/extras/datasets/pickle/pickle_dataset.py +++ /dev/null @@ -1,245 +0,0 @@ -"""``PickleDataSet`` loads/saves data from/to a Pickle file using an underlying -filesystem (e.g.: local, S3, GCS). The underlying functionality is supported by -the specified backend library passed in (defaults to the ``pickle`` library), so it -supports all allowed options for loading and saving pickle files. -""" -import importlib -from copy import deepcopy -from pathlib import PurePosixPath -from typing import Any, Dict - -import fsspec - -from kedro.io.core import ( - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class PickleDataSet(AbstractVersionedDataset[Any, Any]): - """``PickleDataSet`` loads/saves data from/to a Pickle file using an underlying - filesystem (e.g.: local, S3, GCS). The underlying functionality is supported by - the specified backend library passed in (defaults to the ``pickle`` library), so it - supports all allowed options for loading and saving pickle files. - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - test_model: # simple example without compression - type: pickle.PickleDataSet - filepath: data/07_model_output/test_model.pkl - backend: pickle - - final_model: # example with load and save args - type: pickle.PickleDataSet - filepath: s3://your_bucket/final_model.pkl.lz4 - backend: joblib - credentials: s3_credentials - save_args: - compress: lz4 - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.pickle import PickleDataSet - >>> import pandas as pd - >>> - >>> data = pd.DataFrame({'col1': [1, 2], 'col2': [4, 5], - >>> 'col3': [5, 6]}) - >>> - >>> data_set = PickleDataSet(filepath="test.pkl", backend="pickle") - >>> data_set.save(data) - >>> reloaded = data_set.load() - >>> assert data.equals(reloaded) - >>> - >>> data_set = PickleDataSet(filepath="test.pickle.lz4", - >>> backend="compress_pickle", - >>> load_args={"compression":"lz4"}, - >>> save_args={"compression":"lz4"}) - >>> data_set.save(data) - >>> reloaded = data_set.load() - >>> assert data.equals(reloaded) - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments,too-many-locals - self, - filepath: str, - backend: str = "pickle", - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``PickleDataSet`` pointing to a concrete Pickle - file on a specific filesystem. ``PickleDataSet`` supports custom backends to - serialise/deserialise objects. - - Example backends that are compatible (non-exhaustive): - * `pickle` - * `joblib` - * `dill` - * `compress_pickle` - - Example backends that are incompatible: - * `torch` - - Args: - filepath: Filepath in POSIX format to a Pickle file prefixed with a protocol like - `s3://`. If prefix is not provided, `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - Note: `http(s)` doesn't support versioning. - backend: Backend to use, must be an import path to a module which satisfies the - ``pickle`` interface. That is, contains a `load` and `dump` function. - Defaults to 'pickle'. - load_args: Pickle options for loading pickle files. - You can pass in arguments that the backend load function specified accepts, e.g: - pickle.load: https://docs.python.org/3/library/pickle.html#pickle.load - joblib.load: https://joblib.readthedocs.io/en/latest/generated/joblib.load.html - dill.load: https://dill.readthedocs.io/en/latest/index.html#dill.load - compress_pickle.load: - https://lucianopaz.github.io/compress_pickle/html/api/compress_pickle.html#compress_pickle.compress_pickle.load - All defaults are preserved. - save_args: Pickle options for saving pickle files. - You can pass in arguments that the backend dump function specified accepts, e.g: - pickle.dump: https://docs.python.org/3/library/pickle.html#pickle.dump - joblib.dump: https://joblib.readthedocs.io/en/latest/generated/joblib.dump.html - dill.dump: https://dill.readthedocs.io/en/latest/index.html#dill.dump - compress_pickle.dump: - https://lucianopaz.github.io/compress_pickle/html/api/compress_pickle.html#compress_pickle.compress_pickle.dump - All defaults are preserved. - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``), as well as - to pass to the filesystem's `open` method through nested keys - `open_args_load` and `open_args_save`. - Here you can find all available arguments for `open`: - https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem.open - All defaults are preserved, except `mode`, which is set to `wb` when saving. - - Raises: - ValueError: If ``backend`` does not satisfy the `pickle` interface. - ImportError: If the ``backend`` module could not be imported. - """ - # We do not store `imported_backend` as an attribute to be used in `load`/`save` - # as this would mean the dataset cannot be deepcopied (module objects cannot be - # pickled). The import here is purely to raise any errors as early as possible. - # Repeated imports in the `load` and `save` methods should not be a significant - # performance hit as Python caches imports. - try: - imported_backend = importlib.import_module(backend) - except ImportError as exc: - raise ImportError( - f"Selected backend '{backend}' could not be imported. " - "Make sure it is installed and importable." - ) from exc - - if not ( - hasattr(imported_backend, "load") and hasattr(imported_backend, "dump") - ): - raise ValueError( - f"Selected backend '{backend}' should satisfy the pickle interface. " - "Missing one of 'load' and 'dump' on the backend." - ) - - _fs_args = deepcopy(fs_args) or {} - _fs_open_args_load = _fs_args.pop("open_args_load", {}) - _fs_open_args_save = _fs_args.pop("open_args_save", {}) - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath, version) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._fs = fsspec.filesystem(self._protocol, **_credentials, **_fs_args) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - self._backend = backend - - # Handle default load and save arguments - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - _fs_open_args_save.setdefault("mode", "wb") - self._fs_open_args_load = _fs_open_args_load - self._fs_open_args_save = _fs_open_args_save - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "backend": self._backend, - "protocol": self._protocol, - "load_args": self._load_args, - "save_args": self._save_args, - "version": self._version, - } - - def _load(self) -> Any: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - - with self._fs.open(load_path, **self._fs_open_args_load) as fs_file: - imported_backend = importlib.import_module(self._backend) - return imported_backend.load(fs_file, **self._load_args) # type: ignore - - def _save(self, data: Any) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - - with self._fs.open(save_path, **self._fs_open_args_save) as fs_file: - try: - imported_backend = importlib.import_module(self._backend) - imported_backend.dump(data, fs_file, **self._save_args) # type: ignore - except Exception as exc: - raise DatasetError( - f"{data.__class__} was not serialised due to: {exc}" - ) from exc - - self._invalidate_cache() - - def _exists(self) -> bool: - try: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - except DatasetError: - return False - - return self._fs.exists(load_path) - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/pillow/__init__.py b/kedro/extras/datasets/pillow/__init__.py deleted file mode 100644 index 03df85f3ee..0000000000 --- a/kedro/extras/datasets/pillow/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""``AbstractDataset`` implementation to load/save image data.""" - -__all__ = ["ImageDataSet"] - -from contextlib import suppress - -with suppress(ImportError): - from .image_dataset import ImageDataSet diff --git a/kedro/extras/datasets/pillow/image_dataset.py b/kedro/extras/datasets/pillow/image_dataset.py deleted file mode 100644 index a403b74b27..0000000000 --- a/kedro/extras/datasets/pillow/image_dataset.py +++ /dev/null @@ -1,143 +0,0 @@ -"""``ImageDataSet`` loads/saves image data as `numpy` from an underlying -filesystem (e.g.: local, S3, GCS). It uses Pillow to handle image file. -""" -from copy import deepcopy -from pathlib import PurePosixPath -from typing import Any, Dict - -import fsspec -from PIL import Image - -from kedro.io.core import ( - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class ImageDataSet(AbstractVersionedDataset[Image.Image, Image.Image]): - """``ImageDataSet`` loads/saves image data as `numpy` from an underlying - filesystem (e.g.: local, S3, GCS). It uses Pillow to handle image file. - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.pillow import ImageDataSet - >>> - >>> data_set = ImageDataSet(filepath="test.png") - >>> image = data_set.load() - >>> image.show() - - """ - - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``ImageDataSet`` pointing to a concrete image file - on a specific filesystem. - - Args: - filepath: Filepath in POSIX format to an image file prefixed with a protocol like - `s3://`. If prefix is not provided, `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - Note: `http(s)` doesn't support versioning. - save_args: Pillow options for saving image files. - Here you can find all available arguments: - https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.Image.save - All defaults are preserved. - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``), as well as - to pass to the filesystem's `open` method through nested keys - `open_args_load` and `open_args_save`. - Here you can find all available arguments for `open`: - https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem.open - All defaults are preserved, except `mode`, which is set to `r` when loading - and to `w` when saving. - """ - _fs_args = deepcopy(fs_args) or {} - _fs_open_args_load = _fs_args.pop("open_args_load", {}) - _fs_open_args_save = _fs_args.pop("open_args_save", {}) - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath, version) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._fs = fsspec.filesystem(self._protocol, **_credentials, **_fs_args) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - # Handle default save argument - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - _fs_open_args_save.setdefault("mode", "wb") - self._fs_open_args_load = _fs_open_args_load - self._fs_open_args_save = _fs_open_args_save - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._protocol, - "save_args": self._save_args, - "version": self._version, - } - - def _load(self) -> Image.Image: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - - with self._fs.open(load_path, **self._fs_open_args_load) as fs_file: - return Image.open(fs_file).copy() - - def _save(self, data: Image.Image) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - - with self._fs.open(save_path, **self._fs_open_args_save) as fs_file: - data.save(fs_file, **self._save_args) - - self._invalidate_cache() - - def _exists(self) -> bool: - try: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - except DatasetError: - return False - - return self._fs.exists(load_path) - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/plotly/__init__.py b/kedro/extras/datasets/plotly/__init__.py deleted file mode 100644 index c2851bb000..0000000000 --- a/kedro/extras/datasets/plotly/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""``AbstractDataset`` implementations to load/save a plotly figure from/to a JSON -file.""" - -__all__ = ["PlotlyDataSet", "JSONDataSet"] - -from contextlib import suppress - -with suppress(ImportError): - from .plotly_dataset import PlotlyDataSet -with suppress(ImportError): - from .json_dataset import JSONDataSet diff --git a/kedro/extras/datasets/plotly/json_dataset.py b/kedro/extras/datasets/plotly/json_dataset.py deleted file mode 100644 index 3c686ab896..0000000000 --- a/kedro/extras/datasets/plotly/json_dataset.py +++ /dev/null @@ -1,167 +0,0 @@ -"""``JSONDataSet`` loads/saves a plotly figure from/to a JSON file using an underlying -filesystem (e.g.: local, S3, GCS). -""" -from copy import deepcopy -from pathlib import PurePosixPath -from typing import Any, Dict, Union - -import fsspec -import plotly.io as pio -from plotly import graph_objects as go - -from kedro.io.core import ( - AbstractVersionedDataset, - Version, - get_filepath_str, - get_protocol_and_path, -) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class JSONDataSet( - AbstractVersionedDataset[go.Figure, Union[go.Figure, go.FigureWidget]] -): - """``JSONDataSet`` loads/saves a plotly figure from/to a JSON file using an - underlying filesystem (e.g.: local, S3, GCS). - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - scatter_plot: - type: plotly.JSONDataSet - filepath: data/08_reporting/scatter_plot.json - save_args: - engine: auto - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.plotly import JSONDataSet - >>> import plotly.express as px - >>> - >>> fig = px.bar(x=["a", "b", "c"], y=[1, 3, 2]) - >>> data_set = JSONDataSet(filepath="test.json") - >>> data_set.save(fig) - >>> reloaded = data_set.load() - >>> assert fig == reloaded - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``JSONDataSet`` pointing to a concrete JSON file - on a specific filesystem. - - Args: - filepath: Filepath in POSIX format to a JSON file prefixed with a protocol like `s3://`. - If prefix is not provided `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - Note: `http(s)` doesn't support versioning. - load_args: Plotly options for loading JSON files. - Here you can find all available arguments: - https://plotly.com/python-api-reference/generated/plotly.io.from_json.html#plotly.io.from_json - All defaults are preserved. - save_args: Plotly options for saving JSON files. - Here you can find all available arguments: - https://plotly.com/python-api-reference/generated/plotly.io.write_json.html - All defaults are preserved. - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{'token': None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``), as well as - to pass to the filesystem's `open` method through nested keys - `open_args_load` and `open_args_save`. - Here you can find all available arguments for `open`: - https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem.open - All defaults are preserved, except `mode`, which is set to `w` when - saving. - """ - _fs_args = deepcopy(fs_args) or {} - _fs_open_args_load = _fs_args.pop("open_args_load", {}) - _fs_open_args_save = _fs_args.pop("open_args_save", {}) - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath, version) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._fs = fsspec.filesystem(self._protocol, **_credentials, **_fs_args) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - # Handle default load and save arguments - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - _fs_open_args_save.setdefault("mode", "w") - self._fs_open_args_load = _fs_open_args_load - self._fs_open_args_save = _fs_open_args_save - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._load_args, - "save_args": self._save_args, - "version": self._version, - } - - def _load(self) -> Union[go.Figure, go.FigureWidget]: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - - with self._fs.open(load_path, **self._fs_open_args_load) as fs_file: - # read_json doesn't work correctly with file handler, so we have to read - # the file, decode it manually and pass to the low-level from_json instead. - return pio.from_json(str(fs_file.read(), "utf-8"), **self._load_args) - - def _save(self, data: go.Figure) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - - with self._fs.open(save_path, **self._fs_open_args_save) as fs_file: - data.write_json(fs_file, **self._save_args) - - self._invalidate_cache() - - def _exists(self) -> bool: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - - return self._fs.exists(load_path) - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/plotly/plotly_dataset.py b/kedro/extras/datasets/plotly/plotly_dataset.py deleted file mode 100644 index 7cb6477b25..0000000000 --- a/kedro/extras/datasets/plotly/plotly_dataset.py +++ /dev/null @@ -1,142 +0,0 @@ -"""``PlotlyDataSet`` generates a plot from a pandas DataFrame and saves it to a JSON -file using an underlying filesystem (e.g.: local, S3, GCS). It loads the JSON into a -plotly figure. -""" -from copy import deepcopy -from typing import Any, Dict - -import pandas as pd -import plotly.express as px -from plotly import graph_objects as go - -from kedro.io.core import Version - -from .json_dataset import JSONDataSet - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class PlotlyDataSet(JSONDataSet): - """``PlotlyDataSet`` generates a plot from a pandas DataFrame and saves it to a JSON - file using an underlying filesystem (e.g.: local, S3, GCS). It loads the JSON into a - plotly figure. - - ``PlotlyDataSet`` is a convenience wrapper for ``plotly.JSONDataSet``. It generates - the JSON file directly from a pandas DataFrame through ``plotly_args``. - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - bar_plot: - type: plotly.PlotlyDataSet - filepath: data/08_reporting/bar_plot.json - plotly_args: - type: bar - fig: - x: features - y: importance - orientation: h - layout: - xaxis_title: x - yaxis_title: y - title: Title - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.plotly import PlotlyDataSet - >>> import plotly.express as px - >>> import pandas as pd - >>> - >>> df_data = pd.DataFrame([[0, 1], [1, 0]], columns=('x1', 'x2')) - >>> - >>> data_set = PlotlyDataSet( - >>> filepath='scatter_plot.json', - >>> plotly_args={ - >>> 'type': 'scatter', - >>> 'fig': {'x': 'x1', 'y': 'x2'}, - >>> } - >>> ) - >>> data_set.save(df_data) - >>> reloaded = data_set.load() - >>> assert px.scatter(df_data, x='x1', y='x2') == reloaded - - """ - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - plotly_args: Dict[str, Any], - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``PlotlyDataSet`` pointing to a concrete JSON file - on a specific filesystem. - - Args: - filepath: Filepath in POSIX format to a JSON file prefixed with a protocol like `s3://`. - If prefix is not provided `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - Note: `http(s)` doesn't support versioning. - plotly_args: Plotly configuration for generating a plotly figure from the - dataframe. Keys are `type` (plotly express function, e.g. bar, - line, scatter), `fig` (kwargs passed to the plotting function), theme - (defaults to `plotly`), `layout`. - load_args: Plotly options for loading JSON files. - Here you can find all available arguments: - https://plotly.com/python-api-reference/generated/plotly.io.from_json.html#plotly.io.from_json - All defaults are preserved. - save_args: Plotly options for saving JSON files. - Here you can find all available arguments: - https://plotly.com/python-api-reference/generated/plotly.io.write_json.html - All defaults are preserved. - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{'token': None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``), as well as - to pass to the filesystem's `open` method through nested keys - `open_args_load` and `open_args_save`. - Here you can find all available arguments for `open`: - https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem.open - All defaults are preserved, except `mode`, which is set to `w` when saving. - """ - super().__init__(filepath, load_args, save_args, version, credentials, fs_args) - self._plotly_args = plotly_args - - _fs_args = deepcopy(fs_args) or {} - _fs_open_args_load = _fs_args.pop("open_args_load", {}) - _fs_open_args_save = _fs_args.pop("open_args_save", {}) - _fs_open_args_save.setdefault("mode", "w") - - self._fs_open_args_load = _fs_open_args_load - self._fs_open_args_save = _fs_open_args_save - - def _describe(self) -> Dict[str, Any]: - return {**super()._describe(), "plotly_args": self._plotly_args} - - def _save(self, data: pd.DataFrame) -> None: - fig = self._plot_dataframe(data) - super()._save(fig) - - def _plot_dataframe(self, data: pd.DataFrame) -> go.Figure: - plot_type = self._plotly_args.get("type") - fig_params = self._plotly_args.get("fig", {}) - fig = getattr(px, plot_type)(data, **fig_params) # type: ignore - fig.update_layout(template=self._plotly_args.get("theme", "plotly")) - fig.update_layout(self._plotly_args.get("layout", {})) - return fig diff --git a/kedro/extras/datasets/redis/__init__.py b/kedro/extras/datasets/redis/__init__.py deleted file mode 100644 index f3c553ec3b..0000000000 --- a/kedro/extras/datasets/redis/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""``AbstractDataset`` implementation to load/save data from/to a redis db.""" - -__all__ = ["PickleDataSet"] - -from contextlib import suppress - -with suppress(ImportError): - from .redis_dataset import PickleDataSet diff --git a/kedro/extras/datasets/redis/redis_dataset.py b/kedro/extras/datasets/redis/redis_dataset.py deleted file mode 100644 index d4d7b11f74..0000000000 --- a/kedro/extras/datasets/redis/redis_dataset.py +++ /dev/null @@ -1,191 +0,0 @@ -"""``PickleDataSet`` loads/saves data from/to a Redis database. The underlying -functionality is supported by the redis library, so it supports all allowed -options for instantiating the redis app ``from_url`` and setting a value.""" - -import importlib -import os -from copy import deepcopy -from typing import Any, Dict - -import redis - -from kedro.io.core import AbstractDataset, DatasetError - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class PickleDataSet(AbstractDataset[Any, Any]): - """``PickleDataSet`` loads/saves data from/to a Redis database. The - underlying functionality is supported by the redis library, so it supports - all allowed options for instantiating the redis app ``from_url`` and setting - a value. - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - my_python_object: # simple example - type: redis.PickleDataSet - key: my_object - from_url_args: - url: redis://127.0.0.1:6379 - - final_python_object: # example with save args - type: redis.PickleDataSet - key: my_final_object - from_url_args: - url: redis://127.0.0.1:6379 - db: 1 - save_args: - ex: 10 - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.redis import PickleDataSet - >>> import pandas as pd - >>> - >>> data = pd.DataFrame({'col1': [1, 2], 'col2': [4, 5], - >>> 'col3': [5, 6]}) - >>> - >>> my_data = PickleDataSet(key="my_data") - >>> my_data.save(data) - >>> reloaded = my_data.load() - >>> assert data.equals(reloaded) - """ - - DEFAULT_REDIS_URL = os.getenv("REDIS_URL", "redis://127.0.0.1:6379") - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - key: str, - backend: str = "pickle", - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - credentials: Dict[str, Any] = None, - redis_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``PickleDataSet``. This loads/saves data from/to - a Redis database while deserialising/serialising. Supports custom backends to - serialise/deserialise objects. - - Example backends that are compatible (non-exhaustive): - * `pickle` - * `dill` - * `compress_pickle` - - Example backends that are incompatible: - * `torch` - - Args: - key: The key to use for saving/loading object to Redis. - backend: Backend to use, must be an import path to a module which satisfies the - ``pickle`` interface. That is, contains a `loads` and `dumps` function. - Defaults to 'pickle'. - load_args: Pickle options for loading pickle files. - You can pass in arguments that the backend load function specified accepts, e.g: - pickle.loads: https://docs.python.org/3/library/pickle.html#pickle.loads - dill.loads: https://dill.readthedocs.io/en/latest/index.html#dill.loads - compress_pickle.loads: - https://lucianopaz.github.io/compress_pickle/html/api/compress_pickle.html#compress_pickle.compress_pickle.loads - All defaults are preserved. - save_args: Pickle options for saving pickle files. - You can pass in arguments that the backend dump function specified accepts, e.g: - pickle.dumps: https://docs.python.org/3/library/pickle.html#pickle.dump - dill.dumps: https://dill.readthedocs.io/en/latest/index.html#dill.dumps - compress_pickle.dumps: - https://lucianopaz.github.io/compress_pickle/html/api/compress_pickle.html#compress_pickle.compress_pickle.dumps - All defaults are preserved. - credentials: Credentials required to get access to the redis server. - E.g. `{"password": None}`. - redis_args: Extra arguments to pass into the redis client constructor - ``redis.StrictRedis.from_url``. (e.g. `{"socket_timeout": 10}`), as well as to pass - to the ``redis.StrictRedis.set`` through nested keys `from_url_args` and `set_args`. - Here you can find all available arguments for `from_url`: - https://redis-py.readthedocs.io/en/stable/connections.html?highlight=from_url#redis.Redis.from_url - All defaults are preserved, except `url`, which is set to `redis://127.0.0.1:6379`. - You could also specify the url through the env variable ``REDIS_URL``. - - Raises: - ValueError: If ``backend`` does not satisfy the `pickle` interface. - ImportError: If the ``backend`` module could not be imported. - """ - try: - imported_backend = importlib.import_module(backend) - except ImportError as exc: - raise ImportError( - f"Selected backend '{backend}' could not be imported. " - "Make sure it is installed and importable." - ) from exc - - if not ( - hasattr(imported_backend, "loads") and hasattr(imported_backend, "dumps") - ): - raise ValueError( - f"Selected backend '{backend}' should satisfy the pickle interface. " - "Missing one of 'loads' and 'dumps' on the backend." - ) - - self._backend = backend - - self._key = key - - _redis_args = deepcopy(redis_args) or {} - self._redis_from_url_args = _redis_args.pop("from_url_args", {}) - self._redis_from_url_args.setdefault("url", self.DEFAULT_REDIS_URL) - self._redis_set_args = _redis_args.pop("set_args", {}) - _credentials = deepcopy(credentials) or {} - - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - self._redis_db = redis.Redis.from_url( - **self._redis_from_url_args, **_credentials - ) - - def _describe(self) -> Dict[str, Any]: - return {"key": self._key, **self._redis_from_url_args} - - # `redis_db` mypy does not work since it is optional and optional is not - # accepted by pickle.loads. - def _load(self) -> Any: - if not self.exists(): - raise DatasetError(f"The provided key {self._key} does not exists.") - imported_backend = importlib.import_module(self._backend) - return imported_backend.loads( # type: ignore - self._redis_db.get(self._key), **self._load_args - ) # type: ignore - - def _save(self, data: Any) -> None: - try: - imported_backend = importlib.import_module(self._backend) - self._redis_db.set( - self._key, - imported_backend.dumps(data, **self._save_args), # type: ignore - **self._redis_set_args, - ) - except Exception as exc: - raise DatasetError( - f"{data.__class__} was not serialised due to: {exc}" - ) from exc - - def _exists(self) -> bool: - try: - return bool(self._redis_db.exists(self._key)) - except Exception as exc: - raise DatasetError( - f"The existence of key {self._key} could not be established due to: {exc}" - ) from exc diff --git a/kedro/extras/datasets/spark/__init__.py b/kedro/extras/datasets/spark/__init__.py deleted file mode 100644 index 3dede09aa8..0000000000 --- a/kedro/extras/datasets/spark/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Provides I/O modules for Apache Spark.""" - -__all__ = ["SparkDataSet", "SparkHiveDataSet", "SparkJDBCDataSet", "DeltaTableDataSet"] - -from contextlib import suppress - -with suppress(ImportError): - from .spark_dataset import SparkDataSet -with suppress(ImportError): - from .spark_hive_dataset import SparkHiveDataSet -with suppress(ImportError): - from .spark_jdbc_dataset import SparkJDBCDataSet -with suppress(ImportError): - from .deltatable_dataset import DeltaTableDataSet diff --git a/kedro/extras/datasets/spark/deltatable_dataset.py b/kedro/extras/datasets/spark/deltatable_dataset.py deleted file mode 100644 index 6df51fcdd7..0000000000 --- a/kedro/extras/datasets/spark/deltatable_dataset.py +++ /dev/null @@ -1,116 +0,0 @@ -"""``AbstractDataset`` implementation to access DeltaTables using -``delta-spark`` -""" -from pathlib import PurePosixPath -from typing import NoReturn - -from delta.tables import DeltaTable -from pyspark.sql import SparkSession -from pyspark.sql.utils import AnalysisException - -from kedro.extras.datasets.spark.spark_dataset import ( - _split_filepath, - _strip_dbfs_prefix, -) -from kedro.io.core import AbstractDataset, DatasetError - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class DeltaTableDataSet(AbstractDataset[None, DeltaTable]): - """``DeltaTableDataSet`` loads data into DeltaTable objects. - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - weather@spark: - type: spark.SparkDataSet - filepath: data/02_intermediate/data.parquet - file_format: "delta" - - weather@delta: - type: spark.DeltaTableDataSet - filepath: data/02_intermediate/data.parquet - - Example usage for the - `Python API `_: - :: - - >>> from pyspark.sql import SparkSession - >>> from pyspark.sql.types import (StructField, StringType, - >>> IntegerType, StructType) - >>> - >>> from kedro.extras.datasets.spark import DeltaTableDataSet, SparkDataSet - >>> - >>> schema = StructType([StructField("name", StringType(), True), - >>> StructField("age", IntegerType(), True)]) - >>> - >>> data = [('Alex', 31), ('Bob', 12), ('Clarke', 65), ('Dave', 29)] - >>> - >>> spark_df = SparkSession.builder.getOrCreate().createDataFrame(data, schema) - >>> - >>> data_set = SparkDataSet(filepath="test_data", file_format="delta") - >>> data_set.save(spark_df) - >>> deltatable_dataset = DeltaTableDataSet(filepath="test_data") - >>> delta_table = deltatable_dataset.load() - >>> - >>> delta_table.update() - """ - - # this dataset cannot be used with ``ParallelRunner``, - # therefore it has the attribute ``_SINGLE_PROCESS = True`` - # for parallelism within a Spark pipeline please consider - # using ``ThreadRunner`` instead - _SINGLE_PROCESS = True - - def __init__(self, filepath: str) -> None: - """Creates a new instance of ``DeltaTableDataSet``. - - Args: - filepath: Filepath in POSIX format to a Spark dataframe. When using Databricks - and working with data written to mount path points, - specify ``filepath``s for (versioned) ``SparkDataSet``s - starting with ``/dbfs/mnt``. - """ - fs_prefix, filepath = _split_filepath(filepath) - - self._fs_prefix = fs_prefix - self._filepath = PurePosixPath(filepath) - - @staticmethod - def _get_spark(): - return SparkSession.builder.getOrCreate() - - def _load(self) -> DeltaTable: - load_path = self._fs_prefix + str(self._filepath) - return DeltaTable.forPath(self._get_spark(), load_path) - - def _save(self, data: None) -> NoReturn: - raise DatasetError(f"{self.__class__.__name__} is a read only dataset type") - - def _exists(self) -> bool: - load_path = _strip_dbfs_prefix(self._fs_prefix + str(self._filepath)) - - try: - self._get_spark().read.load(path=load_path, format="delta") - except AnalysisException as exception: - # `AnalysisException.desc` is deprecated with pyspark >= 3.4 - message = ( - exception.desc if hasattr(exception, "desc") else exception.message - ) - - if "Path does not exist:" in message or "is not a Delta table" in message: - return False - raise - - return True - - def _describe(self): - return {"filepath": str(self._filepath), "fs_prefix": self._fs_prefix} diff --git a/kedro/extras/datasets/spark/spark_dataset.py b/kedro/extras/datasets/spark/spark_dataset.py deleted file mode 100644 index 0547b3e804..0000000000 --- a/kedro/extras/datasets/spark/spark_dataset.py +++ /dev/null @@ -1,427 +0,0 @@ -"""``AbstractVersionedDataset`` implementation to access Spark dataframes using -``pyspark`` -""" -import json -from copy import deepcopy -from fnmatch import fnmatch -from functools import partial -from pathlib import PurePosixPath -from typing import Any, Dict, List, Optional, Tuple -from warnings import warn - -import fsspec -from hdfs import HdfsError, InsecureClient -from pyspark.sql import DataFrame, SparkSession -from pyspark.sql.types import StructType -from pyspark.sql.utils import AnalysisException -from s3fs import S3FileSystem - -from kedro.io.core import ( - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -def _parse_glob_pattern(pattern: str) -> str: - special = ("*", "?", "[") - clean = [] - for part in pattern.split("/"): - if any(char in part for char in special): - break - clean.append(part) - return "/".join(clean) - - -def _split_filepath(filepath: str) -> Tuple[str, str]: - split_ = filepath.split("://", 1) - MIN_SPLIT_SIZE = 2 - if len(split_) == MIN_SPLIT_SIZE: - return split_[0] + "://", split_[1] - return "", split_[0] - - -def _strip_dbfs_prefix(path: str, prefix: str = "/dbfs") -> str: - return path[len(prefix) :] if path.startswith(prefix) else path - - -def _dbfs_glob(pattern: str, dbutils: Any) -> List[str]: - """Perform a custom glob search in DBFS using the provided pattern. - It is assumed that version paths are managed by Kedro only. - - Args: - pattern: Glob pattern to search for. - dbutils: dbutils instance to operate with DBFS. - - Returns: - List of DBFS paths prefixed with '/dbfs' that satisfy the glob pattern. - """ - pattern = _strip_dbfs_prefix(pattern) - prefix = _parse_glob_pattern(pattern) - matched = set() - filename = pattern.split("/")[-1] - - for file_info in dbutils.fs.ls(prefix): - if file_info.isDir(): - path = str( - PurePosixPath(_strip_dbfs_prefix(file_info.path, "dbfs:")) / filename - ) - if fnmatch(path, pattern): - path = "/dbfs" + path - matched.add(path) - return sorted(matched) - - -def _get_dbutils(spark: SparkSession) -> Optional[Any]: - """Get the instance of 'dbutils' or None if the one could not be found.""" - dbutils = globals().get("dbutils") - if dbutils: - return dbutils - - try: - from pyspark.dbutils import DBUtils # pylint: disable=import-outside-toplevel - - dbutils = DBUtils(spark) - except ImportError: - try: - import IPython # pylint: disable=import-outside-toplevel - except ImportError: - pass - else: - ipython = IPython.get_ipython() - dbutils = ipython.user_ns.get("dbutils") if ipython else None - - return dbutils - - -def _dbfs_exists(pattern: str, dbutils: Any) -> bool: - """Perform an `ls` list operation in DBFS using the provided pattern. - It is assumed that version paths are managed by Kedro. - Broad `Exception` is present due to `dbutils.fs.ExecutionError` that - cannot be imported directly. - Args: - pattern: Filepath to search for. - dbutils: dbutils instance to operate with DBFS. - Returns: - Boolean value if filepath exists. - """ - pattern = _strip_dbfs_prefix(pattern) - file = _parse_glob_pattern(pattern) - try: - dbutils.fs.ls(file) - return True - except Exception: # pylint: disable=broad-except - return False - - -class KedroHdfsInsecureClient(InsecureClient): - """Subclasses ``hdfs.InsecureClient`` and implements ``hdfs_exists`` - and ``hdfs_glob`` methods required by ``SparkDataSet``""" - - def hdfs_exists(self, hdfs_path: str) -> bool: - """Determines whether given ``hdfs_path`` exists in HDFS. - - Args: - hdfs_path: Path to check. - - Returns: - True if ``hdfs_path`` exists in HDFS, False otherwise. - """ - return bool(self.status(hdfs_path, strict=False)) - - def hdfs_glob(self, pattern: str) -> List[str]: - """Perform a glob search in HDFS using the provided pattern. - - Args: - pattern: Glob pattern to search for. - - Returns: - List of HDFS paths that satisfy the glob pattern. - """ - prefix = _parse_glob_pattern(pattern) or "/" - matched = set() - try: - for dpath, _, fnames in self.walk(prefix): - if fnmatch(dpath, pattern): - matched.add(dpath) - matched |= { - f"{dpath}/{fname}" - for fname in fnames - if fnmatch(f"{dpath}/{fname}", pattern) - } - except HdfsError: # pragma: no cover - # HdfsError is raised by `self.walk()` if prefix does not exist in HDFS. - # Ignore and return an empty list. - pass - return sorted(matched) - - -class SparkDataSet(AbstractVersionedDataset[DataFrame, DataFrame]): - """``SparkDataSet`` loads and saves Spark dataframes. - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - weather: - type: spark.SparkDataSet - filepath: s3a://your_bucket/data/01_raw/weather/* - file_format: csv - load_args: - header: True - inferSchema: True - save_args: - sep: '|' - header: True - - weather_with_schema: - type: spark.SparkDataSet - filepath: s3a://your_bucket/data/01_raw/weather/* - file_format: csv - load_args: - header: True - schema: - filepath: path/to/schema.json - save_args: - sep: '|' - header: True - - weather_cleaned: - type: spark.SparkDataSet - filepath: data/02_intermediate/data.parquet - file_format: parquet - - Example usage for the - `Python API `_: - :: - - >>> from pyspark.sql import SparkSession - >>> from pyspark.sql.types import (StructField, StringType, - >>> IntegerType, StructType) - >>> - >>> from kedro.extras.datasets.spark import SparkDataSet - >>> - >>> schema = StructType([StructField("name", StringType(), True), - >>> StructField("age", IntegerType(), True)]) - >>> - >>> data = [('Alex', 31), ('Bob', 12), ('Clarke', 65), ('Dave', 29)] - >>> - >>> spark_df = SparkSession.builder.getOrCreate()\ - >>> .createDataFrame(data, schema) - >>> - >>> data_set = SparkDataSet(filepath="test_data") - >>> data_set.save(spark_df) - >>> reloaded = data_set.load() - >>> - >>> reloaded.take(4) - """ - - # this dataset cannot be used with ``ParallelRunner``, - # therefore it has the attribute ``_SINGLE_PROCESS = True`` - # for parallelism within a Spark pipeline please consider - # ``ThreadRunner`` instead - _SINGLE_PROCESS = True - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # ruff: noqa: PLR0913 - self, - filepath: str, - file_format: str = "parquet", - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``SparkDataSet``. - - Args: - filepath: Filepath in POSIX format to a Spark dataframe. When using Databricks - and working with data written to mount path points, - specify ``filepath``s for (versioned) ``SparkDataSet``s - starting with ``/dbfs/mnt``. - file_format: File format used during load and save - operations. These are formats supported by the running - SparkContext include parquet, csv, delta. For a list of supported - formats please refer to Apache Spark documentation at - https://spark.apache.org/docs/latest/sql-programming-guide.html - load_args: Load args passed to Spark DataFrameReader load method. - It is dependent on the selected file format. You can find - a list of read options for each supported format - in Spark DataFrame read documentation: - https://spark.apache.org/docs/latest/api/python/getting_started/quickstart_df.html - save_args: Save args passed to Spark DataFrame write options. - Similar to load_args this is dependent on the selected file - format. You can pass ``mode`` and ``partitionBy`` to specify - your overwrite mode and partitioning respectively. You can find - a list of options for each format in Spark DataFrame - write documentation: - https://spark.apache.org/docs/latest/api/python/getting_started/quickstart_df.html - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials to access the S3 bucket, such as - ``key``, ``secret``, if ``filepath`` prefix is ``s3a://`` or ``s3n://``. - Optional keyword arguments passed to ``hdfs.client.InsecureClient`` - if ``filepath`` prefix is ``hdfs://``. Ignored otherwise. - """ - credentials = deepcopy(credentials) or {} - fs_prefix, filepath = _split_filepath(filepath) - exists_function = None - glob_function = None - - if fs_prefix in ("s3a://", "s3n://"): - if fs_prefix == "s3n://": - warn( - "'s3n' filesystem has now been deprecated by Spark, " - "please consider switching to 's3a'", - DeprecationWarning, - ) - _s3 = S3FileSystem(**credentials) - exists_function = _s3.exists - glob_function = partial(_s3.glob, refresh=True) - path = PurePosixPath(filepath) - - elif fs_prefix == "hdfs://" and version: - warn( - f"HDFS filesystem support for versioned {self.__class__.__name__} is " - f"in beta and uses 'hdfs.client.InsecureClient', please use with " - f"caution" - ) - - # default namenode address - credentials.setdefault("url", "http://localhost:9870") - credentials.setdefault("user", "hadoop") - - _hdfs_client = KedroHdfsInsecureClient(**credentials) - exists_function = _hdfs_client.hdfs_exists - glob_function = _hdfs_client.hdfs_glob # type: ignore - path = PurePosixPath(filepath) - - else: - path = PurePosixPath(filepath) - - if filepath.startswith("/dbfs"): - dbutils = _get_dbutils(self._get_spark()) - if dbutils: - glob_function = partial(_dbfs_glob, dbutils=dbutils) - exists_function = partial(_dbfs_exists, dbutils=dbutils) - - super().__init__( - filepath=path, - version=version, - exists_function=exists_function, - glob_function=glob_function, - ) - - # Handle default load and save arguments - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - # Handle schema load argument - self._schema = self._load_args.pop("schema", None) - if self._schema is not None: - if isinstance(self._schema, dict): - self._schema = self._load_schema_from_file(self._schema) - - self._file_format = file_format - self._fs_prefix = fs_prefix - self._handle_delta_format() - - @staticmethod - def _load_schema_from_file(schema: Dict[str, Any]) -> StructType: - - filepath = schema.get("filepath") - if not filepath: - raise DatasetError( - "Schema load argument does not specify a 'filepath' attribute. Please" - "include a path to a JSON-serialised 'pyspark.sql.types.StructType'." - ) - - credentials = deepcopy(schema.get("credentials")) or {} - protocol, schema_path = get_protocol_and_path(filepath) - file_system = fsspec.filesystem(protocol, **credentials) - pure_posix_path = PurePosixPath(schema_path) - load_path = get_filepath_str(pure_posix_path, protocol) - - # Open schema file - with file_system.open(load_path) as fs_file: - - try: - return StructType.fromJson(json.loads(fs_file.read())) - except Exception as exc: - raise DatasetError( - f"Contents of 'schema.filepath' ({schema_path}) are invalid. Please" - f"provide a valid JSON-serialised 'pyspark.sql.types.StructType'." - ) from exc - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._fs_prefix + str(self._filepath), - "file_format": self._file_format, - "load_args": self._load_args, - "save_args": self._save_args, - "version": self._version, - } - - @staticmethod - def _get_spark(): - return SparkSession.builder.getOrCreate() - - def _load(self) -> DataFrame: - load_path = _strip_dbfs_prefix(self._fs_prefix + str(self._get_load_path())) - read_obj = self._get_spark().read - - # Pass schema if defined - if self._schema: - read_obj = read_obj.schema(self._schema) - - return read_obj.load(load_path, self._file_format, **self._load_args) - - def _save(self, data: DataFrame) -> None: - save_path = _strip_dbfs_prefix(self._fs_prefix + str(self._get_save_path())) - data.write.save(save_path, self._file_format, **self._save_args) - - def _exists(self) -> bool: - load_path = _strip_dbfs_prefix(self._fs_prefix + str(self._get_load_path())) - - try: - self._get_spark().read.load(load_path, self._file_format) - except AnalysisException as exception: - # `AnalysisException.desc` is deprecated with pyspark >= 3.4 - message = ( - exception.desc if hasattr(exception, "desc") else exception.message - ) - if "Path does not exist:" in message or "is not a Delta table" in message: - return False - raise - return True - - def _handle_delta_format(self) -> None: - supported_modes = {"append", "overwrite", "error", "errorifexists", "ignore"} - write_mode = self._save_args.get("mode") - if ( - write_mode - and self._file_format == "delta" - and write_mode not in supported_modes - ): - raise DatasetError( - f"It is not possible to perform 'save()' for file format 'delta' " - f"with mode '{write_mode}' on 'SparkDataSet'. " - f"Please use 'spark.DeltaTableDataSet' instead." - ) diff --git a/kedro/extras/datasets/spark/spark_hive_dataset.py b/kedro/extras/datasets/spark/spark_hive_dataset.py deleted file mode 100644 index 746f7ae6df..0000000000 --- a/kedro/extras/datasets/spark/spark_hive_dataset.py +++ /dev/null @@ -1,224 +0,0 @@ -"""``AbstractDataset`` implementation to access Spark dataframes using -``pyspark`` on Apache Hive. -""" -import pickle -from copy import deepcopy -from typing import Any, Dict, List - -from pyspark.sql import DataFrame, SparkSession, Window -from pyspark.sql.functions import col, lit, row_number - -from kedro.io.core import AbstractDataset, DatasetError - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -# pylint:disable=too-many-instance-attributes -class SparkHiveDataSet(AbstractDataset[DataFrame, DataFrame]): - """``SparkHiveDataSet`` loads and saves Spark dataframes stored on Hive. - This data set also handles some incompatible file types such as using partitioned parquet on - hive which will not normally allow upserts to existing data without a complete replacement - of the existing file/partition. - - This DataSet has some key assumptions: - - - Schemas do not change during the pipeline run (defined PKs must be present for the - duration of the pipeline) - - Tables are not being externally modified during upserts. The upsert method is NOT ATOMIC - - to external changes to the target table while executing. - Upsert methodology works by leveraging Spark DataFrame execution plan checkpointing. - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - hive_dataset: - type: spark.SparkHiveDataSet - database: hive_database - table: table_name - write_mode: overwrite - - Example usage for the - `Python API `_: - :: - - >>> from pyspark.sql import SparkSession - >>> from pyspark.sql.types import (StructField, StringType, - >>> IntegerType, StructType) - >>> - >>> from kedro.extras.datasets.spark import SparkHiveDataSet - >>> - >>> schema = StructType([StructField("name", StringType(), True), - >>> StructField("age", IntegerType(), True)]) - >>> - >>> data = [('Alex', 31), ('Bob', 12), ('Clarke', 65), ('Dave', 29)] - >>> - >>> spark_df = SparkSession.builder.getOrCreate().createDataFrame(data, schema) - >>> - >>> data_set = SparkHiveDataSet(database="test_database", table="test_table", - >>> write_mode="overwrite") - >>> data_set.save(spark_df) - >>> reloaded = data_set.load() - >>> - >>> reloaded.take(4) - """ - - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - database: str, - table: str, - write_mode: str = "errorifexists", - table_pk: List[str] = None, - save_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``SparkHiveDataSet``. - - Args: - database: The name of the hive database. - table: The name of the table within the database. - write_mode: ``insert``, ``upsert`` or ``overwrite`` are supported. - table_pk: If performing an upsert, this identifies the primary key columns used to - resolve preexisting data. Is required for ``write_mode="upsert"``. - save_args: Optional mapping of any options, - passed to the `DataFrameWriter.saveAsTable` as kwargs. - Key example of this is `partitionBy` which allows data partitioning - on a list of column names. - Other `HiveOptions` can be found here: - https://spark.apache.org/docs/latest/sql-data-sources-hive-tables.html#specifying-storage-format-for-hive-tables - - Note: - For users leveraging the `upsert` functionality, - a `checkpoint` directory must be set, e.g. using - `spark.sparkContext.setCheckpointDir("/path/to/dir")` - or directly in the Spark conf folder. - - Raises: - DatasetError: Invalid configuration supplied - """ - _write_modes = ["append", "error", "errorifexists", "upsert", "overwrite"] - if write_mode not in _write_modes: - valid_modes = ", ".join(_write_modes) - raise DatasetError( - f"Invalid 'write_mode' provided: {write_mode}. " - f"'write_mode' must be one of: {valid_modes}" - ) - if write_mode == "upsert" and not table_pk: - raise DatasetError("'table_pk' must be set to utilise 'upsert' read mode") - - self._write_mode = write_mode - self._table_pk = table_pk or [] - self._database = database - self._table = table - self._full_table_address = f"{database}.{table}" - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - self._format = self._save_args.pop("format", None) or "hive" - self._eager_checkpoint = self._save_args.pop("eager_checkpoint", None) or True - - def _describe(self) -> Dict[str, Any]: - return { - "database": self._database, - "table": self._table, - "write_mode": self._write_mode, - "table_pk": self._table_pk, - "partition_by": self._save_args.get("partitionBy"), - "format": self._format, - } - - @staticmethod - def _get_spark() -> SparkSession: - """ - This method should only be used to get an existing SparkSession - with valid Hive configuration. - Configuration for Hive is read from hive-site.xml on the classpath. - It supports running both SQL and HiveQL commands. - Additionally, if users are leveraging the `upsert` functionality, - then a `checkpoint` directory must be set, e.g. using - `spark.sparkContext.setCheckpointDir("/path/to/dir")` - """ - _spark = SparkSession.builder.getOrCreate() - return _spark - - def _create_hive_table(self, data: DataFrame, mode: str = None): - _mode: str = mode or self._write_mode - data.write.saveAsTable( - self._full_table_address, - mode=_mode, - format=self._format, - **self._save_args, - ) - - def _load(self) -> DataFrame: - return self._get_spark().read.table(self._full_table_address) - - def _save(self, data: DataFrame) -> None: - self._validate_save(data) - if self._write_mode == "upsert": - # check if _table_pk is a subset of df columns - if not set(self._table_pk) <= set(self._load().columns): - raise DatasetError( - f"Columns {str(self._table_pk)} selected as primary key(s) not found in " - f"table {self._full_table_address}" - ) - self._upsert_save(data=data) - else: - self._create_hive_table(data=data) - - def _upsert_save(self, data: DataFrame) -> None: - if not self._exists() or self._load().rdd.isEmpty(): - self._create_hive_table(data=data, mode="overwrite") - else: - _tmp_colname = "tmp_colname" - _tmp_row = "tmp_row" - _w = Window.partitionBy(*self._table_pk).orderBy(col(_tmp_colname).desc()) - df_old = self._load().select("*", lit(1).alias(_tmp_colname)) - df_new = data.select("*", lit(2).alias(_tmp_colname)) - df_stacked = df_new.unionByName(df_old).select( - "*", row_number().over(_w).alias(_tmp_row) - ) - df_filtered = ( - df_stacked.filter(col(_tmp_row) == 1) - .drop(_tmp_colname, _tmp_row) - .checkpoint(eager=self._eager_checkpoint) - ) - self._create_hive_table(data=df_filtered, mode="overwrite") - - def _validate_save(self, data: DataFrame): - # do not validate when the table doesn't exist - # or if the `write_mode` is set to overwrite - if (not self._exists()) or self._write_mode == "overwrite": - return - hive_dtypes = set(self._load().dtypes) - data_dtypes = set(data.dtypes) - if data_dtypes != hive_dtypes: - new_cols = data_dtypes - hive_dtypes - missing_cols = hive_dtypes - data_dtypes - raise DatasetError( - f"Dataset does not match hive table schema.\n" - f"Present on insert only: {sorted(new_cols)}\n" - f"Present on schema only: {sorted(missing_cols)}" - ) - - def _exists(self) -> bool: - # noqa # noqa: protected-access - return ( - self._get_spark() - ._jsparkSession.catalog() - .tableExists(self._database, self._table) - ) - - def __getstate__(self) -> None: - raise pickle.PicklingError( - "PySpark datasets objects cannot be pickled " - "or serialised as Python objects." - ) diff --git a/kedro/extras/datasets/spark/spark_jdbc_dataset.py b/kedro/extras/datasets/spark/spark_jdbc_dataset.py deleted file mode 100644 index bacb492cbd..0000000000 --- a/kedro/extras/datasets/spark/spark_jdbc_dataset.py +++ /dev/null @@ -1,179 +0,0 @@ -"""SparkJDBCDataSet to load and save a PySpark DataFrame via JDBC.""" - -from copy import deepcopy -from typing import Any, Dict - -from pyspark.sql import DataFrame, SparkSession - -from kedro.io.core import AbstractDataset, DatasetError - -__all__ = ["SparkJDBCDataSet"] - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class SparkJDBCDataSet(AbstractDataset[DataFrame, DataFrame]): - """``SparkJDBCDataSet`` loads data from a database table accessible - via JDBC URL url and connection properties and saves the content of - a PySpark DataFrame to an external database table via JDBC. It uses - ``pyspark.sql.DataFrameReader`` and ``pyspark.sql.DataFrameWriter`` - internally, so it supports all allowed PySpark options on ``jdbc``. - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - weather: - type: spark.SparkJDBCDataSet - table: weather_table - url: jdbc:postgresql://localhost/test - credentials: db_credentials - load_args: - properties: - driver: org.postgresql.Driver - save_args: - properties: - driver: org.postgresql.Driver - - Example usage for the - `Python API `_: - :: - - >>> import pandas as pd - >>> - >>> from pyspark.sql import SparkSession - >>> - >>> spark = SparkSession.builder.getOrCreate() - >>> data = spark.createDataFrame(pd.DataFrame({'col1': [1, 2], - >>> 'col2': [4, 5], - >>> 'col3': [5, 6]})) - >>> url = 'jdbc:postgresql://localhost/test' - >>> table = 'table_a' - >>> connection_properties = {'driver': 'org.postgresql.Driver'} - >>> data_set = SparkJDBCDataSet( - >>> url=url, table=table, credentials={'user': 'scott', - >>> 'password': 'tiger'}, - >>> load_args={'properties': connection_properties}, - >>> save_args={'properties': connection_properties}) - >>> - >>> data_set.save(data) - >>> reloaded = data_set.load() - >>> - >>> assert data.toPandas().equals(reloaded.toPandas()) - - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - url: str, - table: str, - credentials: Dict[str, Any] = None, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - ) -> None: - """Creates a new ``SparkJDBCDataSet``. - - Args: - url: A JDBC URL of the form ``jdbc:subprotocol:subname``. - table: The name of the table to load or save data to. - credentials: A dictionary of JDBC database connection arguments. - Normally at least properties ``user`` and ``password`` with - their corresponding values. It updates ``properties`` - parameter in ``load_args`` and ``save_args`` in case it is - provided. - load_args: Provided to underlying PySpark ``jdbc`` function along - with the JDBC URL and the name of the table. To find all - supported arguments, see here: - https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/api/pyspark.sql.DataFrameWriter.jdbc.html - save_args: Provided to underlying PySpark ``jdbc`` function along - with the JDBC URL and the name of the table. To find all - supported arguments, see here: - https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/api/pyspark.sql.DataFrameWriter.jdbc.html - - Raises: - DatasetError: When either ``url`` or ``table`` is empty or - when a property is provided with a None value. - """ - - if not url: - raise DatasetError( - "'url' argument cannot be empty. Please " - "provide a JDBC URL of the form " - "'jdbc:subprotocol:subname'." - ) - - if not table: - raise DatasetError( - "'table' argument cannot be empty. Please " - "provide the name of the table to load or save " - "data to." - ) - - self._url = url - self._table = table - - # Handle default load and save arguments - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - # Update properties in load_args and save_args with credentials. - if credentials is not None: - - # Check credentials for bad inputs. - for cred_key, cred_value in credentials.items(): - if cred_value is None: - raise DatasetError( - f"Credential property '{cred_key}' cannot be None. " - f"Please provide a value." - ) - - load_properties = self._load_args.get("properties", {}) - save_properties = self._save_args.get("properties", {}) - self._load_args["properties"] = {**load_properties, **credentials} - self._save_args["properties"] = {**save_properties, **credentials} - - def _describe(self) -> Dict[str, Any]: - load_args = self._load_args - save_args = self._save_args - - # Remove user and password values from load and save properties. - if "properties" in load_args: - load_properties = load_args["properties"].copy() - load_properties.pop("user", None) - load_properties.pop("password", None) - load_args = {**load_args, "properties": load_properties} - if "properties" in save_args: - save_properties = save_args["properties"].copy() - save_properties.pop("user", None) - save_properties.pop("password", None) - save_args = {**save_args, "properties": save_properties} - - return { - "url": self._url, - "table": self._table, - "load_args": load_args, - "save_args": save_args, - } - - @staticmethod - def _get_spark(): # pragma: no cover - return SparkSession.builder.getOrCreate() - - def _load(self) -> DataFrame: - return self._get_spark().read.jdbc(self._url, self._table, **self._load_args) - - def _save(self, data: DataFrame) -> None: - return data.write.jdbc(self._url, self._table, **self._save_args) diff --git a/kedro/extras/datasets/svmlight/__init__.py b/kedro/extras/datasets/svmlight/__init__.py deleted file mode 100644 index 4b77f3dfde..0000000000 --- a/kedro/extras/datasets/svmlight/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""``AbstractDataset`` implementation to load/save data from/to a svmlight/ -libsvm sparse data file.""" -__all__ = ["SVMLightDataSet"] - -from contextlib import suppress - -with suppress(ImportError): - from .svmlight_dataset import SVMLightDataSet diff --git a/kedro/extras/datasets/svmlight/svmlight_dataset.py b/kedro/extras/datasets/svmlight/svmlight_dataset.py deleted file mode 100644 index af4a1323ad..0000000000 --- a/kedro/extras/datasets/svmlight/svmlight_dataset.py +++ /dev/null @@ -1,169 +0,0 @@ -"""``SVMLightDataSet`` loads/saves data from/to a svmlight/libsvm file using an -underlying filesystem (e.g.: local, S3, GCS). It uses sklearn functions -``dump_svmlight_file`` to save and ``load_svmlight_file`` to load a file. -""" -from copy import deepcopy -from pathlib import PurePosixPath -from typing import Any, Dict, Optional, Tuple, Union - -import fsspec -from numpy import ndarray -from scipy.sparse.csr import csr_matrix -from sklearn.datasets import dump_svmlight_file, load_svmlight_file - -from kedro.io.core import ( - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - -# Type of data input -_DI = Tuple[Union[ndarray, csr_matrix], ndarray] -# Type of data output -_DO = Tuple[csr_matrix, ndarray] - - -class SVMLightDataSet(AbstractVersionedDataset[_DI, _DO]): - """``SVMLightDataSet`` loads/saves data from/to a svmlight/libsvm file using an - underlying filesystem (e.g.: local, S3, GCS). It uses sklearn functions - ``dump_svmlight_file`` to save and ``load_svmlight_file`` to load a file. - - Data is loaded as a tuple of features and labels. Labels is NumPy array, - and features is Compressed Sparse Row matrix. - - This format is a text-based format, with one sample per line. It does - not store zero valued features hence it is suitable for sparse datasets. - - This format is used as the default format for both svmlight and the - libsvm command line programs. - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - svm_dataset: - type: svmlight.SVMLightDataSet - filepath: data/01_raw/location.svm - load_args: - zero_based: False - save_args: - zero_based: False - - cars: - type: svmlight.SVMLightDataSet - filepath: gcs://your_bucket/cars.svm - fs_args: - project: my-project - credentials: my_gcp_credentials - load_args: - zero_based: False - save_args: - zero_based: False - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.svmlight import SVMLightDataSet - >>> import numpy as np - >>> - >>> # Features and labels. - >>> data = (np.array([[0, 1], [2, 3.14159]]), np.array([7, 3])) - >>> - >>> data_set = SVMLightDataSet(filepath="test.svm") - >>> data_set.save(data) - >>> reloaded_features, reloaded_labels = data_set.load() - >>> assert (data[0] == reloaded_features).all() - >>> assert (data[1] == reloaded_labels).all() - - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Optional[Version] = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - _fs_args = deepcopy(fs_args) or {} - _fs_open_args_load = _fs_args.pop("open_args_load", {}) - _fs_open_args_save = _fs_args.pop("open_args_save", {}) - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath, version) - - self._protocol = protocol - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - self._fs = fsspec.filesystem(self._protocol, **_credentials, **_fs_args) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - self._load_args = deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - _fs_open_args_load.setdefault("mode", "rb") - _fs_open_args_save.setdefault("mode", "wb") - self._fs_open_args_load = _fs_open_args_load - self._fs_open_args_save = _fs_open_args_save - - def _describe(self): - return { - "filepath": self._filepath, - "protocol": self._load_args, - "save_args": self._save_args, - "version": self._version, - } - - def _load(self) -> _DO: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - with self._fs.open(load_path, **self._fs_open_args_load) as fs_file: - return load_svmlight_file(fs_file, **self._load_args) - - def _save(self, data: _DI) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - with self._fs.open(save_path, **self._fs_open_args_save) as fs_file: - dump_svmlight_file(data[0], data[1], fs_file, **self._save_args) - - self._invalidate_cache() - - def _exists(self) -> bool: - try: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - except DatasetError: - return False - - return self._fs.exists(load_path) - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/tensorflow/README.md b/kedro/extras/datasets/tensorflow/README.md deleted file mode 100644 index 704d164977..0000000000 --- a/kedro/extras/datasets/tensorflow/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# TensorFlowModelDataset - -``TensorflowModelDataset`` loads and saves TensorFlow models. -The underlying functionality is supported by, and passes input arguments to TensorFlow 2.X load_model and save_model methods. Only TF2 is currently supported for saving and loading, V1 requires HDF5 and serialises differently. - -#### Example use: -```python -import numpy as np -import tensorflow as tf - -from kedro.extras.datasets.tensorflow import TensorFlowModelDataset - -data_set = TensorFlowModelDataset("tf_model_dirname") - -model = tf.keras.Model() -predictions = model.predict([...]) - -data_set.save(model) -loaded_model = data_set.load() - -new_predictions = loaded_model.predict([...]) -np.testing.assert_allclose(predictions, new_predictions, rtol=1e-6, atol=1e-6) -``` - -#### Example catalog.yml: -```yaml -example_tensorflow_data: - type: tensorflow.TensorFlowModelDataset - filepath: data/08_reporting/tf_model_dirname - load_args: - tf_device: "/CPU:0" # optional -``` - -Contributed by (Aleks Hughes)[https://github.com/w0rdsm1th]. diff --git a/kedro/extras/datasets/tensorflow/__init__.py b/kedro/extras/datasets/tensorflow/__init__.py deleted file mode 100644 index 20e1311ded..0000000000 --- a/kedro/extras/datasets/tensorflow/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Provides I/O for TensorFlow Models.""" - -__all__ = ["TensorFlowModelDataset"] - -from contextlib import suppress - -with suppress(ImportError): - from .tensorflow_model_dataset import TensorFlowModelDataset diff --git a/kedro/extras/datasets/tensorflow/tensorflow_model_dataset.py b/kedro/extras/datasets/tensorflow/tensorflow_model_dataset.py deleted file mode 100644 index ce6043b18d..0000000000 --- a/kedro/extras/datasets/tensorflow/tensorflow_model_dataset.py +++ /dev/null @@ -1,195 +0,0 @@ -"""``TensorflowModelDataset`` is a data set implementation which can save and load -TensorFlow models. -""" -import copy -import tempfile -from pathlib import PurePath, PurePosixPath -from typing import Any, Dict - -import fsspec -import tensorflow as tf - -from kedro.io.core import ( - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -TEMPORARY_H5_FILE = "tmp_tensorflow_model.h5" - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class TensorFlowModelDataset(AbstractVersionedDataset[tf.keras.Model, tf.keras.Model]): - """``TensorflowModelDataset`` loads and saves TensorFlow models. - The underlying functionality is supported by, and passes input arguments through to, - TensorFlow 2.X load_model and save_model methods. - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - tensorflow_model: - type: tensorflow.TensorFlowModelDataset - filepath: data/06_models/tensorflow_model.h5 - load_args: - compile: False - save_args: - overwrite: True - include_optimizer: False - credentials: tf_creds - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.tensorflow import TensorFlowModelDataset - >>> import tensorflow as tf - >>> import numpy as np - >>> - >>> data_set = TensorFlowModelDataset("data/06_models/tensorflow_model.h5") - >>> model = tf.keras.Model() - >>> predictions = model.predict([...]) - >>> - >>> data_set.save(model) - >>> loaded_model = data_set.load() - >>> new_predictions = loaded_model.predict([...]) - >>> np.testing.assert_allclose(predictions, new_predictions, rtol=1e-6, atol=1e-6) - - """ - - DEFAULT_LOAD_ARGS = {} # type: Dict[str, Any] - DEFAULT_SAVE_ARGS = {"save_format": "tf"} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - load_args: Dict[str, Any] = None, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``TensorFlowModelDataset``. - - Args: - filepath: Filepath in POSIX format to a TensorFlow model directory prefixed with a - protocol like `s3://`. If prefix is not provided `file` protocol (local filesystem) - will be used. The prefix should be any protocol supported by ``fsspec``. - Note: `http(s)` doesn't support versioning. - load_args: TensorFlow options for loading models. - Here you can find all available arguments: - https://www.tensorflow.org/api_docs/python/tf/keras/models/load_model - All defaults are preserved. - save_args: TensorFlow options for saving models. - Here you can find all available arguments: - https://www.tensorflow.org/api_docs/python/tf/keras/models/save_model - All defaults are preserved, except for "save_format", which is set to "tf". - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{'token': None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``). - """ - _fs_args = copy.deepcopy(fs_args) or {} - _credentials = copy.deepcopy(credentials) or {} - protocol, path = get_protocol_and_path(filepath, version) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._fs = fsspec.filesystem(self._protocol, **_credentials, **_fs_args) - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - self._tmp_prefix = "kedro_tensorflow_tmp" # temp prefix pattern - - # Handle default load and save arguments - self._load_args = copy.deepcopy(self.DEFAULT_LOAD_ARGS) - if load_args is not None: - self._load_args.update(load_args) - self._save_args = copy.deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - self._is_h5 = self._save_args.get("save_format") == "h5" - - def _load(self) -> tf.keras.Model: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - - with tempfile.TemporaryDirectory(prefix=self._tmp_prefix) as path: - if self._is_h5: - path = str( # noqa: PLW2901 - PurePath(path) / TEMPORARY_H5_FILE - ) # noqa: redefined-loop-name - self._fs.copy(load_path, path) - else: - self._fs.get(load_path, path, recursive=True) - - # Pass the local temporary directory/file path to keras.load_model - device_name = self._load_args.pop("tf_device", None) - if device_name: - with tf.device(device_name): - model = tf.keras.models.load_model(path, **self._load_args) - else: - model = tf.keras.models.load_model(path, **self._load_args) - return model - - def _save(self, data: tf.keras.Model) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - - with tempfile.TemporaryDirectory(prefix=self._tmp_prefix) as path: - if self._is_h5: - path = str( # noqa: PLW2901 - PurePath(path) / TEMPORARY_H5_FILE - ) # noqa: redefined-loop-name - - tf.keras.models.save_model(data, path, **self._save_args) - - # Use fsspec to take from local tempfile directory/file and - # put in ArbitraryFileSystem - if self._is_h5: - self._fs.copy(path, save_path) - else: - if self._fs.exists(save_path): - self._fs.rm(save_path, recursive=True) - self._fs.put(path, save_path, recursive=True) - - def _exists(self) -> bool: - try: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - except DatasetError: - return False - return self._fs.exists(load_path) - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._load_args, - "save_args": self._save_args, - "version": self._version, - } - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/text/__init__.py b/kedro/extras/datasets/text/__init__.py deleted file mode 100644 index 9ed2c37c0e..0000000000 --- a/kedro/extras/datasets/text/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""``AbstractDataset`` implementation to load/save data from/to a text file.""" - -__all__ = ["TextDataSet"] - -from contextlib import suppress - -with suppress(ImportError): - from .text_dataset import TextDataSet diff --git a/kedro/extras/datasets/text/text_dataset.py b/kedro/extras/datasets/text/text_dataset.py deleted file mode 100644 index 253ee92826..0000000000 --- a/kedro/extras/datasets/text/text_dataset.py +++ /dev/null @@ -1,144 +0,0 @@ -"""``TextDataSet`` loads/saves data from/to a text file using an underlying -filesystem (e.g.: local, S3, GCS). -""" -from copy import deepcopy -from pathlib import PurePosixPath -from typing import Any, Dict - -import fsspec - -from kedro.io.core import ( - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class TextDataSet(AbstractVersionedDataset[str, str]): - """``TextDataSet`` loads/saves data from/to a text file using an underlying - filesystem (e.g.: local, S3, GCS) - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - alice_book: - type: text.TextDataSet - filepath: data/01_raw/alice.txt - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.text import TextDataSet - >>> - >>> string_to_write = "This will go in a file." - >>> - >>> data_set = TextDataSet(filepath="test.md") - >>> data_set.save(string_to_write) - >>> reloaded = data_set.load() - >>> assert string_to_write == reloaded - - """ - - def __init__( - self, - filepath: str, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``TextDataSet`` pointing to a concrete text file - on a specific filesystem. - - Args: - filepath: Filepath in POSIX format to a text file prefixed with a protocol like `s3://`. - If prefix is not provided, `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - Note: `http(s)` doesn't support versioning. - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``), as well as - to pass to the filesystem's `open` method through nested keys - `open_args_load` and `open_args_save`. - Here you can find all available arguments for `open`: - https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem.open - All defaults are preserved, except `mode`, which is set to `r` when loading - and to `w` when saving. - """ - _fs_args = deepcopy(fs_args) or {} - _fs_open_args_load = _fs_args.pop("open_args_load", {}) - _fs_open_args_save = _fs_args.pop("open_args_save", {}) - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath, version) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._fs = fsspec.filesystem(self._protocol, **_credentials, **_fs_args) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - _fs_open_args_load.setdefault("mode", "r") - _fs_open_args_save.setdefault("mode", "w") - self._fs_open_args_load = _fs_open_args_load - self._fs_open_args_save = _fs_open_args_save - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._protocol, - "version": self._version, - } - - def _load(self) -> str: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - - with self._fs.open(load_path, **self._fs_open_args_load) as fs_file: - return fs_file.read() - - def _save(self, data: str) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - - with self._fs.open(save_path, **self._fs_open_args_save) as fs_file: - fs_file.write(data) - - self._invalidate_cache() - - def _exists(self) -> bool: - try: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - except DatasetError: - return False - - return self._fs.exists(load_path) - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/kedro/extras/datasets/tracking/__init__.py b/kedro/extras/datasets/tracking/__init__.py deleted file mode 100644 index 2b4d185ba8..0000000000 --- a/kedro/extras/datasets/tracking/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Dataset implementations to save data for Kedro Experiment Tracking""" - -__all__ = ["MetricsDataSet", "JSONDataSet"] - - -from contextlib import suppress - -with suppress(ImportError): - from kedro.extras.datasets.tracking.metrics_dataset import MetricsDataSet -with suppress(ImportError): - from kedro.extras.datasets.tracking.json_dataset import JSONDataSet diff --git a/kedro/extras/datasets/tracking/json_dataset.py b/kedro/extras/datasets/tracking/json_dataset.py deleted file mode 100644 index a41491492b..0000000000 --- a/kedro/extras/datasets/tracking/json_dataset.py +++ /dev/null @@ -1,49 +0,0 @@ -"""``JSONDataSet`` saves data to a JSON file using an underlying -filesystem (e.g.: local, S3, GCS). It uses native json to handle the JSON file. -The ``JSONDataSet`` is part of Kedro Experiment Tracking. The dataset is versioned by default. -""" -from typing import NoReturn - -from kedro.extras.datasets.json import JSONDataSet as JDS -from kedro.io.core import DatasetError - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class JSONDataSet(JDS): - """``JSONDataSet`` saves data to a JSON file using an underlying - filesystem (e.g.: local, S3, GCS). It uses native json to handle the JSON file. - The ``JSONDataSet`` is part of Kedro Experiment Tracking. - The dataset is write-only and it is versioned by default. - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - cars: - type: tracking.JSONDataSet - filepath: data/09_tracking/cars.json - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.tracking import JSONDataSet - >>> - >>> data = {'col1': 1, 'col2': 0.23, 'col3': 0.002} - >>> - >>> data_set = JSONDataSet(filepath="test.json") - >>> data_set.save(data) - - """ - - versioned = True - - def _load(self) -> NoReturn: - raise DatasetError(f"Loading not supported for '{self.__class__.__name__}'") diff --git a/kedro/extras/datasets/tracking/metrics_dataset.py b/kedro/extras/datasets/tracking/metrics_dataset.py deleted file mode 100644 index b2a1949702..0000000000 --- a/kedro/extras/datasets/tracking/metrics_dataset.py +++ /dev/null @@ -1,70 +0,0 @@ -"""``MetricsDataSet`` saves data to a JSON file using an underlying -filesystem (e.g.: local, S3, GCS). It uses native json to handle the JSON file. -The ``MetricsDataSet`` is part of Kedro Experiment Tracking. The dataset is versioned by default -and only takes metrics of numeric values. -""" -import json -from typing import Dict, NoReturn - -from kedro.extras.datasets.json import JSONDataSet -from kedro.io.core import DatasetError, get_filepath_str - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class MetricsDataSet(JSONDataSet): - """``MetricsDataSet`` saves data to a JSON file using an underlying - filesystem (e.g.: local, S3, GCS). It uses native json to handle the JSON file. The - ``MetricsDataSet`` is part of Kedro Experiment Tracking. The dataset is write-only, - it is versioned by default and only takes metrics of numeric values. - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - cars: - type: metrics.MetricsDataSet - filepath: data/09_tracking/cars.json - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.tracking import MetricsDataSet - >>> - >>> data = {'col1': 1, 'col2': 0.23, 'col3': 0.002} - >>> - >>> data_set = MetricsDataSet(filepath="test.json") - >>> data_set.save(data) - - """ - - versioned = True - - def _load(self) -> NoReturn: - raise DatasetError(f"Loading not supported for '{self.__class__.__name__}'") - - def _save(self, data: Dict[str, float]) -> None: - """Converts all values in the data from a ``MetricsDataSet`` to float to make sure - they are numeric values which can be displayed in Kedro Viz and then saves the dataset. - """ - try: - for key, value in data.items(): - data[key] = float(value) - except ValueError as exc: - raise DatasetError( - f"The MetricsDataSet expects only numeric values. {exc}" - ) from exc - - save_path = get_filepath_str(self._get_save_path(), self._protocol) - - with self._fs.open(save_path, **self._fs_open_args_save) as fs_file: - json.dump(data, fs_file, **self._save_args) - - self._invalidate_cache() diff --git a/kedro/extras/datasets/video/__init__.py b/kedro/extras/datasets/video/__init__.py deleted file mode 100644 index f5f7af9461..0000000000 --- a/kedro/extras/datasets/video/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Dataset implementation to load/save data from/to a video file.""" - -__all__ = ["VideoDataSet"] - -from kedro.extras.datasets.video.video_dataset import VideoDataSet diff --git a/kedro/extras/datasets/video/video_dataset.py b/kedro/extras/datasets/video/video_dataset.py deleted file mode 100644 index 08e93126ec..0000000000 --- a/kedro/extras/datasets/video/video_dataset.py +++ /dev/null @@ -1,357 +0,0 @@ -"""``VideoDataSet`` loads/saves video data from an underlying -filesystem (e.g.: local, S3, GCS). It uses OpenCV VideoCapture to read -and decode videos and OpenCV VideoWriter to encode and write video. -""" -import itertools -import tempfile -from collections import abc -from copy import deepcopy -from pathlib import Path, PurePosixPath -from typing import Any, Dict, Generator, Optional, Sequence, Tuple, Union - -import cv2 -import fsspec -import numpy as np -import PIL.Image - -from kedro.io.core import AbstractDataset, get_protocol_and_path - - -class SlicedVideo: - """A representation of slices of other video types""" - - def __init__(self, video, slice_indexes): - self.video = video - self.indexes = range(*slice_indexes.indices(len(video))) - - def __getitem__(self, index: Union[int, slice]) -> PIL.Image.Image: - if isinstance(index, slice): - return SlicedVideo(self, index) - return self.video[self.indexes[index]] - - def __len__(self) -> int: - return len(self.indexes) - - def __getattr__(self, item): - return getattr(self.video, item) - - -class AbstractVideo(abc.Sequence): - """Base class for the underlying video data""" - - _n_frames = 0 - _index = 0 # Next available frame - - @property - def fourcc(self) -> str: - """Get the codec fourcc specification""" - raise NotImplementedError() - - @property - def fps(self) -> float: - """Get the video frame rate""" - raise NotImplementedError() - - @property - def size(self) -> Tuple[int, int]: - """Get the resolution of the video""" - raise NotImplementedError() - - def __len__(self) -> int: - return self._n_frames - - def __getitem__(self, index: Union[int, slice]): - """Get a frame from the video""" - raise NotImplementedError() - - -class FileVideo(AbstractVideo): - """A video object read from a file""" - - def __init__(self, filepath: str) -> None: - self._filepath = filepath - self._cap = cv2.VideoCapture(filepath) - self._n_frames = self._get_length() - - @property - def fourcc(self) -> str: - fourcc = self._cap.get(cv2.CAP_PROP_FOURCC) - return int(fourcc).to_bytes(4, "little").decode("ascii") - - @property - def fps(self) -> float: - return self._cap.get(cv2.CAP_PROP_FPS) - - @property - def size(self) -> Tuple[int, int]: - width = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)) - height = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) - return width, height - - def __getitem__(self, index: Union[int, slice]): - if isinstance(index, slice): - return SlicedVideo(self, index) - - if index < 0: - index += len(self) - if index >= len(self): - raise IndexError() - - if index != self._index: - self._cap.set(cv2.CAP_PROP_POS_FRAMES, index) - self._index = index + 1 # Next frame to decode after this - ret, frame_bgr = self._cap.read() - if not ret: - raise IndexError() - - height, width = frame_bgr.shape[:2] - return PIL.Image.frombuffer( # Convert to PIL image with RGB instead of BGR - "RGB", (width, height), frame_bgr, "raw", "BGR", 0, 0 - ) - - def _get_length(self) -> int: - # OpenCV's frame count might be an approximation depending on what - # headers are available in the video file - length = int(round(self._cap.get(cv2.CAP_PROP_FRAME_COUNT))) - if length >= 0: - return length - - # Getting the frame count with OpenCV can fail on some video files, - # counting the frames would be too slow so it is better to raise an exception. - raise ValueError( - "Failed to load video since number of frames can't be inferred" - ) - - -class SequenceVideo(AbstractVideo): - """A video object read from an indexable sequence of frames""" - - def __init__( - self, frames: Sequence[PIL.Image.Image], fps: float, fourcc: str = "mp4v" - ) -> None: - self._n_frames = len(frames) - self._frames = frames - self._fourcc = fourcc - self._size = frames[0].size - self._fps = fps - - @property - def fourcc(self) -> str: - return self._fourcc - - @property - def fps(self) -> float: - return self._fps - - @property - def size(self) -> Tuple[int, int]: - return self._size - - def __getitem__(self, index: Union[int, slice]): - if isinstance(index, slice): - return SlicedVideo(self, index) - return self._frames[index] - - -class GeneratorVideo(AbstractVideo): - """A video object with frames yielded by a generator""" - - def __init__( - self, - frames: Generator[PIL.Image.Image, None, None], - length, - fps: float, - fourcc: str = "mp4v", - ) -> None: - self._n_frames = length - first = next(frames) - self._gen = itertools.chain([first], frames) - self._fourcc = fourcc - self._size = first.size - self._fps = fps - - @property - def fourcc(self) -> str: - return self._fourcc - - @property - def fps(self) -> float: - return self._fps - - @property - def size(self) -> Tuple[int, int]: - return self._size - - def __getitem__(self, index: Union[int, slice]): - raise NotImplementedError("Underlying video is a generator") - - def __next__(self): - return next(self._gen) - - def __iter__(self): - return self - - -class VideoDataSet(AbstractDataset[AbstractVideo, AbstractVideo]): - """``VideoDataSet`` loads / save video data from a given filepath as sequence - of PIL.Image.Image using OpenCV. - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - cars: - type: video.VideoDataSet - filepath: data/01_raw/cars.mp4 - - motorbikes: - type: video.VideoDataSet - filepath: s3://your_bucket/data/02_intermediate/company/motorbikes.mp4 - credentials: dev_s3 - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.video import VideoDataSet - >>> import numpy as np - >>> - >>> video = VideoDataSet(filepath='/video/file/path.mp4').load() - >>> frame = video[0] - >>> np.sum(np.asarray(frame)) - - - Example creating a video from numpy frames using Python API: - :: - - >>> from kedro.extras.datasets.video.video_dataset import VideoDataSet, SequenceVideo - >>> import numpy as np - >>> from PIL import Image - >>> - >>> frame = np.ones((640,480,3), dtype=np.uint8) * 255 - >>> imgs = [] - >>> for i in range(255): - >>> imgs.append(Image.fromarray(frame)) - >>> frame -= 1 - >>> - >>> video = VideoDataSet("my_video.mp4") - >>> video.save(SequenceVideo(imgs, fps=25)) - - - Example creating a video from numpy frames using a generator and the Python API: - :: - - >>> from kedro.extras.datasets.video.video_dataset import VideoDataSet, GeneratorVideo - >>> import numpy as np - >>> from PIL import Image - >>> - >>> def gen(): - >>> frame = np.ones((640,480,3), dtype=np.uint8) * 255 - >>> for i in range(255): - >>> yield Image.fromarray(frame) - >>> frame -= 1 - >>> - >>> video = VideoDataSet("my_video.mp4") - >>> video.save(GeneratorVideo(gen(), fps=25, length=None)) - - """ - - def __init__( - self, - filepath: str, - fourcc: Optional[str] = "mp4v", - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of VideoDataSet to load / save video data for given filepath. - - Args: - filepath: The location of the video file to load / save data. - fourcc: The codec to use when writing video, note that depending on how opencv is - installed there might be more or less codecs avaiable. If set to None, the - fourcc from the video object will be used. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``). - """ - # parse the path and protocol (e.g. file, http, s3, etc.) - protocol, path = get_protocol_and_path(filepath) - self._protocol = protocol - self._filepath = PurePosixPath(path) - self._fourcc = fourcc - _fs_args = deepcopy(fs_args) or {} - _credentials = deepcopy(credentials) or {} - self._storage_options = {**_credentials, **_fs_args} - self._fs = fsspec.filesystem(self._protocol, **self._storage_options) - - def _load(self) -> AbstractVideo: - """Loads data from the video file. - - Returns: - Data from the video file as a AbstractVideo object - """ - with fsspec.open( - f"filecache::{self._protocol}://{self._filepath}", - mode="rb", - **{self._protocol: self._storage_options}, - ) as fs_file: - return FileVideo(fs_file.name) - - def _save(self, data: AbstractVideo) -> None: - """Saves video data to the specified filepath.""" - if self._protocol == "file": - # Write directly to the local file destination - self._write_to_filepath(data, str(self._filepath)) - else: - # VideoWriter can't write to an open file object, instead write to a - # local tmpfile and then copy that to the destination with fsspec. - # Note that the VideoWriter fails to write to the file on Windows if - # the file is already open, thus we can't use NamedTemporaryFile. - with tempfile.TemporaryDirectory() as tmp_dir: - tmp_file = Path(tmp_dir) / self._filepath.name - self._write_to_filepath(data, str(tmp_file)) - with fsspec.open( - f"{self._protocol}://{self._filepath}", - "wb", - **self._storage_options, - ) as f_target: - with tmp_file.open("r+b") as f_tmp: - f_target.write(f_tmp.read()) - - def _write_to_filepath(self, video: AbstractVideo, filepath: str) -> None: - # TODO: This uses the codec specified in the VideoDataSet if it is not None, this is due - # to compatibility issues since e.g. h264 coded is licensed and is thus not included in - # opencv if installed from a binary distribution. Since a h264 video can be read, but not - # written, it would be error prone to use the videos fourcc code. Further, an issue is - # that the video object does not know what container format will be used since that is - # selected by the suffix in the file name of the VideoDataSet. Some combinations of codec - # and container format might not work or will have bad support. - fourcc = self._fourcc or video.fourcc - - writer = cv2.VideoWriter( - filepath, cv2.VideoWriter_fourcc(*fourcc), video.fps, video.size - ) - if not writer.isOpened(): - raise ValueError( - "Failed to open video writer with params: " - + f"fourcc={fourcc} fps={video.fps} size={video.size[0]}x{video.size[1]} " - + f"path={filepath}" - ) - try: - for frame in iter(video): - writer.write( # PIL images are RGB, opencv expects BGR - np.asarray(frame)[:, :, ::-1] - ) - finally: - writer.release() - - def _describe(self) -> Dict[str, Any]: - return {"filepath": self._filepath, "protocol": self._protocol} - - def _exists(self) -> bool: - return self._fs.exists(self._filepath) diff --git a/kedro/extras/datasets/yaml/__init__.py b/kedro/extras/datasets/yaml/__init__.py deleted file mode 100644 index 07abbaf4a5..0000000000 --- a/kedro/extras/datasets/yaml/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""``AbstractDataset`` implementation to load/save data from/to a YAML file.""" - -__all__ = ["YAMLDataSet"] - -from contextlib import suppress - -with suppress(ImportError): - from .yaml_dataset import YAMLDataSet diff --git a/kedro/extras/datasets/yaml/yaml_dataset.py b/kedro/extras/datasets/yaml/yaml_dataset.py deleted file mode 100644 index a98e76314e..0000000000 --- a/kedro/extras/datasets/yaml/yaml_dataset.py +++ /dev/null @@ -1,156 +0,0 @@ -"""``YAMLDataSet`` loads/saves data from/to a YAML file using an underlying -filesystem (e.g.: local, S3, GCS). It uses PyYAML to handle the YAML file. -""" -from copy import deepcopy -from pathlib import PurePosixPath -from typing import Any, Dict - -import fsspec -import yaml - -from kedro.io.core import ( - AbstractVersionedDataset, - DatasetError, - Version, - get_filepath_str, - get_protocol_and_path, -) - -# NOTE: kedro.extras.datasets will be removed in Kedro 0.19.0. -# Any contribution to datasets should be made in kedro-datasets -# in kedro-plugins (https://github.com/kedro-org/kedro-plugins) - - -class YAMLDataSet(AbstractVersionedDataset[Dict, Dict]): - """``YAMLDataSet`` loads/saves data from/to a YAML file using an underlying - filesystem (e.g.: local, S3, GCS). It uses PyYAML to handle the YAML file. - - Example usage for the - `YAML API `_: - - - .. code-block:: yaml - - cars: - type: yaml.YAMLDataSet - filepath: cars.yaml - - Example usage for the - `Python API `_: - :: - - >>> from kedro.extras.datasets.yaml import YAMLDataSet - >>> - >>> data = {'col1': [1, 2], 'col2': [4, 5], 'col3': [5, 6]} - >>> - >>> data_set = YAMLDataSet(filepath="test.yaml") - >>> data_set.save(data) - >>> reloaded = data_set.load() - >>> assert data == reloaded - - """ - - DEFAULT_SAVE_ARGS = {"default_flow_style": False} # type: Dict[str, Any] - - def __init__( # noqa: too-many-arguments - self, - filepath: str, - save_args: Dict[str, Any] = None, - version: Version = None, - credentials: Dict[str, Any] = None, - fs_args: Dict[str, Any] = None, - ) -> None: - """Creates a new instance of ``YAMLDataSet`` pointing to a concrete YAML file - on a specific filesystem. - - Args: - filepath: Filepath in POSIX format to a YAML file prefixed with a protocol like `s3://`. - If prefix is not provided, `file` protocol (local filesystem) will be used. - The prefix should be any protocol supported by ``fsspec``. - Note: `http(s)` doesn't support versioning. - save_args: PyYAML options for saving YAML files (arguments passed - into ```yaml.dump``). Here you can find all available arguments: - https://pyyaml.org/wiki/PyYAMLDocumentation - All defaults are preserved, but "default_flow_style", which is set to False. - version: If specified, should be an instance of - ``kedro.io.core.Version``. If its ``load`` attribute is - None, the latest version will be loaded. If its ``save`` - attribute is None, save version will be autogenerated. - credentials: Credentials required to get access to the underlying filesystem. - E.g. for ``GCSFileSystem`` it should look like `{"token": None}`. - fs_args: Extra arguments to pass into underlying filesystem class constructor - (e.g. `{"project": "my-project"}` for ``GCSFileSystem``), as well as - to pass to the filesystem's `open` method through nested keys - `open_args_load` and `open_args_save`. - Here you can find all available arguments for `open`: - https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem.open - All defaults are preserved, except `mode`, which is set to `r` when loading - and to `w` when saving. - """ - _fs_args = deepcopy(fs_args) or {} - _fs_open_args_load = _fs_args.pop("open_args_load", {}) - _fs_open_args_save = _fs_args.pop("open_args_save", {}) - _credentials = deepcopy(credentials) or {} - - protocol, path = get_protocol_and_path(filepath, version) - if protocol == "file": - _fs_args.setdefault("auto_mkdir", True) - - self._protocol = protocol - self._fs = fsspec.filesystem(self._protocol, **_credentials, **_fs_args) - - super().__init__( - filepath=PurePosixPath(path), - version=version, - exists_function=self._fs.exists, - glob_function=self._fs.glob, - ) - - # Handle default save arguments - self._save_args = deepcopy(self.DEFAULT_SAVE_ARGS) - if save_args is not None: - self._save_args.update(save_args) - - _fs_open_args_save.setdefault("mode", "w") - self._fs_open_args_load = _fs_open_args_load - self._fs_open_args_save = _fs_open_args_save - - def _describe(self) -> Dict[str, Any]: - return { - "filepath": self._filepath, - "protocol": self._protocol, - "save_args": self._save_args, - "version": self._version, - } - - def _load(self) -> Dict: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - - with self._fs.open(load_path, **self._fs_open_args_load) as fs_file: - return yaml.safe_load(fs_file) - - def _save(self, data: Dict) -> None: - save_path = get_filepath_str(self._get_save_path(), self._protocol) - with self._fs.open(save_path, **self._fs_open_args_save) as fs_file: - yaml.dump(data, fs_file, **self._save_args) - - self._invalidate_cache() - - def _exists(self) -> bool: - try: - load_path = get_filepath_str(self._get_load_path(), self._protocol) - except DatasetError: - return False - - return self._fs.exists(load_path) - - def _release(self) -> None: - super()._release() - self._invalidate_cache() - - def _invalidate_cache(self) -> None: - """Invalidate underlying filesystem caches.""" - filepath = get_filepath_str(self._filepath, self._protocol) - self._fs.invalidate_cache(filepath) diff --git a/tests/extras/datasets/__init__.py b/tests/extras/datasets/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/extras/datasets/api/__init__.py b/tests/extras/datasets/api/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/extras/datasets/api/test_api_dataset.py b/tests/extras/datasets/api/test_api_dataset.py deleted file mode 100644 index f08bd41b92..0000000000 --- a/tests/extras/datasets/api/test_api_dataset.py +++ /dev/null @@ -1,170 +0,0 @@ -# pylint: disable=no-member -import json -import socket - -import pytest -import requests -import requests_mock - -from kedro.extras.datasets.api import APIDataSet -from kedro.io.core import DatasetError - -POSSIBLE_METHODS = ["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"] - -TEST_URL = "http://example.com/api/test" -TEST_TEXT_RESPONSE_DATA = "This is a response." -TEST_JSON_RESPONSE_DATA = [{"key": "value"}] - -TEST_PARAMS = {"param": "value"} -TEST_URL_WITH_PARAMS = TEST_URL + "?param=value" - -TEST_HEADERS = {"key": "value"} - - -@pytest.mark.parametrize("method", POSSIBLE_METHODS) -class TestAPIDataSet: - @pytest.fixture - def requests_mocker(self): - with requests_mock.Mocker() as mock: - yield mock - - def test_successfully_load_with_response(self, requests_mocker, method): - api_data_set = APIDataSet( - url=TEST_URL, method=method, params=TEST_PARAMS, headers=TEST_HEADERS - ) - requests_mocker.register_uri( - method, - TEST_URL_WITH_PARAMS, - headers=TEST_HEADERS, - text=TEST_TEXT_RESPONSE_DATA, - ) - - response = api_data_set.load() - assert isinstance(response, requests.Response) - assert response.text == TEST_TEXT_RESPONSE_DATA - - def test_successful_json_load_with_response(self, requests_mocker, method): - api_data_set = APIDataSet( - url=TEST_URL, - method=method, - json=TEST_JSON_RESPONSE_DATA, - headers=TEST_HEADERS, - ) - requests_mocker.register_uri( - method, - TEST_URL, - headers=TEST_HEADERS, - text=json.dumps(TEST_JSON_RESPONSE_DATA), - ) - - response = api_data_set.load() - assert isinstance(response, requests.Response) - assert response.json() == TEST_JSON_RESPONSE_DATA - - def test_http_error(self, requests_mocker, method): - api_data_set = APIDataSet( - url=TEST_URL, method=method, params=TEST_PARAMS, headers=TEST_HEADERS - ) - requests_mocker.register_uri( - method, - TEST_URL_WITH_PARAMS, - headers=TEST_HEADERS, - text="Nope, not found", - status_code=requests.codes.FORBIDDEN, - ) - - with pytest.raises(DatasetError, match="Failed to fetch data"): - api_data_set.load() - - def test_socket_error(self, requests_mocker, method): - api_data_set = APIDataSet( - url=TEST_URL, method=method, params=TEST_PARAMS, headers=TEST_HEADERS - ) - requests_mocker.register_uri(method, TEST_URL_WITH_PARAMS, exc=socket.error) - - with pytest.raises(DatasetError, match="Failed to connect"): - api_data_set.load() - - def test_read_only_mode(self, method): - """ - Saving is disabled on the data set. - """ - api_data_set = APIDataSet(url=TEST_URL, method=method) - with pytest.raises(DatasetError, match="is a read only data set type"): - api_data_set.save({}) - - def test_exists_http_error(self, requests_mocker, method): - """ - In case of an unexpected HTTP error, - ``exists()`` should not silently catch it. - """ - api_data_set = APIDataSet( - url=TEST_URL, method=method, params=TEST_PARAMS, headers=TEST_HEADERS - ) - requests_mocker.register_uri( - method, - TEST_URL_WITH_PARAMS, - headers=TEST_HEADERS, - text="Nope, not found", - status_code=requests.codes.FORBIDDEN, - ) - with pytest.raises(DatasetError, match="Failed to fetch data"): - api_data_set.exists() - - def test_exists_ok(self, requests_mocker, method): - """ - If the file actually exists and server responds 200, - ``exists()`` should return True - """ - api_data_set = APIDataSet( - url=TEST_URL, method=method, params=TEST_PARAMS, headers=TEST_HEADERS - ) - requests_mocker.register_uri( - method, - TEST_URL_WITH_PARAMS, - headers=TEST_HEADERS, - text=TEST_TEXT_RESPONSE_DATA, - ) - - assert api_data_set.exists() - - def test_credentials_auth_error(self, method): - """ - If ``auth`` and ``credentials`` are both provided, - the constructor should raise a ValueError. - """ - with pytest.raises(ValueError, match="both auth and credentials"): - APIDataSet(url=TEST_URL, method=method, auth=[], credentials=[]) - - @pytest.mark.parametrize("auth_kwarg", ["auth", "credentials"]) - @pytest.mark.parametrize( - "auth_seq", - [ - ("username", "password"), - ["username", "password"], - (e for e in ["username", "password"]), # Generator. - ], - ) - def test_auth_sequence(self, requests_mocker, method, auth_seq, auth_kwarg): - """ - ``auth`` and ``credentials`` should be able to be any Iterable. - """ - kwargs = { - "url": TEST_URL, - "method": method, - "params": TEST_PARAMS, - "headers": TEST_HEADERS, - auth_kwarg: auth_seq, - } - - api_data_set = APIDataSet(**kwargs) - requests_mocker.register_uri( - method, - TEST_URL_WITH_PARAMS, - headers=TEST_HEADERS, - text=TEST_TEXT_RESPONSE_DATA, - ) - - response = api_data_set.load() - assert isinstance(response, requests.Response) - assert response.text == TEST_TEXT_RESPONSE_DATA diff --git a/tests/extras/datasets/bioinformatics/__init__.py b/tests/extras/datasets/bioinformatics/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/extras/datasets/bioinformatics/test_biosequence_dataset.py b/tests/extras/datasets/bioinformatics/test_biosequence_dataset.py deleted file mode 100644 index b26271cb36..0000000000 --- a/tests/extras/datasets/bioinformatics/test_biosequence_dataset.py +++ /dev/null @@ -1,107 +0,0 @@ -from io import StringIO -from pathlib import PurePosixPath - -import pytest -from Bio import SeqIO -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.biosequence import BioSequenceDataSet -from kedro.io import DatasetError -from kedro.io.core import PROTOCOL_DELIMITER - -LOAD_ARGS = {"format": "fasta"} -SAVE_ARGS = {"format": "fasta"} - - -@pytest.fixture -def filepath_biosequence(tmp_path): - return str(tmp_path / "test.fasta") - - -@pytest.fixture -def biosequence_data_set(filepath_biosequence, fs_args): - return BioSequenceDataSet( - filepath=filepath_biosequence, - load_args=LOAD_ARGS, - save_args=SAVE_ARGS, - fs_args=fs_args, - ) - - -@pytest.fixture(scope="module") -def dummy_data(): - data = ">Alpha\nACCGGATGTA\n>Beta\nAGGCTCGGTTA\n" - return list(SeqIO.parse(StringIO(data), "fasta")) - - -class TestBioSequenceDataSet: - def test_save_and_load(self, biosequence_data_set, dummy_data): - """Test saving and reloading the data set.""" - biosequence_data_set.save(dummy_data) - reloaded = biosequence_data_set.load() - assert dummy_data[0].id, reloaded[0].id - assert dummy_data[0].seq, reloaded[0].seq - assert len(dummy_data) == len(reloaded) - assert biosequence_data_set._fs_open_args_load == {"mode": "r"} - assert biosequence_data_set._fs_open_args_save == {"mode": "w"} - - def test_exists(self, biosequence_data_set, dummy_data): - """Test `exists` method invocation for both existing and - nonexistent data set.""" - assert not biosequence_data_set.exists() - biosequence_data_set.save(dummy_data) - assert biosequence_data_set.exists() - - def test_load_save_args_propagation(self, biosequence_data_set): - """Test overriding the default load arguments.""" - for key, value in LOAD_ARGS.items(): - assert biosequence_data_set._load_args[key] == value - - for key, value in SAVE_ARGS.items(): - assert biosequence_data_set._save_args[key] == value - - @pytest.mark.parametrize( - "fs_args", - [{"open_args_load": {"mode": "rb", "compression": "gzip"}}], - indirect=True, - ) - def test_open_extra_args(self, biosequence_data_set, fs_args): - assert biosequence_data_set._fs_open_args_load == fs_args["open_args_load"] - assert biosequence_data_set._fs_open_args_save == { - "mode": "w" - } # default unchanged - - def test_load_missing_file(self, biosequence_data_set): - """Check the error when trying to load missing file.""" - pattern = r"Failed while loading data from data set BioSequenceDataSet\(.*\)" - with pytest.raises(DatasetError, match=pattern): - biosequence_data_set.load() - - @pytest.mark.parametrize( - "filepath,instance_type", - [ - ("s3://bucket/file.fasta", S3FileSystem), - ("file:///tmp/test.fasta", LocalFileSystem), - ("/tmp/test.fasta", LocalFileSystem), - ("gcs://bucket/file.fasta", GCSFileSystem), - ("https://example.com/file.fasta", HTTPFileSystem), - ], - ) - def test_protocol_usage(self, filepath, instance_type): - data_set = BioSequenceDataSet(filepath=filepath) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.fasta" - data_set = BioSequenceDataSet(filepath=filepath) - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) diff --git a/tests/extras/datasets/conftest.py b/tests/extras/datasets/conftest.py deleted file mode 100644 index b9fddb3f88..0000000000 --- a/tests/extras/datasets/conftest.py +++ /dev/null @@ -1,35 +0,0 @@ -""" -This file contains the fixtures that are reusable by any tests within -this directory. You don't need to import the fixtures as pytest will -discover them automatically. More info here: -https://docs.pytest.org/en/latest/fixture.html -""" - -from pytest import fixture - -from kedro.io.core import generate_timestamp - - -@fixture(params=[None]) -def load_version(request): - return request.param - - -@fixture(params=[None]) -def save_version(request): - return request.param or generate_timestamp() - - -@fixture(params=[None]) -def load_args(request): - return request.param - - -@fixture(params=[None]) -def save_args(request): - return request.param - - -@fixture(params=[None]) -def fs_args(request): - return request.param diff --git a/tests/extras/datasets/dask/__init__.py b/tests/extras/datasets/dask/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/extras/datasets/dask/test_parquet_dataset.py b/tests/extras/datasets/dask/test_parquet_dataset.py deleted file mode 100644 index 597d8c40a4..0000000000 --- a/tests/extras/datasets/dask/test_parquet_dataset.py +++ /dev/null @@ -1,223 +0,0 @@ -import boto3 -import dask.dataframe as dd -import pandas as pd -import pyarrow as pa -import pyarrow.parquet as pq -import pytest -from moto import mock_s3 -from pandas.util.testing import assert_frame_equal -from s3fs import S3FileSystem - -from kedro.extras.datasets.dask import ParquetDataSet -from kedro.io import DatasetError - -FILE_NAME = "test.parquet" -BUCKET_NAME = "test_bucket" -AWS_CREDENTIALS = {"key": "FAKE_ACCESS_KEY", "secret": "FAKE_SECRET_KEY"} - -# Pathlib cannot be used since it strips out the second slash from "s3://" -S3_PATH = f"s3://{BUCKET_NAME}/{FILE_NAME}" - - -@pytest.fixture -def mocked_s3_bucket(): - """Create a bucket for testing using moto.""" - with mock_s3(): - conn = boto3.client( - "s3", - aws_access_key_id="fake_access_key", - aws_secret_access_key="fake_secret_key", - ) - conn.create_bucket(Bucket=BUCKET_NAME) - yield conn - - -@pytest.fixture -def dummy_dd_dataframe() -> dd.DataFrame: - df = pd.DataFrame( - {"Name": ["Alex", "Bob", "Clarke", "Dave"], "Age": [31, 12, 65, 29]} - ) - return dd.from_pandas(df, npartitions=2) - - -@pytest.fixture -def mocked_s3_object(tmp_path, mocked_s3_bucket, dummy_dd_dataframe: dd.DataFrame): - """Creates test data and adds it to mocked S3 bucket.""" - pandas_df = dummy_dd_dataframe.compute() - table = pa.Table.from_pandas(pandas_df) - temporary_path = tmp_path / FILE_NAME - pq.write_table(table, str(temporary_path)) - - mocked_s3_bucket.put_object( - Bucket=BUCKET_NAME, Key=FILE_NAME, Body=temporary_path.read_bytes() - ) - return mocked_s3_bucket - - -@pytest.fixture -def s3_data_set(load_args, save_args): - return ParquetDataSet( - filepath=S3_PATH, - credentials=AWS_CREDENTIALS, - load_args=load_args, - save_args=save_args, - ) - - -@pytest.fixture() -def s3fs_cleanup(): - # clear cache so we get a clean slate every time we instantiate a S3FileSystem - yield - S3FileSystem.cachable = False - - -@pytest.mark.usefixtures("s3fs_cleanup") -class TestParquetDataSet: - def test_incorrect_credentials_load(self): - """Test that incorrect credential keys won't instantiate dataset.""" - pattern = r"unexpected keyword argument" - with pytest.raises(DatasetError, match=pattern): - ParquetDataSet( - filepath=S3_PATH, - credentials={ - "client_kwargs": {"access_token": "TOKEN", "access_key": "KEY"} - }, - ).load().compute() - - @pytest.mark.parametrize("bad_credentials", [{"key": None, "secret": None}]) - def test_empty_credentials_load(self, bad_credentials): - parquet_data_set = ParquetDataSet(filepath=S3_PATH, credentials=bad_credentials) - pattern = r"Failed while loading data from data set ParquetDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - parquet_data_set.load().compute() - - def test_pass_credentials(self, mocker): - """Test that AWS credentials are passed successfully into boto3 - client instantiation on creating S3 connection.""" - client_mock = mocker.patch("botocore.session.Session.create_client") - s3_data_set = ParquetDataSet(filepath=S3_PATH, credentials=AWS_CREDENTIALS) - pattern = r"Failed while loading data from data set ParquetDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - s3_data_set.load().compute() - - assert client_mock.call_count == 1 - args, kwargs = client_mock.call_args_list[0] - assert args == ("s3",) - assert kwargs["aws_access_key_id"] == AWS_CREDENTIALS["key"] - assert kwargs["aws_secret_access_key"] == AWS_CREDENTIALS["secret"] - - @pytest.mark.usefixtures("mocked_s3_bucket") - def test_save_data(self, s3_data_set): - """Test saving the data to S3.""" - pd_data = pd.DataFrame( - {"col1": ["a", "b"], "col2": ["c", "d"], "col3": ["e", "f"]} - ) - dd_data = dd.from_pandas(pd_data, npartitions=2) - s3_data_set.save(dd_data) - loaded_data = s3_data_set.load() - assert_frame_equal(loaded_data.compute(), dd_data.compute()) - - @pytest.mark.usefixtures("mocked_s3_object") - def test_load_data(self, s3_data_set, dummy_dd_dataframe): - """Test loading the data from S3.""" - loaded_data = s3_data_set.load() - assert_frame_equal(loaded_data.compute(), dummy_dd_dataframe.compute()) - - @pytest.mark.usefixtures("mocked_s3_bucket") - def test_exists(self, s3_data_set, dummy_dd_dataframe): - """Test `exists` method invocation for both existing and - nonexistent data set.""" - assert not s3_data_set.exists() - s3_data_set.save(dummy_dd_dataframe) - assert s3_data_set.exists() - - def test_save_load_locally(self, tmp_path, dummy_dd_dataframe): - """Test loading the data locally.""" - file_path = str(tmp_path / "some" / "dir" / FILE_NAME) - data_set = ParquetDataSet(filepath=file_path) - - assert not data_set.exists() - data_set.save(dummy_dd_dataframe) - assert data_set.exists() - loaded_data = data_set.load() - dummy_dd_dataframe.compute().equals(loaded_data.compute()) - - @pytest.mark.parametrize( - "load_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_load_extra_params(self, s3_data_set, load_args): - """Test overriding the default load arguments.""" - for key, value in load_args.items(): - assert s3_data_set._load_args[key] == value - - @pytest.mark.parametrize( - "save_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_save_extra_params(self, s3_data_set, save_args): - """Test overriding the default save arguments.""" - s3_data_set._process_schema() - assert s3_data_set._save_args.get("schema") is None - - for key, value in save_args.items(): - assert s3_data_set._save_args[key] == value - - for key, value in s3_data_set.DEFAULT_SAVE_ARGS.items(): - assert s3_data_set._save_args[key] == value - - @pytest.mark.parametrize( - "save_args", - [{"schema": {"col1": "[[int64]]", "col2": "string"}}], - indirect=True, - ) - def test_save_extra_params_schema_dict(self, s3_data_set, save_args): - """Test setting the schema as dictionary of pyarrow column types - in save arguments.""" - - for key, value in save_args["schema"].items(): - assert s3_data_set._save_args["schema"][key] == value - - s3_data_set._process_schema() - - for field in s3_data_set._save_args["schema"].values(): - assert isinstance(field, pa.DataType) - - @pytest.mark.parametrize( - "save_args", - [ - { - "schema": { - "col1": "[[int64]]", - "col2": "string", - "col3": float, - "col4": pa.int64(), - } - } - ], - indirect=True, - ) - def test_save_extra_params_schema_dict_mixed_types(self, s3_data_set, save_args): - """Test setting the schema as dictionary of mixed value types - in save arguments.""" - - for key, value in save_args["schema"].items(): - assert s3_data_set._save_args["schema"][key] == value - - s3_data_set._process_schema() - - for field in s3_data_set._save_args["schema"].values(): - assert isinstance(field, pa.DataType) - - @pytest.mark.parametrize( - "save_args", - [{"schema": "c1:[int64],c2:int64"}], - indirect=True, - ) - def test_save_extra_params_schema_str_schema_fields(self, s3_data_set, save_args): - """Test setting the schema as string pyarrow schema (list of fields) - in save arguments.""" - - assert s3_data_set._save_args["schema"] == save_args["schema"] - - s3_data_set._process_schema() - - assert isinstance(s3_data_set._save_args["schema"], pa.Schema) diff --git a/tests/extras/datasets/email/__init__.py b/tests/extras/datasets/email/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/extras/datasets/email/test_message_dataset.py b/tests/extras/datasets/email/test_message_dataset.py deleted file mode 100644 index 9eab39be4d..0000000000 --- a/tests/extras/datasets/email/test_message_dataset.py +++ /dev/null @@ -1,226 +0,0 @@ -from email.message import EmailMessage -from email.policy import default -from pathlib import Path, PurePosixPath - -import pytest -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.email import EmailMessageDataSet -from kedro.io import DatasetError -from kedro.io.core import PROTOCOL_DELIMITER, Version - - -@pytest.fixture -def filepath_message(tmp_path): - return (tmp_path / "test").as_posix() - - -@pytest.fixture -def message_data_set(filepath_message, load_args, save_args, fs_args): - return EmailMessageDataSet( - filepath=filepath_message, - load_args=load_args, - save_args=save_args, - fs_args=fs_args, - ) - - -@pytest.fixture -def versioned_message_data_set(filepath_message, load_version, save_version): - return EmailMessageDataSet( - filepath=filepath_message, version=Version(load_version, save_version) - ) - - -@pytest.fixture -def dummy_msg(): - string_to_write = "what would you do if you were invisible for one day????" - - # Create a text/plain message - msg = EmailMessage() - msg.set_content(string_to_write) - msg["Subject"] = "invisibility" - msg["From"] = '"sin studly17"' - msg["To"] = '"strong bad"' - - return msg - - -class TestEmailMessageDataSet: - def test_save_and_load(self, message_data_set, dummy_msg): - """Test saving and reloading the data set.""" - message_data_set.save(dummy_msg) - reloaded = message_data_set.load() - assert dummy_msg.__dict__ == reloaded.__dict__ - assert message_data_set._fs_open_args_load == {"mode": "r"} - assert message_data_set._fs_open_args_save == {"mode": "w"} - - def test_exists(self, message_data_set, dummy_msg): - """Test `exists` method invocation for both existing and - nonexistent data set.""" - assert not message_data_set.exists() - message_data_set.save(dummy_msg) - assert message_data_set.exists() - - @pytest.mark.parametrize( - "load_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_load_extra_params(self, message_data_set, load_args): - """Test overriding the default load arguments.""" - for key, value in load_args.items(): - assert message_data_set._load_args[key] == value - - @pytest.mark.parametrize( - "save_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_save_extra_params(self, message_data_set, save_args): - """Test overriding the default save arguments.""" - for key, value in save_args.items(): - assert message_data_set._save_args[key] == value - - @pytest.mark.parametrize( - "fs_args", - [{"open_args_load": {"mode": "rb", "compression": "gzip"}}], - indirect=True, - ) - def test_open_extra_args(self, message_data_set, fs_args): - assert message_data_set._fs_open_args_load == fs_args["open_args_load"] - assert message_data_set._fs_open_args_save == {"mode": "w"} # default unchanged - - def test_load_missing_file(self, message_data_set): - """Check the error when trying to load missing file.""" - pattern = r"Failed while loading data from data set EmailMessageDataSet\(.*\)" - with pytest.raises(DatasetError, match=pattern): - message_data_set.load() - - @pytest.mark.parametrize( - "filepath,instance_type", - [ - ("s3://bucket/file", S3FileSystem), - ("file:///tmp/test", LocalFileSystem), - ("/tmp/test", LocalFileSystem), - ("gcs://bucket/file", GCSFileSystem), - ("https://example.com/file", HTTPFileSystem), - ], - ) - def test_protocol_usage(self, filepath, instance_type): - data_set = EmailMessageDataSet(filepath=filepath) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test" - data_set = EmailMessageDataSet(filepath=filepath) - assert data_set._version_cache.currsize == 0 # no cache if unversioned - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - assert data_set._version_cache.currsize == 0 - - -class TestEmailMessageDataSetVersioned: - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - filepath = "test" - ds = EmailMessageDataSet(filepath=filepath) - ds_versioned = EmailMessageDataSet( - filepath=filepath, version=Version(load_version, save_version) - ) - assert filepath in str(ds) - assert "version" not in str(ds) - - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "EmailMessageDataSet" in str(ds_versioned) - assert "EmailMessageDataSet" in str(ds) - assert "protocol" in str(ds_versioned) - assert "protocol" in str(ds) - # Default parser_args - assert f"parser_args={{'policy': {default}}}" in str(ds) - assert f"parser_args={{'policy': {default}}}" in str(ds_versioned) - - def test_save_and_load(self, versioned_message_data_set, dummy_msg): - """Test that saved and reloaded data matches the original one for - the versioned data set.""" - versioned_message_data_set.save(dummy_msg) - reloaded = versioned_message_data_set.load() - assert dummy_msg.__dict__ == reloaded.__dict__ - - def test_no_versions(self, versioned_message_data_set): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for EmailMessageDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_message_data_set.load() - - def test_exists(self, versioned_message_data_set, dummy_msg): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_message_data_set.exists() - versioned_message_data_set.save(dummy_msg) - assert versioned_message_data_set.exists() - - def test_prevent_overwrite(self, versioned_message_data_set, dummy_msg): - """Check the error when attempting to override the data set if the - corresponding text file for a given save version already exists.""" - versioned_message_data_set.save(dummy_msg) - pattern = ( - r"Save path \'.+\' for EmailMessageDataSet\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - versioned_message_data_set.save(dummy_msg) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, versioned_message_data_set, load_version, save_version, dummy_msg - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - f"Save version '{save_version}' did not match " - f"load version '{load_version}' for " - r"EmailMessageDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_message_data_set.save(dummy_msg) - - def test_http_filesystem_no_versioning(self): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - EmailMessageDataSet( - filepath="https://example.com/file", version=Version(None, None) - ) - - def test_versioning_existing_dataset( - self, message_data_set, versioned_message_data_set, dummy_msg - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - message_data_set.save(dummy_msg) - assert message_data_set.exists() - assert message_data_set._filepath == versioned_message_data_set._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_message_data_set._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_message_data_set.save(dummy_msg) - - # Remove non-versioned dataset and try again - Path(message_data_set._filepath.as_posix()).unlink() - versioned_message_data_set.save(dummy_msg) - assert versioned_message_data_set.exists() diff --git a/tests/extras/datasets/geojson/__init__.py b/tests/extras/datasets/geojson/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/extras/datasets/geojson/test_geojson_dataset.py b/tests/extras/datasets/geojson/test_geojson_dataset.py deleted file mode 100644 index 5a2669964c..0000000000 --- a/tests/extras/datasets/geojson/test_geojson_dataset.py +++ /dev/null @@ -1,232 +0,0 @@ -from pathlib import Path, PurePosixPath - -import geopandas as gpd -import pytest -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from pandas.util.testing import assert_frame_equal -from s3fs import S3FileSystem -from shapely.geometry import Point - -from kedro.extras.datasets.geopandas import GeoJSONDataSet -from kedro.io import DatasetError -from kedro.io.core import PROTOCOL_DELIMITER, Version, generate_timestamp - - -@pytest.fixture(params=[None]) -def load_version(request): - return request.param - - -@pytest.fixture(params=[None]) -def save_version(request): - return request.param or generate_timestamp() - - -@pytest.fixture -def filepath(tmp_path): - return (tmp_path / "test.geojson").as_posix() - - -@pytest.fixture(params=[None]) -def load_args(request): - return request.param - - -@pytest.fixture(params=[{"driver": "GeoJSON"}]) -def save_args(request): - return request.param - - -@pytest.fixture -def dummy_dataframe(): - return gpd.GeoDataFrame( - {"col1": [1, 2], "col2": [4, 5], "col3": [5, 6]}, - geometry=[Point(1, 1), Point(2, 2)], - ) - - -@pytest.fixture -def geojson_data_set(filepath, load_args, save_args, fs_args): - return GeoJSONDataSet( - filepath=filepath, load_args=load_args, save_args=save_args, fs_args=fs_args - ) - - -@pytest.fixture -def versioned_geojson_data_set(filepath, load_version, save_version): - return GeoJSONDataSet( - filepath=filepath, version=Version(load_version, save_version) - ) - - -class TestGeoJSONDataSet: - def test_save_and_load(self, geojson_data_set, dummy_dataframe): - """Test that saved and reloaded data matches the original one.""" - geojson_data_set.save(dummy_dataframe) - reloaded_df = geojson_data_set.load() - assert_frame_equal(reloaded_df, dummy_dataframe) - assert geojson_data_set._fs_open_args_load == {} - assert geojson_data_set._fs_open_args_save == {"mode": "wb"} - - @pytest.mark.parametrize("geojson_data_set", [{"index": False}], indirect=True) - def test_load_missing_file(self, geojson_data_set): - """Check the error while trying to load from missing source.""" - pattern = r"Failed while loading data from data set GeoJSONDataSet" - with pytest.raises(DatasetError, match=pattern): - geojson_data_set.load() - - def test_exists(self, geojson_data_set, dummy_dataframe): - """Test `exists` method invocation for both cases.""" - assert not geojson_data_set.exists() - geojson_data_set.save(dummy_dataframe) - assert geojson_data_set.exists() - - @pytest.mark.parametrize( - "load_args", [{"crs": "init:4326"}, {"crs": "init:2154", "driver": "GeoJSON"}] - ) - def test_load_extra_params(self, geojson_data_set, load_args): - """Test overriding default save args""" - for k, v in load_args.items(): - assert geojson_data_set._load_args[k] == v - - @pytest.mark.parametrize( - "save_args", [{"driver": "ESRI Shapefile"}, {"driver": "GPKG"}] - ) - def test_save_extra_params(self, geojson_data_set, save_args): - """Test overriding default save args""" - for k, v in save_args.items(): - assert geojson_data_set._save_args[k] == v - - @pytest.mark.parametrize( - "fs_args", - [{"open_args_load": {"mode": "rb", "compression": "gzip"}}], - indirect=True, - ) - def test_open_extra_args(self, geojson_data_set, fs_args): - assert geojson_data_set._fs_open_args_load == fs_args["open_args_load"] - assert geojson_data_set._fs_open_args_save == {"mode": "wb"} - - @pytest.mark.parametrize( - "path,instance_type", - [ - ("s3://bucket/file.geojson", S3FileSystem), - ("/tmp/test.geojson", LocalFileSystem), - ("gcs://bucket/file.geojson", GCSFileSystem), - ("file:///tmp/file.geojson", LocalFileSystem), - ("https://example.com/file.geojson", HTTPFileSystem), - ], - ) - def test_protocol_usage(self, path, instance_type): - geojson_data_set = GeoJSONDataSet(filepath=path) - assert isinstance(geojson_data_set._fs, instance_type) - - path = path.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(geojson_data_set._filepath) == path - assert isinstance(geojson_data_set._filepath, PurePosixPath) - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.geojson" - geojson_data_set = GeoJSONDataSet(filepath=filepath) - geojson_data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - - -class TestGeoJSONDataSetVersioned: - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - filepath = "test.geojson" - ds = GeoJSONDataSet(filepath=filepath) - ds_versioned = GeoJSONDataSet( - filepath=filepath, version=Version(load_version, save_version) - ) - assert filepath in str(ds) - assert "version" not in str(ds) - - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "GeoJSONDataSet" in str(ds_versioned) - assert "GeoJSONDataSet" in str(ds) - assert "protocol" in str(ds_versioned) - assert "protocol" in str(ds) - - def test_save_and_load(self, versioned_geojson_data_set, dummy_dataframe): - """Test that saved and reloaded data matches the original one for - the versioned data set.""" - versioned_geojson_data_set.save(dummy_dataframe) - reloaded_df = versioned_geojson_data_set.load() - assert_frame_equal(reloaded_df, dummy_dataframe) - - def test_no_versions(self, versioned_geojson_data_set): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for GeoJSONDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_geojson_data_set.load() - - def test_exists(self, versioned_geojson_data_set, dummy_dataframe): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_geojson_data_set.exists() - versioned_geojson_data_set.save(dummy_dataframe) - assert versioned_geojson_data_set.exists() - - def test_prevent_override(self, versioned_geojson_data_set, dummy_dataframe): - """Check the error when attempt to override the same data set - version.""" - versioned_geojson_data_set.save(dummy_dataframe) - pattern = ( - r"Save path \'.+\' for GeoJSONDataSet\(.+\) must not " - r"exist if versioning is enabled" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_geojson_data_set.save(dummy_dataframe) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, versioned_geojson_data_set, load_version, save_version, dummy_dataframe - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - rf"Save version '{save_version}' did not match load version " - rf"'{load_version}' for GeoJSONDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_geojson_data_set.save(dummy_dataframe) - - def test_http_filesystem_no_versioning(self): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - GeoJSONDataSet( - filepath="https://example/file.geojson", version=Version(None, None) - ) - - def test_versioning_existing_dataset( - self, geojson_data_set, versioned_geojson_data_set, dummy_dataframe - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - geojson_data_set.save(dummy_dataframe) - assert geojson_data_set.exists() - assert geojson_data_set._filepath == versioned_geojson_data_set._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_geojson_data_set._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_geojson_data_set.save(dummy_dataframe) - - # Remove non-versioned dataset and try again - Path(geojson_data_set._filepath.as_posix()).unlink() - versioned_geojson_data_set.save(dummy_dataframe) - assert versioned_geojson_data_set.exists() diff --git a/tests/extras/datasets/holoviews/__init__.py b/tests/extras/datasets/holoviews/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/extras/datasets/holoviews/test_holoviews_writer.py b/tests/extras/datasets/holoviews/test_holoviews_writer.py deleted file mode 100644 index 24fb7f6c0f..0000000000 --- a/tests/extras/datasets/holoviews/test_holoviews_writer.py +++ /dev/null @@ -1,220 +0,0 @@ -import sys -from pathlib import Path, PurePosixPath - -import holoviews as hv -import pytest -from adlfs import AzureBlobFileSystem -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.holoviews import HoloviewsWriter -from kedro.io import DatasetError, Version -from kedro.io.core import PROTOCOL_DELIMITER - - -@pytest.fixture -def filepath_png(tmp_path): - return (tmp_path / "test.png").as_posix() - - -@pytest.fixture(scope="module") -def dummy_hv_object(): - return hv.Curve(range(10)) - - -@pytest.fixture -def hv_writer(filepath_png, save_args, fs_args): - return HoloviewsWriter(filepath_png, save_args=save_args, fs_args=fs_args) - - -@pytest.fixture -def versioned_hv_writer(filepath_png, load_version, save_version): - return HoloviewsWriter(filepath_png, version=Version(load_version, save_version)) - - -@pytest.mark.skipif( - sys.version_info.minor == 10, - reason="Python 3.10 needs matplotlib>=3.5 which breaks holoviews.", -) -class TestHoloviewsWriter: - def test_save_data(self, tmp_path, dummy_hv_object, hv_writer): - """Test saving Holoviews object.""" - hv_writer.save(dummy_hv_object) - - actual_filepath = Path(hv_writer._filepath.as_posix()) - test_filepath = tmp_path / "locally_saved.png" - hv.save(dummy_hv_object, test_filepath) - - assert actual_filepath.read_bytes() == test_filepath.read_bytes() - assert hv_writer._fs_open_args_save == {"mode": "wb"} - assert hv_writer._save_args == {"fmt": "png"} - - @pytest.mark.parametrize( - "fs_args", - [ - { - "storage_option": "value", - "open_args_save": {"mode": "w", "compression": "gzip"}, - } - ], - ) - def test_open_extra_args(self, tmp_path, fs_args, mocker): - fs_mock = mocker.patch("fsspec.filesystem") - writer = HoloviewsWriter(str(tmp_path), fs_args) - - fs_mock.assert_called_once_with("file", auto_mkdir=True, storage_option="value") - assert writer._fs_open_args_save == fs_args["open_args_save"] - - def test_load_fail(self, hv_writer): - pattern = r"Loading not supported for 'HoloviewsWriter'" - with pytest.raises(DatasetError, match=pattern): - hv_writer.load() - - def test_exists(self, dummy_hv_object, hv_writer): - assert not hv_writer.exists() - hv_writer.save(dummy_hv_object) - assert hv_writer.exists() - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.png" - data_set = HoloviewsWriter(filepath=filepath) - assert data_set._version_cache.currsize == 0 # no cache if unversioned - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - assert data_set._version_cache.currsize == 0 - - @pytest.mark.parametrize("save_args", [{"k1": "v1", "fmt": "svg"}], indirect=True) - def test_save_extra_params(self, hv_writer, save_args): - """Test overriding the default save arguments.""" - for key, value in save_args.items(): - assert hv_writer._save_args[key] == value - - @pytest.mark.parametrize( - "filepath,instance_type,credentials", - [ - ("s3://bucket/file.png", S3FileSystem, {}), - ("file:///tmp/test.png", LocalFileSystem, {}), - ("/tmp/test.png", LocalFileSystem, {}), - ("gcs://bucket/file.png", GCSFileSystem, {}), - ("https://example.com/file.png", HTTPFileSystem, {}), - ( - "abfs://bucket/file.png", - AzureBlobFileSystem, - {"account_name": "test", "account_key": "test"}, - ), - ], - ) - def test_protocol_usage(self, filepath, instance_type, credentials): - data_set = HoloviewsWriter(filepath=filepath, credentials=credentials) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - -@pytest.mark.skipif( - sys.version_info.minor == 10, - reason="Python 3.10 needs matplotlib>=3.5 which breaks holoviews.", -) -class TestHoloviewsWriterVersioned: - def test_version_str_repr(self, hv_writer, versioned_hv_writer): - """Test that version is in string representation of the class instance - when applicable.""" - - assert str(hv_writer._filepath) in str(hv_writer) - assert "version=" not in str(hv_writer) - assert "protocol" in str(hv_writer) - assert "save_args" in str(hv_writer) - - assert str(versioned_hv_writer._filepath) in str(versioned_hv_writer) - ver_str = f"version={versioned_hv_writer._version}" - assert ver_str in str(versioned_hv_writer) - assert "protocol" in str(versioned_hv_writer) - assert "save_args" in str(versioned_hv_writer) - - def test_prevent_overwrite(self, dummy_hv_object, versioned_hv_writer): - """Check the error when attempting to override the data set if the - corresponding file for a given save version already exists.""" - versioned_hv_writer.save(dummy_hv_object) - pattern = ( - r"Save path \'.+\' for HoloviewsWriter\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - versioned_hv_writer.save(dummy_hv_object) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, load_version, save_version, dummy_hv_object, versioned_hv_writer - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - rf"Save version '{save_version}' did not match load version " - rf"'{load_version}' for HoloviewsWriter\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_hv_writer.save(dummy_hv_object) - - def test_http_filesystem_no_versioning(self): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - HoloviewsWriter( - filepath="https://example.com/file.png", version=Version(None, None) - ) - - def test_load_not_supported(self, versioned_hv_writer): - """Check the error if no versions are available for load.""" - pattern = ( - rf"Loading not supported for '{versioned_hv_writer.__class__.__name__}'" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_hv_writer.load() - - def test_exists(self, versioned_hv_writer, dummy_hv_object): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_hv_writer.exists() - versioned_hv_writer.save(dummy_hv_object) - assert versioned_hv_writer.exists() - - def test_save_data(self, versioned_hv_writer, dummy_hv_object, tmp_path): - """Test saving Holoviews object with enabled versioning.""" - versioned_hv_writer.save(dummy_hv_object) - - test_filepath = tmp_path / "test_image.png" - actual_filepath = Path(versioned_hv_writer._get_load_path().as_posix()) - - hv.save(dummy_hv_object, test_filepath) - - assert actual_filepath.read_bytes() == test_filepath.read_bytes() - - def test_versioning_existing_dataset( - self, hv_writer, versioned_hv_writer, dummy_hv_object - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - hv_writer.save(dummy_hv_object) - assert hv_writer.exists() - assert hv_writer._filepath == versioned_hv_writer._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_hv_writer._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_hv_writer.save(dummy_hv_object) - - # Remove non-versioned dataset and try again - Path(hv_writer._filepath.as_posix()).unlink() - versioned_hv_writer.save(dummy_hv_object) - assert versioned_hv_writer.exists() diff --git a/tests/extras/datasets/json/__init__.py b/tests/extras/datasets/json/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/extras/datasets/json/test_json_dataset.py b/tests/extras/datasets/json/test_json_dataset.py deleted file mode 100644 index 531fd007b7..0000000000 --- a/tests/extras/datasets/json/test_json_dataset.py +++ /dev/null @@ -1,200 +0,0 @@ -from pathlib import Path, PurePosixPath - -import pytest -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.json import JSONDataSet -from kedro.io import DatasetError -from kedro.io.core import PROTOCOL_DELIMITER, Version - - -@pytest.fixture -def filepath_json(tmp_path): - return (tmp_path / "test.json").as_posix() - - -@pytest.fixture -def json_data_set(filepath_json, save_args, fs_args): - return JSONDataSet(filepath=filepath_json, save_args=save_args, fs_args=fs_args) - - -@pytest.fixture -def versioned_json_data_set(filepath_json, load_version, save_version): - return JSONDataSet( - filepath=filepath_json, version=Version(load_version, save_version) - ) - - -@pytest.fixture -def dummy_data(): - return {"col1": 1, "col2": 2, "col3": 3} - - -class TestJSONDataSet: - def test_save_and_load(self, json_data_set, dummy_data): - """Test saving and reloading the data set.""" - json_data_set.save(dummy_data) - reloaded = json_data_set.load() - assert dummy_data == reloaded - assert json_data_set._fs_open_args_load == {} - assert json_data_set._fs_open_args_save == {"mode": "w"} - - def test_exists(self, json_data_set, dummy_data): - """Test `exists` method invocation for both existing and - nonexistent data set.""" - assert not json_data_set.exists() - json_data_set.save(dummy_data) - assert json_data_set.exists() - - @pytest.mark.parametrize( - "save_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_save_extra_params(self, json_data_set, save_args): - """Test overriding the default save arguments.""" - for key, value in save_args.items(): - assert json_data_set._save_args[key] == value - - @pytest.mark.parametrize( - "fs_args", - [{"open_args_load": {"mode": "rb", "compression": "gzip"}}], - indirect=True, - ) - def test_open_extra_args(self, json_data_set, fs_args): - assert json_data_set._fs_open_args_load == fs_args["open_args_load"] - assert json_data_set._fs_open_args_save == {"mode": "w"} # default unchanged - - def test_load_missing_file(self, json_data_set): - """Check the error when trying to load missing file.""" - pattern = r"Failed while loading data from data set JSONDataSet\(.*\)" - with pytest.raises(DatasetError, match=pattern): - json_data_set.load() - - @pytest.mark.parametrize( - "filepath,instance_type", - [ - ("s3://bucket/file.json", S3FileSystem), - ("file:///tmp/test.json", LocalFileSystem), - ("/tmp/test.json", LocalFileSystem), - ("gcs://bucket/file.json", GCSFileSystem), - ("https://example.com/file.json", HTTPFileSystem), - ], - ) - def test_protocol_usage(self, filepath, instance_type): - data_set = JSONDataSet(filepath=filepath) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.json" - data_set = JSONDataSet(filepath=filepath) - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - - -class TestJSONDataSetVersioned: - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - filepath = "test.json" - ds = JSONDataSet(filepath=filepath) - ds_versioned = JSONDataSet( - filepath=filepath, version=Version(load_version, save_version) - ) - assert filepath in str(ds) - assert "version" not in str(ds) - - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "JSONDataSet" in str(ds_versioned) - assert "JSONDataSet" in str(ds) - assert "protocol" in str(ds_versioned) - assert "protocol" in str(ds) - # Default save_args - assert "save_args={'indent': 2}" in str(ds) - assert "save_args={'indent': 2}" in str(ds_versioned) - - def test_save_and_load(self, versioned_json_data_set, dummy_data): - """Test that saved and reloaded data matches the original one for - the versioned data set.""" - versioned_json_data_set.save(dummy_data) - reloaded = versioned_json_data_set.load() - assert dummy_data == reloaded - - def test_no_versions(self, versioned_json_data_set): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for JSONDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_json_data_set.load() - - def test_exists(self, versioned_json_data_set, dummy_data): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_json_data_set.exists() - versioned_json_data_set.save(dummy_data) - assert versioned_json_data_set.exists() - - def test_prevent_overwrite(self, versioned_json_data_set, dummy_data): - """Check the error when attempting to override the data set if the - corresponding json file for a given save version already exists.""" - versioned_json_data_set.save(dummy_data) - pattern = ( - r"Save path \'.+\' for JSONDataSet\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - versioned_json_data_set.save(dummy_data) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, versioned_json_data_set, load_version, save_version, dummy_data - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - f"Save version '{save_version}' did not match " - f"load version '{load_version}' for " - r"JSONDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_json_data_set.save(dummy_data) - - def test_http_filesystem_no_versioning(self): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - JSONDataSet( - filepath="https://example.com/file.json", version=Version(None, None) - ) - - def test_versioning_existing_dataset( - self, json_data_set, versioned_json_data_set, dummy_data - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - json_data_set.save(dummy_data) - assert json_data_set.exists() - assert json_data_set._filepath == versioned_json_data_set._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_json_data_set._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_json_data_set.save(dummy_data) - - # Remove non-versioned dataset and try again - Path(json_data_set._filepath.as_posix()).unlink() - versioned_json_data_set.save(dummy_data) - assert versioned_json_data_set.exists() diff --git a/tests/extras/datasets/libsvm/__init__.py b/tests/extras/datasets/libsvm/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/extras/datasets/libsvm/test_svmlight_dataset.py b/tests/extras/datasets/libsvm/test_svmlight_dataset.py deleted file mode 100644 index 52bfba394d..0000000000 --- a/tests/extras/datasets/libsvm/test_svmlight_dataset.py +++ /dev/null @@ -1,214 +0,0 @@ -from pathlib import Path, PurePosixPath - -import numpy as np -import pytest -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.svmlight import SVMLightDataSet -from kedro.io import DatasetError -from kedro.io.core import PROTOCOL_DELIMITER, Version - - -@pytest.fixture -def filepath_svm(tmp_path): - return (tmp_path / "test.svm").as_posix() - - -@pytest.fixture -def svm_data_set(filepath_svm, save_args, load_args, fs_args): - return SVMLightDataSet( - filepath=filepath_svm, save_args=save_args, load_args=load_args, fs_args=fs_args - ) - - -@pytest.fixture -def versioned_svm_data_set(filepath_svm, load_version, save_version): - return SVMLightDataSet( - filepath=filepath_svm, version=Version(load_version, save_version) - ) - - -@pytest.fixture -def dummy_data(): - features = np.array([[1, 2, 10], [1, 0.4, 3.2], [0, 0, 0]]) - label = np.array([1, 0, 3]) - return features, label - - -class TestSVMLightDataSet: - def test_save_and_load(self, svm_data_set, dummy_data): - """Test saving and reloading the data set.""" - svm_data_set.save(dummy_data) - reloaded_features, reloaded_label = svm_data_set.load() - original_features, original_label = dummy_data - assert (original_features == reloaded_features).all() - assert (original_label == reloaded_label).all() - assert svm_data_set._fs_open_args_load == {"mode": "rb"} - assert svm_data_set._fs_open_args_save == {"mode": "wb"} - - def test_exists(self, svm_data_set, dummy_data): - """Test `exists` method invocation for both existing and - nonexistent data set.""" - assert not svm_data_set.exists() - svm_data_set.save(dummy_data) - assert svm_data_set.exists() - - @pytest.mark.parametrize( - "save_args", [{"zero_based": False, "comment": "comment"}], indirect=True - ) - def test_save_extra_save_args(self, svm_data_set, save_args): - """Test overriding the default save arguments.""" - for key, value in save_args.items(): - assert svm_data_set._save_args[key] == value - - @pytest.mark.parametrize( - "load_args", [{"zero_based": False, "n_features": 3}], indirect=True - ) - def test_save_extra_load_args(self, svm_data_set, load_args): - """Test overriding the default load arguments.""" - for key, value in load_args.items(): - assert svm_data_set._load_args[key] == value - - @pytest.mark.parametrize( - "fs_args", - [{"open_args_load": {"mode": "rb", "compression": "gzip"}}], - indirect=True, - ) - def test_open_extra_args(self, svm_data_set, fs_args): - assert svm_data_set._fs_open_args_load == fs_args["open_args_load"] - assert svm_data_set._fs_open_args_save == {"mode": "wb"} # default unchanged - - def test_load_missing_file(self, svm_data_set): - """Check the error when trying to load missing file.""" - pattern = r"Failed while loading data from data set SVMLightDataSet\(.*\)" - with pytest.raises(DatasetError, match=pattern): - svm_data_set.load() - - @pytest.mark.parametrize( - "filepath,instance_type", - [ - ("s3://bucket/file.svm", S3FileSystem), - ("file:///tmp/test.svm", LocalFileSystem), - ("/tmp/test.svm", LocalFileSystem), - ("gcs://bucket/file.svm", GCSFileSystem), - ("https://example.com/file.svm", HTTPFileSystem), - ], - ) - def test_protocol_usage(self, filepath, instance_type): - data_set = SVMLightDataSet(filepath=filepath) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.svm" - data_set = SVMLightDataSet(filepath=filepath) - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - - -class TestSVMLightDataSetVersioned: - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - filepath = "test.svm" - ds = SVMLightDataSet(filepath=filepath) - ds_versioned = SVMLightDataSet( - filepath=filepath, version=Version(load_version, save_version) - ) - assert filepath in str(ds) - assert "version" not in str(ds) - - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "SVMLightDataSet" in str(ds_versioned) - assert "SVMLightDataSet" in str(ds) - assert "protocol" in str(ds_versioned) - assert "protocol" in str(ds) - - def test_save_and_load(self, versioned_svm_data_set, dummy_data): - """Test that saved and reloaded data matches the original one for - the versioned data set.""" - versioned_svm_data_set.save(dummy_data) - reloaded_features, reloaded_label = versioned_svm_data_set.load() - original_features, original_label = dummy_data - assert (original_features == reloaded_features).all() - assert (original_label == reloaded_label).all() - - def test_no_versions(self, versioned_svm_data_set): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for SVMLightDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_svm_data_set.load() - - def test_exists(self, versioned_svm_data_set, dummy_data): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_svm_data_set.exists() - versioned_svm_data_set.save(dummy_data) - assert versioned_svm_data_set.exists() - - def test_prevent_overwrite(self, versioned_svm_data_set, dummy_data): - """Check the error when attempting to override the data set if the - corresponding json file for a given save version already exists.""" - versioned_svm_data_set.save(dummy_data) - pattern = ( - r"Save path \'.+\' for SVMLightDataSet\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - versioned_svm_data_set.save(dummy_data) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, versioned_svm_data_set, load_version, save_version, dummy_data - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - f"Save version '{save_version}' did not match " - f"load version '{load_version}' for " - r"SVMLightDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_svm_data_set.save(dummy_data) - - def test_http_filesystem_no_versioning(self): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - SVMLightDataSet( - filepath="https://example.com/file.svm", version=Version(None, None) - ) - - def test_versioning_existing_dataset( - self, svm_data_set, versioned_svm_data_set, dummy_data - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - svm_data_set.save(dummy_data) - assert svm_data_set.exists() - assert svm_data_set._filepath == versioned_svm_data_set._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_svm_data_set._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_svm_data_set.save(dummy_data) - - # Remove non-versioned dataset and try again - Path(svm_data_set._filepath.as_posix()).unlink() - versioned_svm_data_set.save(dummy_data) - assert versioned_svm_data_set.exists() diff --git a/tests/extras/datasets/matplotlib/__init__.py b/tests/extras/datasets/matplotlib/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/extras/datasets/matplotlib/test_matplotlib_writer.py b/tests/extras/datasets/matplotlib/test_matplotlib_writer.py deleted file mode 100644 index e6ee5be83b..0000000000 --- a/tests/extras/datasets/matplotlib/test_matplotlib_writer.py +++ /dev/null @@ -1,436 +0,0 @@ -import json -from pathlib import Path - -import boto3 -import matplotlib -import matplotlib.pyplot as plt -import pytest -from moto import mock_s3 -from s3fs import S3FileSystem - -from kedro.extras.datasets.matplotlib import MatplotlibWriter -from kedro.io import DatasetError, Version - -BUCKET_NAME = "test_bucket" -AWS_CREDENTIALS = {"key": "testing", "secret": "testing"} -KEY_PATH = "matplotlib" -COLOUR_LIST = ["blue", "green", "red"] -FULL_PATH = f"s3://{BUCKET_NAME}/{KEY_PATH}" - -matplotlib.use("Agg") # Disable interactive mode - - -@pytest.fixture -def mock_single_plot(): - plt.plot([1, 2, 3], [4, 5, 6]) - plt.close("all") - return plt - - -@pytest.fixture -def mock_list_plot(): - plots_list = [] - colour = "red" - for index in range(5): # pylint: disable=unused-variable - plots_list.append(plt.figure()) - plt.plot([1, 2, 3], [4, 5, 6], color=colour) - plt.close("all") - return plots_list - - -@pytest.fixture -def mock_dict_plot(): - plots_dict = {} - for colour in COLOUR_LIST: - plots_dict[colour] = plt.figure() - plt.plot([1, 2, 3], [4, 5, 6], color=colour) - plt.close("all") - return plots_dict - - -@pytest.fixture -def mocked_s3_bucket(): - """Create a bucket for testing using moto.""" - with mock_s3(): - conn = boto3.client( - "s3", - aws_access_key_id="fake_access_key", - aws_secret_access_key="fake_secret_key", - ) - conn.create_bucket(Bucket=BUCKET_NAME) - yield conn - - -@pytest.fixture -def mocked_encrypted_s3_bucket(): - bucket_policy = { - "Version": "2012-10-17", - "Id": "PutObjPolicy", - "Statement": [ - { - "Sid": "DenyUnEncryptedObjectUploads", - "Effect": "Deny", - "Principal": "*", - "Action": "s3:PutObject", - "Resource": f"arn:aws:s3:::{BUCKET_NAME}/*", - "Condition": {"Null": {"s3:x-amz-server-side-encryption": "aws:kms"}}, - } - ], - } - bucket_policy = json.dumps(bucket_policy) - - with mock_s3(): - conn = boto3.client( - "s3", - aws_access_key_id="fake_access_key", - aws_secret_access_key="fake_secret_key", - ) - conn.create_bucket(Bucket=BUCKET_NAME) - conn.put_bucket_policy(Bucket=BUCKET_NAME, Policy=bucket_policy) - yield conn - - -@pytest.fixture() -def s3fs_cleanup(): - # clear cache for clean mocked s3 bucket each time - yield - S3FileSystem.cachable = False - - -@pytest.fixture(params=[False]) -def overwrite(request): - return request.param - - -@pytest.fixture -def plot_writer( - mocked_s3_bucket, fs_args, save_args, overwrite -): # pylint: disable=unused-argument - return MatplotlibWriter( - filepath=FULL_PATH, - credentials=AWS_CREDENTIALS, - fs_args=fs_args, - save_args=save_args, - overwrite=overwrite, - ) - - -@pytest.fixture -def versioned_plot_writer(tmp_path, load_version, save_version): - filepath = (tmp_path / "matplotlib.png").as_posix() - return MatplotlibWriter( - filepath=filepath, version=Version(load_version, save_version) - ) - - -@pytest.fixture(autouse=True) -def cleanup_plt(): - yield - plt.close("all") - - -class TestMatplotlibWriter: - @pytest.mark.parametrize("save_args", [{"k1": "v1"}], indirect=True) - def test_save_data( - self, tmp_path, mock_single_plot, plot_writer, mocked_s3_bucket, save_args - ): - """Test saving single matplotlib plot to S3.""" - plot_writer.save(mock_single_plot) - - download_path = tmp_path / "downloaded_image.png" - actual_filepath = tmp_path / "locally_saved.png" - - mock_single_plot.savefig(str(actual_filepath)) - - mocked_s3_bucket.download_file(BUCKET_NAME, KEY_PATH, str(download_path)) - - assert actual_filepath.read_bytes() == download_path.read_bytes() - assert plot_writer._fs_open_args_save == {"mode": "wb"} - for key, value in save_args.items(): - assert plot_writer._save_args[key] == value - - def test_list_save(self, tmp_path, mock_list_plot, plot_writer, mocked_s3_bucket): - """Test saving list of plots to S3.""" - - plot_writer.save(mock_list_plot) - - for index in range(5): - download_path = tmp_path / "downloaded_image.png" - actual_filepath = tmp_path / "locally_saved.png" - - mock_list_plot[index].savefig(str(actual_filepath)) - _key_path = f"{KEY_PATH}/{index}.png" - mocked_s3_bucket.download_file(BUCKET_NAME, _key_path, str(download_path)) - - assert actual_filepath.read_bytes() == download_path.read_bytes() - - def test_dict_save(self, tmp_path, mock_dict_plot, plot_writer, mocked_s3_bucket): - """Test saving dictionary of plots to S3.""" - - plot_writer.save(mock_dict_plot) - - for colour in COLOUR_LIST: - - download_path = tmp_path / "downloaded_image.png" - actual_filepath = tmp_path / "locally_saved.png" - - mock_dict_plot[colour].savefig(str(actual_filepath)) - - _key_path = f"{KEY_PATH}/{colour}" - - mocked_s3_bucket.download_file(BUCKET_NAME, _key_path, str(download_path)) - - assert actual_filepath.read_bytes() == download_path.read_bytes() - - @pytest.mark.parametrize( - "overwrite,expected_num_plots", [(False, 8), (True, 3)], indirect=["overwrite"] - ) - def test_overwrite( - self, - mock_list_plot, - mock_dict_plot, - plot_writer, - mocked_s3_bucket, - expected_num_plots, - ): - """Test saving dictionary of plots after list of plots to S3.""" - - plot_writer.save(mock_list_plot) - plot_writer.save(mock_dict_plot) - - response = mocked_s3_bucket.list_objects(Bucket=BUCKET_NAME) - saved_plots = {obj["Key"] for obj in response["Contents"]} - - assert {f"{KEY_PATH}/{colour}" for colour in COLOUR_LIST} <= saved_plots - assert len(saved_plots) == expected_num_plots - - def test_fs_args(self, tmp_path, mock_single_plot, mocked_encrypted_s3_bucket): - """Test writing to encrypted bucket.""" - normal_encryped_writer = MatplotlibWriter( - fs_args={"s3_additional_kwargs": {"ServerSideEncryption": "AES256"}}, - filepath=FULL_PATH, - credentials=AWS_CREDENTIALS, - ) - - normal_encryped_writer.save(mock_single_plot) - - download_path = tmp_path / "downloaded_image.png" - actual_filepath = tmp_path / "locally_saved.png" - - mock_single_plot.savefig(str(actual_filepath)) - - mocked_encrypted_s3_bucket.download_file( - BUCKET_NAME, KEY_PATH, str(download_path) - ) - - assert actual_filepath.read_bytes() == download_path.read_bytes() - - @pytest.mark.parametrize( - "fs_args", - [{"open_args_save": {"mode": "w", "compression": "gzip"}}], - indirect=True, - ) - def test_open_extra_args(self, plot_writer, fs_args): - assert plot_writer._fs_open_args_save == fs_args["open_args_save"] - - def test_load_fail(self, plot_writer): - pattern = r"Loading not supported for 'MatplotlibWriter'" - with pytest.raises(DatasetError, match=pattern): - plot_writer.load() - - @pytest.mark.usefixtures("s3fs_cleanup") - def test_exists_single(self, mock_single_plot, plot_writer): - assert not plot_writer.exists() - plot_writer.save(mock_single_plot) - assert plot_writer.exists() - - @pytest.mark.usefixtures("s3fs_cleanup") - def test_exists_multiple(self, mock_dict_plot, plot_writer): - assert not plot_writer.exists() - plot_writer.save(mock_dict_plot) - assert plot_writer.exists() - - def test_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - data_set = MatplotlibWriter(filepath=FULL_PATH) - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(f"{BUCKET_NAME}/{KEY_PATH}") - - -class TestMatplotlibWriterVersioned: - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - filepath = "chart.png" - chart = MatplotlibWriter(filepath=filepath) - chart_versioned = MatplotlibWriter( - filepath=filepath, version=Version(load_version, save_version) - ) - assert filepath in str(chart) - assert "version" not in str(chart) - - assert filepath in str(chart_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(chart_versioned) - - def test_prevent_overwrite(self, mock_single_plot, versioned_plot_writer): - """Check the error when attempting to override the data set if the - corresponding matplotlib file for a given save version already exists.""" - versioned_plot_writer.save(mock_single_plot) - pattern = ( - r"Save path \'.+\' for MatplotlibWriter\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - versioned_plot_writer.save(mock_single_plot) - - def test_ineffective_overwrite(self, load_version, save_version): - pattern = ( - "Setting 'overwrite=True' is ineffective if versioning " - "is enabled, since the versioned path must not already " - "exist; overriding flag with 'overwrite=False' instead." - ) - with pytest.warns(UserWarning, match=pattern): - versioned_plot_writer = MatplotlibWriter( - filepath="/tmp/file.txt", - version=Version(load_version, save_version), - overwrite=True, - ) - assert not versioned_plot_writer._overwrite - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, load_version, save_version, mock_single_plot, versioned_plot_writer - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - rf"Save version '{save_version}' did not match load version " - rf"'{load_version}' for MatplotlibWriter\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_plot_writer.save(mock_single_plot) - - def test_http_filesystem_no_versioning(self): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - MatplotlibWriter( - filepath="https://example.com/file.png", version=Version(None, None) - ) - - def test_load_not_supported(self, versioned_plot_writer): - """Check the error if no versions are available for load.""" - pattern = ( - rf"Loading not supported for '{versioned_plot_writer.__class__.__name__}'" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_plot_writer.load() - - def test_exists(self, versioned_plot_writer, mock_single_plot): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_plot_writer.exists() - versioned_plot_writer.save(mock_single_plot) - assert versioned_plot_writer.exists() - - def test_exists_multiple(self, versioned_plot_writer, mock_list_plot): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_plot_writer.exists() - versioned_plot_writer.save(mock_list_plot) - assert versioned_plot_writer.exists() - - def test_save_data(self, versioned_plot_writer, mock_single_plot, tmp_path): - """Test saving dictionary of plots with enabled versioning.""" - versioned_plot_writer.save(mock_single_plot) - - test_path = tmp_path / "test_image.png" - actual_filepath = Path(versioned_plot_writer._get_load_path().as_posix()) - - plt.savefig(str(test_path)) - - assert actual_filepath.read_bytes() == test_path.read_bytes() - - def test_list_save(self, tmp_path, mock_list_plot, versioned_plot_writer): - """Test saving list of plots to with enabled versioning.""" - - versioned_plot_writer.save(mock_list_plot) - - for index in range(5): - - test_path = tmp_path / "test_image.png" - versioned_filepath = str(versioned_plot_writer._get_load_path()) - - mock_list_plot[index].savefig(str(test_path)) - actual_filepath = Path(f"{versioned_filepath}/{index}.png") - - assert actual_filepath.read_bytes() == test_path.read_bytes() - - def test_dict_save(self, tmp_path, mock_dict_plot, versioned_plot_writer): - """Test saving dictionary of plots with enabled versioning.""" - - versioned_plot_writer.save(mock_dict_plot) - - for colour in COLOUR_LIST: - test_path = tmp_path / "test_image.png" - versioned_filepath = str(versioned_plot_writer._get_load_path()) - - mock_dict_plot[colour].savefig(str(test_path)) - actual_filepath = Path(f"{versioned_filepath}/{colour}") - - assert actual_filepath.read_bytes() == test_path.read_bytes() - - def test_versioning_existing_dataset_single_plot( - self, plot_writer, versioned_plot_writer, mock_single_plot - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset, using a single plot.""" - - plot_writer = MatplotlibWriter( - filepath=versioned_plot_writer._filepath.as_posix() - ) - plot_writer.save(mock_single_plot) - assert plot_writer.exists() - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_plot_writer._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_plot_writer.save(mock_single_plot) - - # Remove non-versioned dataset and try again - Path(plot_writer._filepath.as_posix()).unlink() - versioned_plot_writer.save(mock_single_plot) - assert versioned_plot_writer.exists() - - def test_versioning_existing_dataset_list_plot( - self, plot_writer, versioned_plot_writer, mock_list_plot - ): - """Check the behavior when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset, using a list of plots. Note: because - a list of plots saves to a directory, an error is not expected.""" - plot_writer = MatplotlibWriter( - filepath=versioned_plot_writer._filepath.as_posix() - ) - plot_writer.save(mock_list_plot) - assert plot_writer.exists() - versioned_plot_writer.save(mock_list_plot) - assert versioned_plot_writer.exists() - - def test_versioning_existing_dataset_dict_plot( - self, plot_writer, versioned_plot_writer, mock_dict_plot - ): - """Check the behavior when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset, using a dict of plots. Note: because - a dict of plots saves to a directory, an error is not expected.""" - plot_writer = MatplotlibWriter( - filepath=versioned_plot_writer._filepath.as_posix() - ) - plot_writer.save(mock_dict_plot) - assert plot_writer.exists() - versioned_plot_writer.save(mock_dict_plot) - assert versioned_plot_writer.exists() diff --git a/tests/extras/datasets/networkx/__init__.py b/tests/extras/datasets/networkx/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/extras/datasets/networkx/test_gml_dataset.py b/tests/extras/datasets/networkx/test_gml_dataset.py deleted file mode 100644 index 88f7b18a77..0000000000 --- a/tests/extras/datasets/networkx/test_gml_dataset.py +++ /dev/null @@ -1,188 +0,0 @@ -from pathlib import Path, PurePosixPath - -import networkx -import pytest -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.networkx import GMLDataSet -from kedro.io import DatasetError, Version -from kedro.io.core import PROTOCOL_DELIMITER - -ATTRS = { - "source": "from", - "target": "to", - "name": "fake_id", - "key": "fake_key", - "link": "fake_link", -} - - -@pytest.fixture -def filepath_gml(tmp_path): - return (tmp_path / "some_dir" / "test.gml").as_posix() - - -@pytest.fixture -def gml_data_set(filepath_gml): - return GMLDataSet( - filepath=filepath_gml, - load_args={"destringizer": int}, - save_args={"stringizer": str}, - ) - - -@pytest.fixture -def versioned_gml_data_set(filepath_gml, load_version, save_version): - return GMLDataSet( - filepath=filepath_gml, - version=Version(load_version, save_version), - load_args={"destringizer": int}, - save_args={"stringizer": str}, - ) - - -@pytest.fixture() -def dummy_graph_data(): - return networkx.complete_graph(3) - - -class TestGMLDataSet: - def test_save_and_load(self, gml_data_set, dummy_graph_data): - """Test saving and reloading the data set.""" - gml_data_set.save(dummy_graph_data) - reloaded = gml_data_set.load() - assert dummy_graph_data.nodes(data=True) == reloaded.nodes(data=True) - assert gml_data_set._fs_open_args_load == {"mode": "rb"} - assert gml_data_set._fs_open_args_save == {"mode": "wb"} - - def test_load_missing_file(self, gml_data_set): - """Check the error when trying to load missing file.""" - pattern = r"Failed while loading data from data set GMLDataSet\(.*\)" - with pytest.raises(DatasetError, match=pattern): - assert gml_data_set.load() - - def test_exists(self, gml_data_set, dummy_graph_data): - """Test `exists` method invocation.""" - assert not gml_data_set.exists() - gml_data_set.save(dummy_graph_data) - assert gml_data_set.exists() - - @pytest.mark.parametrize( - "filepath,instance_type", - [ - ("s3://bucket/file.gml", S3FileSystem), - ("file:///tmp/test.gml", LocalFileSystem), - ("/tmp/test.gml", LocalFileSystem), - ("gcs://bucket/file.gml", GCSFileSystem), - ("https://example.com/file.gml", HTTPFileSystem), - ], - ) - def test_protocol_usage(self, filepath, instance_type): - data_set = GMLDataSet(filepath=filepath) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.gml" - data_set = GMLDataSet(filepath=filepath) - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - - -class TestGMLDataSetVersioned: - def test_save_and_load(self, versioned_gml_data_set, dummy_graph_data): - """Test that saved and reloaded data matches the original one for - the versioned data set.""" - versioned_gml_data_set.save(dummy_graph_data) - reloaded = versioned_gml_data_set.load() - assert dummy_graph_data.nodes(data=True) == reloaded.nodes(data=True) - assert versioned_gml_data_set._fs_open_args_load == {"mode": "rb"} - assert versioned_gml_data_set._fs_open_args_save == {"mode": "wb"} - - def test_no_versions(self, versioned_gml_data_set): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for GMLDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_gml_data_set.load() - - def test_exists(self, versioned_gml_data_set, dummy_graph_data): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_gml_data_set.exists() - versioned_gml_data_set.save(dummy_graph_data) - assert versioned_gml_data_set.exists() - - def test_prevent_override(self, versioned_gml_data_set, dummy_graph_data): - """Check the error when attempt to override the same data set - version.""" - versioned_gml_data_set.save(dummy_graph_data) - pattern = ( - r"Save path \'.+\' for GMLDataSet\(.+\) must not " - r"exist if versioning is enabled" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_gml_data_set.save(dummy_graph_data) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, versioned_gml_data_set, load_version, save_version, dummy_graph_data - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - rf"Save version '{save_version}' did not match " - rf"load version '{load_version}' for GMLDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_gml_data_set.save(dummy_graph_data) - - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - filepath = "test.gml" - ds = GMLDataSet(filepath=filepath) - ds_versioned = GMLDataSet( - filepath=filepath, version=Version(load_version, save_version) - ) - assert filepath in str(ds) - assert "version" not in str(ds) - - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "GMLDataSet" in str(ds_versioned) - assert "GMLDataSet" in str(ds) - assert "protocol" in str(ds_versioned) - assert "protocol" in str(ds) - - def test_versioning_existing_dataset( - self, gml_data_set, versioned_gml_data_set, dummy_graph_data - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - gml_data_set.save(dummy_graph_data) - assert gml_data_set.exists() - assert gml_data_set._filepath == versioned_gml_data_set._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_gml_data_set._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_gml_data_set.save(dummy_graph_data) - - # Remove non-versioned dataset and try again - Path(gml_data_set._filepath.as_posix()).unlink() - versioned_gml_data_set.save(dummy_graph_data) - assert versioned_gml_data_set.exists() diff --git a/tests/extras/datasets/networkx/test_graphml_dataset.py b/tests/extras/datasets/networkx/test_graphml_dataset.py deleted file mode 100644 index 1d744a61cb..0000000000 --- a/tests/extras/datasets/networkx/test_graphml_dataset.py +++ /dev/null @@ -1,188 +0,0 @@ -from pathlib import Path, PurePosixPath - -import networkx -import pytest -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.networkx import GraphMLDataSet -from kedro.io import DatasetError, Version -from kedro.io.core import PROTOCOL_DELIMITER - -ATTRS = { - "source": "from", - "target": "to", - "name": "fake_id", - "key": "fake_key", - "link": "fake_link", -} - - -@pytest.fixture -def filepath_graphml(tmp_path): - return (tmp_path / "some_dir" / "test.graphml").as_posix() - - -@pytest.fixture -def graphml_data_set(filepath_graphml): - return GraphMLDataSet( - filepath=filepath_graphml, - load_args={"node_type": int}, - save_args={}, - ) - - -@pytest.fixture -def versioned_graphml_data_set(filepath_graphml, load_version, save_version): - return GraphMLDataSet( - filepath=filepath_graphml, - version=Version(load_version, save_version), - load_args={"node_type": int}, - save_args={}, - ) - - -@pytest.fixture() -def dummy_graph_data(): - return networkx.complete_graph(3) - - -class TestGraphMLDataSet: - def test_save_and_load(self, graphml_data_set, dummy_graph_data): - """Test saving and reloading the data set.""" - graphml_data_set.save(dummy_graph_data) - reloaded = graphml_data_set.load() - assert dummy_graph_data.nodes(data=True) == reloaded.nodes(data=True) - assert graphml_data_set._fs_open_args_load == {"mode": "rb"} - assert graphml_data_set._fs_open_args_save == {"mode": "wb"} - - def test_load_missing_file(self, graphml_data_set): - """Check the error when trying to load missing file.""" - pattern = r"Failed while loading data from data set GraphMLDataSet\(.*\)" - with pytest.raises(DatasetError, match=pattern): - assert graphml_data_set.load() - - def test_exists(self, graphml_data_set, dummy_graph_data): - """Test `exists` method invocation.""" - assert not graphml_data_set.exists() - graphml_data_set.save(dummy_graph_data) - assert graphml_data_set.exists() - - @pytest.mark.parametrize( - "filepath,instance_type", - [ - ("s3://bucket/file.graphml", S3FileSystem), - ("file:///tmp/test.graphml", LocalFileSystem), - ("/tmp/test.graphml", LocalFileSystem), - ("gcs://bucket/file.graphml", GCSFileSystem), - ("https://example.com/file.graphml", HTTPFileSystem), - ], - ) - def test_protocol_usage(self, filepath, instance_type): - data_set = GraphMLDataSet(filepath=filepath) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.graphml" - data_set = GraphMLDataSet(filepath=filepath) - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - - -class TestGraphMLDataSetVersioned: - def test_save_and_load(self, versioned_graphml_data_set, dummy_graph_data): - """Test that saved and reloaded data matches the original one for - the versioned data set.""" - versioned_graphml_data_set.save(dummy_graph_data) - reloaded = versioned_graphml_data_set.load() - assert dummy_graph_data.nodes(data=True) == reloaded.nodes(data=True) - assert versioned_graphml_data_set._fs_open_args_load == {"mode": "rb"} - assert versioned_graphml_data_set._fs_open_args_save == {"mode": "wb"} - - def test_no_versions(self, versioned_graphml_data_set): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for GraphMLDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_graphml_data_set.load() - - def test_exists(self, versioned_graphml_data_set, dummy_graph_data): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_graphml_data_set.exists() - versioned_graphml_data_set.save(dummy_graph_data) - assert versioned_graphml_data_set.exists() - - def test_prevent_override(self, versioned_graphml_data_set, dummy_graph_data): - """Check the error when attempt to override the same data set - version.""" - versioned_graphml_data_set.save(dummy_graph_data) - pattern = ( - r"Save path \'.+\' for GraphMLDataSet\(.+\) must not " - r"exist if versioning is enabled" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_graphml_data_set.save(dummy_graph_data) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, versioned_graphml_data_set, load_version, save_version, dummy_graph_data - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - rf"Save version '{save_version}' did not match " - rf"load version '{load_version}' for GraphMLDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_graphml_data_set.save(dummy_graph_data) - - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - filepath = "test.graphml" - ds = GraphMLDataSet(filepath=filepath) - ds_versioned = GraphMLDataSet( - filepath=filepath, version=Version(load_version, save_version) - ) - assert filepath in str(ds) - assert "version" not in str(ds) - - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "GraphMLDataSet" in str(ds_versioned) - assert "GraphMLDataSet" in str(ds) - assert "protocol" in str(ds_versioned) - assert "protocol" in str(ds) - - def test_versioning_existing_dataset( - self, graphml_data_set, versioned_graphml_data_set, dummy_graph_data - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - graphml_data_set.save(dummy_graph_data) - assert graphml_data_set.exists() - assert graphml_data_set._filepath == versioned_graphml_data_set._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_graphml_data_set._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_graphml_data_set.save(dummy_graph_data) - - # Remove non-versioned dataset and try again - Path(graphml_data_set._filepath.as_posix()).unlink() - versioned_graphml_data_set.save(dummy_graph_data) - assert versioned_graphml_data_set.exists() diff --git a/tests/extras/datasets/networkx/test_json_dataset.py b/tests/extras/datasets/networkx/test_json_dataset.py deleted file mode 100644 index 55c7ebd213..0000000000 --- a/tests/extras/datasets/networkx/test_json_dataset.py +++ /dev/null @@ -1,226 +0,0 @@ -from pathlib import Path, PurePosixPath - -import networkx -import pytest -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.networkx import JSONDataSet -from kedro.io import DatasetError, Version -from kedro.io.core import PROTOCOL_DELIMITER - -ATTRS = { - "source": "from", - "target": "to", - "name": "fake_id", - "key": "fake_key", - "link": "fake_link", -} - - -@pytest.fixture -def filepath_json(tmp_path): - return (tmp_path / "some_dir" / "test.json").as_posix() - - -@pytest.fixture -def json_data_set(filepath_json, fs_args): - return JSONDataSet(filepath=filepath_json, fs_args=fs_args) - - -@pytest.fixture -def versioned_json_data_set(filepath_json, load_version, save_version): - return JSONDataSet( - filepath=filepath_json, version=Version(load_version, save_version) - ) - - -@pytest.fixture -def json_data_set_args(filepath_json): - return JSONDataSet( - filepath=filepath_json, load_args={"attrs": ATTRS}, save_args={"attrs": ATTRS} - ) - - -@pytest.fixture() -def dummy_graph_data(): - return networkx.complete_graph(3) - - -class TestJSONDataSet: - def test_save_and_load(self, json_data_set, dummy_graph_data): - """Test saving and reloading the data set.""" - json_data_set.save(dummy_graph_data) - reloaded = json_data_set.load() - assert dummy_graph_data.nodes(data=True) == reloaded.nodes(data=True) - assert json_data_set._fs_open_args_load == {} - assert json_data_set._fs_open_args_save == {"mode": "w"} - - def test_load_missing_file(self, json_data_set): - """Check the error when trying to load missing file.""" - pattern = r"Failed while loading data from data set JSONDataSet\(.*\)" - with pytest.raises(DatasetError, match=pattern): - assert json_data_set.load() - - def test_load_args_save_args(self, mocker, json_data_set_args, dummy_graph_data): - """Test saving and reloading with save and load arguments.""" - patched_save = mocker.patch( - "networkx.node_link_data", wraps=networkx.node_link_data - ) - json_data_set_args.save(dummy_graph_data) - patched_save.assert_called_once_with(dummy_graph_data, attrs=ATTRS) - - patched_load = mocker.patch( - "networkx.node_link_graph", wraps=networkx.node_link_graph - ) - # load args need to be the same attrs as the ones used for saving - # in order to successfully retrieve data - reloaded = json_data_set_args.load() - - patched_load.assert_called_once_with( - { - "directed": False, - "multigraph": False, - "graph": {}, - "nodes": [{"fake_id": 0}, {"fake_id": 1}, {"fake_id": 2}], - "fake_link": [ - {"from": 0, "to": 1}, - {"from": 0, "to": 2}, - {"from": 1, "to": 2}, - ], - }, - attrs=ATTRS, - ) - assert dummy_graph_data.nodes(data=True) == reloaded.nodes(data=True) - - @pytest.mark.parametrize( - "fs_args", - [{"open_args_load": {"mode": "rb", "compression": "gzip"}}], - indirect=True, - ) - def test_open_extra_args(self, json_data_set, fs_args): - assert json_data_set._fs_open_args_load == fs_args["open_args_load"] - assert json_data_set._fs_open_args_save == {"mode": "w"} # default unchanged - - def test_exists(self, json_data_set, dummy_graph_data): - """Test `exists` method invocation.""" - assert not json_data_set.exists() - json_data_set.save(dummy_graph_data) - assert json_data_set.exists() - - @pytest.mark.parametrize( - "filepath,instance_type", - [ - ("s3://bucket/file.json", S3FileSystem), - ("file:///tmp/test.json", LocalFileSystem), - ("/tmp/test.json", LocalFileSystem), - ("gcs://bucket/file.json", GCSFileSystem), - ("https://example.com/file.json", HTTPFileSystem), - ], - ) - def test_protocol_usage(self, filepath, instance_type): - data_set = JSONDataSet(filepath=filepath) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.json" - data_set = JSONDataSet(filepath=filepath) - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - - -class TestJSONDataSetVersioned: - def test_save_and_load(self, versioned_json_data_set, dummy_graph_data): - """Test that saved and reloaded data matches the original one for - the versioned data set.""" - versioned_json_data_set.save(dummy_graph_data) - reloaded = versioned_json_data_set.load() - assert dummy_graph_data.nodes(data=True) == reloaded.nodes(data=True) - - def test_no_versions(self, versioned_json_data_set): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for JSONDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_json_data_set.load() - - def test_exists(self, versioned_json_data_set, dummy_graph_data): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_json_data_set.exists() - versioned_json_data_set.save(dummy_graph_data) - assert versioned_json_data_set.exists() - - def test_prevent_override(self, versioned_json_data_set, dummy_graph_data): - """Check the error when attempt to override the same data set - version.""" - versioned_json_data_set.save(dummy_graph_data) - pattern = ( - r"Save path \'.+\' for JSONDataSet\(.+\) must not " - r"exist if versioning is enabled" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_json_data_set.save(dummy_graph_data) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, versioned_json_data_set, load_version, save_version, dummy_graph_data - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - rf"Save version '{save_version}' did not match load version " - rf"'{load_version}' for JSONDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_json_data_set.save(dummy_graph_data) - - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - filepath = "test.json" - ds = JSONDataSet(filepath=filepath) - ds_versioned = JSONDataSet( - filepath=filepath, version=Version(load_version, save_version) - ) - assert filepath in str(ds) - assert "version" not in str(ds) - - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "JSONDataSet" in str(ds_versioned) - assert "JSONDataSet" in str(ds) - assert "protocol" in str(ds_versioned) - assert "protocol" in str(ds) - - def test_versioning_existing_dataset( - self, json_data_set, versioned_json_data_set, dummy_graph_data - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - json_data_set.save(dummy_graph_data) - assert json_data_set.exists() - assert json_data_set._filepath == versioned_json_data_set._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_json_data_set._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_json_data_set.save(dummy_graph_data) - - # Remove non-versioned dataset and try again - Path(json_data_set._filepath.as_posix()).unlink() - versioned_json_data_set.save(dummy_graph_data) - assert versioned_json_data_set.exists() diff --git a/tests/extras/datasets/pandas/__init__.py b/tests/extras/datasets/pandas/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/extras/datasets/pandas/test_csv_dataset.py b/tests/extras/datasets/pandas/test_csv_dataset.py deleted file mode 100644 index a2a15f5938..0000000000 --- a/tests/extras/datasets/pandas/test_csv_dataset.py +++ /dev/null @@ -1,300 +0,0 @@ -from pathlib import Path, PurePosixPath -from time import sleep - -import pandas as pd -import pytest -from adlfs import AzureBlobFileSystem -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from pandas.testing import assert_frame_equal -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.pandas import CSVDataSet -from kedro.io import DatasetError -from kedro.io.core import PROTOCOL_DELIMITER, Version, generate_timestamp - - -@pytest.fixture -def filepath_csv(tmp_path): - return (tmp_path / "test.csv").as_posix() - - -@pytest.fixture -def csv_data_set(filepath_csv, load_args, save_args, fs_args): - return CSVDataSet( - filepath=filepath_csv, load_args=load_args, save_args=save_args, fs_args=fs_args - ) - - -@pytest.fixture -def versioned_csv_data_set(filepath_csv, load_version, save_version): - return CSVDataSet( - filepath=filepath_csv, version=Version(load_version, save_version) - ) - - -@pytest.fixture -def dummy_dataframe(): - return pd.DataFrame({"col1": [1, 2], "col2": [4, 5], "col3": [5, 6]}) - - -class TestCSVDataSet: - def test_save_and_load(self, csv_data_set, dummy_dataframe): - """Test saving and reloading the data set.""" - csv_data_set.save(dummy_dataframe) - reloaded = csv_data_set.load() - assert_frame_equal(dummy_dataframe, reloaded) - - def test_exists(self, csv_data_set, dummy_dataframe): - """Test `exists` method invocation for both existing and - nonexistent data set.""" - assert not csv_data_set.exists() - csv_data_set.save(dummy_dataframe) - assert csv_data_set.exists() - - @pytest.mark.parametrize( - "load_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_load_extra_params(self, csv_data_set, load_args): - """Test overriding the default load arguments.""" - for key, value in load_args.items(): - assert csv_data_set._load_args[key] == value - - @pytest.mark.parametrize( - "save_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_save_extra_params(self, csv_data_set, save_args): - """Test overriding the default save arguments.""" - for key, value in save_args.items(): - assert csv_data_set._save_args[key] == value - - @pytest.mark.parametrize( - "load_args,save_args", - [ - ({"storage_options": {"a": "b"}}, {}), - ({}, {"storage_options": {"a": "b"}}), - ({"storage_options": {"a": "b"}}, {"storage_options": {"x": "y"}}), - ], - ) - def test_storage_options_dropped(self, load_args, save_args, caplog, tmp_path): - filepath = str(tmp_path / "test.csv") - - ds = CSVDataSet(filepath=filepath, load_args=load_args, save_args=save_args) - - records = [r for r in caplog.records if r.levelname == "WARNING"] - expected_log_message = ( - f"Dropping 'storage_options' for {filepath}, " - f"please specify them under 'fs_args' or 'credentials'." - ) - assert records[0].getMessage() == expected_log_message - assert "storage_options" not in ds._save_args - assert "storage_options" not in ds._load_args - - def test_load_missing_file(self, csv_data_set): - """Check the error when trying to load missing file.""" - pattern = r"Failed while loading data from data set CSVDataSet\(.*\)" - with pytest.raises(DatasetError, match=pattern): - csv_data_set.load() - - @pytest.mark.parametrize( - "filepath,instance_type,credentials", - [ - ("s3://bucket/file.csv", S3FileSystem, {}), - ("file:///tmp/test.csv", LocalFileSystem, {}), - ("/tmp/test.csv", LocalFileSystem, {}), - ("gcs://bucket/file.csv", GCSFileSystem, {}), - ("https://example.com/file.csv", HTTPFileSystem, {}), - ( - "abfs://bucket/file.csv", - AzureBlobFileSystem, - {"account_name": "test", "account_key": "test"}, - ), - ], - ) - def test_protocol_usage(self, filepath, instance_type, credentials): - data_set = CSVDataSet(filepath=filepath, credentials=credentials) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.csv" - data_set = CSVDataSet(filepath=filepath) - assert data_set._version_cache.currsize == 0 # no cache if unversioned - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - assert data_set._version_cache.currsize == 0 - - -class TestCSVDataSetVersioned: - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - filepath = "test.csv" - ds = CSVDataSet(filepath=filepath) - ds_versioned = CSVDataSet( - filepath=filepath, version=Version(load_version, save_version) - ) - assert filepath in str(ds) - assert "version" not in str(ds) - - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "CSVDataSet" in str(ds_versioned) - assert "CSVDataSet" in str(ds) - assert "protocol" in str(ds_versioned) - assert "protocol" in str(ds) - # Default save_args - assert "save_args={'index': False}" in str(ds) - assert "save_args={'index': False}" in str(ds_versioned) - - def test_save_and_load(self, versioned_csv_data_set, dummy_dataframe): - """Test that saved and reloaded data matches the original one for - the versioned data set.""" - versioned_csv_data_set.save(dummy_dataframe) - reloaded_df = versioned_csv_data_set.load() - assert_frame_equal(dummy_dataframe, reloaded_df) - - def test_multiple_loads( - self, versioned_csv_data_set, dummy_dataframe, filepath_csv - ): - """Test that if a new version is created mid-run, by an - external system, it won't be loaded in the current run.""" - versioned_csv_data_set.save(dummy_dataframe) - versioned_csv_data_set.load() - v1 = versioned_csv_data_set.resolve_load_version() - - sleep(0.5) - # force-drop a newer version into the same location - v_new = generate_timestamp() - CSVDataSet(filepath=filepath_csv, version=Version(v_new, v_new)).save( - dummy_dataframe - ) - - versioned_csv_data_set.load() - v2 = versioned_csv_data_set.resolve_load_version() - - assert v2 == v1 # v2 should not be v_new! - ds_new = CSVDataSet(filepath=filepath_csv, version=Version(None, None)) - assert ( - ds_new.resolve_load_version() == v_new - ) # new version is discoverable by a new instance - - def test_multiple_saves(self, dummy_dataframe, filepath_csv): - """Test multiple cycles of save followed by load for the same dataset""" - ds_versioned = CSVDataSet(filepath=filepath_csv, version=Version(None, None)) - - # first save - ds_versioned.save(dummy_dataframe) - first_save_version = ds_versioned.resolve_save_version() - first_load_version = ds_versioned.resolve_load_version() - assert first_load_version == first_save_version - - # second save - sleep(0.5) - ds_versioned.save(dummy_dataframe) - second_save_version = ds_versioned.resolve_save_version() - second_load_version = ds_versioned.resolve_load_version() - assert second_load_version == second_save_version - assert second_load_version > first_load_version - - # another dataset - ds_new = CSVDataSet(filepath=filepath_csv, version=Version(None, None)) - assert ds_new.resolve_load_version() == second_load_version - - def test_release_instance_cache(self, dummy_dataframe, filepath_csv): - """Test that cache invalidation does not affect other instances""" - ds_a = CSVDataSet(filepath=filepath_csv, version=Version(None, None)) - assert ds_a._version_cache.currsize == 0 - ds_a.save(dummy_dataframe) # create a version - assert ds_a._version_cache.currsize == 2 - - ds_b = CSVDataSet(filepath=filepath_csv, version=Version(None, None)) - assert ds_b._version_cache.currsize == 0 - ds_b.resolve_save_version() - assert ds_b._version_cache.currsize == 1 - ds_b.resolve_load_version() - assert ds_b._version_cache.currsize == 2 - - ds_a.release() - - # dataset A cache is cleared - assert ds_a._version_cache.currsize == 0 - - # dataset B cache is unaffected - assert ds_b._version_cache.currsize == 2 - - def test_no_versions(self, versioned_csv_data_set): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for CSVDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_csv_data_set.load() - - def test_exists(self, versioned_csv_data_set, dummy_dataframe): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_csv_data_set.exists() - versioned_csv_data_set.save(dummy_dataframe) - assert versioned_csv_data_set.exists() - - def test_prevent_overwrite(self, versioned_csv_data_set, dummy_dataframe): - """Check the error when attempting to override the data set if the - corresponding CSV file for a given save version already exists.""" - versioned_csv_data_set.save(dummy_dataframe) - pattern = ( - r"Save path \'.+\' for CSVDataSet\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - versioned_csv_data_set.save(dummy_dataframe) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, versioned_csv_data_set, load_version, save_version, dummy_dataframe - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - rf"Save version '{save_version}' did not match load version " - rf"'{load_version}' for CSVDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_csv_data_set.save(dummy_dataframe) - - def test_http_filesystem_no_versioning(self): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - CSVDataSet( - filepath="https://example.com/file.csv", version=Version(None, None) - ) - - def test_versioning_existing_dataset( - self, csv_data_set, versioned_csv_data_set, dummy_dataframe - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - csv_data_set.save(dummy_dataframe) - assert csv_data_set.exists() - assert csv_data_set._filepath == versioned_csv_data_set._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_csv_data_set._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_csv_data_set.save(dummy_dataframe) - - # Remove non-versioned dataset and try again - Path(csv_data_set._filepath.as_posix()).unlink() - versioned_csv_data_set.save(dummy_dataframe) - assert versioned_csv_data_set.exists() diff --git a/tests/extras/datasets/pandas/test_excel_dataset.py b/tests/extras/datasets/pandas/test_excel_dataset.py deleted file mode 100644 index d558d3b22f..0000000000 --- a/tests/extras/datasets/pandas/test_excel_dataset.py +++ /dev/null @@ -1,281 +0,0 @@ -from pathlib import Path, PurePosixPath - -import pandas as pd -import pytest -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from pandas.testing import assert_frame_equal -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.pandas import ExcelDataSet -from kedro.io import DatasetError -from kedro.io.core import PROTOCOL_DELIMITER, Version - - -@pytest.fixture -def filepath_excel(tmp_path): - return (tmp_path / "test.xlsx").as_posix() - - -@pytest.fixture -def excel_data_set(filepath_excel, load_args, save_args, fs_args): - return ExcelDataSet( - filepath=filepath_excel, - load_args=load_args, - save_args=save_args, - fs_args=fs_args, - ) - - -@pytest.fixture -def excel_multisheet_data_set(filepath_excel, save_args, fs_args): - load_args = {"sheet_name": None} - return ExcelDataSet( - filepath=filepath_excel, - load_args=load_args, - save_args=save_args, - fs_args=fs_args, - ) - - -@pytest.fixture -def versioned_excel_data_set(filepath_excel, load_version, save_version): - return ExcelDataSet( - filepath=filepath_excel, version=Version(load_version, save_version) - ) - - -@pytest.fixture -def dummy_dataframe(): - return pd.DataFrame({"col1": [1, 2], "col2": [4, 5], "col3": [5, 6]}) - - -@pytest.fixture -def another_dummy_dataframe(): - return pd.DataFrame({"x": [10, 20], "y": ["hello", "world"]}) - - -class TestExcelDataSet: - def test_save_and_load(self, excel_data_set, dummy_dataframe): - """Test saving and reloading the data set.""" - excel_data_set.save(dummy_dataframe) - reloaded = excel_data_set.load() - assert_frame_equal(dummy_dataframe, reloaded) - - def test_save_and_load_multiple_sheets( - self, excel_multisheet_data_set, dummy_dataframe, another_dummy_dataframe - ): - """Test saving and reloading the data set with multiple sheets.""" - dummy_multisheet = { - "sheet 1": dummy_dataframe, - "sheet 2": another_dummy_dataframe, - } - excel_multisheet_data_set.save(dummy_multisheet) - reloaded = excel_multisheet_data_set.load() - assert_frame_equal(dummy_multisheet["sheet 1"], reloaded["sheet 1"]) - assert_frame_equal(dummy_multisheet["sheet 2"], reloaded["sheet 2"]) - - def test_exists(self, excel_data_set, dummy_dataframe): - """Test `exists` method invocation for both existing and - nonexistent data set.""" - assert not excel_data_set.exists() - excel_data_set.save(dummy_dataframe) - assert excel_data_set.exists() - - @pytest.mark.parametrize( - "load_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_load_extra_params(self, excel_data_set, load_args): - """Test overriding the default load arguments.""" - for key, value in load_args.items(): - assert excel_data_set._load_args[key] == value - - @pytest.mark.parametrize( - "save_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_save_extra_params(self, excel_data_set, save_args): - """Test overriding the default save arguments.""" - for key, value in save_args.items(): - assert excel_data_set._save_args[key] == value - - @pytest.mark.parametrize( - "load_args,save_args", - [ - ({"storage_options": {"a": "b"}}, {}), - ({}, {"storage_options": {"a": "b"}}), - ({"storage_options": {"a": "b"}}, {"storage_options": {"x": "y"}}), - ], - ) - def test_storage_options_dropped(self, load_args, save_args, caplog, tmp_path): - filepath = str(tmp_path / "test.csv") - - ds = ExcelDataSet(filepath=filepath, load_args=load_args, save_args=save_args) - - records = [r for r in caplog.records if r.levelname == "WARNING"] - expected_log_message = ( - f"Dropping 'storage_options' for {filepath}, " - f"please specify them under 'fs_args' or 'credentials'." - ) - assert records[0].getMessage() == expected_log_message - assert "storage_options" not in ds._save_args - assert "storage_options" not in ds._load_args - - def test_load_missing_file(self, excel_data_set): - """Check the error when trying to load missing file.""" - pattern = r"Failed while loading data from data set ExcelDataSet\(.*\)" - with pytest.raises(DatasetError, match=pattern): - excel_data_set.load() - - @pytest.mark.parametrize( - "filepath,instance_type,load_path", - [ - ("s3://bucket/file.xlsx", S3FileSystem, "s3://bucket/file.xlsx"), - ("file:///tmp/test.xlsx", LocalFileSystem, "/tmp/test.xlsx"), - ("/tmp/test.xlsx", LocalFileSystem, "/tmp/test.xlsx"), - ("gcs://bucket/file.xlsx", GCSFileSystem, "gcs://bucket/file.xlsx"), - ( - "https://example.com/file.xlsx", - HTTPFileSystem, - "https://example.com/file.xlsx", - ), - ], - ) - def test_protocol_usage(self, filepath, instance_type, load_path, mocker): - data_set = ExcelDataSet(filepath=filepath) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - mock_pandas_call = mocker.patch("pandas.read_excel") - data_set.load() - assert mock_pandas_call.call_count == 1 - assert mock_pandas_call.call_args_list[0][0][0] == load_path - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.xlsx" - data_set = ExcelDataSet(filepath=filepath) - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - - -class TestExcelDataSetVersioned: - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - filepath = "test.xlsx" - ds = ExcelDataSet(filepath=filepath) - ds_versioned = ExcelDataSet( - filepath=filepath, version=Version(load_version, save_version) - ) - assert filepath in str(ds) - assert "version" not in str(ds) - - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "ExcelDataSet" in str(ds_versioned) - assert "ExcelDataSet" in str(ds) - assert "protocol" in str(ds_versioned) - assert "protocol" in str(ds) - assert "writer_args" in str(ds_versioned) - assert "writer_args" in str(ds) - # Default save_args and load_args - assert "save_args={'index': False}" in str(ds) - assert "save_args={'index': False}" in str(ds_versioned) - assert "load_args={'engine': openpyxl}" in str(ds_versioned) - assert "load_args={'engine': openpyxl}" in str(ds) - - def test_save_and_load(self, versioned_excel_data_set, dummy_dataframe): - """Test that saved and reloaded data matches the original one for - the versioned data set.""" - versioned_excel_data_set.save(dummy_dataframe) - reloaded_df = versioned_excel_data_set.load() - assert_frame_equal(dummy_dataframe, reloaded_df) - - def test_no_versions(self, versioned_excel_data_set): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for ExcelDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_excel_data_set.load() - - def test_versioning_not_supported_in_append_mode( - self, tmp_path, load_version, save_version - ): - filepath = str(tmp_path / "test.xlsx") - save_args = {"writer": {"mode": "a"}} - - pattern = "'ExcelDataSet' doesn't support versioning in append mode." - with pytest.raises(DatasetError, match=pattern): - ExcelDataSet( - filepath=filepath, - version=Version(load_version, save_version), - save_args=save_args, - ) - - def test_exists(self, versioned_excel_data_set, dummy_dataframe): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_excel_data_set.exists() - versioned_excel_data_set.save(dummy_dataframe) - assert versioned_excel_data_set.exists() - - def test_prevent_overwrite(self, versioned_excel_data_set, dummy_dataframe): - """Check the error when attempting to override the data set if the - corresponding Excel file for a given save version already exists.""" - versioned_excel_data_set.save(dummy_dataframe) - pattern = ( - r"Save path \'.+\' for ExcelDataSet\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - versioned_excel_data_set.save(dummy_dataframe) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, versioned_excel_data_set, load_version, save_version, dummy_dataframe - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - rf"Save version '{save_version}' did not match load version " - rf"'{load_version}' for ExcelDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_excel_data_set.save(dummy_dataframe) - - def test_http_filesystem_no_versioning(self): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - ExcelDataSet( - filepath="https://example.com/file.xlsx", version=Version(None, None) - ) - - def test_versioning_existing_dataset( - self, excel_data_set, versioned_excel_data_set, dummy_dataframe - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - excel_data_set.save(dummy_dataframe) - assert excel_data_set.exists() - assert excel_data_set._filepath == versioned_excel_data_set._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_excel_data_set._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_excel_data_set.save(dummy_dataframe) - - # Remove non-versioned dataset and try again - Path(excel_data_set._filepath.as_posix()).unlink() - versioned_excel_data_set.save(dummy_dataframe) - assert versioned_excel_data_set.exists() diff --git a/tests/extras/datasets/pandas/test_feather_dataset.py b/tests/extras/datasets/pandas/test_feather_dataset.py deleted file mode 100644 index 8637bd2bcf..0000000000 --- a/tests/extras/datasets/pandas/test_feather_dataset.py +++ /dev/null @@ -1,220 +0,0 @@ -from pathlib import Path, PurePosixPath - -import pandas as pd -import pytest -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from pandas.testing import assert_frame_equal -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.pandas import FeatherDataSet -from kedro.io import DatasetError -from kedro.io.core import PROTOCOL_DELIMITER, Version - - -@pytest.fixture -def filepath_feather(tmp_path): - return (tmp_path / "test.feather").as_posix() - - -@pytest.fixture -def feather_data_set(filepath_feather, load_args, fs_args): - return FeatherDataSet( - filepath=filepath_feather, load_args=load_args, fs_args=fs_args - ) - - -@pytest.fixture -def versioned_feather_data_set(filepath_feather, load_version, save_version): - return FeatherDataSet( - filepath=filepath_feather, version=Version(load_version, save_version) - ) - - -@pytest.fixture -def dummy_dataframe(): - return pd.DataFrame({"col1": [1, 2], "col2": [4, 5], "col3": [5, 6]}) - - -class TestFeatherDataSet: - def test_save_and_load(self, feather_data_set, dummy_dataframe): - """Test saving and reloading the data set.""" - feather_data_set.save(dummy_dataframe) - reloaded = feather_data_set.load() - assert_frame_equal(dummy_dataframe, reloaded) - - def test_exists(self, feather_data_set, dummy_dataframe): - """Test `exists` method invocation for both existing and - nonexistent data set.""" - assert not feather_data_set.exists() - feather_data_set.save(dummy_dataframe) - assert feather_data_set.exists() - - @pytest.mark.parametrize( - "load_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_load_extra_params(self, feather_data_set, load_args): - """Test overriding the default load arguments.""" - for key, value in load_args.items(): - assert feather_data_set._load_args[key] == value - - @pytest.mark.parametrize( - "load_args,save_args", - [ - ({"storage_options": {"a": "b"}}, {}), - ({}, {"storage_options": {"a": "b"}}), - ({"storage_options": {"a": "b"}}, {"storage_options": {"x": "y"}}), - ], - ) - def test_storage_options_dropped(self, load_args, save_args, caplog, tmp_path): - filepath = str(tmp_path / "test.csv") - - ds = FeatherDataSet(filepath=filepath, load_args=load_args, save_args=save_args) - - records = [r for r in caplog.records if r.levelname == "WARNING"] - expected_log_message = ( - f"Dropping 'storage_options' for {filepath}, " - f"please specify them under 'fs_args' or 'credentials'." - ) - assert records[0].getMessage() == expected_log_message - assert "storage_options" not in ds._save_args - assert "storage_options" not in ds._load_args - - def test_load_missing_file(self, feather_data_set): - """Check the error when trying to load missing file.""" - pattern = r"Failed while loading data from data set FeatherDataSet\(.*\)" - with pytest.raises(DatasetError, match=pattern): - feather_data_set.load() - - @pytest.mark.parametrize( - "filepath,instance_type,load_path", - [ - ("s3://bucket/file.feather", S3FileSystem, "s3://bucket/file.feather"), - ("file:///tmp/test.feather", LocalFileSystem, "/tmp/test.feather"), - ("/tmp/test.feather", LocalFileSystem, "/tmp/test.feather"), - ("gcs://bucket/file.feather", GCSFileSystem, "gcs://bucket/file.feather"), - ( - "https://example.com/file.feather", - HTTPFileSystem, - "https://example.com/file.feather", - ), - ], - ) - def test_protocol_usage(self, filepath, instance_type, load_path, mocker): - data_set = FeatherDataSet(filepath=filepath) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - mock_pandas_call = mocker.patch("pandas.read_feather") - data_set.load() - assert mock_pandas_call.call_count == 1 - assert mock_pandas_call.call_args_list[0][0][0] == load_path - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.feather" - data_set = FeatherDataSet(filepath=filepath) - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - - -class TestFeatherDataSetVersioned: - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - filepath = "test.feather" - ds = FeatherDataSet(filepath=filepath) - ds_versioned = FeatherDataSet( - filepath=filepath, version=Version(load_version, save_version) - ) - assert filepath in str(ds) - assert "version" not in str(ds) - - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "FeatherDataSet" in str(ds_versioned) - assert "FeatherDataSet" in str(ds) - assert "protocol" in str(ds_versioned) - assert "protocol" in str(ds) - - def test_save_and_load(self, versioned_feather_data_set, dummy_dataframe): - """Test that saved and reloaded data matches the original one for - the versioned data set.""" - versioned_feather_data_set.save(dummy_dataframe) - reloaded_df = versioned_feather_data_set.load() - assert_frame_equal(dummy_dataframe, reloaded_df) - - def test_no_versions(self, versioned_feather_data_set): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for FeatherDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_feather_data_set.load() - - def test_exists(self, versioned_feather_data_set, dummy_dataframe): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_feather_data_set.exists() - versioned_feather_data_set.save(dummy_dataframe) - assert versioned_feather_data_set.exists() - - def test_prevent_overwrite(self, versioned_feather_data_set, dummy_dataframe): - """Check the error when attempting to overwrite the data set if the - corresponding feather file for a given save version already exists.""" - versioned_feather_data_set.save(dummy_dataframe) - pattern = ( - r"Save path \'.+\' for FeatherDataSet\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - versioned_feather_data_set.save(dummy_dataframe) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, versioned_feather_data_set, load_version, save_version, dummy_dataframe - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - rf"Save version '{save_version}' did not match load version " - rf"'{load_version}' for FeatherDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_feather_data_set.save(dummy_dataframe) - - def test_http_filesystem_no_versioning(self): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - FeatherDataSet( - filepath="https://example.com/file.feather", version=Version(None, None) - ) - - def test_versioning_existing_dataset( - self, feather_data_set, versioned_feather_data_set, dummy_dataframe - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - feather_data_set.save(dummy_dataframe) - assert feather_data_set.exists() - assert feather_data_set._filepath == versioned_feather_data_set._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_feather_data_set._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_feather_data_set.save(dummy_dataframe) - - # Remove non-versioned dataset and try again - Path(feather_data_set._filepath.as_posix()).unlink() - versioned_feather_data_set.save(dummy_dataframe) - assert versioned_feather_data_set.exists() diff --git a/tests/extras/datasets/pandas/test_gbq_dataset.py b/tests/extras/datasets/pandas/test_gbq_dataset.py deleted file mode 100644 index 475f25c93b..0000000000 --- a/tests/extras/datasets/pandas/test_gbq_dataset.py +++ /dev/null @@ -1,315 +0,0 @@ -from pathlib import PosixPath - -import pandas as pd -import pytest -from google.cloud.exceptions import NotFound -from pandas.testing import assert_frame_equal - -from kedro.extras.datasets.pandas import GBQQueryDataSet, GBQTableDataSet -from kedro.io.core import DatasetError - -DATASET = "dataset" -TABLE_NAME = "table_name" -PROJECT = "project" -SQL_QUERY = "SELECT * FROM table_a" - - -@pytest.fixture -def dummy_dataframe(): - return pd.DataFrame({"col1": [1, 2], "col2": [4, 5], "col3": [5, 6]}) - - -@pytest.fixture -def mock_bigquery_client(mocker): - mocked = mocker.patch("google.cloud.bigquery.Client", autospec=True) - return mocked - - -@pytest.fixture -def gbq_dataset( - load_args, save_args, mock_bigquery_client -): # pylint: disable=unused-argument - return GBQTableDataSet( - dataset=DATASET, - table_name=TABLE_NAME, - project=PROJECT, - credentials=None, - load_args=load_args, - save_args=save_args, - ) - - -@pytest.fixture(params=[{}]) -def gbq_sql_dataset(load_args, mock_bigquery_client): # pylint: disable=unused-argument - return GBQQueryDataSet( - sql=SQL_QUERY, - project=PROJECT, - credentials=None, - load_args=load_args, - ) - - -@pytest.fixture -def sql_file(tmp_path: PosixPath): - file = tmp_path / "test.sql" - file.write_text(SQL_QUERY) - return file.as_posix() - - -@pytest.fixture(params=[{}]) -def gbq_sql_file_dataset( - load_args, sql_file, mock_bigquery_client -): # pylint: disable=unused-argument - return GBQQueryDataSet( - filepath=sql_file, - project=PROJECT, - credentials=None, - load_args=load_args, - ) - - -class TestGBQDataSet: - def test_exists(self, mock_bigquery_client): - """Test `exists` method invocation.""" - mock_bigquery_client.return_value.get_table.side_effect = [ - NotFound("NotFound"), - "exists", - ] - - data_set = GBQTableDataSet(DATASET, TABLE_NAME) - assert not data_set.exists() - assert data_set.exists() - - @pytest.mark.parametrize( - "load_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_load_extra_params(self, gbq_dataset, load_args): - """Test overriding the default load arguments.""" - for key, value in load_args.items(): - assert gbq_dataset._load_args[key] == value - - @pytest.mark.parametrize( - "save_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_save_extra_params(self, gbq_dataset, save_args): - """Test overriding the default save arguments.""" - for key, value in save_args.items(): - assert gbq_dataset._save_args[key] == value - - def test_load_missing_file(self, gbq_dataset, mocker): - """Check the error when trying to load missing table.""" - pattern = r"Failed while loading data from data set GBQTableDataSet\(.*\)" - mocked_read_gbq = mocker.patch( - "kedro.extras.datasets.pandas.gbq_dataset.pd.read_gbq" - ) - mocked_read_gbq.side_effect = ValueError - with pytest.raises(DatasetError, match=pattern): - gbq_dataset.load() - - @pytest.mark.parametrize("load_args", [{"location": "l1"}], indirect=True) - @pytest.mark.parametrize("save_args", [{"location": "l2"}], indirect=True) - def test_invalid_location(self, save_args, load_args): - """Check the error when initializing instance if save_args and load_args - 'location' are different.""" - pattern = r""""load_args\['location'\]" is different from "save_args\['location'\]".""" - with pytest.raises(DatasetError, match=pattern): - GBQTableDataSet( - dataset=DATASET, - table_name=TABLE_NAME, - project=PROJECT, - credentials=None, - load_args=load_args, - save_args=save_args, - ) - - @pytest.mark.parametrize("save_args", [{"option1": "value1"}], indirect=True) - @pytest.mark.parametrize("load_args", [{"option2": "value2"}], indirect=True) - def test_str_representation(self, gbq_dataset, save_args, load_args): - """Test string representation of the data set instance.""" - str_repr = str(gbq_dataset) - assert "GBQTableDataSet" in str_repr - assert TABLE_NAME in str_repr - assert DATASET in str_repr - for k in save_args.keys(): - assert k in str_repr - for k in load_args.keys(): - assert k in str_repr - - def test_save_load_data(self, gbq_dataset, dummy_dataframe, mocker): - """Test saving and reloading the data set.""" - sql = f"select * from {DATASET}.{TABLE_NAME}" - table_id = f"{DATASET}.{TABLE_NAME}" - mocked_read_gbq = mocker.patch( - "kedro.extras.datasets.pandas.gbq_dataset.pd.read_gbq" - ) - mocked_read_gbq.return_value = dummy_dataframe - mocked_df = mocker.Mock() - - gbq_dataset.save(mocked_df) - loaded_data = gbq_dataset.load() - - mocked_df.to_gbq.assert_called_once_with( - table_id, project_id=PROJECT, credentials=None, progress_bar=False - ) - mocked_read_gbq.assert_called_once_with( - project_id=PROJECT, credentials=None, query=sql - ) - assert_frame_equal(dummy_dataframe, loaded_data) - - @pytest.mark.parametrize("load_args", [{"query": "Select 1"}], indirect=True) - def test_read_gbq_with_query(self, gbq_dataset, dummy_dataframe, mocker, load_args): - """Test loading data set with query in the argument.""" - mocked_read_gbq = mocker.patch( - "kedro.extras.datasets.pandas.gbq_dataset.pd.read_gbq" - ) - mocked_read_gbq.return_value = dummy_dataframe - loaded_data = gbq_dataset.load() - - mocked_read_gbq.assert_called_once_with( - project_id=PROJECT, credentials=None, query=load_args["query"] - ) - - assert_frame_equal(dummy_dataframe, loaded_data) - - @pytest.mark.parametrize( - "dataset,table_name", - [ - ("data set", TABLE_NAME), - ("data;set", TABLE_NAME), - (DATASET, "table name"), - (DATASET, "table;name"), - ], - ) - def test_validation_of_dataset_and_table_name(self, dataset, table_name): - pattern = "Neither white-space nor semicolon are allowed.*" - with pytest.raises(DatasetError, match=pattern): - GBQTableDataSet(dataset=dataset, table_name=table_name) - - def test_credentials_propagation(self, mocker): - credentials = {"token": "my_token"} - credentials_obj = "credentials" - mocked_credentials = mocker.patch( - "kedro.extras.datasets.pandas.gbq_dataset.Credentials", - return_value=credentials_obj, - ) - mocked_bigquery = mocker.patch( - "kedro.extras.datasets.pandas.gbq_dataset.bigquery" - ) - - data_set = GBQTableDataSet( - dataset=DATASET, - table_name=TABLE_NAME, - credentials=credentials, - project=PROJECT, - ) - - assert data_set._credentials == credentials_obj - mocked_credentials.assert_called_once_with(**credentials) - mocked_bigquery.Client.assert_called_once_with( - project=PROJECT, credentials=credentials_obj, location=None - ) - - -class TestGBQQueryDataSet: - def test_empty_query_error(self): - """Check the error when instantiating with empty query or file""" - pattern = ( - r"'sql' and 'filepath' arguments cannot both be empty\." - r"Please provide a sql query or path to a sql query file\." - ) - with pytest.raises(DatasetError, match=pattern): - GBQQueryDataSet(sql="", filepath="", credentials=None) - - @pytest.mark.parametrize( - "load_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_load_extra_params(self, gbq_sql_dataset, load_args): - """Test overriding the default load arguments.""" - for key, value in load_args.items(): - assert gbq_sql_dataset._load_args[key] == value - - def test_credentials_propagation(self, mocker): - credentials = {"token": "my_token"} - credentials_obj = "credentials" - mocked_credentials = mocker.patch( - "kedro.extras.datasets.pandas.gbq_dataset.Credentials", - return_value=credentials_obj, - ) - mocked_bigquery = mocker.patch( - "kedro.extras.datasets.pandas.gbq_dataset.bigquery" - ) - - data_set = GBQQueryDataSet( - sql=SQL_QUERY, - credentials=credentials, - project=PROJECT, - ) - - assert data_set._credentials == credentials_obj - mocked_credentials.assert_called_once_with(**credentials) - mocked_bigquery.Client.assert_called_once_with( - project=PROJECT, credentials=credentials_obj, location=None - ) - - def test_load(self, mocker, gbq_sql_dataset, dummy_dataframe): - """Test `load` method invocation""" - mocked_read_gbq = mocker.patch( - "kedro.extras.datasets.pandas.gbq_dataset.pd.read_gbq" - ) - mocked_read_gbq.return_value = dummy_dataframe - - loaded_data = gbq_sql_dataset.load() - - mocked_read_gbq.assert_called_once_with( - project_id=PROJECT, credentials=None, query=SQL_QUERY - ) - - assert_frame_equal(dummy_dataframe, loaded_data) - - def test_load_query_file(self, mocker, gbq_sql_file_dataset, dummy_dataframe): - """Test `load` method invocation using a file as input query""" - mocked_read_gbq = mocker.patch( - "kedro.extras.datasets.pandas.gbq_dataset.pd.read_gbq" - ) - mocked_read_gbq.return_value = dummy_dataframe - - loaded_data = gbq_sql_file_dataset.load() - - mocked_read_gbq.assert_called_once_with( - project_id=PROJECT, credentials=None, query=SQL_QUERY - ) - - assert_frame_equal(dummy_dataframe, loaded_data) - - def test_save_error(self, gbq_sql_dataset, dummy_dataframe): - """Check the error when trying to save to the data set""" - pattern = r"'save' is not supported on GBQQueryDataSet" - with pytest.raises(DatasetError, match=pattern): - gbq_sql_dataset.save(dummy_dataframe) - - def test_str_representation_sql(self, gbq_sql_dataset, sql_file): - """Test the data set instance string representation""" - str_repr = str(gbq_sql_dataset) - assert ( - f"GBQQueryDataSet(filepath=None, load_args={{}}, sql={SQL_QUERY})" - in str_repr - ) - assert sql_file not in str_repr - - def test_str_representation_filepath(self, gbq_sql_file_dataset, sql_file): - """Test the data set instance string representation with filepath arg.""" - str_repr = str(gbq_sql_file_dataset) - assert ( - f"GBQQueryDataSet(filepath={str(sql_file)}, load_args={{}}, sql=None)" - in str_repr - ) - assert SQL_QUERY not in str_repr - - def test_sql_and_filepath_args(self, sql_file): - """Test that an error is raised when both `sql` and `filepath` args are given.""" - pattern = ( - r"'sql' and 'filepath' arguments cannot both be provided." - r"Please only provide one." - ) - with pytest.raises(DatasetError, match=pattern): - GBQQueryDataSet(sql=SQL_QUERY, filepath=sql_file) diff --git a/tests/extras/datasets/pandas/test_generic_dataset.py b/tests/extras/datasets/pandas/test_generic_dataset.py deleted file mode 100644 index 23feb861e8..0000000000 --- a/tests/extras/datasets/pandas/test_generic_dataset.py +++ /dev/null @@ -1,383 +0,0 @@ -from pathlib import Path, PurePosixPath -from time import sleep - -import pandas as pd -import pytest -from adlfs import AzureBlobFileSystem -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from pandas._testing import assert_frame_equal -from s3fs import S3FileSystem - -from kedro.extras.datasets.pandas import GenericDataSet -from kedro.io import DatasetError, Version -from kedro.io.core import PROTOCOL_DELIMITER, generate_timestamp - - -@pytest.fixture -def filepath_sas(tmp_path): - return tmp_path / "test.sas7bdat" - - -@pytest.fixture -def filepath_csv(tmp_path): - return tmp_path / "test.csv" - - -@pytest.fixture -def filepath_html(tmp_path): - return tmp_path / "test.html" - - -# pylint: disable = line-too-long -@pytest.fixture() -def sas_binary(): - return b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xc2\xea\x81`\xb3\x14\x11\xcf\xbd\x92\x08\x00\t\xc71\x8c\x18\x1f\x10\x11""\x002"\x01\x022\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x03\x01\x18\x1f\x10\x11""\x002"\x01\x022\x042\x01""\x00\x00\x00\x00\x10\x03\x01\x00\x00\x00\x00\x00\x00\x00\x00SAS FILEAIRLINE DATA \x00\x00\xc0\x95j\xbe\xd6A\x00\x00\xc0\x95j\xbe\xd6A\x00\x00\x00\x00\x00 \xbc@\x00\x00\x00\x00\x00 \xbc@\x00\x04\x00\x00\x00\x10\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x009.0000M0WIN\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00WIN\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xc0\x95LN\xaf\xf0LN\xaf\xf0LN\xaf\xf0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00jIW-\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00kIW-\x00\x00\x00\x00\x00\x00\x00\x00<\x04\x00\x00\x00\x02-\x00\r\x00\x00\x00 \x0e\x00\x00\xe0\x01\x00\x00\x00\x00\x00\x00\x14\x0e\x00\x00\x0c\x00\x00\x00\x00\x00\x00\x00\xe4\x0c\x00\x000\x01\x00\x00\x00\x00\x00\x00H\x0c\x00\x00\x9c\x00\x00\x00\x00\x01\x00\x00\x04\x0c\x00\x00D\x00\x00\x00\x00\x01\x00\x00\xa8\x0b\x00\x00\\\x00\x00\x00\x00\x01\x00\x00t\x0b\x00\x004\x00\x00\x00\x00\x00\x00\x00@\x0b\x00\x004\x00\x00\x00\x00\x00\x00\x00\x0c\x0b\x00\x004\x00\x00\x00\x00\x00\x00\x00\xd8\n\x00\x004\x00\x00\x00\x00\x00\x00\x00\xa4\n\x00\x004\x00\x00\x00\x00\x00\x00\x00p\n\x00\x004\x00\x00\x00\x00\x00\x00\x00p\n\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00p\x9e@\x00\x00\x00@\x8bl\xf3?\x00\x00\x00\xc0\x9f\x1a\xcf?\x00\x00\x00\xa0w\x9c\xc2?\x00\x00\x00\x00\xd7\xa3\xf6?\x00\x00\x00\x00\x81\x95\xe3?\x00t\x9e@\x00\x00\x00\xe0\xfb\xa9\xf5?\x00\x00\x00\x00\xd7\xa3\xd0?\x00\x00\x00`\xb3\xea\xcb?\x00\x00\x00 \xdd$\xf6?\x00\x00\x00\x00T\xe3\xe1?\x00x\x9e@\x00\x00\x00\xc0\x9f\x1a\xf9?\x00\x00\x00\x80\xc0\xca\xd1?\x00\x00\x00\xc0m4\xd4?\x00\x00\x00\x80?5\xf6?\x00\x00\x00 \x04V\xe2?\x00|\x9e@\x00\x00\x00\x00\x02+\xff?\x00\x00\x00@\x0c\x02\xd3?\x00\x00\x00\xc0K7\xd9?\x00\x00\x00\xc0\xcc\xcc\xf8?\x00\x00\x00\xc0I\x0c\xe2?\x00\x80\x9e@\x00\x00\x00`\xb8\x1e\x02@\x00\x00\x00@\n\xd7\xd3?\x00\x00\x00\xc0\x10\xc7\xd6?\x00\x00\x00\x00\xfe\xd4\xfc?\x00\x00\x00@5^\xe2?\x00\x84\x9e@\x00\x00\x00\x80\x16\xd9\x05@\x00\x00\x00\xe0\xa5\x9b\xd4?\x00\x00\x00`\xc5\xfe\xd6?\x00\x00\x00`\xe5\xd0\xfe?\x00\x00\x00 \x83\xc0\xe6?\x00\x88\x9e@\x00\x00\x00@33\x08@\x00\x00\x00\xe0\xa3p\xd5?\x00\x00\x00`\x8f\xc2\xd9?\x00\x00\x00@\x8bl\xff?\x00\x00\x00\x00\xfe\xd4\xe8?\x00\x8c\x9e@\x00\x00\x00\xe0\xf9~\x0c@\x00\x00\x00`ff\xd6?\x00\x00\x00\xe0\xb3Y\xd9?\x00\x00\x00`\x91\xed\x00@\x00\x00\x00\xc0\xc8v\xea?\x00\x90\x9e@\x00\x00\x00\x00\xfe\xd4\x0f@\x00\x00\x00\xc0\x9f\x1a\xd7?\x00\x00\x00\x00\xf7u\xd8?\x00\x00\x00@\xe1z\x03@\x00\x00\x00\xa0\x99\x99\xe9?\x00\x94\x9e@\x00\x00\x00\x80\x14\xae\x11@\x00\x00\x00@\x89A\xd8?\x00\x00\x00\xa0\xed|\xd3?\x00\x00\x00\xa0\xef\xa7\x05@\x00\x00\x00\x00\xd5x\xed?\x00\x98\x9e@\x00\x00\x00 \x83@\x12@\x00\x00\x00\xe0$\x06\xd9?\x00\x00\x00`\x81\x04\xd5?\x00\x00\x00`\xe3\xa5\x05@\x00\x00\x00\xa0n\x12\xf1?\x00\x9c\x9e@\x00\x00\x00\x80=\x8a\x15@\x00\x00\x00\x80\x95C\xdb?\x00\x00\x00\xa0\xab\xad\xd8?\x00\x00\x00\xa0\x9b\xc4\x06@\x00\x00\x00\xc0\xf7S\xf1?\x00\xa0\x9e@\x00\x00\x00\xc0K7\x16@\x00\x00\x00 X9\xdc?\x00\x00\x00@io\xd4?\x00\x00\x00\xa0E\xb6\x08@\x00\x00\x00\x00-\xb2\xf7?\x00\xa4\x9e@\x00\x00\x00\x00)\xdc\x15@\x00\x00\x00\xe0\xa3p\xdd?\x00\x00\x00@\xa2\xb4\xd3?\x00\x00\x00 \xdb\xf9\x08@\x00\x00\x00\xe0\xa7\xc6\xfb?\x00\xa8\x9e@\x00\x00\x00\xc0\xccL\x17@\x00\x00\x00\x80=\n\xdf?\x00\x00\x00@\x116\xd8?\x00\x00\x00\x00\xd5x\t@\x00\x00\x00`\xe5\xd0\xfe?\x00\xac\x9e@\x00\x00\x00 \x06\x81\x1b@\x00\x00\x00\xe0&1\xe0?\x00\x00\x00 \x83\xc0\xda?\x00\x00\x00\xc0\x9f\x1a\n@\x00\x00\x00\xc0\xf7S\x00@\x00\xb0\x9e@\x00\x00\x00\x80\xc0J\x1f@\x00\x00\x00\xc0K7\xe1?\x00\x00\x00\xa0\x87\x85\xe0?\x00\x00\x00\xa0\xc6K\x0b@\x00\x00\x00@\xb6\xf3\xff?\x00\xb4\x9e@\x00\x00\x00\xa0p="@\x00\x00\x00\xc0I\x0c\xe2?\x00\x00\x00\xa0\x13\xd0\xe2?\x00\x00\x00`\xe7\xfb\x0c@\x00\x00\x00\x00V\x0e\x02@\x00\xb8\x9e@\x00\x00\x00\xe0$\x06%@\x00\x00\x00 \x83\xc0\xe2?\x00\x00\x00\xe0H.\xe1?\x00\x00\x00\xa0\xc6K\x10@\x00\x00\x00\xc0\x9d\xef\x05@\x00\xbc\x9e@\x00\x00\x00\x80=\n*@\x00\x00\x00\x80l\xe7\xe3?\x00\x00\x00@io\xdc?\x00\x00\x00@\n\xd7\x12@\x00\x00\x00`\x12\x83\x0c@\x00\xc0\x9e@\x00\x00\x00\xc0\xa1\x85.@\x00\x00\x00@\xdfO\xe5?\x00\x00\x00\xa0e\x88\xd3?\x00\x00\x00@5\xde\x14@\x00\x00\x00\x80h\x11\x13@\x00\xc4\x9e@\x00\x00\x00\xc0 P0@\x00\x00\x00 Zd\xe7?\x00\x00\x00`\x7f\xd9\xcd?\x00\x00\x00\xe0\xa7F\x16@\x00\x00\x00\xa0C\x0b\x1a@\x00\xc8\x9e@\x00\x00\x00 \x83\x000@\x00\x00\x00@\x8d\x97\xea?\x00\x00\x00\xe06\x1a\xc8?\x00\x00\x00@\xe1\xfa\x15@\x00\x00\x00@\x0c\x82\x1e@\x00\xcc\x9e@\x00\x00\x00 \x83\xc0/@\x00\x00\x00\xc0\xf3\xfd\xec?\x00\x00\x00`\xf7\xe4\xc9?\x00\x00\x00 \x04V\x15@\x00\x00\x00\x80\x93X!@\x00\xd0\x9e@\x00\x00\x00\xe0x\xa90@\x00\x00\x00\x00\x00\x00\xf0?\x00\x00\x00\xa0\xd4\t\xd0?\x00\x00\x00\xa0Ga\x15@\x00\x00\x00\xe0x\xa9 @\x00\xd4\x9e@\x00\x00\x00\x80\x95\x031@\x00\x00\x00@`\xe5\xf0?\x00\x00\x00@@\x13\xd1?\x00\x00\x00`\xe3\xa5\x16@\x00\x00\x00 /\x1d!@\x00\xd8\x9e@\x00\x00\x00\x80\x14N3@\x00\x00\x00\x80\x93\x18\xf2?\x00\x00\x00\xa0\xb2\x0c\xd1?\x00\x00\x00\x00\x7f\xea\x16@\x00\x00\x00\xa0\x18\x04#@\x00\xdc\x9e@\x00\x00\x00\x80\x93\xb82@\x00\x00\x00@\xb6\xf3\xf3?\x00\x00\x00\xc0\xeas\xcd?\x00\x00\x00\x00T\xe3\x16@\x00\x00\x00\x80\xbe\x1f"@\x00\xe0\x9e@\x00\x00\x00\x00\x00@3@\x00\x00\x00\x00\x00\x00\xf6?\x00\x00\x00\xc0\xc1\x17\xd6?\x00\x00\x00\xc0I\x0c\x17@\x00\x00\x00\xe0$\x86 @\x00\xe4\x9e@\x00\x00\x00\xc0\xa1\xa54@\x00\x00\x00`9\xb4\xf8?\x00\x00\x00@\xe8\xd9\xdc?\x00\x00\x00@\x0c\x82\x17@\x00\x00\x00@`\xe5\x1d@\x00\xe8\x9e@\x00\x00\x00 \xdb\xb96@\x00\x00\x00\xe0|?\xfb?\x00\x00\x00@p\xce\xe2?\x00\x00\x00\x80\x97n\x18@\x00\x00\x00\x00\x7fj\x1c@\x00\xec\x9e@\x00\x00\x00\xc0v\x9e7@\x00\x00\x00\xc0\xc8v\xfc?\x00\x00\x00\x80q\x1b\xe1?\x00\x00\x00\xc0rh\x1b@\x00\x00\x00\xe0\xf9~\x1b@\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe\xfb\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00p\x00\r\x00\x00\x00\x00\x00\x00\x00\xfe\xfb\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00`\x00\x0b\x00\x00\x00\x00\x00\x00\x00\xfe\xfb\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00L\x00\r\x00\x00\x00\x00\x00\x00\x00\xfe\xfb\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00<\x00\t\x00\x00\x00\x00\x00\x00\x00\xfe\xfb\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00(\x00\x0f\x00\x00\x00\x00\x00\x00\x00\xfe\xfb\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 \x00\x04\x00\x00\x00\x00\x00\x00\x00\xfc\xff\xff\xffP\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x04\x01\x00\x04\x00\x00\x00\x08\x00\x00\x00\x00\x04\x01\x00\x0c\x00\x00\x00\x08\x00\x00\x00\x00\x04\x01\x00\x14\x00\x00\x00\x08\x00\x00\x00\x00\x04\x01\x00\x1c\x00\x00\x00\x08\x00\x00\x00\x00\x04\x01\x00$\x00\x00\x00\x08\x00\x00\x00\x00\x04\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1c\x00\x04\x00\x00\x00\x00\x00$\x00\x01\x00\x00\x00\x00\x008\x00\x01\x00\x00\x00\x00\x00H\x00\x01\x00\x00\x00\x00\x00\\\x00\x01\x00\x00\x00\x00\x00l\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfd\xff\xff\xff\x90\x00\x10\x00\x80\x00\x00\x00\x00\x00\x00\x00Written by SAS\x00\x00YEARyearY\x00\x00\x00level of output\x00W\x00\x00\x00wage rate\x00\x00\x00R\x00\x00\x00interest rate\x00\x00\x00L\x00\x00\x00labor input\x00K\x00\x00\x00capital input\x00\x00\x00\x01\x00\x00\x00\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfc\xff\xff0\x00\x00\x00\x04\x00\x00\x00\x07\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x07\x00\x00\x00\x00\x00\x00\xfc\xff\xff\xff\x01\x00\x00\x00\x06\x00\x00\x00\x01\x00\x00\x00\x06\x00\x00\x00\xfd\xff\xff\xff\x01\x00\x00\x00\x04\x00\x00\x00\x01\x00\x00\x00\x04\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x05\x00\x00\x00\x01\x00\x00\x00\x05\x00\x00\x00\xfe\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfb\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfa\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xf9\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xf6\xf6\xf6\xf6\x06\x00\x00\x00\x00\x00\x00\x00\xf7\xf7\xf7\xf7\xcd\x00\x00\x00\x0e\x00\x00\x00\x00\x00\x00\x00\x110\x02\x00,\x00\x00\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00.\x00\x00\x00\x00\x10\x00\x00\x00\x00\x00\x00 \x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00kIW-\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x0c\x00\x00\x00\x01\x00\x00\x00\x0e\x00\x00\x00\x01\x00\x00\x00-\x00\x00\x00\x01\x00\x00\x00\x07\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x0c\x00\x10\x00\x00\x00\x14\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x08\x00\x00\x00\x1c\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x04\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\\\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' - - -@pytest.fixture -def sas_data_set(filepath_sas, fs_args): - return GenericDataSet( - filepath=filepath_sas.as_posix(), - file_format="sas", - load_args={"format": "sas7bdat"}, - fs_args=fs_args, - ) - - -@pytest.fixture -def html_data_set(filepath_html, fs_args): - return GenericDataSet( - filepath=filepath_html.as_posix(), - file_format="html", - fs_args=fs_args, - save_args={"index": False}, - ) - - -@pytest.fixture -def sas_data_set_bad_config(filepath_sas, fs_args): - return GenericDataSet( - filepath=filepath_sas.as_posix(), - file_format="sas", - load_args={}, # SAS reader requires a type param - fs_args=fs_args, - ) - - -@pytest.fixture -def versioned_csv_data_set(filepath_csv, load_version, save_version): - return GenericDataSet( - filepath=filepath_csv.as_posix(), - file_format="csv", - version=Version(load_version, save_version), - save_args={"index": False}, - ) - - -@pytest.fixture -def csv_data_set(filepath_csv): - return GenericDataSet( - filepath=filepath_csv.as_posix(), - file_format="csv", - save_args={"index": False}, - ) - - -@pytest.fixture -def dummy_dataframe(): - return pd.DataFrame({"col1": [1, 2], "col2": [4, 5], "col3": [5, 6]}) - - -class TestGenericSasDataSet: - def test_load(self, sas_binary, sas_data_set, filepath_sas): - filepath_sas.write_bytes(sas_binary) - df = sas_data_set.load() - assert df.shape == (32, 6) - - def test_save_fail(self, sas_data_set, dummy_dataframe): - pattern = ( - "Unable to retrieve 'pandas.DataFrame.to_sas' method, please ensure that your " - "'file_format' parameter has been defined correctly as per the Pandas API " - "https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html" - ) - with pytest.raises(DatasetError, match=pattern): - sas_data_set.save(dummy_dataframe) - # Pandas does not implement a SAS writer - - def test_bad_load(self, sas_data_set_bad_config, sas_binary, filepath_sas): - # SAS reader requires a format param e.g. sas7bdat - filepath_sas.write_bytes(sas_binary) - pattern = "you must specify a format string" - with pytest.raises(DatasetError, match=pattern): - sas_data_set_bad_config.load() - - @pytest.mark.parametrize( - "filepath,instance_type,credentials", - [ - ("s3://bucket/file.sas7bdat", S3FileSystem, {}), - ("file:///tmp/test.sas7bdat", LocalFileSystem, {}), - ("/tmp/test.sas7bdat", LocalFileSystem, {}), - ("gcs://bucket/file.sas7bdat", GCSFileSystem, {}), - ("https://example.com/file.sas7bdat", HTTPFileSystem, {}), - ( - "abfs://bucket/file.sas7bdat", - AzureBlobFileSystem, - {"account_name": "test", "account_key": "test"}, - ), - ], - ) - def test_protocol_usage(self, filepath, instance_type, credentials): - data_set = GenericDataSet( - filepath=filepath, file_format="sas", credentials=credentials - ) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.csv" - data_set = GenericDataSet(filepath=filepath, file_format="sas") - assert data_set._version_cache.currsize == 0 # no cache if unversioned - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - assert data_set._version_cache.currsize == 0 - - -class TestGenericCSVDataSetVersioned: - def test_version_str_repr(self, filepath_csv, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - filepath = filepath_csv.as_posix() - ds = GenericDataSet(filepath=filepath, file_format="csv") - ds_versioned = GenericDataSet( - filepath=filepath, - file_format="csv", - version=Version(load_version, save_version), - ) - assert filepath in str(ds) - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "GenericDataSet" in str(ds_versioned) - assert "GenericDataSet" in str(ds) - assert "protocol" in str(ds_versioned) - assert "protocol" in str(ds) - - def test_save_and_load(self, versioned_csv_data_set, dummy_dataframe): - """Test that saved and reloaded data matches the original one for - the versioned data set.""" - versioned_csv_data_set.save(dummy_dataframe) - reloaded_df = versioned_csv_data_set.load() - assert_frame_equal(dummy_dataframe, reloaded_df) - - def test_multiple_loads( - self, versioned_csv_data_set, dummy_dataframe, filepath_csv - ): - """Test that if a new version is created mid-run, by an - external system, it won't be loaded in the current run.""" - versioned_csv_data_set.save(dummy_dataframe) - versioned_csv_data_set.load() - v1 = versioned_csv_data_set.resolve_load_version() - - sleep(0.5) - # force-drop a newer version into the same location - v_new = generate_timestamp() - GenericDataSet( - filepath=filepath_csv.as_posix(), - file_format="csv", - version=Version(v_new, v_new), - ).save(dummy_dataframe) - - versioned_csv_data_set.load() - v2 = versioned_csv_data_set.resolve_load_version() - - assert v2 == v1 # v2 should not be v_new! - ds_new = GenericDataSet( - filepath=filepath_csv.as_posix(), - file_format="csv", - version=Version(None, None), - ) - assert ( - ds_new.resolve_load_version() == v_new - ) # new version is discoverable by a new instance - - def test_multiple_saves(self, dummy_dataframe, filepath_csv): - """Test multiple cycles of save followed by load for the same dataset""" - ds_versioned = GenericDataSet( - filepath=filepath_csv.as_posix(), - file_format="csv", - version=Version(None, None), - ) - - # first save - ds_versioned.save(dummy_dataframe) - first_save_version = ds_versioned.resolve_save_version() - first_load_version = ds_versioned.resolve_load_version() - assert first_load_version == first_save_version - - # second save - sleep(0.5) - ds_versioned.save(dummy_dataframe) - second_save_version = ds_versioned.resolve_save_version() - second_load_version = ds_versioned.resolve_load_version() - assert second_load_version == second_save_version - assert second_load_version > first_load_version - - # another dataset - ds_new = GenericDataSet( - filepath=filepath_csv.as_posix(), - file_format="csv", - version=Version(None, None), - ) - assert ds_new.resolve_load_version() == second_load_version - - def test_release_instance_cache(self, dummy_dataframe, filepath_csv): - """Test that cache invalidation does not affect other instances""" - ds_a = GenericDataSet( - filepath=filepath_csv.as_posix(), - file_format="csv", - version=Version(None, None), - ) - assert ds_a._version_cache.currsize == 0 - ds_a.save(dummy_dataframe) # create a version - assert ds_a._version_cache.currsize == 2 - - ds_b = GenericDataSet( - filepath=filepath_csv.as_posix(), - file_format="csv", - version=Version(None, None), - ) - assert ds_b._version_cache.currsize == 0 - ds_b.resolve_save_version() - assert ds_b._version_cache.currsize == 1 - ds_b.resolve_load_version() - assert ds_b._version_cache.currsize == 2 - - ds_a.release() - - # dataset A cache is cleared - assert ds_a._version_cache.currsize == 0 - - # dataset B cache is unaffected - assert ds_b._version_cache.currsize == 2 - - def test_no_versions(self, versioned_csv_data_set): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for GenericDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_csv_data_set.load() - - def test_exists(self, versioned_csv_data_set, dummy_dataframe): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_csv_data_set.exists() - versioned_csv_data_set.save(dummy_dataframe) - assert versioned_csv_data_set.exists() - - def test_prevent_overwrite(self, versioned_csv_data_set, dummy_dataframe): - """Check the error when attempting to override the data set if the - corresponding Generic (csv) file for a given save version already exists.""" - versioned_csv_data_set.save(dummy_dataframe) - pattern = ( - r"Save path \'.+\' for GenericDataSet\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - versioned_csv_data_set.save(dummy_dataframe) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, versioned_csv_data_set, load_version, save_version, dummy_dataframe - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - rf"Save version '{save_version}' did not match load version " - rf"'{load_version}' for GenericDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_csv_data_set.save(dummy_dataframe) - - def test_versioning_existing_dataset( - self, csv_data_set, versioned_csv_data_set, dummy_dataframe - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - csv_data_set.save(dummy_dataframe) - assert csv_data_set.exists() - assert csv_data_set._filepath == versioned_csv_data_set._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_csv_data_set._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_csv_data_set.save(dummy_dataframe) - - # Remove non-versioned dataset and try again - Path(csv_data_set._filepath.as_posix()).unlink() - versioned_csv_data_set.save(dummy_dataframe) - assert versioned_csv_data_set.exists() - - -class TestGenericHtmlDataSet: - def test_save_and_load(self, dummy_dataframe, html_data_set): - html_data_set.save(dummy_dataframe) - df = html_data_set.load() - assert_frame_equal(dummy_dataframe, df[0]) - - -class TestBadGenericDataSet: - def test_bad_file_format_argument(self): - ds = GenericDataSet(filepath="test.kedro", file_format="kedro") - - pattern = ( - "Unable to retrieve 'pandas.read_kedro' method, please ensure that your 'file_format' " - "parameter has been defined correctly as per the Pandas API " - "https://pandas.pydata.org/docs/reference/io.html" - ) - - with pytest.raises(DatasetError, match=pattern): - _ = ds.load() - - pattern2 = ( - "Unable to retrieve 'pandas.DataFrame.to_kedro' method, please ensure that your 'file_format' " - "parameter has been defined correctly as per the Pandas API " - "https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html" - ) - with pytest.raises(DatasetError, match=pattern2): - ds.save(pd.DataFrame([1])) - - @pytest.mark.parametrize( - "file_format", - [ - "clipboard", - "sql_table", - "sql", - "numpy", - "records", - ], - ) - def test_generic_no_filepaths(self, file_format): - error = ( - "Cannot create a dataset of file_format " - f"'{file_format}' as it does not support a filepath target/source" - ) - - with pytest.raises(DatasetError, match=error): - _ = GenericDataSet( - filepath="/file/thing.file", file_format=file_format - ).load() - with pytest.raises(DatasetError, match=error): - GenericDataSet(filepath="/file/thing.file", file_format=file_format).save( - pd.DataFrame([1]) - ) diff --git a/tests/extras/datasets/pandas/test_hdf_dataset.py b/tests/extras/datasets/pandas/test_hdf_dataset.py deleted file mode 100644 index 0580e510b4..0000000000 --- a/tests/extras/datasets/pandas/test_hdf_dataset.py +++ /dev/null @@ -1,245 +0,0 @@ -from pathlib import Path, PurePosixPath - -import pandas as pd -import pytest -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from pandas.testing import assert_frame_equal -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.pandas import HDFDataSet -from kedro.io import DatasetError -from kedro.io.core import PROTOCOL_DELIMITER, Version - -HDF_KEY = "data" - - -@pytest.fixture -def filepath_hdf(tmp_path): - return (tmp_path / "test.h5").as_posix() - - -@pytest.fixture -def hdf_data_set(filepath_hdf, load_args, save_args, mocker, fs_args): - HDFDataSet._lock = mocker.MagicMock() - return HDFDataSet( - filepath=filepath_hdf, - key=HDF_KEY, - load_args=load_args, - save_args=save_args, - fs_args=fs_args, - ) - - -@pytest.fixture -def versioned_hdf_data_set(filepath_hdf, load_version, save_version): - return HDFDataSet( - filepath=filepath_hdf, key=HDF_KEY, version=Version(load_version, save_version) - ) - - -@pytest.fixture -def dummy_dataframe(): - return pd.DataFrame({"col1": [1, 2], "col2": [4, 5], "col3": [5, 6]}) - - -class TestHDFDataSet: - def test_save_and_load(self, hdf_data_set, dummy_dataframe): - """Test saving and reloading the data set.""" - hdf_data_set.save(dummy_dataframe) - reloaded = hdf_data_set.load() - assert_frame_equal(dummy_dataframe, reloaded) - assert hdf_data_set._fs_open_args_load == {} - assert hdf_data_set._fs_open_args_save == {"mode": "wb"} - - def test_exists(self, hdf_data_set, dummy_dataframe): - """Test `exists` method invocation for both existing and - nonexistent data set.""" - assert not hdf_data_set.exists() - hdf_data_set.save(dummy_dataframe) - assert hdf_data_set.exists() - - @pytest.mark.parametrize( - "load_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_load_extra_params(self, hdf_data_set, load_args): - """Test overriding the default load arguments.""" - for key, value in load_args.items(): - assert hdf_data_set._load_args[key] == value - - @pytest.mark.parametrize( - "save_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_save_extra_params(self, hdf_data_set, save_args): - """Test overriding the default save arguments.""" - for key, value in save_args.items(): - assert hdf_data_set._save_args[key] == value - - @pytest.mark.parametrize( - "fs_args", - [{"open_args_load": {"mode": "rb", "compression": "gzip"}}], - indirect=True, - ) - def test_open_extra_args(self, hdf_data_set, fs_args): - assert hdf_data_set._fs_open_args_load == fs_args["open_args_load"] - assert hdf_data_set._fs_open_args_save == {"mode": "wb"} # default unchanged - - def test_load_missing_file(self, hdf_data_set): - """Check the error when trying to load missing file.""" - pattern = r"Failed while loading data from data set HDFDataSet\(.*\)" - with pytest.raises(DatasetError, match=pattern): - hdf_data_set.load() - - @pytest.mark.parametrize( - "filepath,instance_type", - [ - ("s3://bucket/file.h5", S3FileSystem), - ("file:///tmp/test.h5", LocalFileSystem), - ("/tmp/test.h5", LocalFileSystem), - ("gcs://bucket/file.h5", GCSFileSystem), - ("https://example.com/file.h5", HTTPFileSystem), - ], - ) - def test_protocol_usage(self, filepath, instance_type): - data_set = HDFDataSet(filepath=filepath, key=HDF_KEY) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.h5" - data_set = HDFDataSet(filepath=filepath, key=HDF_KEY) - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - - def test_save_and_load_df_with_categorical_variables(self, hdf_data_set): - """Test saving and reloading the data set with categorical variables.""" - df = pd.DataFrame( - {"A": [1, 2, 3], "B": pd.Series(list("aab")).astype("category")} - ) - hdf_data_set.save(df) - reloaded = hdf_data_set.load() - assert_frame_equal(df, reloaded) - - def test_thread_lock_usage(self, hdf_data_set, dummy_dataframe, mocker): - """Test thread lock usage.""" - # pylint: disable=no-member - mocked_lock = HDFDataSet._lock - mocked_lock.assert_not_called() - - hdf_data_set.save(dummy_dataframe) - calls = [ - mocker.call.__enter__(), # pylint: disable=unnecessary-dunder-call - mocker.call.__exit__(None, None, None), - ] - mocked_lock.assert_has_calls(calls) - - mocked_lock.reset_mock() - hdf_data_set.load() - mocked_lock.assert_has_calls(calls) - - -class TestHDFDataSetVersioned: - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - filepath = "test.h5" - ds = HDFDataSet(filepath=filepath, key=HDF_KEY) - ds_versioned = HDFDataSet( - filepath=filepath, key=HDF_KEY, version=Version(load_version, save_version) - ) - assert filepath in str(ds) - assert "version" not in str(ds) - - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "HDFDataSet" in str(ds_versioned) - assert "HDFDataSet" in str(ds) - assert "protocol" in str(ds_versioned) - assert "protocol" in str(ds) - assert "key" in str(ds_versioned) - assert "key" in str(ds) - - def test_save_and_load(self, versioned_hdf_data_set, dummy_dataframe): - """Test that saved and reloaded data matches the original one for - the versioned data set.""" - versioned_hdf_data_set.save(dummy_dataframe) - reloaded_df = versioned_hdf_data_set.load() - assert_frame_equal(dummy_dataframe, reloaded_df) - - def test_no_versions(self, versioned_hdf_data_set): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for HDFDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_hdf_data_set.load() - - def test_exists(self, versioned_hdf_data_set, dummy_dataframe): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_hdf_data_set.exists() - versioned_hdf_data_set.save(dummy_dataframe) - assert versioned_hdf_data_set.exists() - - def test_prevent_overwrite(self, versioned_hdf_data_set, dummy_dataframe): - """Check the error when attempting to override the data set if the - corresponding hdf file for a given save version already exists.""" - versioned_hdf_data_set.save(dummy_dataframe) - pattern = ( - r"Save path \'.+\' for HDFDataSet\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - versioned_hdf_data_set.save(dummy_dataframe) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, versioned_hdf_data_set, load_version, save_version, dummy_dataframe - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - rf"Save version '{save_version}' did not match load version " - rf"'{load_version}' for HDFDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_hdf_data_set.save(dummy_dataframe) - - def test_http_filesystem_no_versioning(self): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - HDFDataSet( - filepath="https://example.com/file.h5", - key=HDF_KEY, - version=Version(None, None), - ) - - def test_versioning_existing_dataset( - self, hdf_data_set, versioned_hdf_data_set, dummy_dataframe - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - hdf_data_set.save(dummy_dataframe) - assert hdf_data_set.exists() - assert hdf_data_set._filepath == versioned_hdf_data_set._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_hdf_data_set._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_hdf_data_set.save(dummy_dataframe) - - # Remove non-versioned dataset and try again - Path(hdf_data_set._filepath.as_posix()).unlink() - versioned_hdf_data_set.save(dummy_dataframe) - assert versioned_hdf_data_set.exists() diff --git a/tests/extras/datasets/pandas/test_json_dataset.py b/tests/extras/datasets/pandas/test_json_dataset.py deleted file mode 100644 index fe5c7f8c42..0000000000 --- a/tests/extras/datasets/pandas/test_json_dataset.py +++ /dev/null @@ -1,241 +0,0 @@ -from pathlib import Path, PurePosixPath - -import pandas as pd -import pytest -from adlfs import AzureBlobFileSystem -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from pandas.testing import assert_frame_equal -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.pandas import JSONDataSet -from kedro.io import DatasetError -from kedro.io.core import PROTOCOL_DELIMITER, Version - - -@pytest.fixture -def filepath_json(tmp_path): - return (tmp_path / "test.json").as_posix() - - -@pytest.fixture -def json_data_set(filepath_json, load_args, save_args, fs_args): - return JSONDataSet( - filepath=filepath_json, - load_args=load_args, - save_args=save_args, - fs_args=fs_args, - ) - - -@pytest.fixture -def versioned_json_data_set(filepath_json, load_version, save_version): - return JSONDataSet( - filepath=filepath_json, version=Version(load_version, save_version) - ) - - -@pytest.fixture -def dummy_dataframe(): - return pd.DataFrame({"col1": [1, 2], "col2": [4, 5], "col3": [5, 6]}) - - -class TestJSONDataSet: - def test_save_and_load(self, json_data_set, dummy_dataframe): - """Test saving and reloading the data set.""" - json_data_set.save(dummy_dataframe) - reloaded = json_data_set.load() - assert_frame_equal(dummy_dataframe, reloaded) - - def test_exists(self, json_data_set, dummy_dataframe): - """Test `exists` method invocation for both existing and - nonexistent data set.""" - assert not json_data_set.exists() - json_data_set.save(dummy_dataframe) - assert json_data_set.exists() - - @pytest.mark.parametrize( - "load_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_load_extra_params(self, json_data_set, load_args): - """Test overriding the default load arguments.""" - for key, value in load_args.items(): - assert json_data_set._load_args[key] == value - - @pytest.mark.parametrize( - "save_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_save_extra_params(self, json_data_set, save_args): - """Test overriding the default save arguments.""" - for key, value in save_args.items(): - assert json_data_set._save_args[key] == value - - @pytest.mark.parametrize( - "load_args,save_args", - [ - ({"storage_options": {"a": "b"}}, {}), - ({}, {"storage_options": {"a": "b"}}), - ({"storage_options": {"a": "b"}}, {"storage_options": {"x": "y"}}), - ], - ) - def test_storage_options_dropped(self, load_args, save_args, caplog, tmp_path): - filepath = str(tmp_path / "test.csv") - - ds = JSONDataSet(filepath=filepath, load_args=load_args, save_args=save_args) - - records = [r for r in caplog.records if r.levelname == "WARNING"] - expected_log_message = ( - f"Dropping 'storage_options' for {filepath}, " - f"please specify them under 'fs_args' or 'credentials'." - ) - assert records[0].getMessage() == expected_log_message - assert "storage_options" not in ds._save_args - assert "storage_options" not in ds._load_args - - def test_load_missing_file(self, json_data_set): - """Check the error when trying to load missing file.""" - pattern = r"Failed while loading data from data set JSONDataSet\(.*\)" - with pytest.raises(DatasetError, match=pattern): - json_data_set.load() - - @pytest.mark.parametrize( - "filepath,instance_type,credentials,load_path", - [ - ("s3://bucket/file.json", S3FileSystem, {}, "s3://bucket/file.json"), - ("file:///tmp/test.json", LocalFileSystem, {}, "/tmp/test.json"), - ("/tmp/test.json", LocalFileSystem, {}, "/tmp/test.json"), - ("gcs://bucket/file.json", GCSFileSystem, {}, "gcs://bucket/file.json"), - ( - "https://example.com/file.json", - HTTPFileSystem, - {}, - "https://example.com/file.json", - ), - ( - "abfs://bucket/file.csv", - AzureBlobFileSystem, - {"account_name": "test", "account_key": "test"}, - "abfs://bucket/file.csv", - ), - ], - ) - def test_protocol_usage( - self, filepath, instance_type, credentials, load_path, mocker - ): - data_set = JSONDataSet(filepath=filepath, credentials=credentials) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - mock_pandas_call = mocker.patch("pandas.read_json") - data_set.load() - assert mock_pandas_call.call_count == 1 - assert mock_pandas_call.call_args_list[0][0][0] == load_path - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.json" - data_set = JSONDataSet(filepath=filepath) - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - - -class TestJSONDataSetVersioned: - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - filepath = "test.json" - ds = JSONDataSet(filepath=filepath) - ds_versioned = JSONDataSet( - filepath=filepath, version=Version(load_version, save_version) - ) - assert filepath in str(ds) - assert "version" not in str(ds) - - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "JSONDataSet" in str(ds_versioned) - assert "JSONDataSet" in str(ds) - assert "protocol" in str(ds_versioned) - assert "protocol" in str(ds) - - def test_save_and_load(self, versioned_json_data_set, dummy_dataframe): - """Test that saved and reloaded data matches the original one for - the versioned data set.""" - versioned_json_data_set.save(dummy_dataframe) - reloaded_df = versioned_json_data_set.load() - assert_frame_equal(dummy_dataframe, reloaded_df) - - def test_no_versions(self, versioned_json_data_set): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for JSONDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_json_data_set.load() - - def test_exists(self, versioned_json_data_set, dummy_dataframe): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_json_data_set.exists() - versioned_json_data_set.save(dummy_dataframe) - assert versioned_json_data_set.exists() - - def test_prevent_overwrite(self, versioned_json_data_set, dummy_dataframe): - """Check the error when attempting to override the data set if the - corresponding hdf file for a given save version already exists.""" - versioned_json_data_set.save(dummy_dataframe) - pattern = ( - r"Save path \'.+\' for JSONDataSet\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - versioned_json_data_set.save(dummy_dataframe) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, versioned_json_data_set, load_version, save_version, dummy_dataframe - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - rf"Save version '{save_version}' did not match load version " - rf"'{load_version}' for JSONDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_json_data_set.save(dummy_dataframe) - - def test_http_filesystem_no_versioning(self): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - JSONDataSet( - filepath="https://example.com/file.json", version=Version(None, None) - ) - - def test_versioning_existing_dataset( - self, json_data_set, versioned_json_data_set, dummy_dataframe - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - json_data_set.save(dummy_dataframe) - assert json_data_set.exists() - assert json_data_set._filepath == versioned_json_data_set._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_json_data_set._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_json_data_set.save(dummy_dataframe) - - # Remove non-versioned dataset and try again - Path(json_data_set._filepath.as_posix()).unlink() - versioned_json_data_set.save(dummy_dataframe) - assert versioned_json_data_set.exists() diff --git a/tests/extras/datasets/pandas/test_parquet_dataset.py b/tests/extras/datasets/pandas/test_parquet_dataset.py deleted file mode 100644 index 5e415bd75b..0000000000 --- a/tests/extras/datasets/pandas/test_parquet_dataset.py +++ /dev/null @@ -1,344 +0,0 @@ -from pathlib import Path, PurePosixPath - -import pandas as pd -import pyarrow.parquet as pq -import pytest -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from pandas.testing import assert_frame_equal -from pyarrow.fs import FSSpecHandler, PyFileSystem -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.pandas import ParquetDataSet -from kedro.io import DatasetError -from kedro.io.core import PROTOCOL_DELIMITER, Version - -FILENAME = "test.parquet" - - -@pytest.fixture -def filepath_parquet(tmp_path): - return (tmp_path / FILENAME).as_posix() - - -@pytest.fixture -def parquet_data_set(filepath_parquet, load_args, save_args, fs_args): - return ParquetDataSet( - filepath=filepath_parquet, - load_args=load_args, - save_args=save_args, - fs_args=fs_args, - ) - - -@pytest.fixture -def versioned_parquet_data_set(filepath_parquet, load_version, save_version): - return ParquetDataSet( - filepath=filepath_parquet, version=Version(load_version, save_version) - ) - - -@pytest.fixture -def dummy_dataframe(): - return pd.DataFrame({"col1": [1, 2], "col2": [4, 5], "col3": [5, 6]}) - - -class TestParquetDataSet: - def test_credentials_propagated(self, mocker): - """Test propagating credentials for connecting to GCS""" - mock_fs = mocker.patch("fsspec.filesystem") - credentials = {"key": "value"} - - ParquetDataSet(filepath=FILENAME, credentials=credentials) - - mock_fs.assert_called_once_with("file", auto_mkdir=True, **credentials) - - def test_save_and_load(self, tmp_path, dummy_dataframe): - """Test saving and reloading the data set.""" - filepath = (tmp_path / FILENAME).as_posix() - data_set = ParquetDataSet(filepath=filepath) - data_set.save(dummy_dataframe) - reloaded = data_set.load() - assert_frame_equal(dummy_dataframe, reloaded) - - files = [child.is_file() for child in tmp_path.iterdir()] - assert all(files) - assert len(files) == 1 - - def test_save_and_load_non_existing_dir(self, tmp_path, dummy_dataframe): - """Test saving and reloading the data set to non-existing directory.""" - filepath = (tmp_path / "non-existing" / FILENAME).as_posix() - data_set = ParquetDataSet(filepath=filepath) - data_set.save(dummy_dataframe) - reloaded = data_set.load() - assert_frame_equal(dummy_dataframe, reloaded) - - def test_exists(self, parquet_data_set, dummy_dataframe): - """Test `exists` method invocation for both existing and - nonexistent data set.""" - assert not parquet_data_set.exists() - parquet_data_set.save(dummy_dataframe) - assert parquet_data_set.exists() - - @pytest.mark.parametrize( - "load_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_load_extra_params(self, parquet_data_set, load_args): - """Test overriding the default load arguments.""" - for key, value in load_args.items(): - assert parquet_data_set._load_args[key] == value - - @pytest.mark.parametrize( - "save_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_save_extra_params(self, parquet_data_set, save_args): - """Test overriding the default save arguments.""" - for key, value in save_args.items(): - assert parquet_data_set._save_args[key] == value - - @pytest.mark.parametrize( - "load_args,save_args", - [ - ({"storage_options": {"a": "b"}}, {}), - ({}, {"storage_options": {"a": "b"}}), - ({"storage_options": {"a": "b"}}, {"storage_options": {"x": "y"}}), - ], - ) - def test_storage_options_dropped(self, load_args, save_args, caplog, tmp_path): - filepath = str(tmp_path / "test.csv") - - ds = ParquetDataSet(filepath=filepath, load_args=load_args, save_args=save_args) - - records = [r for r in caplog.records if r.levelname == "WARNING"] - expected_log_message = ( - f"Dropping 'storage_options' for {filepath}, " - f"please specify them under 'fs_args' or 'credentials'." - ) - assert records[0].getMessage() == expected_log_message - assert "storage_options" not in ds._save_args - assert "storage_options" not in ds._load_args - - def test_load_missing_file(self, parquet_data_set): - """Check the error when trying to load missing file.""" - pattern = r"Failed while loading data from data set ParquetDataSet\(.*\)" - with pytest.raises(DatasetError, match=pattern): - parquet_data_set.load() - - @pytest.mark.parametrize( - "filepath,instance_type,load_path", - [ - ("s3://bucket/file.parquet", S3FileSystem, "s3://bucket/file.parquet"), - ("file:///tmp/test.parquet", LocalFileSystem, "/tmp/test.parquet"), - ("/tmp/test.parquet", LocalFileSystem, "/tmp/test.parquet"), - ("gcs://bucket/file.parquet", GCSFileSystem, "gcs://bucket/file.parquet"), - ( - "https://example.com/file.parquet", - HTTPFileSystem, - "https://example.com/file.parquet", - ), - ], - ) - def test_protocol_usage(self, filepath, instance_type, load_path, mocker): - data_set = ParquetDataSet(filepath=filepath) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - mocker.patch.object(data_set._fs, "isdir", return_value=False) - mock_pandas_call = mocker.patch("pandas.read_parquet") - data_set.load() - assert mock_pandas_call.call_count == 1 - assert mock_pandas_call.call_args_list[0][0][0] == load_path - - @pytest.mark.parametrize( - "protocol,path", [("https://", "example.com/"), ("s3://", "bucket/")] - ) - def test_catalog_release(self, protocol, path, mocker): - filepath = protocol + path + FILENAME - fs_mock = mocker.patch("fsspec.filesystem").return_value - data_set = ParquetDataSet(filepath=filepath) - data_set.release() - if protocol != "https://": - filepath = path + FILENAME - fs_mock.invalidate_cache.assert_called_once_with(filepath) - - def test_read_partitioned_file(self, mocker, tmp_path, dummy_dataframe): - """Test read partitioned parquet file from local directory.""" - pq_ds_mock = mocker.patch( - "pyarrow.parquet.ParquetDataset", wraps=pq.ParquetDataset - ) - dummy_dataframe.to_parquet(str(tmp_path), partition_cols=["col2"]) - data_set = ParquetDataSet(filepath=tmp_path.as_posix()) - - reloaded = data_set.load() - # Sort by columns because reading partitioned file results - # in different columns order - reloaded = reloaded.sort_index(axis=1) - # dtype for partition column is 'category' - assert_frame_equal( - dummy_dataframe, reloaded, check_dtype=False, check_categorical=False - ) - pq_ds_mock.assert_called_once() - - def test_write_to_dir(self, dummy_dataframe, tmp_path): - data_set = ParquetDataSet(filepath=tmp_path.as_posix()) - pattern = "Saving ParquetDataSet to a directory is not supported" - - with pytest.raises(DatasetError, match=pattern): - data_set.save(dummy_dataframe) - - def test_read_from_non_local_dir(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - fs_mock.isdir.return_value = True - pq_ds_mock = mocker.patch("pyarrow.parquet.ParquetDataset") - - data_set = ParquetDataSet(filepath="s3://bucket/dir") - - data_set.load() - fs_mock.isdir.assert_called_once() - assert not fs_mock.open.called - pq_ds_mock.assert_called_once_with("bucket/dir", filesystem=fs_mock) - pq_ds_mock().read().to_pandas.assert_called_once_with() - - def test_read_from_file(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - fs_mock.isdir.return_value = False - mocker.patch("pandas.read_parquet") - - data_set = ParquetDataSet(filepath="/tmp/test.parquet") - - data_set.load() - fs_mock.isdir.assert_called_once() - - def test_arg_partition_cols(self, dummy_dataframe, tmp_path): - data_set = ParquetDataSet( - filepath=(tmp_path / FILENAME).as_posix(), - save_args={"partition_cols": ["col2"]}, - ) - pattern = "does not support save argument 'partition_cols'" - - with pytest.raises(DatasetError, match=pattern): - data_set.save(dummy_dataframe) - - -class TestParquetDataSetVersioned: - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - ds = ParquetDataSet(filepath=FILENAME) - ds_versioned = ParquetDataSet( - filepath=FILENAME, version=Version(load_version, save_version) - ) - assert FILENAME in str(ds) - assert "version" not in str(ds) - - assert FILENAME in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "ParquetDataSet" in str(ds_versioned) - assert "ParquetDataSet" in str(ds) - assert "protocol" in str(ds_versioned) - assert "protocol" in str(ds) - - def test_save_and_load(self, versioned_parquet_data_set, dummy_dataframe, mocker): - """Test that saved and reloaded data matches the original one for - the versioned data set.""" - mocker.patch( - "pyarrow.fs._ensure_filesystem", - return_value=PyFileSystem(FSSpecHandler(versioned_parquet_data_set._fs)), - ) - versioned_parquet_data_set.save(dummy_dataframe) - reloaded_df = versioned_parquet_data_set.load() - assert_frame_equal(dummy_dataframe, reloaded_df) - - def test_no_versions(self, versioned_parquet_data_set): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for ParquetDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_parquet_data_set.load() - - def test_exists(self, versioned_parquet_data_set, dummy_dataframe, mocker): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_parquet_data_set.exists() - mocker.patch( - "pyarrow.fs._ensure_filesystem", - return_value=PyFileSystem(FSSpecHandler(versioned_parquet_data_set._fs)), - ) - versioned_parquet_data_set.save(dummy_dataframe) - assert versioned_parquet_data_set.exists() - - def test_prevent_overwrite( - self, versioned_parquet_data_set, dummy_dataframe, mocker - ): - """Check the error when attempting to override the data set if the - corresponding parquet file for a given save version already exists.""" - mocker.patch( - "pyarrow.fs._ensure_filesystem", - return_value=PyFileSystem(FSSpecHandler(versioned_parquet_data_set._fs)), - ) - versioned_parquet_data_set.save(dummy_dataframe) - pattern = ( - r"Save path \'.+\' for ParquetDataSet\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - versioned_parquet_data_set.save(dummy_dataframe) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, - versioned_parquet_data_set, - load_version, - save_version, - dummy_dataframe, - mocker, - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - rf"Save version '{save_version}' did not match load version " - rf"'{load_version}' for ParquetDataSet\(.+\)" - ) - mocker.patch( - "pyarrow.fs._ensure_filesystem", - return_value=PyFileSystem(FSSpecHandler(versioned_parquet_data_set._fs)), - ) - with pytest.warns(UserWarning, match=pattern): - versioned_parquet_data_set.save(dummy_dataframe) - - def test_http_filesystem_no_versioning(self): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - ParquetDataSet( - filepath="https://example.com/test.parquet", version=Version(None, None) - ) - - def test_versioning_existing_dataset( - self, parquet_data_set, versioned_parquet_data_set, dummy_dataframe - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - parquet_data_set.save(dummy_dataframe) - assert parquet_data_set.exists() - assert parquet_data_set._filepath == versioned_parquet_data_set._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_parquet_data_set._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_parquet_data_set.save(dummy_dataframe) - - # Remove non-versioned dataset and try again - Path(parquet_data_set._filepath.as_posix()).unlink() - versioned_parquet_data_set.save(dummy_dataframe) - assert versioned_parquet_data_set.exists() diff --git a/tests/extras/datasets/pandas/test_sql_dataset.py b/tests/extras/datasets/pandas/test_sql_dataset.py deleted file mode 100644 index d80ee12090..0000000000 --- a/tests/extras/datasets/pandas/test_sql_dataset.py +++ /dev/null @@ -1,425 +0,0 @@ -# pylint: disable=no-member -from pathlib import PosixPath -from unittest.mock import ANY - -import pandas as pd -import pytest -import sqlalchemy - -from kedro.extras.datasets.pandas import SQLQueryDataSet, SQLTableDataSet -from kedro.io import DatasetError - -TABLE_NAME = "table_a" -CONNECTION = "sqlite:///kedro.db" -SQL_QUERY = "SELECT * FROM table_a" -EXECUTION_OPTIONS = {"stream_results": True} -FAKE_CONN_STR = "some_sql://scott:tiger@localhost/foo" -ERROR_PREFIX = ( - r"A module\/driver is missing when connecting to your SQL server\.(.|\n)*" -) - - -@pytest.fixture(autouse=True) -def cleanup_engines(): - yield - SQLTableDataSet.engines = {} - SQLQueryDataSet.engines = {} - - -@pytest.fixture -def dummy_dataframe(): - return pd.DataFrame({"col1": [1, 2], "col2": [4, 5], "col3": [5, 6]}) - - -@pytest.fixture -def sql_file(tmp_path: PosixPath): - file = tmp_path / "test.sql" - file.write_text(SQL_QUERY) - return file.as_posix() - - -@pytest.fixture(params=[{}]) -def table_data_set(request): - kwargs = {"table_name": TABLE_NAME, "credentials": {"con": CONNECTION}} - kwargs.update(request.param) - return SQLTableDataSet(**kwargs) - - -@pytest.fixture(params=[{}]) -def query_data_set(request): - kwargs = {"sql": SQL_QUERY, "credentials": {"con": CONNECTION}} - kwargs.update(request.param) - return SQLQueryDataSet(**kwargs) - - -@pytest.fixture(params=[{}]) -def query_file_data_set(request, sql_file): - kwargs = {"filepath": sql_file, "credentials": {"con": CONNECTION}} - kwargs.update(request.param) - return SQLQueryDataSet(**kwargs) - - -class TestSQLTableDataSet: - _unknown_conn = "mysql+unknown_module://scott:tiger@localhost/foo" - - @staticmethod - def _assert_sqlalchemy_called_once(*args): - _callable = sqlalchemy.engine.Engine.table_names - if args: - _callable.assert_called_once_with(*args) - else: - assert _callable.call_count == 1 - - def test_empty_table_name(self): - """Check the error when instantiating with an empty table""" - pattern = r"'table\_name' argument cannot be empty\." - with pytest.raises(DatasetError, match=pattern): - SQLTableDataSet(table_name="", credentials={"con": CONNECTION}) - - def test_empty_connection(self): - """Check the error when instantiating with an empty - connection string""" - pattern = ( - r"'con' argument cannot be empty\. " - r"Please provide a SQLAlchemy connection string\." - ) - with pytest.raises(DatasetError, match=pattern): - SQLTableDataSet(table_name=TABLE_NAME, credentials={"con": ""}) - - def test_driver_missing(self, mocker): - """Check the error when the sql driver is missing""" - mocker.patch( - "kedro.extras.datasets.pandas.sql_dataset.create_engine", - side_effect=ImportError("No module named 'mysqldb'"), - ) - with pytest.raises(DatasetError, match=ERROR_PREFIX + "mysqlclient"): - SQLTableDataSet(table_name=TABLE_NAME, credentials={"con": CONNECTION}) - - def test_unknown_sql(self): - """Check the error when unknown sql dialect is provided; - this means the error is raised on catalog creation, rather - than on load or save operation. - """ - pattern = r"The SQL dialect in your connection is not supported by SQLAlchemy" - with pytest.raises(DatasetError, match=pattern): - SQLTableDataSet(table_name=TABLE_NAME, credentials={"con": FAKE_CONN_STR}) - - def test_unknown_module(self, mocker): - """Test that if an unknown module/driver is encountered by SQLAlchemy - then the error should contain the original error message""" - mocker.patch( - "kedro.extras.datasets.pandas.sql_dataset.create_engine", - side_effect=ImportError("No module named 'unknown_module'"), - ) - pattern = ERROR_PREFIX + r"No module named \'unknown\_module\'" - with pytest.raises(DatasetError, match=pattern): - SQLTableDataSet(table_name=TABLE_NAME, credentials={"con": CONNECTION}) - - def test_str_representation_table(self, table_data_set): - """Test the data set instance string representation""" - str_repr = str(table_data_set) - assert ( - "SQLTableDataSet(load_args={}, save_args={'index': False}, " - f"table_name={TABLE_NAME})" in str_repr - ) - assert CONNECTION not in str(str_repr) - - def test_table_exists(self, mocker, table_data_set): - """Test `exists` method invocation""" - mocker.patch("sqlalchemy.engine.Engine.table_names") - assert not table_data_set.exists() - self._assert_sqlalchemy_called_once() - - @pytest.mark.parametrize( - "table_data_set", [{"load_args": {"schema": "ingested"}}], indirect=True - ) - def test_table_exists_schema(self, mocker, table_data_set): - """Test `exists` method invocation with DB schema provided""" - mocker.patch("sqlalchemy.engine.Engine.table_names") - assert not table_data_set.exists() - self._assert_sqlalchemy_called_once("ingested") - - def test_table_exists_mocked(self, mocker, table_data_set): - """Test `exists` method invocation with mocked list of tables""" - mocker.patch("sqlalchemy.engine.Engine.table_names", return_value=[TABLE_NAME]) - assert table_data_set.exists() - self._assert_sqlalchemy_called_once() - - def test_load_sql_params(self, mocker, table_data_set): - """Test `load` method invocation""" - mocker.patch("pandas.read_sql_table") - table_data_set.load() - pd.read_sql_table.assert_called_once_with( - table_name=TABLE_NAME, con=table_data_set.engines[CONNECTION] - ) - - def test_save_default_index(self, mocker, table_data_set, dummy_dataframe): - """Test `save` method invocation""" - mocker.patch.object(dummy_dataframe, "to_sql") - table_data_set.save(dummy_dataframe) - dummy_dataframe.to_sql.assert_called_once_with( - name=TABLE_NAME, con=table_data_set.engines[CONNECTION], index=False - ) - - @pytest.mark.parametrize( - "table_data_set", [{"save_args": {"index": True}}], indirect=True - ) - def test_save_overwrite_index(self, mocker, table_data_set, dummy_dataframe): - """Test writing DataFrame index as a column""" - mocker.patch.object(dummy_dataframe, "to_sql") - table_data_set.save(dummy_dataframe) - dummy_dataframe.to_sql.assert_called_once_with( - name=TABLE_NAME, con=table_data_set.engines[CONNECTION], index=True - ) - - @pytest.mark.parametrize( - "table_data_set", [{"save_args": {"name": "TABLE_B"}}], indirect=True - ) - def test_save_ignore_table_name_override( - self, mocker, table_data_set, dummy_dataframe - ): - """Test that putting the table name is `save_args` does not have any - effect""" - mocker.patch.object(dummy_dataframe, "to_sql") - table_data_set.save(dummy_dataframe) - dummy_dataframe.to_sql.assert_called_once_with( - name=TABLE_NAME, con=table_data_set.engines[CONNECTION], index=False - ) - - -class TestSQLTableDataSetSingleConnection: - def test_single_connection(self, dummy_dataframe, mocker): - """Test to make sure multiple instances use the same connection object.""" - mocker.patch("pandas.read_sql_table") - dummy_to_sql = mocker.patch.object(dummy_dataframe, "to_sql") - kwargs = {"table_name": TABLE_NAME, "credentials": {"con": CONNECTION}} - - first = SQLTableDataSet(**kwargs) - unique_connection = first.engines[CONNECTION] - datasets = [SQLTableDataSet(**kwargs) for _ in range(10)] - - for ds in datasets: - ds.save(dummy_dataframe) - engine = ds.engines[CONNECTION] - assert engine is unique_connection - - expected_call = mocker.call(name=TABLE_NAME, con=unique_connection, index=False) - dummy_to_sql.assert_has_calls([expected_call] * 10) - - for ds in datasets: - ds.load() - engine = ds.engines[CONNECTION] - assert engine is unique_connection - - def test_create_connection_only_once(self, mocker): - """Test that two datasets that need to connect to the same db - (but different tables, for example) only create a connection once. - """ - mock_engine = mocker.patch( - "kedro.extras.datasets.pandas.sql_dataset.create_engine" - ) - first = SQLTableDataSet(table_name=TABLE_NAME, credentials={"con": CONNECTION}) - assert len(first.engines) == 1 - - second = SQLTableDataSet( - table_name="other_table", credentials={"con": CONNECTION} - ) - assert len(second.engines) == 1 - assert len(first.engines) == 1 - - mock_engine.assert_called_once_with(CONNECTION) - - def test_multiple_connections(self, mocker): - """Test that two datasets that need to connect to different dbs - only create one connection per db. - """ - mock_engine = mocker.patch( - "kedro.extras.datasets.pandas.sql_dataset.create_engine" - ) - first = SQLTableDataSet(table_name=TABLE_NAME, credentials={"con": CONNECTION}) - assert len(first.engines) == 1 - - second_con = f"other_{CONNECTION}" - second = SQLTableDataSet(table_name=TABLE_NAME, credentials={"con": second_con}) - assert len(second.engines) == 2 - assert len(first.engines) == 2 - - expected_calls = [mocker.call(CONNECTION), mocker.call(second_con)] - assert mock_engine.call_args_list == expected_calls - - -class TestSQLQueryDataSet: - def test_empty_query_error(self): - """Check the error when instantiating with empty query or file""" - pattern = ( - r"'sql' and 'filepath' arguments cannot both be empty\." - r"Please provide a sql query or path to a sql query file\." - ) - with pytest.raises(DatasetError, match=pattern): - SQLQueryDataSet(sql="", filepath="", credentials={"con": CONNECTION}) - - def test_empty_con_error(self): - """Check the error when instantiating with empty connection string""" - pattern = ( - r"'con' argument cannot be empty\. Please provide " - r"a SQLAlchemy connection string" - ) - with pytest.raises(DatasetError, match=pattern): - SQLQueryDataSet(sql=SQL_QUERY, credentials={"con": ""}) - - @pytest.mark.parametrize( - "query_data_set, has_execution_options", - [ - ({"execution_options": EXECUTION_OPTIONS}, True), - ({"execution_options": {}}, False), - ({}, False), - ], - indirect=["query_data_set"], - ) - def test_load(self, mocker, query_data_set, has_execution_options): - """Test `load` method invocation""" - mocker.patch("pandas.read_sql_query") - query_data_set.load() - - # Check that data was loaded with the expected query, connection string and - # execution options: - pd.read_sql_query.assert_called_once_with(sql=SQL_QUERY, con=ANY) - con_arg = pd.read_sql_query.call_args_list[0][1]["con"] - assert str(con_arg.url) == CONNECTION - assert len(con_arg.get_execution_options()) == bool(has_execution_options) - if has_execution_options: - assert con_arg.get_execution_options() == EXECUTION_OPTIONS - - @pytest.mark.parametrize( - "query_file_data_set, has_execution_options", - [ - ({"execution_options": EXECUTION_OPTIONS}, True), - ({"execution_options": {}}, False), - ({}, False), - ], - indirect=["query_file_data_set"], - ) - def test_load_query_file(self, mocker, query_file_data_set, has_execution_options): - """Test `load` method with a query file""" - mocker.patch("pandas.read_sql_query") - query_file_data_set.load() - - # Check that data was loaded with the expected query, connection string and - # execution options: - pd.read_sql_query.assert_called_once_with(sql=SQL_QUERY, con=ANY) - con_arg = pd.read_sql_query.call_args_list[0][1]["con"] - assert str(con_arg.url) == CONNECTION - assert len(con_arg.get_execution_options()) == bool(has_execution_options) - if has_execution_options: - assert con_arg.get_execution_options() == EXECUTION_OPTIONS - - def test_load_driver_missing(self, mocker): - """Test that if an unknown module/driver is encountered by SQLAlchemy - then the error should contain the original error message""" - _err = ImportError("No module named 'mysqldb'") - mocker.patch( - "kedro.extras.datasets.pandas.sql_dataset.create_engine", side_effect=_err - ) - with pytest.raises(DatasetError, match=ERROR_PREFIX + "mysqlclient"): - SQLQueryDataSet(sql=SQL_QUERY, credentials={"con": CONNECTION}) - - def test_invalid_module(self, mocker): - """Test that if an unknown module/driver is encountered by SQLAlchemy - then the error should contain the original error message""" - _err = ImportError("Invalid module some_module") - mocker.patch( - "kedro.extras.datasets.pandas.sql_dataset.create_engine", side_effect=_err - ) - pattern = ERROR_PREFIX + r"Invalid module some\_module" - with pytest.raises(DatasetError, match=pattern): - SQLQueryDataSet(sql=SQL_QUERY, credentials={"con": CONNECTION}) - - def test_load_unknown_module(self, mocker): - """Test that if an unknown module/driver is encountered by SQLAlchemy - then the error should contain the original error message""" - _err = ImportError("No module named 'unknown_module'") - mocker.patch( - "kedro.extras.datasets.pandas.sql_dataset.create_engine", side_effect=_err - ) - pattern = ERROR_PREFIX + r"No module named \'unknown\_module\'" - with pytest.raises(DatasetError, match=pattern): - SQLQueryDataSet(sql=SQL_QUERY, credentials={"con": CONNECTION}) - - def test_load_unknown_sql(self): - """Check the error when unknown SQL dialect is provided - in the connection string""" - pattern = r"The SQL dialect in your connection is not supported by SQLAlchemy" - with pytest.raises(DatasetError, match=pattern): - SQLQueryDataSet(sql=SQL_QUERY, credentials={"con": FAKE_CONN_STR}) - - def test_save_error(self, query_data_set, dummy_dataframe): - """Check the error when trying to save to the data set""" - pattern = r"'save' is not supported on SQLQueryDataSet" - with pytest.raises(DatasetError, match=pattern): - query_data_set.save(dummy_dataframe) - - def test_str_representation_sql(self, query_data_set, sql_file): - """Test the data set instance string representation""" - str_repr = str(query_data_set) - assert ( - "SQLQueryDataSet(execution_options={}, filepath=None, " - f"load_args={{}}, sql={SQL_QUERY})" in str_repr - ) - assert CONNECTION not in str_repr - assert sql_file not in str_repr - - def test_str_representation_filepath(self, query_file_data_set, sql_file): - """Test the data set instance string representation with filepath arg.""" - str_repr = str(query_file_data_set) - assert ( - f"SQLQueryDataSet(execution_options={{}}, filepath={str(sql_file)}, " - "load_args={}, sql=None)" in str_repr - ) - assert CONNECTION not in str_repr - assert SQL_QUERY not in str_repr - - def test_sql_and_filepath_args(self, sql_file): - """Test that an error is raised when both `sql` and `filepath` args are given.""" - pattern = ( - r"'sql' and 'filepath' arguments cannot both be provided." - r"Please only provide one." - ) - with pytest.raises(DatasetError, match=pattern): - SQLQueryDataSet(sql=SQL_QUERY, filepath=sql_file) - - def test_create_connection_only_once(self, mocker): - """Test that two datasets that need to connect to the same db (but different - tables and execution options, for example) only create a connection once. - """ - mock_engine = mocker.patch( - "kedro.extras.datasets.pandas.sql_dataset.create_engine" - ) - first = SQLQueryDataSet(sql=SQL_QUERY, credentials={"con": CONNECTION}) - assert len(first.engines) == 1 - - # second engine has identical params to the first one - # => no new engine should be created - second = SQLQueryDataSet(sql=SQL_QUERY, credentials={"con": CONNECTION}) - mock_engine.assert_called_once_with(CONNECTION) - assert second.engines == first.engines - assert len(first.engines) == 1 - - # third engine only differs by its query execution options - # => no new engine should be created - third = SQLQueryDataSet( - sql="a different query", - credentials={"con": CONNECTION}, - execution_options=EXECUTION_OPTIONS, - ) - assert mock_engine.call_count == 1 - assert third.engines == first.engines - assert len(first.engines) == 1 - - # fourth engine has a different connection string - # => a new engine has to be created - fourth = SQLQueryDataSet( - sql=SQL_QUERY, credentials={"con": "an other connection string"} - ) - assert mock_engine.call_count == 2 - assert fourth.engines == first.engines - assert len(first.engines) == 2 diff --git a/tests/extras/datasets/pandas/test_xml_dataset.py b/tests/extras/datasets/pandas/test_xml_dataset.py deleted file mode 100644 index 9dc8f47dc1..0000000000 --- a/tests/extras/datasets/pandas/test_xml_dataset.py +++ /dev/null @@ -1,241 +0,0 @@ -from pathlib import Path, PurePosixPath - -import pandas as pd -import pytest -from adlfs import AzureBlobFileSystem -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from pandas.testing import assert_frame_equal -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.pandas import XMLDataSet -from kedro.io import DatasetError -from kedro.io.core import PROTOCOL_DELIMITER, Version - - -@pytest.fixture -def filepath_xml(tmp_path): - return (tmp_path / "test.xml").as_posix() - - -@pytest.fixture -def xml_data_set(filepath_xml, load_args, save_args, fs_args): - return XMLDataSet( - filepath=filepath_xml, - load_args=load_args, - save_args=save_args, - fs_args=fs_args, - ) - - -@pytest.fixture -def versioned_xml_data_set(filepath_xml, load_version, save_version): - return XMLDataSet( - filepath=filepath_xml, version=Version(load_version, save_version) - ) - - -@pytest.fixture -def dummy_dataframe(): - return pd.DataFrame({"col1": [1, 2], "col2": [4, 5], "col3": [5, 6]}) - - -class TestXMLDataSet: - def test_save_and_load(self, xml_data_set, dummy_dataframe): - """Test saving and reloading the data set.""" - xml_data_set.save(dummy_dataframe) - reloaded = xml_data_set.load() - assert_frame_equal(dummy_dataframe, reloaded) - - def test_exists(self, xml_data_set, dummy_dataframe): - """Test `exists` method invocation for both existing and - nonexistent data set.""" - assert not xml_data_set.exists() - xml_data_set.save(dummy_dataframe) - assert xml_data_set.exists() - - @pytest.mark.parametrize( - "load_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_load_extra_params(self, xml_data_set, load_args): - """Test overriding the default load arguments.""" - for key, value in load_args.items(): - assert xml_data_set._load_args[key] == value - - @pytest.mark.parametrize( - "save_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_save_extra_params(self, xml_data_set, save_args): - """Test overriding the default save arguments.""" - for key, value in save_args.items(): - assert xml_data_set._save_args[key] == value - - @pytest.mark.parametrize( - "load_args,save_args", - [ - ({"storage_options": {"a": "b"}}, {}), - ({}, {"storage_options": {"a": "b"}}), - ({"storage_options": {"a": "b"}}, {"storage_options": {"x": "y"}}), - ], - ) - def test_storage_options_dropped(self, load_args, save_args, caplog, tmp_path): - filepath = str(tmp_path / "test.csv") - - ds = XMLDataSet(filepath=filepath, load_args=load_args, save_args=save_args) - - records = [r for r in caplog.records if r.levelname == "WARNING"] - expected_log_message = ( - f"Dropping 'storage_options' for {filepath}, " - f"please specify them under 'fs_args' or 'credentials'." - ) - assert records[0].getMessage() == expected_log_message - assert "storage_options" not in ds._save_args - assert "storage_options" not in ds._load_args - - def test_load_missing_file(self, xml_data_set): - """Check the error when trying to load missing file.""" - pattern = r"Failed while loading data from data set XMLDataSet\(.*\)" - with pytest.raises(DatasetError, match=pattern): - xml_data_set.load() - - @pytest.mark.parametrize( - "filepath,instance_type,credentials,load_path", - [ - ("s3://bucket/file.xml", S3FileSystem, {}, "s3://bucket/file.xml"), - ("file:///tmp/test.xml", LocalFileSystem, {}, "/tmp/test.xml"), - ("/tmp/test.xml", LocalFileSystem, {}, "/tmp/test.xml"), - ("gcs://bucket/file.xml", GCSFileSystem, {}, "gcs://bucket/file.xml"), - ( - "https://example.com/file.xml", - HTTPFileSystem, - {}, - "https://example.com/file.xml", - ), - ( - "abfs://bucket/file.csv", - AzureBlobFileSystem, - {"account_name": "test", "account_key": "test"}, - "abfs://bucket/file.csv", - ), - ], - ) - def test_protocol_usage( - self, filepath, instance_type, credentials, load_path, mocker - ): - data_set = XMLDataSet(filepath=filepath, credentials=credentials) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - mock_pandas_call = mocker.patch("pandas.read_xml") - data_set.load() - assert mock_pandas_call.call_count == 1 - assert mock_pandas_call.call_args_list[0][0][0] == load_path - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.xml" - data_set = XMLDataSet(filepath=filepath) - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - - -class TestXMLDataSetVersioned: - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - filepath = "test.xml" - ds = XMLDataSet(filepath=filepath) - ds_versioned = XMLDataSet( - filepath=filepath, version=Version(load_version, save_version) - ) - assert filepath in str(ds) - assert "version" not in str(ds) - - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "XMLDataSet" in str(ds_versioned) - assert "XMLDataSet" in str(ds) - assert "protocol" in str(ds_versioned) - assert "protocol" in str(ds) - - def test_save_and_load(self, versioned_xml_data_set, dummy_dataframe): - """Test that saved and reloaded data matches the original one for - the versioned data set.""" - versioned_xml_data_set.save(dummy_dataframe) - reloaded_df = versioned_xml_data_set.load() - assert_frame_equal(dummy_dataframe, reloaded_df) - - def test_no_versions(self, versioned_xml_data_set): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for XMLDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_xml_data_set.load() - - def test_exists(self, versioned_xml_data_set, dummy_dataframe): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_xml_data_set.exists() - versioned_xml_data_set.save(dummy_dataframe) - assert versioned_xml_data_set.exists() - - def test_prevent_overwrite(self, versioned_xml_data_set, dummy_dataframe): - """Check the error when attempting to override the data set if the - corresponding hdf file for a given save version already exists.""" - versioned_xml_data_set.save(dummy_dataframe) - pattern = ( - r"Save path \'.+\' for XMLDataSet\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - versioned_xml_data_set.save(dummy_dataframe) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, versioned_xml_data_set, load_version, save_version, dummy_dataframe - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - rf"Save version '{save_version}' did not match " - rf"load version '{load_version}' for XMLDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_xml_data_set.save(dummy_dataframe) - - def test_http_filesystem_no_versioning(self): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - XMLDataSet( - filepath="https://example.com/file.xml", version=Version(None, None) - ) - - def test_versioning_existing_dataset( - self, xml_data_set, versioned_xml_data_set, dummy_dataframe - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - xml_data_set.save(dummy_dataframe) - assert xml_data_set.exists() - assert xml_data_set._filepath == versioned_xml_data_set._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_xml_data_set._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_xml_data_set.save(dummy_dataframe) - - # Remove non-versioned dataset and try again - Path(xml_data_set._filepath.as_posix()).unlink() - versioned_xml_data_set.save(dummy_dataframe) - assert versioned_xml_data_set.exists() diff --git a/tests/extras/datasets/pickle/__init__.py b/tests/extras/datasets/pickle/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/extras/datasets/pickle/test_pickle_dataset.py b/tests/extras/datasets/pickle/test_pickle_dataset.py deleted file mode 100644 index 65f7495a06..0000000000 --- a/tests/extras/datasets/pickle/test_pickle_dataset.py +++ /dev/null @@ -1,269 +0,0 @@ -import pickle -from pathlib import Path, PurePosixPath - -import pandas as pd -import pytest -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from pandas.testing import assert_frame_equal -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.pickle import PickleDataSet -from kedro.io import DatasetError -from kedro.io.core import PROTOCOL_DELIMITER, Version - - -@pytest.fixture -def filepath_pickle(tmp_path): - return (tmp_path / "test.pkl").as_posix() - - -@pytest.fixture(params=["pickle"]) -def backend(request): - return request.param - - -@pytest.fixture -def pickle_data_set(filepath_pickle, backend, load_args, save_args, fs_args): - return PickleDataSet( - filepath=filepath_pickle, - backend=backend, - load_args=load_args, - save_args=save_args, - fs_args=fs_args, - ) - - -@pytest.fixture -def versioned_pickle_data_set(filepath_pickle, load_version, save_version): - return PickleDataSet( - filepath=filepath_pickle, version=Version(load_version, save_version) - ) - - -@pytest.fixture -def dummy_dataframe(): - return pd.DataFrame({"col1": [1, 2], "col2": [4, 5], "col3": [5, 6]}) - - -class TestPickleDataSet: - @pytest.mark.parametrize( - "backend,load_args,save_args", - [ - ("pickle", None, None), - ("joblib", None, None), - ("dill", None, None), - ("compress_pickle", {"compression": "lz4"}, {"compression": "lz4"}), - ], - indirect=True, - ) - def test_save_and_load(self, pickle_data_set, dummy_dataframe): - """Test saving and reloading the data set.""" - pickle_data_set.save(dummy_dataframe) - reloaded = pickle_data_set.load() - assert_frame_equal(dummy_dataframe, reloaded) - assert pickle_data_set._fs_open_args_load == {} - assert pickle_data_set._fs_open_args_save == {"mode": "wb"} - - def test_exists(self, pickle_data_set, dummy_dataframe): - """Test `exists` method invocation for both existing and - nonexistent data set.""" - assert not pickle_data_set.exists() - pickle_data_set.save(dummy_dataframe) - assert pickle_data_set.exists() - - @pytest.mark.parametrize( - "load_args", [{"k1": "v1", "errors": "strict"}], indirect=True - ) - def test_load_extra_params(self, pickle_data_set, load_args): - """Test overriding the default load arguments.""" - for key, value in load_args.items(): - assert pickle_data_set._load_args[key] == value - - @pytest.mark.parametrize("save_args", [{"k1": "v1", "protocol": 2}], indirect=True) - def test_save_extra_params(self, pickle_data_set, save_args): - """Test overriding the default save arguments.""" - for key, value in save_args.items(): - assert pickle_data_set._save_args[key] == value - - @pytest.mark.parametrize( - "fs_args", - [{"open_args_load": {"mode": "rb", "compression": "gzip"}}], - indirect=True, - ) - def test_open_extra_args(self, pickle_data_set, fs_args): - assert pickle_data_set._fs_open_args_load == fs_args["open_args_load"] - assert pickle_data_set._fs_open_args_save == {"mode": "wb"} # default unchanged - - def test_load_missing_file(self, pickle_data_set): - """Check the error when trying to load missing file.""" - pattern = r"Failed while loading data from data set PickleDataSet\(.*\)" - with pytest.raises(DatasetError, match=pattern): - pickle_data_set.load() - - @pytest.mark.parametrize( - "filepath,instance_type", - [ - ("s3://bucket/file.pkl", S3FileSystem), - ("file:///tmp/test.pkl", LocalFileSystem), - ("/tmp/test.pkl", LocalFileSystem), - ("gcs://bucket/file.pkl", GCSFileSystem), - ("https://example.com/file.pkl", HTTPFileSystem), - ], - ) - def test_protocol_usage(self, filepath, instance_type): - data_set = PickleDataSet(filepath=filepath) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.pkl" - data_set = PickleDataSet(filepath=filepath) - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - - def test_unserialisable_data(self, pickle_data_set, dummy_dataframe, mocker): - mocker.patch("pickle.dump", side_effect=pickle.PickleError) - pattern = r".+ was not serialised due to:.*" - - with pytest.raises(DatasetError, match=pattern): - pickle_data_set.save(dummy_dataframe) - - def test_invalid_backend(self, mocker): - pattern = ( - r"Selected backend 'invalid' should satisfy the pickle interface. " - r"Missing one of 'load' and 'dump' on the backend." - ) - mocker.patch( - "kedro.extras.datasets.pickle.pickle_dataset.importlib.import_module", - return_value=object, - ) - with pytest.raises(ValueError, match=pattern): - PickleDataSet(filepath="test.pkl", backend="invalid") - - def test_no_backend(self, mocker): - pattern = ( - r"Selected backend 'fake.backend.does.not.exist' could not be imported. " - r"Make sure it is installed and importable." - ) - mocker.patch( - "kedro.extras.datasets.pickle.pickle_dataset.importlib.import_module", - side_effect=ImportError, - ) - with pytest.raises(ImportError, match=pattern): - PickleDataSet(filepath="test.pkl", backend="fake.backend.does.not.exist") - - def test_copy(self, pickle_data_set): - pickle_data_set_copy = pickle_data_set._copy() - assert pickle_data_set_copy is not pickle_data_set - assert pickle_data_set_copy._describe() == pickle_data_set._describe() - - -class TestPickleDataSetVersioned: - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - filepath = "test.pkl" - ds = PickleDataSet(filepath=filepath) - ds_versioned = PickleDataSet( - filepath=filepath, version=Version(load_version, save_version) - ) - assert filepath in str(ds) - assert "version" not in str(ds) - - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "PickleDataSet" in str(ds_versioned) - assert "PickleDataSet" in str(ds) - assert "protocol" in str(ds_versioned) - assert "protocol" in str(ds) - assert "backend" in str(ds_versioned) - assert "backend" in str(ds) - - def test_save_and_load(self, versioned_pickle_data_set, dummy_dataframe): - """Test that saved and reloaded data matches the original one for - the versioned data set.""" - versioned_pickle_data_set.save(dummy_dataframe) - reloaded_df = versioned_pickle_data_set.load() - assert_frame_equal(dummy_dataframe, reloaded_df) - - def test_no_versions(self, versioned_pickle_data_set): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for PickleDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_pickle_data_set.load() - - def test_exists(self, versioned_pickle_data_set, dummy_dataframe): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_pickle_data_set.exists() - versioned_pickle_data_set.save(dummy_dataframe) - assert versioned_pickle_data_set.exists() - - def test_prevent_overwrite(self, versioned_pickle_data_set, dummy_dataframe): - """Check the error when attempting to override the data set if the - corresponding Pickle file for a given save version already exists.""" - versioned_pickle_data_set.save(dummy_dataframe) - pattern = ( - r"Save path \'.+\' for PickleDataSet\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - versioned_pickle_data_set.save(dummy_dataframe) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, versioned_pickle_data_set, load_version, save_version, dummy_dataframe - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - rf"Save version '{save_version}' did not match load version " - rf"'{load_version}' for PickleDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_pickle_data_set.save(dummy_dataframe) - - def test_http_filesystem_no_versioning(self): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - PickleDataSet( - filepath="https://example.com/file.pkl", version=Version(None, None) - ) - - def test_versioning_existing_dataset( - self, pickle_data_set, versioned_pickle_data_set, dummy_dataframe - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - pickle_data_set.save(dummy_dataframe) - assert pickle_data_set.exists() - assert pickle_data_set._filepath == versioned_pickle_data_set._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_pickle_data_set._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_pickle_data_set.save(dummy_dataframe) - - # Remove non-versioned dataset and try again - Path(pickle_data_set._filepath.as_posix()).unlink() - versioned_pickle_data_set.save(dummy_dataframe) - assert versioned_pickle_data_set.exists() - - def test_copy(self, versioned_pickle_data_set): - pickle_data_set_copy = versioned_pickle_data_set._copy() - assert pickle_data_set_copy is not versioned_pickle_data_set - assert pickle_data_set_copy._describe() == versioned_pickle_data_set._describe() diff --git a/tests/extras/datasets/pillow/__init__.py b/tests/extras/datasets/pillow/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/extras/datasets/pillow/data/image.png b/tests/extras/datasets/pillow/data/image.png deleted file mode 100644 index 4147f3ef7a83e2e76fd7100c9a269f3a438625d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1554 zcmV+t2JQKYP)MU85$b@|Nj&f6&M*A6BHL9ARrME5+Wlb7Z(>OC@B>d7YPar000*M$_W4f z6aWAND=RAv4-t)xjQ{``001EX03Mcd82|tq000XrB_BOBDkdQu$d(npj~AwX8!##( z<>lp&ZXcq19>9q392lOSo^*6|s;a8i*4DeayF5HS@$vCz zW@oB^6@OtM1`iu_SsphtFj!bvOiN72$jEuh(na@y6M8CY8QZRT|0{p(vG!RH;m;tL}L+QSDv~QkH&t7qY>7g78@*#c-mP^i3Z=+U@zYSh?d4FaSbPC0 zdZo&TI#m;O>}|_Wm4>NkG0%ollT!@kc@6cs(`+hzmi79fv`i86Vr~{O(2A0`|~5AD3uW( zexg+>IZ=Zk^hGGMQIAaJhrxhe18Q^U$@z8n_aF$umf_S5)o9p`(F%iL@U{C>qoHEo zX=SEVhFv?9NF_qu=oCt%x|XTjMzg*~ONG*kOvPIt=p;B?vxyQY)vy1bO!EVsO!Mj6 zFYQ8!RDYg_;wR;`JDL9Y`Hy~~7;~P6x5FdbHS2NRqv37z6dwAt_s_9igHrjfYuD&x zyRJXJjSSb;E>$Rh;VPn3c0E2#lp(%Ivlqyr`rR~2Ww>=1NhsY?Ilg+;7hMeLWy(95 z=(yoUq+a>?&Y@3!pEz^W4+oq$mA|8tABRf6;?O6*Um}$wh0#S6wvr5G=oktW#)s<` zN~Ur$shsLmxloRF|MiumDsSb;P?t$4?IubnrYe^WrQt*goQ3MaUvN3DOch4s%Rsls zeA~oR_c#Zox(rG~1yTAL-5$zhah~K6Q&pBK6H4ES5-3%eUVtT26>Kg&ohM4zN)={T zE82%p8LK`4Md;w2ve00000000000002gfQJk9yxhrD)BD`s_Vh4k^O^N6QED;D?|rZi zb2gt@Uo=_Fw)Ub_ktn4?-Hm3C^zL;zT2m=+&el`0F@HGh8GaU2N`<0MeQU*Dtm)%) zVbNI4W~(>aSX-|LKF80YZ!Jcan3~ms-u$)LXie|$EY`E`Xxiv6n~!$?6PoB-bG8(b z=8GkBhRH>DhUazIv#dPpcXkS?q2VQ>PN1(HC5!BrLcUx*1duCZfC zlyB3Iupgm^9(!E%+z-%0&sE=yfdGL@)uy!ozj^P?d%t-zZ^S8TaSY9*n6K*aqF+@~ z1g(vR921Sx@n0hGUnA*idgtE-UZ#lm#9CyNdFlH z?x#VvhF<5MPmOKmo?9N%;NVZSYuq0`TjkCzasL!c|NauVp9a|)nu#Xs1u796YuARQ zFfoduzmA66_$cy^VvdT_$=&4P^X_{3nRg@o%vFm2LC0xWGQ7W}sN`NE<|wwVj1zy+ z_pE8_#^e$XzHixM_HF9>d-eu&AhE7u_YHP0#oCHxfWr2NLl})c#r0sACGyAtktd>o zMegAT7=-aINpNEpb~VbCX$>Uq+k1R*tV^8V<-^=^cahWYN!$?os&;3r3OJwa$kODd%WueA zDFb`?uA6HWv3!D_TWUu{r}?Aa+mgwyq&Yw$}#U z>e!T}!a(I)j%sNnUZ>Nl)X)3o)f&4t1^==K^_z2r>uQ8~Ek){#<#KVc9?^pO9vz#c zJ{!29*4gIZjM!oAo>m=R>D6HX>o|ZKI;wrfH#ad?3Y=P*6zc-s%QY60tZ@sA`2Vs@ z|B0pXpRsh?_zX3tvQ*BhIT4>*sMEyh!I{?V8?whxYmjTVddSGj-P)ZZ6@7B{2gq-n z_xy%XgBrtGU+kSc)a<6C@_7=Y(as0;LBqU)-h;k{{whJwKwscC#NR6E$2>@3|Ghtg zrhfyYW1)(b#0mHF?5WM|s1#lb__qXSGq@uj&AENc{Knk=Wd4-Qhf4r17q}OCs1%?E z)*pj+pg5Kr{(9hHDv(EZUjynW4aQnRZt#bpW^ cZol0`r|s&=dGc_VqQVdL+YUvY!vEmE0W~wNRR910 diff --git a/tests/extras/datasets/spark/test_deltatable_dataset.py b/tests/extras/datasets/spark/test_deltatable_dataset.py deleted file mode 100644 index a0ad5bc9d9..0000000000 --- a/tests/extras/datasets/spark/test_deltatable_dataset.py +++ /dev/null @@ -1,100 +0,0 @@ -import pytest -from delta import DeltaTable -from pyspark import __version__ -from pyspark.sql import SparkSession -from pyspark.sql.types import IntegerType, StringType, StructField, StructType -from pyspark.sql.utils import AnalysisException -from semver import VersionInfo - -from kedro.extras.datasets.spark import DeltaTableDataSet, SparkDataSet -from kedro.io import DataCatalog, DatasetError -from kedro.pipeline import node -from kedro.pipeline.modular_pipeline import pipeline as modular_pipeline -from kedro.runner import ParallelRunner - -SPARK_VERSION = VersionInfo.parse(__version__) - - -@pytest.fixture -def sample_spark_df(): - schema = StructType( - [ - StructField("name", StringType(), True), - StructField("age", IntegerType(), True), - ] - ) - - data = [("Alex", 31), ("Bob", 12), ("Clarke", 65), ("Dave", 29)] - - return SparkSession.builder.getOrCreate().createDataFrame(data, schema) - - -class TestDeltaTableDataSet: - def test_load(self, tmp_path, sample_spark_df): - filepath = (tmp_path / "test_data").as_posix() - spark_delta_ds = SparkDataSet(filepath=filepath, file_format="delta") - spark_delta_ds.save(sample_spark_df) - loaded_with_spark = spark_delta_ds.load() - assert loaded_with_spark.exceptAll(sample_spark_df).count() == 0 - - delta_ds = DeltaTableDataSet(filepath=filepath) - delta_table = delta_ds.load() - - assert isinstance(delta_table, DeltaTable) - loaded_with_deltalake = delta_table.toDF() - assert loaded_with_deltalake.exceptAll(loaded_with_spark).count() == 0 - - def test_save(self, tmp_path, sample_spark_df): - filepath = (tmp_path / "test_data").as_posix() - delta_ds = DeltaTableDataSet(filepath=filepath) - assert not delta_ds.exists() - - pattern = "DeltaTableDataSet is a read only dataset type" - with pytest.raises(DatasetError, match=pattern): - delta_ds.save(sample_spark_df) - - # check that indeed nothing is written - assert not delta_ds.exists() - - def test_exists(self, tmp_path, sample_spark_df): - filepath = (tmp_path / "test_data").as_posix() - delta_ds = DeltaTableDataSet(filepath=filepath) - - assert not delta_ds.exists() - - spark_delta_ds = SparkDataSet(filepath=filepath, file_format="delta") - spark_delta_ds.save(sample_spark_df) - - assert delta_ds.exists() - - def test_exists_raises_error(self, mocker): - delta_ds = DeltaTableDataSet(filepath="") - if SPARK_VERSION.match(">=3.4.0"): - mocker.patch.object( - delta_ds, "_get_spark", side_effect=AnalysisException("Other Exception") - ) - else: - mocker.patch.object( - delta_ds, - "_get_spark", - side_effect=AnalysisException("Other Exception", []), - ) - with pytest.raises(DatasetError, match="Other Exception"): - delta_ds.exists() - - @pytest.mark.parametrize("is_async", [False, True]) - def test_parallel_runner(self, is_async): - """Test ParallelRunner with SparkDataSet fails.""" - - def no_output(x): - _ = x + 1 # pragma: no cover - - delta_ds = DeltaTableDataSet(filepath="") - catalog = DataCatalog(data_sets={"delta_in": delta_ds}) - pipeline = modular_pipeline([node(no_output, "delta_in", None)]) - pattern = ( - r"The following data sets cannot be used with " - r"multiprocessing: \['delta_in'\]" - ) - with pytest.raises(AttributeError, match=pattern): - ParallelRunner(is_async=is_async).run(pipeline, catalog) diff --git a/tests/extras/datasets/spark/test_memory_dataset.py b/tests/extras/datasets/spark/test_memory_dataset.py deleted file mode 100644 index d678f42d63..0000000000 --- a/tests/extras/datasets/spark/test_memory_dataset.py +++ /dev/null @@ -1,67 +0,0 @@ -import pytest -from pyspark.sql import DataFrame as SparkDataFrame -from pyspark.sql import SparkSession -from pyspark.sql.functions import col, when - -from kedro.io import MemoryDataset - - -def _update_spark_df(data, idx, jdx, value): - session = SparkSession.builder.getOrCreate() - data = session.createDataFrame(data.rdd.zipWithIndex()).select( - col("_1.*"), col("_2").alias("__id") - ) - cname = data.columns[idx] - return data.withColumn( - cname, when(col("__id") == jdx, value).otherwise(col(cname)) - ).drop("__id") - - -def _check_equals(data1, data2): - if isinstance(data1, SparkDataFrame) and isinstance(data2, SparkDataFrame): - return data1.toPandas().equals(data2.toPandas()) - return False # pragma: no cover - - -@pytest.fixture -def spark_data_frame(spark_session): - return spark_session.createDataFrame( - [(1, 4, 5), (2, 5, 6)], ["col1", "col2", "col3"] - ) - - -@pytest.fixture -def memory_dataset(spark_data_frame): - return MemoryDataset(data=spark_data_frame) - - -def test_load_modify_original_data(memory_dataset, spark_data_frame): - """Check that the data set object is not updated when the original - SparkDataFrame is changed.""" - spark_data_frame = _update_spark_df(spark_data_frame, 1, 1, -5) - assert not _check_equals(memory_dataset.load(), spark_data_frame) - - -def test_save_modify_original_data(spark_data_frame): - """Check that the data set object is not updated when the original - SparkDataFrame is changed.""" - memory_dataset = MemoryDataset() - memory_dataset.save(spark_data_frame) - spark_data_frame = _update_spark_df(spark_data_frame, 1, 1, "new value") - - assert not _check_equals(memory_dataset.load(), spark_data_frame) - - -def test_load_returns_same_spark_object(memory_dataset, spark_data_frame): - """Test that consecutive loads point to the same object in case of - a SparkDataFrame""" - loaded_data = memory_dataset.load() - reloaded_data = memory_dataset.load() - assert _check_equals(loaded_data, spark_data_frame) - assert _check_equals(reloaded_data, spark_data_frame) - assert loaded_data is reloaded_data - - -def test_str_representation(memory_dataset): - """Test string representation of the data set""" - assert "MemoryDataset(data=)" in str(memory_dataset) diff --git a/tests/extras/datasets/spark/test_spark_dataset.py b/tests/extras/datasets/spark/test_spark_dataset.py deleted file mode 100644 index a491ef6aeb..0000000000 --- a/tests/extras/datasets/spark/test_spark_dataset.py +++ /dev/null @@ -1,996 +0,0 @@ -import re -import sys -import tempfile -from pathlib import Path, PurePosixPath - -import boto3 -import pandas as pd -import pytest -from moto import mock_s3 -from pyspark import __version__ -from pyspark.sql import SparkSession -from pyspark.sql.functions import col -from pyspark.sql.types import ( - FloatType, - IntegerType, - StringType, - StructField, - StructType, -) -from pyspark.sql.utils import AnalysisException -from semver import VersionInfo - -from kedro.extras.datasets.pandas import CSVDataSet, ParquetDataSet -from kedro.extras.datasets.pickle import PickleDataSet -from kedro.extras.datasets.spark import SparkDataSet -from kedro.extras.datasets.spark.spark_dataset import ( - _dbfs_exists, - _dbfs_glob, - _get_dbutils, -) -from kedro.io import DataCatalog, DatasetError, Version -from kedro.io.core import generate_timestamp -from kedro.pipeline import node -from kedro.pipeline.modular_pipeline import pipeline as modular_pipeline -from kedro.runner import ParallelRunner, SequentialRunner - -FOLDER_NAME = "fake_folder" -FILENAME = "test.parquet" -BUCKET_NAME = "test_bucket" -SCHEMA_FILE_NAME = "schema.json" -AWS_CREDENTIALS = {"key": "FAKE_ACCESS_KEY", "secret": "FAKE_SECRET_KEY"} - -HDFS_PREFIX = f"{FOLDER_NAME}/{FILENAME}" -HDFS_FOLDER_STRUCTURE = [ - ( - HDFS_PREFIX, - [ - "2019-01-01T23.59.59.999Z", - "2019-01-02T00.00.00.000Z", - "2019-01-02T00.00.00.001Z", - "2019-01-02T01.00.00.000Z", - "2019-02-01T00.00.00.000Z", - ], - [], - ), - (HDFS_PREFIX + "/2019-01-01T23.59.59.999Z", [FILENAME], []), - (HDFS_PREFIX + "/2019-01-01T23.59.59.999Z/" + FILENAME, [], ["part1", "part2"]), - (HDFS_PREFIX + "/2019-01-02T00.00.00.000Z", [], ["other_file"]), - (HDFS_PREFIX + "/2019-01-02T00.00.00.001Z", [], []), - (HDFS_PREFIX + "/2019-01-02T01.00.00.000Z", [FILENAME], []), - (HDFS_PREFIX + "/2019-01-02T01.00.00.000Z/" + FILENAME, [], ["part1"]), - (HDFS_PREFIX + "/2019-02-01T00.00.00.000Z", [], ["other_file"]), -] - -SPARK_VERSION = VersionInfo.parse(__version__) - - -@pytest.fixture -def sample_pandas_df() -> pd.DataFrame: - return pd.DataFrame( - {"Name": ["Alex", "Bob", "Clarke", "Dave"], "Age": [31, 12, 65, 29]} - ) - - -@pytest.fixture -def version(): - load_version = None # use latest - save_version = generate_timestamp() # freeze save version - return Version(load_version, save_version) - - -@pytest.fixture -def versioned_dataset_local(tmp_path, version): - return SparkDataSet(filepath=(tmp_path / FILENAME).as_posix(), version=version) - - -@pytest.fixture -def versioned_dataset_dbfs(tmp_path, version): - return SparkDataSet( - filepath="/dbfs" + (tmp_path / FILENAME).as_posix(), version=version - ) - - -@pytest.fixture -def versioned_dataset_s3(version): - return SparkDataSet( - filepath=f"s3a://{BUCKET_NAME}/{FILENAME}", - version=version, - credentials=AWS_CREDENTIALS, - ) - - -@pytest.fixture -def sample_spark_df(): - schema = StructType( - [ - StructField("name", StringType(), True), - StructField("age", IntegerType(), True), - ] - ) - - data = [("Alex", 31), ("Bob", 12), ("Clarke", 65), ("Dave", 29)] - - return SparkSession.builder.getOrCreate().createDataFrame(data, schema) - - -@pytest.fixture -def sample_spark_df_schema() -> StructType: - return StructType( - [ - StructField("name", StringType(), True), - StructField("age", IntegerType(), True), - StructField("height", FloatType(), True), - ] - ) - - -def identity(arg): - return arg # pragma: no cover - - -@pytest.fixture -def spark_in(tmp_path, sample_spark_df): - spark_in = SparkDataSet(filepath=(tmp_path / "input").as_posix()) - spark_in.save(sample_spark_df) - return spark_in - - -@pytest.fixture -def mocked_s3_bucket(): - """Create a bucket for testing using moto.""" - with mock_s3(): - conn = boto3.client( - "s3", - aws_access_key_id="fake_access_key", - aws_secret_access_key="fake_secret_key", - ) - conn.create_bucket(Bucket=BUCKET_NAME) - yield conn - - -@pytest.fixture -def mocked_s3_schema(tmp_path, mocked_s3_bucket, sample_spark_df_schema: StructType): - """Creates schema file and adds it to mocked S3 bucket.""" - temporary_path = tmp_path / SCHEMA_FILE_NAME - temporary_path.write_text(sample_spark_df_schema.json(), encoding="utf-8") - - mocked_s3_bucket.put_object( - Bucket=BUCKET_NAME, Key=SCHEMA_FILE_NAME, Body=temporary_path.read_bytes() - ) - return mocked_s3_bucket - - -class FileInfo: - def __init__(self, path): - self.path = "dbfs:" + path - - def isDir(self): - return "." not in self.path.split("/")[-1] - - -class TestSparkDataSet: - def test_load_parquet(self, tmp_path, sample_pandas_df): - temp_path = (tmp_path / "data").as_posix() - local_parquet_set = ParquetDataSet(filepath=temp_path) - local_parquet_set.save(sample_pandas_df) - spark_data_set = SparkDataSet(filepath=temp_path) - spark_df = spark_data_set.load() - assert spark_df.count() == 4 - - def test_save_parquet(self, tmp_path, sample_spark_df): - # To cross check the correct Spark save operation we save to - # a single spark partition and retrieve it with Kedro - # ParquetDataSet - temp_dir = Path(str(tmp_path / "test_data")) - spark_data_set = SparkDataSet( - filepath=temp_dir.as_posix(), save_args={"compression": "none"} - ) - spark_df = sample_spark_df.coalesce(1) - spark_data_set.save(spark_df) - - single_parquet = [ - f for f in temp_dir.iterdir() if f.is_file() and f.name.startswith("part") - ][0] - - local_parquet_data_set = ParquetDataSet(filepath=single_parquet.as_posix()) - - pandas_df = local_parquet_data_set.load() - - assert pandas_df[pandas_df["name"] == "Bob"]["age"].iloc[0] == 12 - - def test_load_options_csv(self, tmp_path, sample_pandas_df): - filepath = (tmp_path / "data").as_posix() - local_csv_data_set = CSVDataSet(filepath=filepath) - local_csv_data_set.save(sample_pandas_df) - spark_data_set = SparkDataSet( - filepath=filepath, file_format="csv", load_args={"header": True} - ) - spark_df = spark_data_set.load() - assert spark_df.filter(col("Name") == "Alex").count() == 1 - - def test_load_options_schema_ddl_string( - self, tmp_path, sample_pandas_df, sample_spark_df_schema - ): - filepath = (tmp_path / "data").as_posix() - local_csv_data_set = CSVDataSet(filepath=filepath) - local_csv_data_set.save(sample_pandas_df) - spark_data_set = SparkDataSet( - filepath=filepath, - file_format="csv", - load_args={"header": True, "schema": "name STRING, age INT, height FLOAT"}, - ) - spark_df = spark_data_set.load() - assert spark_df.schema == sample_spark_df_schema - - def test_load_options_schema_obj( - self, tmp_path, sample_pandas_df, sample_spark_df_schema - ): - filepath = (tmp_path / "data").as_posix() - local_csv_data_set = CSVDataSet(filepath=filepath) - local_csv_data_set.save(sample_pandas_df) - - spark_data_set = SparkDataSet( - filepath=filepath, - file_format="csv", - load_args={"header": True, "schema": sample_spark_df_schema}, - ) - - spark_df = spark_data_set.load() - assert spark_df.schema == sample_spark_df_schema - - def test_load_options_schema_path( - self, tmp_path, sample_pandas_df, sample_spark_df_schema - ): - filepath = (tmp_path / "data").as_posix() - schemapath = (tmp_path / SCHEMA_FILE_NAME).as_posix() - local_csv_data_set = CSVDataSet(filepath=filepath) - local_csv_data_set.save(sample_pandas_df) - Path(schemapath).write_text(sample_spark_df_schema.json(), encoding="utf-8") - - spark_data_set = SparkDataSet( - filepath=filepath, - file_format="csv", - load_args={"header": True, "schema": {"filepath": schemapath}}, - ) - - spark_df = spark_data_set.load() - assert spark_df.schema == sample_spark_df_schema - - @pytest.mark.usefixtures("mocked_s3_schema") - def test_load_options_schema_path_with_credentials( - self, tmp_path, sample_pandas_df, sample_spark_df_schema - ): - filepath = (tmp_path / "data").as_posix() - local_csv_data_set = CSVDataSet(filepath=filepath) - local_csv_data_set.save(sample_pandas_df) - - spark_data_set = SparkDataSet( - filepath=filepath, - file_format="csv", - load_args={ - "header": True, - "schema": { - "filepath": f"s3://{BUCKET_NAME}/{SCHEMA_FILE_NAME}", - "credentials": AWS_CREDENTIALS, - }, - }, - ) - - spark_df = spark_data_set.load() - assert spark_df.schema == sample_spark_df_schema - - def test_load_options_invalid_schema_file(self, tmp_path): - filepath = (tmp_path / "data").as_posix() - schemapath = (tmp_path / SCHEMA_FILE_NAME).as_posix() - Path(schemapath).write_text("dummy", encoding="utf-8") - - pattern = ( - f"Contents of 'schema.filepath' ({schemapath}) are invalid. Please" - f"provide a valid JSON-serialised 'pyspark.sql.types.StructType'." - ) - - with pytest.raises(DatasetError, match=re.escape(pattern)): - SparkDataSet( - filepath=filepath, - file_format="csv", - load_args={"header": True, "schema": {"filepath": schemapath}}, - ) - - def test_load_options_invalid_schema(self, tmp_path): - filepath = (tmp_path / "data").as_posix() - - pattern = ( - "Schema load argument does not specify a 'filepath' attribute. Please" - "include a path to a JSON-serialised 'pyspark.sql.types.StructType'." - ) - - with pytest.raises(DatasetError, match=pattern): - SparkDataSet( - filepath=filepath, - file_format="csv", - load_args={"header": True, "schema": {}}, - ) - - def test_save_options_csv(self, tmp_path, sample_spark_df): - # To cross check the correct Spark save operation we save to - # a single spark partition with csv format and retrieve it with Kedro - # CSVDataSet - temp_dir = Path(str(tmp_path / "test_data")) - spark_data_set = SparkDataSet( - filepath=temp_dir.as_posix(), - file_format="csv", - save_args={"sep": "|", "header": True}, - ) - spark_df = sample_spark_df.coalesce(1) - spark_data_set.save(spark_df) - - single_csv_file = [ - f for f in temp_dir.iterdir() if f.is_file() and f.suffix == ".csv" - ][0] - - csv_local_data_set = CSVDataSet( - filepath=single_csv_file.as_posix(), load_args={"sep": "|"} - ) - pandas_df = csv_local_data_set.load() - - assert pandas_df[pandas_df["name"] == "Alex"]["age"][0] == 31 - - def test_str_representation(self): - with tempfile.NamedTemporaryFile() as temp_data_file: - filepath = Path(temp_data_file.name).as_posix() - spark_data_set = SparkDataSet( - filepath=filepath, file_format="csv", load_args={"header": True} - ) - assert "SparkDataSet" in str(spark_data_set) - assert f"filepath={filepath}" in str(spark_data_set) - - def test_save_overwrite_fail(self, tmp_path, sample_spark_df): - # Writes a data frame twice and expects it to fail. - filepath = (tmp_path / "test_data").as_posix() - spark_data_set = SparkDataSet(filepath=filepath) - spark_data_set.save(sample_spark_df) - - with pytest.raises(DatasetError): - spark_data_set.save(sample_spark_df) - - def test_save_overwrite_mode(self, tmp_path, sample_spark_df): - # Writes a data frame in overwrite mode. - filepath = (tmp_path / "test_data").as_posix() - spark_data_set = SparkDataSet( - filepath=filepath, save_args={"mode": "overwrite"} - ) - - spark_data_set.save(sample_spark_df) - spark_data_set.save(sample_spark_df) - - @pytest.mark.parametrize("mode", ["merge", "delete", "update"]) - def test_file_format_delta_and_unsupported_mode(self, tmp_path, mode): - filepath = (tmp_path / "test_data").as_posix() - pattern = ( - f"It is not possible to perform 'save()' for file format 'delta' " - f"with mode '{mode}' on 'SparkDataSet'. " - f"Please use 'spark.DeltaTableDataSet' instead." - ) - - with pytest.raises(DatasetError, match=re.escape(pattern)): - _ = SparkDataSet( - filepath=filepath, file_format="delta", save_args={"mode": mode} - ) - - def test_save_partition(self, tmp_path, sample_spark_df): - # To verify partitioning this test will partition the data by one - # of the columns and then check whether partitioned column is added - # to the save path - - filepath = Path(str(tmp_path / "test_data")) - spark_data_set = SparkDataSet( - filepath=filepath.as_posix(), - save_args={"mode": "overwrite", "partitionBy": ["name"]}, - ) - - spark_data_set.save(sample_spark_df) - - expected_path = filepath / "name=Alex" - - assert expected_path.exists() - - @pytest.mark.parametrize("file_format", ["csv", "parquet", "delta"]) - def test_exists(self, file_format, tmp_path, sample_spark_df): - filepath = (tmp_path / "test_data").as_posix() - spark_data_set = SparkDataSet(filepath=filepath, file_format=file_format) - - assert not spark_data_set.exists() - - spark_data_set.save(sample_spark_df) - assert spark_data_set.exists() - - def test_exists_raises_error(self, mocker): - # exists should raise all errors except for - # AnalysisExceptions clearly indicating a missing file - spark_data_set = SparkDataSet(filepath="") - if SPARK_VERSION.match(">=3.4.0"): - mocker.patch.object( - spark_data_set, - "_get_spark", - side_effect=AnalysisException("Other Exception"), - ) - else: - mocker.patch.object( # pylint: disable=expression-not-assigned - spark_data_set, - "_get_spark", - side_effect=AnalysisException("Other Exception", []), - ) - - with pytest.raises(DatasetError, match="Other Exception"): - spark_data_set.exists() - - @pytest.mark.parametrize("is_async", [False, True]) - def test_parallel_runner(self, is_async, spark_in): - """Test ParallelRunner with SparkDataSet fails.""" - catalog = DataCatalog(data_sets={"spark_in": spark_in}) - pipeline = modular_pipeline([node(identity, "spark_in", "spark_out")]) - pattern = ( - r"The following data sets cannot be used with " - r"multiprocessing: \['spark_in'\]" - ) - with pytest.raises(AttributeError, match=pattern): - ParallelRunner(is_async=is_async).run(pipeline, catalog) - - def test_s3_glob_refresh(self): - spark_dataset = SparkDataSet(filepath="s3a://bucket/data") - assert spark_dataset._glob_function.keywords == {"refresh": True} - - def test_copy(self): - spark_dataset = SparkDataSet( - filepath="/tmp/data", save_args={"mode": "overwrite"} - ) - assert spark_dataset._file_format == "parquet" - - spark_dataset_copy = spark_dataset._copy(_file_format="csv") - - assert spark_dataset is not spark_dataset_copy - assert spark_dataset._file_format == "parquet" - assert spark_dataset._save_args == {"mode": "overwrite"} - assert spark_dataset_copy._file_format == "csv" - assert spark_dataset_copy._save_args == {"mode": "overwrite"} - - -class TestSparkDataSetVersionedLocal: - def test_no_version(self, versioned_dataset_local): - pattern = r"Did not find any versions for SparkDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_dataset_local.load() - - def test_load_latest(self, versioned_dataset_local, sample_spark_df): - versioned_dataset_local.save(sample_spark_df) - reloaded = versioned_dataset_local.load() - - assert reloaded.exceptAll(sample_spark_df).count() == 0 - - def test_load_exact(self, tmp_path, sample_spark_df): - ts = generate_timestamp() - ds_local = SparkDataSet( - filepath=(tmp_path / FILENAME).as_posix(), version=Version(ts, ts) - ) - - ds_local.save(sample_spark_df) - reloaded = ds_local.load() - - assert reloaded.exceptAll(sample_spark_df).count() == 0 - - def test_save(self, versioned_dataset_local, version, tmp_path, sample_spark_df): - versioned_dataset_local.save(sample_spark_df) - assert (tmp_path / FILENAME / version.save / FILENAME).exists() - - def test_repr(self, versioned_dataset_local, tmp_path, version): - assert f"version=Version(load=None, save='{version.save}')" in str( - versioned_dataset_local - ) - - dataset_local = SparkDataSet(filepath=(tmp_path / FILENAME).as_posix()) - assert "version=" not in str(dataset_local) - - def test_save_version_warning(self, tmp_path, sample_spark_df): - exact_version = Version("2019-01-01T23.59.59.999Z", "2019-01-02T00.00.00.000Z") - ds_local = SparkDataSet( - filepath=(tmp_path / FILENAME).as_posix(), version=exact_version - ) - - pattern = ( - r"Save version '{ev.save}' did not match load version " - r"'{ev.load}' for SparkDataSet\(.+\)".format(ev=exact_version) - ) - with pytest.warns(UserWarning, match=pattern): - ds_local.save(sample_spark_df) - - def test_prevent_overwrite(self, tmp_path, version, sample_spark_df): - versioned_local = SparkDataSet( - filepath=(tmp_path / FILENAME).as_posix(), - version=version, - # second save should fail even in overwrite mode - save_args={"mode": "overwrite"}, - ) - versioned_local.save(sample_spark_df) - - pattern = ( - r"Save path '.+' for SparkDataSet\(.+\) must not exist " - r"if versioning is enabled" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_local.save(sample_spark_df) - - def test_versioning_existing_dataset( - self, versioned_dataset_local, sample_spark_df - ): - """Check behavior when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset. Note: because SparkDataSet saves to a - directory even if non-versioned, an error is not expected.""" - spark_data_set = SparkDataSet( - filepath=versioned_dataset_local._filepath.as_posix() - ) - spark_data_set.save(sample_spark_df) - assert spark_data_set.exists() - versioned_dataset_local.save(sample_spark_df) - assert versioned_dataset_local.exists() - - -@pytest.mark.skipif( - sys.platform.startswith("win"), reason="DBFS doesn't work on Windows" -) -class TestSparkDataSetVersionedDBFS: - def test_load_latest( # noqa: too-many-arguments - self, mocker, versioned_dataset_dbfs, version, tmp_path, sample_spark_df - ): - mocked_glob = mocker.patch.object(versioned_dataset_dbfs, "_glob_function") - mocked_glob.return_value = [str(tmp_path / FILENAME / version.save / FILENAME)] - - versioned_dataset_dbfs.save(sample_spark_df) - reloaded = versioned_dataset_dbfs.load() - - expected_calls = [ - mocker.call("/dbfs" + str(tmp_path / FILENAME / "*" / FILENAME)) - ] - assert mocked_glob.call_args_list == expected_calls - - assert reloaded.exceptAll(sample_spark_df).count() == 0 - - def test_load_exact(self, tmp_path, sample_spark_df): - ts = generate_timestamp() - ds_dbfs = SparkDataSet( - filepath="/dbfs" + str(tmp_path / FILENAME), version=Version(ts, ts) - ) - - ds_dbfs.save(sample_spark_df) - reloaded = ds_dbfs.load() - - assert reloaded.exceptAll(sample_spark_df).count() == 0 - - def test_save( # noqa: too-many-arguments - self, mocker, versioned_dataset_dbfs, version, tmp_path, sample_spark_df - ): - mocked_glob = mocker.patch.object(versioned_dataset_dbfs, "_glob_function") - mocked_glob.return_value = [str(tmp_path / FILENAME / version.save / FILENAME)] - - versioned_dataset_dbfs.save(sample_spark_df) - - mocked_glob.assert_called_once_with( - "/dbfs" + str(tmp_path / FILENAME / "*" / FILENAME) - ) - assert (tmp_path / FILENAME / version.save / FILENAME).exists() - - def test_exists( # noqa: too-many-arguments - self, mocker, versioned_dataset_dbfs, version, tmp_path, sample_spark_df - ): - mocked_glob = mocker.patch.object(versioned_dataset_dbfs, "_glob_function") - mocked_glob.return_value = [str(tmp_path / FILENAME / version.save / FILENAME)] - - assert not versioned_dataset_dbfs.exists() - - versioned_dataset_dbfs.save(sample_spark_df) - assert versioned_dataset_dbfs.exists() - - expected_calls = [ - mocker.call("/dbfs" + str(tmp_path / FILENAME / "*" / FILENAME)) - ] * 2 - assert mocked_glob.call_args_list == expected_calls - - def test_dbfs_glob(self, mocker): - dbutils_mock = mocker.Mock() - dbutils_mock.fs.ls.return_value = [ - FileInfo("/tmp/file/date1"), - FileInfo("/tmp/file/date2"), - FileInfo("/tmp/file/file.csv"), - FileInfo("/tmp/file/"), - ] - pattern = "/tmp/file/*/file" - expected = ["/dbfs/tmp/file/date1/file", "/dbfs/tmp/file/date2/file"] - - result = _dbfs_glob(pattern, dbutils_mock) - assert result == expected - dbutils_mock.fs.ls.assert_called_once_with("/tmp/file") - - def test_dbfs_exists(self, mocker): - dbutils_mock = mocker.Mock() - test_path = "/dbfs/tmp/file/date1/file" - dbutils_mock.fs.ls.return_value = [ - FileInfo("/tmp/file/date1"), - FileInfo("/tmp/file/date2"), - FileInfo("/tmp/file/file.csv"), - FileInfo("/tmp/file/"), - ] - - assert _dbfs_exists(test_path, dbutils_mock) - - # add side effect to test that non-existence is handled - dbutils_mock.fs.ls.side_effect = Exception() - assert not _dbfs_exists(test_path, dbutils_mock) - - def test_ds_init_no_dbutils(self, mocker): - get_dbutils_mock = mocker.patch( - "kedro.extras.datasets.spark.spark_dataset._get_dbutils", return_value=None - ) - - data_set = SparkDataSet(filepath="/dbfs/tmp/data") - - get_dbutils_mock.assert_called_once() - assert data_set._glob_function.__name__ == "iglob" - - def test_ds_init_dbutils_available(self, mocker): - get_dbutils_mock = mocker.patch( - "kedro.extras.datasets.spark.spark_dataset._get_dbutils", - return_value="mock", - ) - - data_set = SparkDataSet(filepath="/dbfs/tmp/data") - - get_dbutils_mock.assert_called_once() - assert data_set._glob_function.__class__.__name__ == "partial" - assert data_set._glob_function.func.__name__ == "_dbfs_glob" - assert data_set._glob_function.keywords == { - "dbutils": get_dbutils_mock.return_value - } - - def test_get_dbutils_from_globals(self, mocker): - mocker.patch( - "kedro.extras.datasets.spark.spark_dataset.globals", - return_value={"dbutils": "dbutils_from_globals"}, - ) - assert _get_dbutils("spark") == "dbutils_from_globals" - - def test_get_dbutils_from_pyspark(self, mocker): - dbutils_mock = mocker.Mock() - dbutils_mock.DBUtils.return_value = "dbutils_from_pyspark" - mocker.patch.dict("sys.modules", {"pyspark.dbutils": dbutils_mock}) - assert _get_dbutils("spark") == "dbutils_from_pyspark" - dbutils_mock.DBUtils.assert_called_once_with("spark") - - def test_get_dbutils_from_ipython(self, mocker): - ipython_mock = mocker.Mock() - ipython_mock.get_ipython.return_value.user_ns = { - "dbutils": "dbutils_from_ipython" - } - mocker.patch.dict("sys.modules", {"IPython": ipython_mock}) - assert _get_dbutils("spark") == "dbutils_from_ipython" - ipython_mock.get_ipython.assert_called_once_with() - - def test_get_dbutils_no_modules(self, mocker): - mocker.patch( - "kedro.extras.datasets.spark.spark_dataset.globals", return_value={} - ) - mocker.patch.dict("sys.modules", {"pyspark": None, "IPython": None}) - assert _get_dbutils("spark") is None - - @pytest.mark.parametrize("os_name", ["nt", "posix"]) - def test_regular_path_in_different_os(self, os_name, mocker): - """Check that class of filepath depends on OS for regular path.""" - mocker.patch("os.name", os_name) - data_set = SparkDataSet(filepath="/some/path") - assert isinstance(data_set._filepath, PurePosixPath) - - @pytest.mark.parametrize("os_name", ["nt", "posix"]) - def test_dbfs_path_in_different_os(self, os_name, mocker): - """Check that class of filepath doesn't depend on OS if it references DBFS.""" - mocker.patch("os.name", os_name) - data_set = SparkDataSet(filepath="/dbfs/some/path") - assert isinstance(data_set._filepath, PurePosixPath) - - -class TestSparkDataSetVersionedS3: - def test_no_version(self, versioned_dataset_s3): - pattern = r"Did not find any versions for SparkDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_dataset_s3.load() - - def test_load_latest(self, mocker, versioned_dataset_s3): - get_spark = mocker.patch.object(versioned_dataset_s3, "_get_spark") - mocked_glob = mocker.patch.object(versioned_dataset_s3, "_glob_function") - mocked_glob.return_value = [ - "{b}/{f}/{v}/{f}".format(b=BUCKET_NAME, f=FILENAME, v="mocked_version") - ] - mocker.patch.object(versioned_dataset_s3, "_exists_function", return_value=True) - - versioned_dataset_s3.load() - - mocked_glob.assert_called_once_with( - "{b}/{f}/*/{f}".format(b=BUCKET_NAME, f=FILENAME) - ) - get_spark.return_value.read.load.assert_called_once_with( - "s3a://{b}/{f}/{v}/{f}".format( - b=BUCKET_NAME, f=FILENAME, v="mocked_version" - ), - "parquet", - ) - - def test_load_exact(self, mocker): - ts = generate_timestamp() - ds_s3 = SparkDataSet( - filepath=f"s3a://{BUCKET_NAME}/{FILENAME}", - version=Version(ts, None), - ) - get_spark = mocker.patch.object(ds_s3, "_get_spark") - - ds_s3.load() - - get_spark.return_value.read.load.assert_called_once_with( - "s3a://{b}/{f}/{v}/{f}".format(b=BUCKET_NAME, f=FILENAME, v=ts), "parquet" - ) - - def test_save(self, versioned_dataset_s3, version, mocker): - mocked_spark_df = mocker.Mock() - - # need resolve_load_version() call to return a load version that - # matches save version due to consistency check in versioned_dataset_s3.save() - mocker.patch.object( - versioned_dataset_s3, "resolve_load_version", return_value=version.save - ) - - versioned_dataset_s3.save(mocked_spark_df) - mocked_spark_df.write.save.assert_called_once_with( - "s3a://{b}/{f}/{v}/{f}".format(b=BUCKET_NAME, f=FILENAME, v=version.save), - "parquet", - ) - - def test_save_version_warning(self, mocker): - exact_version = Version("2019-01-01T23.59.59.999Z", "2019-01-02T00.00.00.000Z") - ds_s3 = SparkDataSet( - filepath=f"s3a://{BUCKET_NAME}/{FILENAME}", - version=exact_version, - credentials=AWS_CREDENTIALS, - ) - mocked_spark_df = mocker.Mock() - - pattern = ( - r"Save version '{ev.save}' did not match load version " - r"'{ev.load}' for SparkDataSet\(.+\)".format(ev=exact_version) - ) - with pytest.warns(UserWarning, match=pattern): - ds_s3.save(mocked_spark_df) - mocked_spark_df.write.save.assert_called_once_with( - "s3a://{b}/{f}/{v}/{f}".format( - b=BUCKET_NAME, f=FILENAME, v=exact_version.save - ), - "parquet", - ) - - def test_prevent_overwrite(self, mocker, versioned_dataset_s3): - mocked_spark_df = mocker.Mock() - mocker.patch.object(versioned_dataset_s3, "_exists_function", return_value=True) - - pattern = ( - r"Save path '.+' for SparkDataSet\(.+\) must not exist " - r"if versioning is enabled" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_dataset_s3.save(mocked_spark_df) - - mocked_spark_df.write.save.assert_not_called() - - def test_s3n_warning(self, version): - pattern = ( - "'s3n' filesystem has now been deprecated by Spark, " - "please consider switching to 's3a'" - ) - with pytest.warns(DeprecationWarning, match=pattern): - SparkDataSet(filepath=f"s3n://{BUCKET_NAME}/{FILENAME}", version=version) - - def test_repr(self, versioned_dataset_s3, version): - assert "filepath=s3a://" in str(versioned_dataset_s3) - assert f"version=Version(load=None, save='{version.save}')" in str( - versioned_dataset_s3 - ) - - dataset_s3 = SparkDataSet(filepath=f"s3a://{BUCKET_NAME}/{FILENAME}") - assert "filepath=s3a://" in str(dataset_s3) - assert "version=" not in str(dataset_s3) - - -class TestSparkDataSetVersionedHdfs: - def test_no_version(self, mocker, version): - hdfs_walk = mocker.patch( - "kedro.extras.datasets.spark.spark_dataset.InsecureClient.walk" - ) - hdfs_walk.return_value = [] - - versioned_hdfs = SparkDataSet(filepath=f"hdfs://{HDFS_PREFIX}", version=version) - - pattern = r"Did not find any versions for SparkDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_hdfs.load() - - hdfs_walk.assert_called_once_with(HDFS_PREFIX) - - def test_load_latest(self, mocker, version): - mocker.patch( - "kedro.extras.datasets.spark.spark_dataset.InsecureClient.status", - return_value=True, - ) - hdfs_walk = mocker.patch( - "kedro.extras.datasets.spark.spark_dataset.InsecureClient.walk" - ) - hdfs_walk.return_value = HDFS_FOLDER_STRUCTURE - - versioned_hdfs = SparkDataSet(filepath=f"hdfs://{HDFS_PREFIX}", version=version) - get_spark = mocker.patch.object(versioned_hdfs, "_get_spark") - - versioned_hdfs.load() - - hdfs_walk.assert_called_once_with(HDFS_PREFIX) - get_spark.return_value.read.load.assert_called_once_with( - "hdfs://{fn}/{f}/{v}/{f}".format( - fn=FOLDER_NAME, v="2019-01-02T01.00.00.000Z", f=FILENAME - ), - "parquet", - ) - - def test_load_exact(self, mocker): - ts = generate_timestamp() - versioned_hdfs = SparkDataSet( - filepath=f"hdfs://{HDFS_PREFIX}", version=Version(ts, None) - ) - get_spark = mocker.patch.object(versioned_hdfs, "_get_spark") - - versioned_hdfs.load() - - get_spark.return_value.read.load.assert_called_once_with( - "hdfs://{fn}/{f}/{v}/{f}".format(fn=FOLDER_NAME, f=FILENAME, v=ts), - "parquet", - ) - - def test_save(self, mocker, version): - hdfs_status = mocker.patch( - "kedro.extras.datasets.spark.spark_dataset.InsecureClient.status" - ) - hdfs_status.return_value = None - - versioned_hdfs = SparkDataSet(filepath=f"hdfs://{HDFS_PREFIX}", version=version) - - # need resolve_load_version() call to return a load version that - # matches save version due to consistency check in versioned_hdfs.save() - mocker.patch.object( - versioned_hdfs, "resolve_load_version", return_value=version.save - ) - - mocked_spark_df = mocker.Mock() - versioned_hdfs.save(mocked_spark_df) - - hdfs_status.assert_called_once_with( - "{fn}/{f}/{v}/{f}".format(fn=FOLDER_NAME, v=version.save, f=FILENAME), - strict=False, - ) - mocked_spark_df.write.save.assert_called_once_with( - "hdfs://{fn}/{f}/{v}/{f}".format( - fn=FOLDER_NAME, v=version.save, f=FILENAME - ), - "parquet", - ) - - def test_save_version_warning(self, mocker): - exact_version = Version("2019-01-01T23.59.59.999Z", "2019-01-02T00.00.00.000Z") - versioned_hdfs = SparkDataSet( - filepath=f"hdfs://{HDFS_PREFIX}", version=exact_version - ) - mocker.patch.object(versioned_hdfs, "_exists_function", return_value=False) - mocked_spark_df = mocker.Mock() - - pattern = ( - r"Save version '{ev.save}' did not match load version " - r"'{ev.load}' for SparkDataSet\(.+\)".format(ev=exact_version) - ) - - with pytest.warns(UserWarning, match=pattern): - versioned_hdfs.save(mocked_spark_df) - mocked_spark_df.write.save.assert_called_once_with( - "hdfs://{fn}/{f}/{sv}/{f}".format( - fn=FOLDER_NAME, f=FILENAME, sv=exact_version.save - ), - "parquet", - ) - - def test_prevent_overwrite(self, mocker, version): - hdfs_status = mocker.patch( - "kedro.extras.datasets.spark.spark_dataset.InsecureClient.status" - ) - hdfs_status.return_value = True - - versioned_hdfs = SparkDataSet(filepath=f"hdfs://{HDFS_PREFIX}", version=version) - - mocked_spark_df = mocker.Mock() - - pattern = ( - r"Save path '.+' for SparkDataSet\(.+\) must not exist " - r"if versioning is enabled" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_hdfs.save(mocked_spark_df) - - hdfs_status.assert_called_once_with( - "{fn}/{f}/{v}/{f}".format(fn=FOLDER_NAME, v=version.save, f=FILENAME), - strict=False, - ) - mocked_spark_df.write.save.assert_not_called() - - def test_hdfs_warning(self, version): - pattern = ( - "HDFS filesystem support for versioned SparkDataSet is in beta " - "and uses 'hdfs.client.InsecureClient', please use with caution" - ) - with pytest.warns(UserWarning, match=pattern): - SparkDataSet(filepath=f"hdfs://{HDFS_PREFIX}", version=version) - - def test_repr(self, version): - versioned_hdfs = SparkDataSet(filepath=f"hdfs://{HDFS_PREFIX}", version=version) - assert "filepath=hdfs://" in str(versioned_hdfs) - assert f"version=Version(load=None, save='{version.save}')" in str( - versioned_hdfs - ) - - dataset_hdfs = SparkDataSet(filepath=f"hdfs://{HDFS_PREFIX}") - assert "filepath=hdfs://" in str(dataset_hdfs) - assert "version=" not in str(dataset_hdfs) - - -@pytest.fixture -def data_catalog(tmp_path): - source_path = Path(__file__).parent / "data/test.parquet" - spark_in = SparkDataSet(source_path.as_posix()) - spark_out = SparkDataSet((tmp_path / "spark_data").as_posix()) - pickle_ds = PickleDataSet((tmp_path / "pickle/test.pkl").as_posix()) - - return DataCatalog( - {"spark_in": spark_in, "spark_out": spark_out, "pickle_ds": pickle_ds} - ) - - -@pytest.mark.parametrize("is_async", [False, True]) -class TestDataFlowSequentialRunner: - def test_spark_load_save(self, is_async, data_catalog): - """SparkDataSet(load) -> node -> Spark (save).""" - pipeline = modular_pipeline([node(identity, "spark_in", "spark_out")]) - SequentialRunner(is_async=is_async).run(pipeline, data_catalog) - - save_path = Path(data_catalog._data_sets["spark_out"]._filepath.as_posix()) - files = list(save_path.glob("*.parquet")) - assert len(files) > 0 - - def test_spark_pickle(self, is_async, data_catalog): - """SparkDataSet(load) -> node -> PickleDataSet (save)""" - pipeline = modular_pipeline([node(identity, "spark_in", "pickle_ds")]) - pattern = ".* was not serialised due to.*" - with pytest.raises(DatasetError, match=pattern): - SequentialRunner(is_async=is_async).run(pipeline, data_catalog) - - def test_spark_memory_spark(self, is_async, data_catalog): - """SparkDataSet(load) -> node -> MemoryDataSet (save and then load) -> - node -> SparkDataSet (save)""" - pipeline = modular_pipeline( - [ - node(identity, "spark_in", "memory_ds"), - node(identity, "memory_ds", "spark_out"), - ] - ) - SequentialRunner(is_async=is_async).run(pipeline, data_catalog) - - save_path = Path(data_catalog._data_sets["spark_out"]._filepath.as_posix()) - files = list(save_path.glob("*.parquet")) - assert len(files) > 0 diff --git a/tests/extras/datasets/spark/test_spark_hive_dataset.py b/tests/extras/datasets/spark/test_spark_hive_dataset.py deleted file mode 100644 index 399ebc4169..0000000000 --- a/tests/extras/datasets/spark/test_spark_hive_dataset.py +++ /dev/null @@ -1,311 +0,0 @@ -import gc -import re -from pathlib import Path -from tempfile import TemporaryDirectory - -import pytest -from psutil import Popen -from pyspark import SparkContext -from pyspark.sql import SparkSession -from pyspark.sql.types import IntegerType, StringType, StructField, StructType - -from kedro.extras.datasets.spark import SparkHiveDataSet -from kedro.io import DatasetError - -TESTSPARKDIR = "test_spark_dir" - - -@pytest.fixture(scope="module") -def spark_session(): - try: - with TemporaryDirectory(TESTSPARKDIR) as tmpdir: - spark = ( - SparkSession.builder.config( - "spark.local.dir", (Path(tmpdir) / "spark_local").absolute() - ) - .config( - "spark.sql.warehouse.dir", (Path(tmpdir) / "warehouse").absolute() - ) - .config( - "javax.jdo.option.ConnectionURL", - f"jdbc:derby:;" - f"databaseName={(Path(tmpdir) / 'warehouse_db').absolute()};" - f"create=true", - ) - .enableHiveSupport() - .getOrCreate() - ) - spark.sparkContext.setCheckpointDir( - str((Path(tmpdir) / "spark_checkpoint").absolute()) - ) - yield spark - - # This fixture should be a dependency of other fixtures dealing with spark hive data - # in this module so that it always exits last and stops the spark session - # after tests are finished. - spark.stop() - except PermissionError: # pragma: no cover - # On Windows machine TemporaryDirectory can't be removed because some - # files are still used by Java process. - pass - - # remove the cached JVM vars - SparkContext._jvm = None # pylint: disable=protected-access - SparkContext._gateway = None # pylint: disable=protected-access - - # py4j doesn't shutdown properly so kill the actual JVM process - for obj in gc.get_objects(): - try: - if isinstance(obj, Popen) and "pyspark" in obj.args[0]: - obj.terminate() # pragma: no cover - except ReferenceError: # pragma: no cover - # gc.get_objects may return dead weak proxy objects that will raise - # ReferenceError when you isinstance them - pass - - -@pytest.fixture(scope="module", autouse=True) -def spark_test_databases(spark_session): - """Setup spark test databases for all tests in this module.""" - dataset = _generate_spark_df_one() - dataset.createOrReplaceTempView("tmp") - databases = ["default_1", "default_2"] - - # Setup the databases and test table before testing - for database in databases: - spark_session.sql(f"create database {database}") - spark_session.sql("use default_1") - spark_session.sql("create table table_1 as select * from tmp") - - yield spark_session - - # Drop the databases after testing - for database in databases: - spark_session.sql(f"drop database {database} cascade") - - -def assert_df_equal(expected, result): - def indexRDD(data_frame): - return data_frame.rdd.zipWithIndex().map(lambda x: (x[1], x[0])) - - index_expected = indexRDD(expected) - index_result = indexRDD(result) - assert ( - index_expected.cogroup(index_result) - .map(lambda x: tuple(map(list, x[1]))) - .filter(lambda x: x[0] != x[1]) - .take(1) - == [] - ) - - -def _generate_spark_df_one(): - schema = StructType( - [ - StructField("name", StringType(), True), - StructField("age", IntegerType(), True), - ] - ) - data = [("Alex", 31), ("Bob", 12), ("Clarke", 65), ("Dave", 29)] - return SparkSession.builder.getOrCreate().createDataFrame(data, schema).coalesce(1) - - -def _generate_spark_df_upsert(): - schema = StructType( - [ - StructField("name", StringType(), True), - StructField("age", IntegerType(), True), - ] - ) - data = [("Alex", 99), ("Jeremy", 55)] - return SparkSession.builder.getOrCreate().createDataFrame(data, schema).coalesce(1) - - -def _generate_spark_df_upsert_expected(): - schema = StructType( - [ - StructField("name", StringType(), True), - StructField("age", IntegerType(), True), - ] - ) - data = [("Alex", 99), ("Bob", 12), ("Clarke", 65), ("Dave", 29), ("Jeremy", 55)] - return SparkSession.builder.getOrCreate().createDataFrame(data, schema).coalesce(1) - - -class TestSparkHiveDataSet: - def test_cant_pickle(self): - import pickle # pylint: disable=import-outside-toplevel - - with pytest.raises(pickle.PicklingError): - pickle.dumps( - SparkHiveDataSet( - database="default_1", table="table_1", write_mode="overwrite" - ) - ) - - def test_read_existing_table(self): - dataset = SparkHiveDataSet( - database="default_1", table="table_1", write_mode="overwrite", save_args={} - ) - assert_df_equal(_generate_spark_df_one(), dataset.load()) - - def test_overwrite_empty_table(self, spark_session): - spark_session.sql( - "create table default_1.test_overwrite_empty_table (name string, age integer)" - ).take(1) - dataset = SparkHiveDataSet( - database="default_1", - table="test_overwrite_empty_table", - write_mode="overwrite", - ) - dataset.save(_generate_spark_df_one()) - assert_df_equal(dataset.load(), _generate_spark_df_one()) - - def test_overwrite_not_empty_table(self, spark_session): - spark_session.sql( - "create table default_1.test_overwrite_full_table (name string, age integer)" - ).take(1) - dataset = SparkHiveDataSet( - database="default_1", - table="test_overwrite_full_table", - write_mode="overwrite", - ) - dataset.save(_generate_spark_df_one()) - dataset.save(_generate_spark_df_one()) - assert_df_equal(dataset.load(), _generate_spark_df_one()) - - def test_insert_not_empty_table(self, spark_session): - spark_session.sql( - "create table default_1.test_insert_not_empty_table (name string, age integer)" - ).take(1) - dataset = SparkHiveDataSet( - database="default_1", - table="test_insert_not_empty_table", - write_mode="append", - ) - dataset.save(_generate_spark_df_one()) - dataset.save(_generate_spark_df_one()) - assert_df_equal( - dataset.load(), _generate_spark_df_one().union(_generate_spark_df_one()) - ) - - def test_upsert_config_err(self): - # no pk provided should prompt config error - with pytest.raises( - DatasetError, match="'table_pk' must be set to utilise 'upsert' read mode" - ): - SparkHiveDataSet(database="default_1", table="table_1", write_mode="upsert") - - def test_upsert_empty_table(self, spark_session): - spark_session.sql( - "create table default_1.test_upsert_empty_table (name string, age integer)" - ).take(1) - dataset = SparkHiveDataSet( - database="default_1", - table="test_upsert_empty_table", - write_mode="upsert", - table_pk=["name"], - ) - dataset.save(_generate_spark_df_one()) - assert_df_equal( - dataset.load().sort("name"), _generate_spark_df_one().sort("name") - ) - - def test_upsert_not_empty_table(self, spark_session): - spark_session.sql( - "create table default_1.test_upsert_not_empty_table (name string, age integer)" - ).take(1) - dataset = SparkHiveDataSet( - database="default_1", - table="test_upsert_not_empty_table", - write_mode="upsert", - table_pk=["name"], - ) - dataset.save(_generate_spark_df_one()) - dataset.save(_generate_spark_df_upsert()) - - assert_df_equal( - dataset.load().sort("name"), - _generate_spark_df_upsert_expected().sort("name"), - ) - - def test_invalid_pk_provided(self): - _test_columns = ["column_doesnt_exist"] - dataset = SparkHiveDataSet( - database="default_1", - table="table_1", - write_mode="upsert", - table_pk=_test_columns, - ) - with pytest.raises( - DatasetError, - match=re.escape( - f"Columns {str(_test_columns)} selected as primary key(s) " - f"not found in table default_1.table_1", - ), - ): - dataset.save(_generate_spark_df_one()) - - def test_invalid_write_mode_provided(self): - pattern = ( - "Invalid 'write_mode' provided: not_a_write_mode. " - "'write_mode' must be one of: " - "append, error, errorifexists, upsert, overwrite" - ) - with pytest.raises(DatasetError, match=re.escape(pattern)): - SparkHiveDataSet( - database="default_1", - table="table_1", - write_mode="not_a_write_mode", - table_pk=["name"], - ) - - def test_invalid_schema_insert(self, spark_session): - spark_session.sql( - "create table default_1.test_invalid_schema_insert " - "(name string, additional_column_on_hive integer)" - ).take(1) - dataset = SparkHiveDataSet( - database="default_1", - table="test_invalid_schema_insert", - write_mode="append", - ) - with pytest.raises( - DatasetError, - match=r"Dataset does not match hive table schema\.\n" - r"Present on insert only: \[\('age', 'int'\)\]\n" - r"Present on schema only: \[\('additional_column_on_hive', 'int'\)\]", - ): - dataset.save(_generate_spark_df_one()) - - def test_insert_to_non_existent_table(self): - dataset = SparkHiveDataSet( - database="default_1", table="table_not_yet_created", write_mode="append" - ) - dataset.save(_generate_spark_df_one()) - assert_df_equal( - dataset.load().sort("name"), _generate_spark_df_one().sort("name") - ) - - def test_read_from_non_existent_table(self): - dataset = SparkHiveDataSet( - database="default_1", table="table_doesnt_exist", write_mode="append" - ) - with pytest.raises( - DatasetError, - match=r"Failed while loading data from data set SparkHiveDataSet" - r"|table_doesnt_exist" - r"|UnresolvedRelation", - ): - dataset.load() - - def test_save_delta_format(self, mocker): - dataset = SparkHiveDataSet( - database="default_1", table="delta_table", save_args={"format": "delta"} - ) - mocked_save = mocker.patch("pyspark.sql.DataFrameWriter.saveAsTable") - dataset.save(_generate_spark_df_one()) - mocked_save.assert_called_with( - "default_1.delta_table", mode="errorifexists", format="delta" - ) - assert dataset._format == "delta" diff --git a/tests/extras/datasets/spark/test_spark_jdbc_dataset.py b/tests/extras/datasets/spark/test_spark_jdbc_dataset.py deleted file mode 100644 index 6d89251fc5..0000000000 --- a/tests/extras/datasets/spark/test_spark_jdbc_dataset.py +++ /dev/null @@ -1,113 +0,0 @@ -import pytest - -from kedro.extras.datasets.spark import SparkJDBCDataSet -from kedro.io import DatasetError - - -@pytest.fixture -def spark_jdbc_args(): - return {"url": "dummy_url", "table": "dummy_table"} - - -@pytest.fixture -def spark_jdbc_args_credentials(spark_jdbc_args): - args = spark_jdbc_args - args.update({"credentials": {"user": "dummy_user", "password": "dummy_pw"}}) - return args - - -@pytest.fixture -def spark_jdbc_args_credentials_with_none_password(spark_jdbc_args): - args = spark_jdbc_args - args.update({"credentials": {"user": "dummy_user", "password": None}}) - return args - - -@pytest.fixture -def spark_jdbc_args_save_load(spark_jdbc_args): - args = spark_jdbc_args - connection_properties = {"properties": {"driver": "dummy_driver"}} - args.update( - {"save_args": connection_properties, "load_args": connection_properties} - ) - return args - - -def test_missing_url(): - error_message = ( - "'url' argument cannot be empty. Please provide a JDBC" - " URL of the form 'jdbc:subprotocol:subname'." - ) - with pytest.raises(DatasetError, match=error_message): - SparkJDBCDataSet(url=None, table="dummy_table") - - -def test_missing_table(): - error_message = ( - "'table' argument cannot be empty. Please provide" - " the name of the table to load or save data to." - ) - with pytest.raises(DatasetError, match=error_message): - SparkJDBCDataSet(url="dummy_url", table=None) - - -def test_save(mocker, spark_jdbc_args): - mock_data = mocker.Mock() - data_set = SparkJDBCDataSet(**spark_jdbc_args) - data_set.save(mock_data) - mock_data.write.jdbc.assert_called_with("dummy_url", "dummy_table") - - -def test_save_credentials(mocker, spark_jdbc_args_credentials): - mock_data = mocker.Mock() - data_set = SparkJDBCDataSet(**spark_jdbc_args_credentials) - data_set.save(mock_data) - mock_data.write.jdbc.assert_called_with( - "dummy_url", - "dummy_table", - properties={"user": "dummy_user", "password": "dummy_pw"}, - ) - - -def test_save_args(mocker, spark_jdbc_args_save_load): - mock_data = mocker.Mock() - data_set = SparkJDBCDataSet(**spark_jdbc_args_save_load) - data_set.save(mock_data) - mock_data.write.jdbc.assert_called_with( - "dummy_url", "dummy_table", properties={"driver": "dummy_driver"} - ) - - -def test_except_bad_credentials(mocker, spark_jdbc_args_credentials_with_none_password): - pattern = r"Credential property 'password' cannot be None(.+)" - with pytest.raises(DatasetError, match=pattern): - mock_data = mocker.Mock() - data_set = SparkJDBCDataSet(**spark_jdbc_args_credentials_with_none_password) - data_set.save(mock_data) - - -def test_load(mocker, spark_jdbc_args): - spark = mocker.patch.object(SparkJDBCDataSet, "_get_spark").return_value - data_set = SparkJDBCDataSet(**spark_jdbc_args) - data_set.load() - spark.read.jdbc.assert_called_with("dummy_url", "dummy_table") - - -def test_load_credentials(mocker, spark_jdbc_args_credentials): - spark = mocker.patch.object(SparkJDBCDataSet, "_get_spark").return_value - data_set = SparkJDBCDataSet(**spark_jdbc_args_credentials) - data_set.load() - spark.read.jdbc.assert_called_with( - "dummy_url", - "dummy_table", - properties={"user": "dummy_user", "password": "dummy_pw"}, - ) - - -def test_load_args(mocker, spark_jdbc_args_save_load): - spark = mocker.patch.object(SparkJDBCDataSet, "_get_spark").return_value - data_set = SparkJDBCDataSet(**spark_jdbc_args_save_load) - data_set.load() - spark.read.jdbc.assert_called_with( - "dummy_url", "dummy_table", properties={"driver": "dummy_driver"} - ) diff --git a/tests/extras/datasets/tensorflow/__init__.py b/tests/extras/datasets/tensorflow/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/extras/datasets/tensorflow/test_tensorflow_model_dataset.py b/tests/extras/datasets/tensorflow/test_tensorflow_model_dataset.py deleted file mode 100644 index 69c5c46149..0000000000 --- a/tests/extras/datasets/tensorflow/test_tensorflow_model_dataset.py +++ /dev/null @@ -1,441 +0,0 @@ -# pylint: disable=import-outside-toplevel -from pathlib import PurePosixPath - -import numpy as np -import pytest -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from s3fs import S3FileSystem - -from kedro.io import DatasetError -from kedro.io.core import PROTOCOL_DELIMITER, Version - - -# In this test module, we wrap tensorflow and TensorFlowModelDataset imports into a module-scoped -# fixtures to avoid them being evaluated immediately when a new test process is spawned. -# Specifically: -# - ParallelRunner spawns a new subprocess. -# - pytest coverage is initialised on every new subprocess to update the global coverage -# statistics. -# - Coverage has to import the tests including tensorflow tests, which then import tensorflow. -# - tensorflow in eager mode triggers the remove_function method in -# tensorflow/python/eager/context.py, which acquires a threading.Lock. -# - Using a mutex/condition variable after fork (from the child process) is unsafe: -# it can lead to deadlocks" and can lead to segfault. -# -# So tl;dr is pytest-coverage importing of tensorflow creates a potential deadlock within -# a subprocess spawned by the parallel runner, so we wrap the import inside fixtures. -@pytest.fixture(scope="module") -def tf(): - import tensorflow as tf - - return tf - - -@pytest.fixture(scope="module") -def tensorflow_model_dataset(): - from kedro.extras.datasets.tensorflow import TensorFlowModelDataset - - return TensorFlowModelDataset - - -@pytest.fixture -def filepath(tmp_path): - return (tmp_path / "test_tf").as_posix() - - -@pytest.fixture -def dummy_x_train(): - return np.array([[[1.0], [1.0]], [[0.0], [0.0]]]) - - -@pytest.fixture -def dummy_y_train(): - return np.array([[[1], [1]], [[1], [1]]]) - - -@pytest.fixture -def dummy_x_test(): - return np.array([[[0.0], [0.0]], [[1.0], [1.0]]]) - - -@pytest.fixture -def tf_model_dataset(filepath, load_args, save_args, fs_args, tensorflow_model_dataset): - return tensorflow_model_dataset( - filepath=filepath, load_args=load_args, save_args=save_args, fs_args=fs_args - ) - - -@pytest.fixture -def versioned_tf_model_dataset( - filepath, load_version, save_version, tensorflow_model_dataset -): - return tensorflow_model_dataset( - filepath=filepath, version=Version(load_version, save_version) - ) - - -@pytest.fixture -def dummy_tf_base_model(dummy_x_train, dummy_y_train, tf): - # dummy 1 layer model as used in TF tests, see - # https://github.com/tensorflow/tensorflow/blob/8de272b3f3b73bea8d947c5f15143a9f1cfcfc6f/tensorflow/python/keras/models_test.py#L342 - inputs = tf.keras.Input(shape=(2, 1)) - x = tf.keras.layers.Dense(1)(inputs) - outputs = tf.keras.layers.Dense(1)(x) - - model = tf.keras.Model(inputs=inputs, outputs=outputs, name="1_layer_dummy") - model.compile("rmsprop", "mse") - model.fit(dummy_x_train, dummy_y_train, batch_size=64, epochs=1) - # from https://www.tensorflow.org/guide/keras/save_and_serialize - # Reset metrics before saving so that loaded model has same state, - # since metric states are not preserved by Model.save_weights - model.reset_metrics() - return model - - -@pytest.fixture -def dummy_tf_base_model_new(dummy_x_train, dummy_y_train, tf): - # dummy 2 layer model - inputs = tf.keras.Input(shape=(2, 1)) - x = tf.keras.layers.Dense(1)(inputs) - x = tf.keras.layers.Dense(1)(x) - outputs = tf.keras.layers.Dense(1)(x) - - model = tf.keras.Model(inputs=inputs, outputs=outputs, name="2_layer_dummy") - model.compile("rmsprop", "mse") - model.fit(dummy_x_train, dummy_y_train, batch_size=64, epochs=1) - # from https://www.tensorflow.org/guide/keras/save_and_serialize - # Reset metrics before saving so that loaded model has same state, - # since metric states are not preserved by Model.save_weights - model.reset_metrics() - return model - - -@pytest.fixture -def dummy_tf_subclassed_model(dummy_x_train, dummy_y_train, tf): - """Demonstrate that own class models cannot be saved - using HDF5 format but can using TF format - """ - - class MyModel(tf.keras.Model): - def __init__(self): - super().__init__() - self.dense1 = tf.keras.layers.Dense(4, activation=tf.nn.relu) - self.dense2 = tf.keras.layers.Dense(5, activation=tf.nn.softmax) - - # pylint: disable=unused-argument - def call(self, inputs, training=None, mask=None): # pragma: no cover - x = self.dense1(inputs) - return self.dense2(x) - - model = MyModel() - model.compile("rmsprop", "mse") - model.fit(dummy_x_train, dummy_y_train, batch_size=64, epochs=1) - return model - - -class TestTensorFlowModelDataset: - """No versioning passed to creator""" - - def test_save_and_load(self, tf_model_dataset, dummy_tf_base_model, dummy_x_test): - """Test saving and reloading the data set.""" - predictions = dummy_tf_base_model.predict(dummy_x_test) - tf_model_dataset.save(dummy_tf_base_model) - - reloaded = tf_model_dataset.load() - new_predictions = reloaded.predict(dummy_x_test) - np.testing.assert_allclose(predictions, new_predictions, rtol=1e-6, atol=1e-6) - - assert tf_model_dataset._load_args == {} - assert tf_model_dataset._save_args == {"save_format": "tf"} - - def test_load_missing_model(self, tf_model_dataset): - """Test error message when trying to load missing model.""" - pattern = ( - r"Failed while loading data from data set TensorFlowModelDataset\(.*\)" - ) - with pytest.raises(DatasetError, match=pattern): - tf_model_dataset.load() - - def test_exists(self, tf_model_dataset, dummy_tf_base_model): - """Test `exists` method invocation for both existing and nonexistent data set.""" - assert not tf_model_dataset.exists() - tf_model_dataset.save(dummy_tf_base_model) - assert tf_model_dataset.exists() - - def test_hdf5_save_format( - self, dummy_tf_base_model, dummy_x_test, filepath, tensorflow_model_dataset - ): - """Test TensorflowModelDataset can save TF graph models in HDF5 format""" - hdf5_dataset = tensorflow_model_dataset( - filepath=filepath, save_args={"save_format": "h5"} - ) - - predictions = dummy_tf_base_model.predict(dummy_x_test) - hdf5_dataset.save(dummy_tf_base_model) - - reloaded = hdf5_dataset.load() - new_predictions = reloaded.predict(dummy_x_test) - np.testing.assert_allclose(predictions, new_predictions, rtol=1e-6, atol=1e-6) - - def test_unused_subclass_model_hdf5_save_format( - self, - dummy_tf_subclassed_model, - dummy_x_train, - dummy_y_train, - dummy_x_test, - filepath, - tensorflow_model_dataset, - ): - """Test TensorflowModelDataset cannot save subclassed user models in HDF5 format - - Subclassed model - - From TF docs - First of all, a subclassed model that has never been used cannot be saved. - That's because a subclassed model needs to be called on some data in order to - create its weights. - """ - hdf5_data_set = tensorflow_model_dataset( - filepath=filepath, save_args={"save_format": "h5"} - ) - # demonstrating is a working model - dummy_tf_subclassed_model.fit( - dummy_x_train, dummy_y_train, batch_size=64, epochs=1 - ) - dummy_tf_subclassed_model.predict(dummy_x_test) - pattern = ( - r"Saving the model to HDF5 format requires the model to be a Functional model or a " - r"Sequential model. It does not work for subclassed models, because such models are " - r"defined via the body of a Python method, which isn\'t safely serializable. Consider " - r"saving to the Tensorflow SavedModel format \(by setting save_format=\"tf\"\) " - r"or using `save_weights`." - ) - with pytest.raises(DatasetError, match=pattern): - hdf5_data_set.save(dummy_tf_subclassed_model) - - @pytest.mark.parametrize( - "filepath,instance_type", - [ - ("s3://bucket/test_tf", S3FileSystem), - ("file:///tmp/test_tf", LocalFileSystem), - ("/tmp/test_tf", LocalFileSystem), - ("gcs://bucket/test_tf", GCSFileSystem), - ("https://example.com/test_tf", HTTPFileSystem), - ], - ) - def test_protocol_usage(self, filepath, instance_type, tensorflow_model_dataset): - """Test that can be instantiated with mocked arbitrary file systems.""" - data_set = tensorflow_model_dataset(filepath=filepath) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - @pytest.mark.parametrize( - "load_args", [{"k1": "v1", "compile": False}], indirect=True - ) - def test_load_extra_params(self, tf_model_dataset, load_args): - """Test overriding the default load arguments.""" - for key, value in load_args.items(): - assert tf_model_dataset._load_args[key] == value - - def test_catalog_release(self, mocker, tensorflow_model_dataset): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.tf" - data_set = tensorflow_model_dataset(filepath=filepath) - assert data_set._version_cache.currsize == 0 # no cache if unversioned - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - assert data_set._version_cache.currsize == 0 - - @pytest.mark.parametrize("fs_args", [{"storage_option": "value"}]) - def test_fs_args(self, fs_args, mocker, tensorflow_model_dataset): - fs_mock = mocker.patch("fsspec.filesystem") - tensorflow_model_dataset("test.tf", fs_args=fs_args) - - fs_mock.assert_called_once_with("file", auto_mkdir=True, storage_option="value") - - def test_exists_with_exception(self, tf_model_dataset, mocker): - """Test `exists` method invocation when `get_filepath_str` raises an exception.""" - mocker.patch("kedro.io.core.get_filepath_str", side_effect=DatasetError) - assert not tf_model_dataset.exists() - - def test_save_and_overwrite_existing_model( - self, tf_model_dataset, dummy_tf_base_model, dummy_tf_base_model_new - ): - """Test models are correcty overwritten.""" - tf_model_dataset.save(dummy_tf_base_model) - - tf_model_dataset.save(dummy_tf_base_model_new) - - reloaded = tf_model_dataset.load() - - assert len(dummy_tf_base_model.layers) != len(reloaded.layers) - assert len(dummy_tf_base_model_new.layers) == len(reloaded.layers) - - -class TestTensorFlowModelDatasetVersioned: - """Test suite with versioning argument passed into TensorFlowModelDataset creator""" - - @pytest.mark.parametrize( - "load_version,save_version", - [ - ( - "2019-01-01T23.59.59.999Z", - "2019-01-01T23.59.59.999Z", - ), # long version names can fail on Win machines due to 260 max filepath - ( - None, - None, - ), # passing None default behaviour of generating timestamp for current time - ], - indirect=True, - ) - def test_save_and_load( - self, - dummy_tf_base_model, - versioned_tf_model_dataset, - dummy_x_test, - load_version, - save_version, - ): # pylint: disable=unused-argument - """Test saving and reloading the versioned data set.""" - - predictions = dummy_tf_base_model.predict(dummy_x_test) - versioned_tf_model_dataset.save(dummy_tf_base_model) - - reloaded = versioned_tf_model_dataset.load() - new_predictions = reloaded.predict(dummy_x_test) - np.testing.assert_allclose(predictions, new_predictions, rtol=1e-6, atol=1e-6) - - def test_hdf5_save_format( - self, - dummy_tf_base_model, - dummy_x_test, - filepath, - tensorflow_model_dataset, - load_version, - save_version, - ): - """Test versioned TensorflowModelDataset can save TF graph models in - HDF5 format""" - hdf5_dataset = tensorflow_model_dataset( - filepath=filepath, - save_args={"save_format": "h5"}, - version=Version(load_version, save_version), - ) - - predictions = dummy_tf_base_model.predict(dummy_x_test) - hdf5_dataset.save(dummy_tf_base_model) - - reloaded = hdf5_dataset.load() - new_predictions = reloaded.predict(dummy_x_test) - np.testing.assert_allclose(predictions, new_predictions, rtol=1e-6, atol=1e-6) - - def test_prevent_overwrite(self, dummy_tf_base_model, versioned_tf_model_dataset): - """Check the error when attempting to override the data set if the - corresponding file for a given save version already exists.""" - versioned_tf_model_dataset.save(dummy_tf_base_model) - pattern = ( - r"Save path \'.+\' for TensorFlowModelDataset\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - versioned_tf_model_dataset.save(dummy_tf_base_model) - - @pytest.mark.parametrize( - "load_version,save_version", - [("2019-01-01T23.59.59.999Z", "2019-01-02T00.00.00.000Z")], - indirect=True, - ) - def test_save_version_warning( - self, - versioned_tf_model_dataset, - load_version, - save_version, - dummy_tf_base_model, - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - rf"Save version '{save_version}' did not match load version '{load_version}' " - rf"for TensorFlowModelDataset\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_tf_model_dataset.save(dummy_tf_base_model) - - def test_http_filesystem_no_versioning(self, tensorflow_model_dataset): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - tensorflow_model_dataset( - filepath="https://example.com/file.tf", version=Version(None, None) - ) - - def test_exists(self, versioned_tf_model_dataset, dummy_tf_base_model): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_tf_model_dataset.exists() - versioned_tf_model_dataset.save(dummy_tf_base_model) - assert versioned_tf_model_dataset.exists() - - def test_no_versions(self, versioned_tf_model_dataset): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for TensorFlowModelDataset\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_tf_model_dataset.load() - - def test_version_str_repr(self, tf_model_dataset, versioned_tf_model_dataset): - """Test that version is in string representation of the class instance - when applicable.""" - - assert str(tf_model_dataset._filepath) in str(tf_model_dataset) - assert "version=" not in str(tf_model_dataset) - assert "protocol" in str(tf_model_dataset) - assert "save_args" in str(tf_model_dataset) - - assert str(versioned_tf_model_dataset._filepath) in str( - versioned_tf_model_dataset - ) - ver_str = f"version={versioned_tf_model_dataset._version}" - assert ver_str in str(versioned_tf_model_dataset) - assert "protocol" in str(versioned_tf_model_dataset) - assert "save_args" in str(versioned_tf_model_dataset) - - def test_versioning_existing_dataset( - self, tf_model_dataset, versioned_tf_model_dataset, dummy_tf_base_model - ): - """Check behavior when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset. Note: because TensorFlowModelDataset - saves to a directory even if non-versioned, an error is not expected.""" - tf_model_dataset.save(dummy_tf_base_model) - assert tf_model_dataset.exists() - assert tf_model_dataset._filepath == versioned_tf_model_dataset._filepath - versioned_tf_model_dataset.save(dummy_tf_base_model) - assert versioned_tf_model_dataset.exists() - - def test_save_and_load_with_device( - self, - dummy_tf_base_model, - dummy_x_test, - filepath, - tensorflow_model_dataset, - load_version, - save_version, - ): - """Test versioned TensorflowModelDataset can load models using an explicit tf_device""" - hdf5_dataset = tensorflow_model_dataset( - filepath=filepath, - load_args={"tf_device": "/CPU:0"}, - version=Version(load_version, save_version), - ) - - predictions = dummy_tf_base_model.predict(dummy_x_test) - hdf5_dataset.save(dummy_tf_base_model) - - reloaded = hdf5_dataset.load() - new_predictions = reloaded.predict(dummy_x_test) - np.testing.assert_allclose(predictions, new_predictions, rtol=1e-6, atol=1e-6) diff --git a/tests/extras/datasets/text/__init__.py b/tests/extras/datasets/text/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/extras/datasets/text/test_text_dataset.py b/tests/extras/datasets/text/test_text_dataset.py deleted file mode 100644 index 1cb866988d..0000000000 --- a/tests/extras/datasets/text/test_text_dataset.py +++ /dev/null @@ -1,187 +0,0 @@ -from pathlib import Path, PurePosixPath - -import pytest -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.text import TextDataSet -from kedro.io import DatasetError -from kedro.io.core import PROTOCOL_DELIMITER, Version - -STRING = "Write to text file." - - -@pytest.fixture -def filepath_txt(tmp_path): - return (tmp_path / "test.txt").as_posix() - - -@pytest.fixture -def txt_data_set(filepath_txt, fs_args): - return TextDataSet(filepath=filepath_txt, fs_args=fs_args) - - -@pytest.fixture -def versioned_txt_data_set(filepath_txt, load_version, save_version): - return TextDataSet( - filepath=filepath_txt, version=Version(load_version, save_version) - ) - - -class TestTextDataSet: - def test_save_and_load(self, txt_data_set): - """Test saving and reloading the data set.""" - txt_data_set.save(STRING) - reloaded = txt_data_set.load() - assert STRING == reloaded - assert txt_data_set._fs_open_args_load == {"mode": "r"} - assert txt_data_set._fs_open_args_save == {"mode": "w"} - - def test_exists(self, txt_data_set): - """Test `exists` method invocation for both existing and - nonexistent data set.""" - assert not txt_data_set.exists() - txt_data_set.save(STRING) - assert txt_data_set.exists() - - @pytest.mark.parametrize( - "fs_args", - [{"open_args_load": {"mode": "rb", "compression": "gzip"}}], - indirect=True, - ) - def test_open_extra_args(self, txt_data_set, fs_args): - assert txt_data_set._fs_open_args_load == fs_args["open_args_load"] - assert txt_data_set._fs_open_args_save == {"mode": "w"} # default unchanged - - def test_load_missing_file(self, txt_data_set): - """Check the error when trying to load missing file.""" - pattern = r"Failed while loading data from data set TextDataSet\(.*\)" - with pytest.raises(DatasetError, match=pattern): - txt_data_set.load() - - @pytest.mark.parametrize( - "filepath,instance_type", - [ - ("s3://bucket/file.txt", S3FileSystem), - ("file:///tmp/test.txt", LocalFileSystem), - ("/tmp/test.txt", LocalFileSystem), - ("gcs://bucket/file.txt", GCSFileSystem), - ("https://example.com/file.txt", HTTPFileSystem), - ], - ) - def test_protocol_usage(self, filepath, instance_type): - data_set = TextDataSet(filepath=filepath) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.txt" - data_set = TextDataSet(filepath=filepath) - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - - -class TestTextDataSetVersioned: - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - filepath = "test.txt" - ds = TextDataSet(filepath=filepath) - ds_versioned = TextDataSet( - filepath=filepath, version=Version(load_version, save_version) - ) - assert filepath in str(ds) - assert "version" not in str(ds) - - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "TextDataSet" in str(ds_versioned) - assert "TextDataSet" in str(ds) - assert "protocol" in str(ds_versioned) - assert "protocol" in str(ds) - - def test_save_and_load(self, versioned_txt_data_set): - """Test that saved and reloaded data matches the original one for - the versioned data set.""" - versioned_txt_data_set.save(STRING) - reloaded_df = versioned_txt_data_set.load() - assert STRING == reloaded_df - - def test_no_versions(self, versioned_txt_data_set): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for TextDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_txt_data_set.load() - - def test_exists(self, versioned_txt_data_set): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_txt_data_set.exists() - versioned_txt_data_set.save(STRING) - assert versioned_txt_data_set.exists() - - def test_prevent_overwrite(self, versioned_txt_data_set): - """Check the error when attempting to override the data set if the - corresponding text file for a given save version already exists.""" - versioned_txt_data_set.save(STRING) - pattern = ( - r"Save path \'.+\' for TextDataSet\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - versioned_txt_data_set.save(STRING) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, versioned_txt_data_set, load_version, save_version - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - rf"Save version '{save_version}' did not match load version " - rf"'{load_version}' for TextDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_txt_data_set.save(STRING) - - def test_http_filesystem_no_versioning(self): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - TextDataSet( - filepath="https://example.com/file.txt", version=Version(None, None) - ) - - def test_versioning_existing_dataset( - self, - txt_data_set, - versioned_txt_data_set, - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - txt_data_set.save(STRING) - assert txt_data_set.exists() - assert txt_data_set._filepath == versioned_txt_data_set._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_txt_data_set._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_txt_data_set.save(STRING) - - # Remove non-versioned dataset and try again - Path(txt_data_set._filepath.as_posix()).unlink() - versioned_txt_data_set.save(STRING) - assert versioned_txt_data_set.exists() diff --git a/tests/extras/datasets/tracking/__init__.py b/tests/extras/datasets/tracking/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/extras/datasets/tracking/test_json_dataset.py b/tests/extras/datasets/tracking/test_json_dataset.py deleted file mode 100644 index 9e0c046558..0000000000 --- a/tests/extras/datasets/tracking/test_json_dataset.py +++ /dev/null @@ -1,185 +0,0 @@ -import json -from pathlib import Path, PurePosixPath - -import pytest -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.tracking import JSONDataSet -from kedro.io import DatasetError -from kedro.io.core import PROTOCOL_DELIMITER, Version - - -@pytest.fixture -def filepath_json(tmp_path): - return (tmp_path / "test.json").as_posix() - - -@pytest.fixture -def json_dataset(filepath_json, save_args, fs_args): - return JSONDataSet(filepath=filepath_json, save_args=save_args, fs_args=fs_args) - - -@pytest.fixture -def explicit_versioned_json_dataset(filepath_json, load_version, save_version): - return JSONDataSet( - filepath=filepath_json, version=Version(load_version, save_version) - ) - - -@pytest.fixture -def dummy_data(): - return {"col1": 1, "col2": 2, "col3": "mystring"} - - -class TestJSONDataSet: - def test_save(self, filepath_json, dummy_data, tmp_path, save_version): - """Test saving and reloading the data set.""" - json_dataset = JSONDataSet( - filepath=filepath_json, version=Version(None, save_version) - ) - json_dataset.save(dummy_data) - - actual_filepath = Path(json_dataset._filepath.as_posix()) - test_filepath = tmp_path / "locally_saved.json" - - test_filepath.parent.mkdir(parents=True, exist_ok=True) - with open(test_filepath, "w", encoding="utf-8") as file: - json.dump(dummy_data, file) - - with open(test_filepath, encoding="utf-8") as file: - test_data = json.load(file) - - with open( - (actual_filepath / save_version / "test.json"), encoding="utf-8" - ) as actual_file: - actual_data = json.load(actual_file) - - assert actual_data == test_data - assert json_dataset._fs_open_args_load == {} - assert json_dataset._fs_open_args_save == {"mode": "w"} - - def test_load_fail(self, json_dataset, dummy_data): - json_dataset.save(dummy_data) - pattern = r"Loading not supported for 'JSONDataSet'" - with pytest.raises(DatasetError, match=pattern): - json_dataset.load() - - def test_exists(self, json_dataset, dummy_data): - """Test `exists` method invocation for both existing and - nonexistent data set.""" - assert not json_dataset.exists() - json_dataset.save(dummy_data) - assert json_dataset.exists() - - @pytest.mark.parametrize( - "save_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_save_extra_params(self, json_dataset, save_args): - """Test overriding the default save arguments.""" - for key, value in save_args.items(): - assert json_dataset._save_args[key] == value - - @pytest.mark.parametrize( - "fs_args", - [{"open_args_load": {"mode": "rb", "compression": "gzip"}}], - indirect=True, - ) - def test_open_extra_args(self, json_dataset, fs_args): - assert json_dataset._fs_open_args_load == fs_args["open_args_load"] - assert json_dataset._fs_open_args_save == {"mode": "w"} # default unchanged - - @pytest.mark.parametrize( - "filepath,instance_type", - [ - ("s3://bucket/file.json", S3FileSystem), - ("file:///tmp/test.json", LocalFileSystem), - ("/tmp/test.json", LocalFileSystem), - ("gcs://bucket/file.json", GCSFileSystem), - ], - ) - def test_protocol_usage(self, filepath, instance_type): - data_set = JSONDataSet(filepath=filepath) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.json" - data_set = JSONDataSet(filepath=filepath) - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - - def test_not_version_str_repr(self): - """Test that version is not in string representation of the class instance.""" - filepath = "test.json" - ds = JSONDataSet(filepath=filepath) - - assert filepath in str(ds) - assert "version" not in str(ds) - assert "JSONDataSet" in str(ds) - assert "protocol" in str(ds) - # Default save_args - assert "save_args={'indent': 2}" in str(ds) - - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance.""" - filepath = "test.json" - ds_versioned = JSONDataSet( - filepath=filepath, version=Version(load_version, save_version) - ) - - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "JSONDataSet" in str(ds_versioned) - assert "protocol" in str(ds_versioned) - # Default save_args - assert "save_args={'indent': 2}" in str(ds_versioned) - - def test_prevent_overwrite(self, explicit_versioned_json_dataset, dummy_data): - """Check the error when attempting to override the data set if the - corresponding json file for a given save version already exists.""" - explicit_versioned_json_dataset.save(dummy_data) - pattern = ( - r"Save path \'.+\' for JSONDataSet\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - explicit_versioned_json_dataset.save(dummy_data) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, - explicit_versioned_json_dataset, - load_version, - save_version, - dummy_data, - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - f"Save version '{save_version}' did not match " - f"load version '{load_version}' for " - r"JSONDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - explicit_versioned_json_dataset.save(dummy_data) - - def test_http_filesystem_no_versioning(self): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - JSONDataSet( - filepath="https://example.com/file.json", version=Version(None, None) - ) diff --git a/tests/extras/datasets/tracking/test_metrics_dataset.py b/tests/extras/datasets/tracking/test_metrics_dataset.py deleted file mode 100644 index d65b50215d..0000000000 --- a/tests/extras/datasets/tracking/test_metrics_dataset.py +++ /dev/null @@ -1,194 +0,0 @@ -import json -from pathlib import Path, PurePosixPath - -import pytest -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.tracking import MetricsDataSet -from kedro.io import DatasetError -from kedro.io.core import PROTOCOL_DELIMITER, Version - - -@pytest.fixture -def filepath_json(tmp_path): - return (tmp_path / "test.json").as_posix() - - -@pytest.fixture -def metrics_dataset(filepath_json, save_args, fs_args): - return MetricsDataSet(filepath=filepath_json, save_args=save_args, fs_args=fs_args) - - -@pytest.fixture -def explicit_versioned_metrics_dataset(filepath_json, load_version, save_version): - return MetricsDataSet( - filepath=filepath_json, version=Version(load_version, save_version) - ) - - -@pytest.fixture -def dummy_data(): - return {"col1": 1, "col2": 2, "col3": 3} - - -class TestMetricsDataSet: - def test_save_data( - self, - dummy_data, - tmp_path, - filepath_json, - save_version, - ): - """Test saving and reloading the data set.""" - metrics_dataset = MetricsDataSet( - filepath=filepath_json, version=Version(None, save_version) - ) - metrics_dataset.save(dummy_data) - - actual_filepath = Path(metrics_dataset._filepath.as_posix()) - test_filepath = tmp_path / "locally_saved.json" - - test_filepath.parent.mkdir(parents=True, exist_ok=True) - with open(test_filepath, "w", encoding="utf-8") as file: - json.dump(dummy_data, file) - - with open(test_filepath, encoding="utf-8") as file: - test_data = json.load(file) - - with open( - (actual_filepath / save_version / "test.json"), encoding="utf-8" - ) as actual_file: - actual_data = json.load(actual_file) - - assert actual_data == test_data - assert metrics_dataset._fs_open_args_load == {} - assert metrics_dataset._fs_open_args_save == {"mode": "w"} - - def test_load_fail(self, metrics_dataset, dummy_data): - metrics_dataset.save(dummy_data) - pattern = r"Loading not supported for 'MetricsDataSet'" - with pytest.raises(DatasetError, match=pattern): - metrics_dataset.load() - - def test_exists(self, metrics_dataset, dummy_data): - """Test `exists` method invocation for both existing and - nonexistent data set.""" - assert not metrics_dataset.exists() - metrics_dataset.save(dummy_data) - assert metrics_dataset.exists() - - @pytest.mark.parametrize( - "save_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_save_extra_params(self, metrics_dataset, save_args): - """Test overriding the default save arguments.""" - for key, value in save_args.items(): - assert metrics_dataset._save_args[key] == value - - @pytest.mark.parametrize( - "fs_args", - [{"open_args_load": {"mode": "rb", "compression": "gzip"}}], - indirect=True, - ) - def test_open_extra_args(self, metrics_dataset, fs_args): - assert metrics_dataset._fs_open_args_load == fs_args["open_args_load"] - assert metrics_dataset._fs_open_args_save == {"mode": "w"} # default unchanged - - @pytest.mark.parametrize( - "filepath,instance_type", - [ - ("s3://bucket/file.json", S3FileSystem), - ("file:///tmp/test.json", LocalFileSystem), - ("/tmp/test.json", LocalFileSystem), - ("gcs://bucket/file.json", GCSFileSystem), - ], - ) - def test_protocol_usage(self, filepath, instance_type): - data_set = MetricsDataSet(filepath=filepath) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.json" - data_set = MetricsDataSet(filepath=filepath) - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - - def test_fail_on_saving_non_numeric_value(self, metrics_dataset): - data = {"col1": 1, "col2": 2, "col3": "hello"} - - pattern = "The MetricsDataSet expects only numeric values." - with pytest.raises(DatasetError, match=pattern): - metrics_dataset.save(data) - - def test_not_version_str_repr(self): - """Test that version is not in string representation of the class instance.""" - filepath = "test.json" - ds = MetricsDataSet(filepath=filepath) - - assert filepath in str(ds) - assert "version" not in str(ds) - assert "MetricsDataSet" in str(ds) - assert "protocol" in str(ds) - # Default save_args - assert "save_args={'indent': 2}" in str(ds) - - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance.""" - filepath = "test.json" - ds_versioned = MetricsDataSet( - filepath=filepath, version=Version(load_version, save_version) - ) - - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "MetricsDataSet" in str(ds_versioned) - assert "protocol" in str(ds_versioned) - # Default save_args - assert "save_args={'indent': 2}" in str(ds_versioned) - - def test_prevent_overwrite(self, explicit_versioned_metrics_dataset, dummy_data): - """Check the error when attempting to override the data set if the - corresponding json file for a given save version already exists.""" - explicit_versioned_metrics_dataset.save(dummy_data) - pattern = ( - r"Save path \'.+\' for MetricsDataSet\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - explicit_versioned_metrics_dataset.save(dummy_data) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, explicit_versioned_metrics_dataset, load_version, save_version, dummy_data - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - f"Save version '{save_version}' did not match " - f"load version '{load_version}' for " - r"MetricsDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - explicit_versioned_metrics_dataset.save(dummy_data) - - def test_http_filesystem_no_versioning(self): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - MetricsDataSet( - filepath="https://example.com/file.json", version=Version(None, None) - ) diff --git a/tests/extras/datasets/video/conftest.py b/tests/extras/datasets/video/conftest.py deleted file mode 100644 index ff084cdb5e..0000000000 --- a/tests/extras/datasets/video/conftest.py +++ /dev/null @@ -1,107 +0,0 @@ -from pathlib import Path - -import pytest -from PIL import Image -from utils import TEST_FPS, TEST_HEIGHT, TEST_WIDTH - -from kedro.extras.datasets.video.video_dataset import ( - FileVideo, - GeneratorVideo, - SequenceVideo, -) - - -@pytest.fixture(scope="module") -def red_frame(): - return Image.new("RGB", (TEST_WIDTH, TEST_HEIGHT), (255, 0, 0)) - - -@pytest.fixture(scope="module") -def green_frame(): - return Image.new("RGB", (TEST_WIDTH, TEST_HEIGHT), (0, 255, 0)) - - -@pytest.fixture(scope="module") -def blue_frame(): - return Image.new("RGB", (TEST_WIDTH, TEST_HEIGHT), (0, 0, 255)) - - -@pytest.fixture(scope="module") -def yellow_frame(): - return Image.new("RGB", (TEST_WIDTH, TEST_HEIGHT), (255, 255, 0)) - - -@pytest.fixture(scope="module") -def purple_frame(): - return Image.new("RGB", (TEST_WIDTH, TEST_HEIGHT), (255, 0, 255)) - - -@pytest.fixture -def color_video(red_frame, green_frame, blue_frame, yellow_frame, purple_frame): - return SequenceVideo( - [red_frame, green_frame, blue_frame, yellow_frame, purple_frame], - fps=TEST_FPS, - ) - - -@pytest.fixture -def color_video_generator( - red_frame, green_frame, blue_frame, yellow_frame, purple_frame -): - sequence = [red_frame, green_frame, blue_frame, yellow_frame, purple_frame] - - def generator(): - yield from sequence - - return GeneratorVideo( - generator(), - length=len(sequence), - fps=TEST_FPS, - ) - - -@pytest.fixture -def filepath_mp4(): - """This is a real video converted to mp4/h264 with ffmpeg command""" - return str(Path(__file__).parent / "data/video.mp4") - - -@pytest.fixture -def filepath_mkv(): - """This a a real video recoreded with an Axis network camera""" - return str(Path(__file__).parent / "data/video.mkv") - - -@pytest.fixture -def filepath_mjpeg(): - """This is a real video recorded with an Axis network camera""" - return str(Path(__file__).parent / "data/video.mjpeg") - - -@pytest.fixture -def filepath_color_mp4(): - """This is a video created with the OpenCV VideoWriter - - it contains 5 frames which each is a single color: red, green, blue, yellow, purple - """ - return str(Path(__file__).parent / "data/color_video.mp4") - - -@pytest.fixture -def mp4_object(filepath_mp4): - return FileVideo(filepath_mp4) - - -@pytest.fixture -def mkv_object(filepath_mkv): - return FileVideo(filepath_mkv) - - -@pytest.fixture -def mjpeg_object(filepath_mjpeg): - return FileVideo(filepath_mjpeg) - - -@pytest.fixture -def color_video_object(filepath_color_mp4): - return FileVideo(filepath_color_mp4) diff --git a/tests/extras/datasets/video/data/color_video.mp4 b/tests/extras/datasets/video/data/color_video.mp4 deleted file mode 100644 index 01944b1b7876bd5d618050ff51c313a122d1801a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17452 zcmeHP&ubGw6n>ketw;~8=1^#a2=&&MG}a#n!J?N69=s@6d)Q5OLqm6(WOp0XOQRyF z=%3&p;7t_c!Go6~1@R^#^w1o|n-@<){bsh&N&6QBzJblWd2i-rXWnMNZysbsq)faJZvws%2?vBlasqNA5<{}$;bz4j?Oji=^?cf0ajr6`!yQ!86VemX6VemX z6CxRrj7d8t?U=M<(#~SV_O55rZ9RP19lLv*SV62HRuC(Q6~qb)D=0FV!ek1QDNLr2 zAP+Y|9@zEtT92-EbIpK39qyo#o{*l9o{*l9o)F21WX#_&f5-eC^LG|2ws$=z+}5)@ zZs2?%RuC(Q6~qc+1+l`y3W`joFqy(+3X>@$$iq#LvFpi}Rin|yDF>lyulcC0lKp|k zjyU-DkEI=Kz%XFMISF>(qjCl(EUYGxw+_>4vTnXJYjx{;+p%=1*thfzhuTZfXF9l@ z9t4q@z)xb-g&>S^YJ(|yMC#X!b-pUU?m<^n{^7Zop*}7*x6Qb@H9w4ugpYRJ$7vA% z%Pq7U%ie|`2D*>4rRp0s2>G@$PVL*A@l#vDj{@CC7i}NkjKX3nJD2065<=dMleixc z-RCooqtJoU#rA0wnGUAC3{hXY9O;p&tbZ;a3uhPS7fPj33~~@_$ybsn zUH=1j&`rgOi48<^qa*s%4U__=3}2?`3Fr$pQ|Dp3Z!9wY`#=v}wUO#fpIOM2O+WEa xuc*M(+I-rMA7UPP&8A-Be`T(oJ|t&eB@ONH6i)ikPV@~3CRW45E}Yl%`U{yEQ=9+* diff --git a/tests/extras/datasets/video/data/video.mjpeg b/tests/extras/datasets/video/data/video.mjpeg deleted file mode 100644 index cab90dda941ae3b2f169461c1c652d2b2c8d31c3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 430080 zcmbTdbyyVf*Ec*%cP>bGmnVj|0P?2vJ@yo!KDK@A)gam z{S*)YPFEjQD;-n7048}uMqJ+#kdu&=l#rH?1*9aUq~s-Kb!HMVKmaZzrKDl_N&oqSeQ=%Nzhq+YUy@Vs{v9JL1p`w4Uow#YU+2OkB_a9v z|9PkwFoFVekM-aLIbL1N{8&uIPWaO-)u64%d{H)0CEk0hbp5H5tS)RpAce z-wg6^13(-}YJ9U9VvG17yU+B*a8yBqSu{WMt%2Of*!KlvL~tjI>O=ocw$| zoIKnDqBpM#2*ZWBc_dX~a5)7fB_)1|+dAsEv~MaZDO~*sgp8b=ijs`&;WQ4 ze0)3td_qD3@PR-=!2JLLEg>B@OqGb<#DSQ{7b+EzTtLFBR{N0wF|x}i?dTUtO2&AN ziJ668Ku}0n1TJ&qrmUR2`fUwOEo~iLQ!{f5ODk&|r~A$>u5L(o|AzsAj~)jFM?H&< zdHy06m6Dp4o{^cAjV>(06k|(D%gXEC);BaZHMg{Oe(LJ(>Fw+Pj2j&rpO~DQo>^F2 zT3%UQ`?C@v;gr3{h}4LLq2mju2D1_D|$@y9eWedFGeka4IRq5QMq- zem+||&)dbASLliumtO*#*B&*`kN7eGWkA_Lb=Y{#1E-IrpP{n%R1bKPNQ66+$b2v` z4RI&}g7h~if&*u`DE$EmB(R`M31=};p(@6r?1u5nG;YG{fqCRK7G=(HNub&k}G>yMJsl{DqKZ&Y4w4sK@4Y>5)OmMpfjkrzR8l9 zwOlK=_Muqd2#Hs=A;(*DcnMHnG`PKf?yz;&N4w+D#XIWEKuZXZSU9|hFjo1=q~K5D z$zqa1V>L#r@fcW-?-0ivp@_c3ZB(|^BzYT!>-zDdKTxO=>CLjv8oSiy-?_n;K%R+? za`sB}vwOLagFlymGA;{OA&U}V%{XQLWr#`#zv%~3$@5>gz{B2pr$|}N;up^0}4cDpoD1Z3sW+}$xvDyC8_2m%EFkA zuPtpU!cY4KNh)q!@ID9h$?|N5a_*!1S`nBDOD6^XNimX3z^=akmZ}5OC1691YukA8 zJhr|3oqg?3_v)@klS<-E`X{d-EZP@~zIP)^&%hnf#~otB z-3BSS*bOp-c6i*1jX!@PV0mN%)2H}L?o^V|AP8H7rLM1Az9_W%n*91hP>>A@w=Q8C zwJ-dMT3uTmg=5L8lB~kx;mPVO7B%3BpphEWZ@7<&pU7gH8I9(GF4$q0qGe49Zy8Hz zl$n|7nTm7qnIMF);@ZL_K4t38fz4F!<4sVH^Bq~;ew^ESDbIQ5y;j?8)1tz-?#bLY z9iO5IATGTXrLDmtvZM-qtsM9x>k?pz)C=NS$xQevM-7VG)wf^@lgQD=$ol+Y z{Ex*0k}M$d*Z&*&h}9(Of>>p)jo{HoUUz;Zm5*i%q*<$V-HLixt-OK`i``a&{#&zORJm%0+b! zy9mvTv*fUMiXWtT|Kraqf=hrajNZbGeqXY;@YJG=1lD}RkP6o8j)IF3z=*Vs@4aQq zIDHb9W7@HA6_NB}_HXWobRj+m`#N&NZ=B3FuckkMnQ__nm zIB0+3xUu7TuaPQjXl$(B#L|(q83bt;QMgcHI3r3|IK19O1Av-nNOHI2*!E!JG6OU? zFlcmP${ash*jDaEO3|(Rajs{lWA*e^of#=8B5{6k?tCJ2q)C#ylfDC@qc2y&z&Mw^ zBT_Wb>XEsQ_}iI$D#U45kf(1FF;7c)Tlp9?U4*4gFKs*vs%c zw#AFIFV|28zgUE*_CI+a>3sxNB%ZNuV^Xu5BnZf(*WrFq8-loMH2 z6}(DP&RBQ~gO^L__v!`VEzm@l55p{qZSFDfppxmtwd=c~hxx>)$Ji_EMi}NJa5RVt zsRP*oe&{paZs=>xo?QtoagSjX)x0yZKW}aB9zD`pI^wzOjz58 z1>^R&rDDwi?2}CsNi-&UD1Bp?zaH8P8vYpGf_2HfGV2M*?1CvkRTglL-4cQ{UE@GJ zAa9TTM)$}?QKUP)X}W;>+rv6`+=nvIxaw9p#sn#S_jd>n-*z3%PDzBZ4EscdTAnCgC@iJ3<`|!&S^T~Xw=pf`?03>zF zjFmZJ_QKBN^-frvQx3-`!8Z;1Y|_Ek4W@r>o2ap@0?xN-`dJpmLW7jDI(i#SwC^XA zClw0175S#nc=7oTEyIq-XLLnUYF;%XaENg_5N!VdM+PGBYPv$<3majfe8R$%xM4gT zi;XUzZou+~Fvaa$d+N#lSXrYM`EnXTVTYkk#f-7iIP5NVY=^lrCb7)_F-*poEPo4k1C0bf8F&i+RZ0B$S$EZ z=*GVmNE9X#Uh+-wuaZ9<&1l{)j)JhVLZr3IUeA61Mt-IDCEwa@8=4q*E8TSztrIO2 z2S~_)rS)O6@GZmcS7S?Om%!pD^d zV0E%>T|(95E&-2Apz0FX_+`dlCMDHMq)AaJMe-AA9a(L32~;ODm@q&c%m83Z_?zmz zE=VL7glhv=Am}gE3&s)Ii|WLm3LhP4VRAir(1R z;(Iw1JRIM|rPOe9%#BjBYYbybg2h9aOk_lmqF{O&zzvkbhzhw%VGbc4Aaa{+RHY=6 zF(NT=D!uP<&dEs4QFY3qXC$G2aL4TP?`1S|(_O_a2PLcz+$U}I14|UKma=8!S=U{2 z$2wBg9xq#r+Ws}A{Lp|eowD{8Pvl{?ZF2>sOVzl$bP3fo)QT$I-MKWQ*k;vLRj4FF zpMk3k(qYVQjiqCZ$@O&fGafGto-On0gi8fhOP_VSKjU0R?(I&7}0A%b3h{ z1|emsw8^FVddcXikv9ap1cIyfqIg@Y>iYHXp6EoqFspVqJ|!)?kaA+QqEe z-`be3L0*!N8SriYT5R5?w0vU!7-N#bbyUoF`1E7KSRtpo2CcYm1KC))$Lm)qs3bGB zTuN;^d?QK|d{bLZ*DOJYFCoIf84ZLh?qDd`N2bu+Ixw9ugk&*4rJt0;6e`$cV)|7y zy<3*WGuePzBKz*I>paPJH`NUn`Jwe4$c|zBQVdZ>IFkmy?5F`39cY|^dJuixg(xkb z7gk3Y0_nPpnO>48e{={sCFT(mGN|{DWzAW6g>Y^_l5Am7_{ zI)zgq7C*mlNqMcHaQxG}W@Rl`$J|)nC1AOaZ4l`yeF=52j5didDsAi7_ayUSWoDNm zKK^}JR%^>+P5E_kW)90M+krop0&r$Se`d9%yv`@|Ld{J9(x=Oz(0-?zbcLxe7VmAr z{$%+T?aDs*^1Y;j1~H;-0EqMBN1)W)ApV+2AuP8W=AV1A8-`p3_{INO1A}|;eh@;_ zj3GK;xNmAT8ZSA_9rw80|9sTqN3nU@eU|=6$?D8C8G{PLb-BuuYeX0@u=wKsx5!bk zBJfKWsza=eWsQ%)e6KAEh1XlCUiR&?tgi)Om)3E2P?~&P@|swv&0EISAN#|JJ!RUj zI9{%4oG<8mlx7}B`&3lcg6Y`v{rru%u(*o=OWNuFvd+9Nk+hl41Dh3tizm2{Ds%*E z-G=_0O?mr*H>7fSr4^m7&2E6W-Dqba=I0*ZQ}hE@iYfl)>Ev7WgcbkOmx(G~fnM8h z_0OM-&u204l$wMKc@rv-N>#pW9_C%dlA{AZTL??lCZv8Iu%6cZY$38@3MiI+aIJWP zB*7-__-`V_H5+&)S>^i)72qHB&`Ip*@!IE~qs0H@rJjq*eX(HY$%`kagmF_S`8!2k>iqGULjE9KhygZftGo)IUVL{2ZW=L}p`P2QIJB#%_?siaC8=yG!8F2^dzFKb@yv zw;s62^aF1(=}O{X#Y;e5pIrVSKaaGm!m8X_&GvDVlg^UX=OG56=+gn@$$ zBogORVB&grl7TNGg|0T~O&s2nCu5Fctr>`NywW%hBGTTIqq5iJ%YnR{OQ#me>dcCf`)M;pWu-K72 zU}g;*n)|g;u7yxEQqC2%>gmf?sQX+Ey}$oGs#_e|#m_GxObHO+z!ZSYM%RV_bfiL8 zx$KK8_xfL987Sxq<>RHGz&$A_fKqZa9zNU!Yc+qwaBs{|RDdX;E2y%+u-jWVn?cjf zAAx{jbSro)>N5_l=a2GT_`k}@m7Csn^z6}L7}oJh#+CKE_~WveHvP=zqfGg6r{j$^ zb2C}+jwj|RUACCRvXpHFdc#EByTKp4FeRMgaCMPhU)~%Zmf`wQZ;7mHeGC=O#pO$+ zrxs(3Pl)v`Vwsz!N}RFXo<(u!HdzMQaH{%uP}5f4yJ?i>dTJ?MqBrXq z^|}F!9%ULy!bKAtWy06D1}uDNeXN|WgU%QIQqpzSqi?X?2O8RszQo@RHqVz=>-Tjj z6)1Y>L=);ieXNT0a>&cnzxgLK%1m(7#V*TAo7m|ihR_oiW&Ael$9+`a_c%$UjkAWB z5ni%t(2cRZkDHV0I@s<#_*c243-t!mkkQtX-@0MkKMtzycb4Kuvyquh8Vi_zSw3JF z=_HF0OHTb!Y-M7T^f5uRTh{56+9m;do##DSPj-i*)#>Prh6Rt0y(u zPc)iR4;A01>oCxnned8r1)hYw{jnJrB4VQg>(=1q8{jYXBUNK7_^6+t^gIVO&0|e) zoulB(SOIICV zM-`q1IHH8G5DlintVk4yQ*csM`ET^1bpHpv7%*Jq#=z@i!dcvJFh~ev%-}pkHW5n$ zHqKb7F{{F;Pus?d&59K|sWQw7Njokw4Vn|-3F)PdM@9WZ?)RKnZ*iopyd83%W<1#t zvD*5>K?r{~olcdP+EHbD=Q@*^$k~m4nlinmVjG@GmSjHlaA8n}*GCNo7RW+ci!*+!L03WUxaK*kevOEPOO!CQ0z?>E6E|ubMjJWv-6zz zIB#7)>2-nGLe4y=QP}5VCXk#5IvA3k;~PRh8bX0n69Z^H$>YLTdMXjgb`EK@zYMR(_!-rRNtW{Y^pSb;! zyPd$@f*B+t-YY9W%PrBV^YlG067DOviQr4>miNq%2{>@bu^_xiOR+y;%ypmz_OO0L z!PIxxZ0iOycH3}leD2nXHI*-eT$)Cf+j!5Vji2Tph<8$V_@CfX-bZPMl!{A#)*@b3 z3#O6J8+;Sh{9^3fLyh%uRV8i~K**3u$^X3wpqnOSEs`B_x@{g&>trvYDN5UmuxR@Zi?m5Bhyy(4KN56e!JZgO>1bOWz>b=oY;k@%u;K^qX9w*%s!H1fh5wS zeEfEPFhD50BK}Nt^zp#>6oM2gjJOI$$R|xsVp(Mg2#pUSS zVxmm9N%|bfa4@c6F6mWcWsOCa#oKzy0GU1xv$%YVUYRPCph5SHTHILr2t?ONqS6b~ zttGUhx`KPLfe^yW6RMWs?JpZ6fDWsnJQQICTL+zEonz02@xjDc|M~zk7B2%Mu8nNL zaz8R9zlTG|=%;q$^HWh06~xg_V2L7Nlw7jK@KJrz#Y{Giv@i81kE%4OL>}Jz($LC6 zUxRM>8l{I$gs%|JnFQB%R7+Qmj`DPQ!8XpQ^TFf;bbiA`1@X7tO536LOtX);Wry+) zW#V2>Q5&HxLci=KZk{XKWXiBebdl#Ql^rUscZ`MaB0EM2Wm$kE#3-c1C7EY66FxhF zc4SA}jSBng6T8G6L1)J@`tCQgJ+0_JJ>p!$m5c0j+-+>Stn(cA76zyNwXkjeJ~v&sj>d zIM~^iS9xs$O94x`NGlBYg~U)w_9nhNF)j1ZgD@>c8QWtf8Mg`B3I)SX+$?3;Tsh9h zGXqRA2ylpis^s{})CV*dD@*f|9 zEXII_3~GE8aMGA)T;;VIc#QuXFX)nmkswR*4|tdXD1b20`4@)bjTo8_xj(tx;R>x* zpm?Kr91*r=ujPn2Yu9*4U1hIPUe_kui2ugVBXMG+fih5szOZK2sO611sZKcxA?wRP zpD3bmoya;Q^>!jJ;YoF5`Fbhu=z_`lh=qaQM5dzV08+w=k6_e*U{t5_B@}Jafu%B_ z7|S+MduvLH5qO0r5_UHj8&!n^90tso!mQ{40q`>Ml2AhM4*e@a9Kj;30VCC|iqg^1 zc9o^H8#R5psy;%@X=?qqM%1N8S!5cr@dI||fimn{mx$7Qq+3)M-+LN%_j4Adn6iJ^ zpTRJyAH|MRKFE%-d!rE;g0%cA)I?AdWx_}FRH(puM|SZ59y?mvV1PMaJ+>QZtx+dw zWd9-FwIla_M#@X5L})(5H~*M}TP%(F5kvNeY!Q6PPR?^573D(} zTX8kei=YR-^)CuHLTuk0o{J3Nefwgk*~^y0frK1l@s{Avf)OJsl!ZA#*kpZzt49t# zdoUrn50>G%^kRyq5=wMdu**E9`(~*(O{yNd(l=vgo2mAwPf8stkl--qhG?b+Lm-;RdDxI z!h8)c*Cc+$I!pSG*rIs)b6Qq~ijj}wO|3)942C;Dw;t|aL|yw^SRzmu`?SirT)^)b1`cO^-SpmC~HA7ru{~1!rK|Mkc`}z!j!CVkkY-XoTPz7PD(gq z)=3G%n2Q~hv&eNc-9P~0a4f(omy!HK!>;`UkVEMmy~e+C350_dMbyNm?PMn)rM8d! zd@1qwxwNTwd7nSG@*LBd(Fx~keM%RC{Lzo4swMa;^ihTkbm+(MXSY}ak>R-2a1bb9 zjm!;lf*H6e<1#KMmA9}EGGJyt_sq7YNl5ReO)IjdB$I}HNZ|FIS_=!BG{lHL!5iu0 zk5$s@P75zxl$`fttMLwLh37;0=q~}_jb5PD$m=N{A^xl!fY{Xjnrqn2m$$r1H+{L2 zS9}T6TJt~Z}K|ie`-<5@Uz7~hCa;?ksVsH-FOwWri9J* z^32Hi-SMm@mI0#J!E})>u0J=ecUvaW>3g!)Z`ywB($nuFV1a8Nm6Fu4D}vu&#wW`_ zgMr}UMnD#XpPv~E1Yx?F3nNH1gfUpW5gk31$dn9_4nZJr|FJ0IVCIT90^-LdB_k!A z4n@fiBjnDac{qIl%CU1ua%!^Z@uLz!@J<0U&S7Gi&zBg@#ag#Loo5pj~#g%v59ZKl+W$u&e z=xHOvS#*V^RiX!=Y1vm-h6Z7*iImpIhl7R;0$Q}}e8K@<4q+iI(QQc`B+DlesE?E? z`$i|a#o6`dYpCDQs%{=EyRq zVG7*1$0~0IPzX1fcNMje)Z6jawg|4x82$&adxrdmygB8e9sFFpB^5SWB_!Ac!x4<*3ML#H~on7D-ARNiLe>DQ`{(p!zfM+PYdfx{poGIgKwY^hmaA|^ zUllAu18HCmNe2esYM^x@5dztFFcQatBndx21V%)B!u8l!Dp@SL-~mx6MAsn}N3UYX zG^UTV8Z#V=iRn8o_t!ya9~wTYTpmD(em!yjUdz;Evqt*mLI!7j3G}v(_4?L1>BE=w zWook!EFhFX&=93$MPo3a;U_Fig}eMe@&G?b{YjF6e}$KX7N(ShixvIw&)cCmC!G&{ z^+DwNLGy#hj-Nx!7F%rZ#-jDfH0I*ZU)8dGp)#Wgj!GRgGCZvCSenDzI%|DKzRl~Y ze0(Hg(~L%62O*0b|AGdUdio~z0r^zPH0gu&@jBcQXc=cB_HMD<(~o~|Oi z&G9eGoFNf?O#Pf$XX~vQ4H%nmkaD|kTF@Mb0_5F#ZbRD7vTH2y-Y)e<^j-lXA?qp? zG?mwUNb6r7?{F#d^6BQOsP>&vFPJ3Ur|l>I$je5(q!#k3^h~AN8j<2Mf_8ZyPvCM* zf+4$ij#v5mo3*pDNtWAQ`zqh9FV>gC4l8tJEl3bDoB?XfzUZn)K8-gJhM`-qWg@vj zd5XU|mUrtC#j_=BviLlchm+GBtD0__E*G_0e_>*?rwlVtsf$wOSnu zjFkX`T0*#EVf-Vb+>ykwv7UEZ>NGhZ=~sSZ`{>m~reAtuV)7^aK`q0OOF*|^QQUh; zx67xu{+3493Pl!ox(^NQ`MiH#$M`wR@*QNdPS&4lgbC*yo$$;QdyHln%j9&wdR{?h z{mVQ-QxywKR71n=TFiq6gyu1ilCk3QEv`%8J?NgzER)XsCiQOQEgaik@04IPq)t5TMJZvm#cl&U;6T-?g2aAe29_YDRn9Q`^Pa2<8lTSbCYml2R)r6 z5@u#a;wo`fRgjeI_!lD>Ba#0)5=cNM=N=S{4@iW;S`#=xv0PxL0%j}HplgT&oh*1X zCBX|MB~ePGT1ph?Mqu&6@E$lNg_E)NI4}AQcbRjcFe|D^!MuzTk+yFweOo=(15@Yu z4YeBoU_G&Q)`Y!NW7Gs4R65frO@RZcHEDgd)g-VQ$8wLk2%eS(Y>j~B%#J{$^p(-C zN4S931I_>&b&zJoe>GJ_BdJ}d`6--AoqE#_`)1)Ve#Z9U6lO2qr~bGkWBiNCUn##Y zaTOt2hfGR`Z;<8R>=GsBN$h^_b0)uke##aqlM1ix4$0L+ni088?XfBZKN;n3{qVl! zV{5Zt?7mLhTc?`-?H?S6qnk62n;MhTo>t$e)=~=_^vFu+neUv0X*;<(MJwI_Yv~2B zc+J*&p9NbVkY!DZuU0DhurT00bNtrsg?H10w;dHuIg5DghOgE3Be>u-GIfr%q%f8k zkF5I&eLSioWAhgeJN_6EJ+9mMtJ;upY9#M6%l92N(#Pbm9hJCZ(Pmz$GGRQUxm6|V zeQ$}4DoT-BuvSs@))#N?&|@-=9`_ zTY!|)B(F2p;J}i~_VthG?YsR?Vg_*wnopKe&qc1&ZiE^ZKLF%tR;HdL_k`h}ZPaZX zO|XPz7j4$wQDsJm4|h%Xv-$5kP2yU%ZJvB+81fGyNUk5$ZTg|_8qVaJe^^;8(|=#d zav(6E)%j~Mrc)?^vjTtWk<8ok!uYTwQRC{G6&FYCyV9#$w8 z!m-5;Cg|D7s+H)5Q=ZxGHtx?!-P(uCH12L(F*E!zBq>6ZJB32V$`GS`bAJV!Q~J+u zp14!2v%pg%&5W(H4uAleee;$5<4?U!EK%Omw^lM05i$$J^S~SDFT(W_a=X6C9kK6$e8fgrnzD_dyI|{OqvdF z?M=-ubI-i(>#KFiHH{+|gW8;bZ?5b93EbxSrm5$h*7KF5LzXXbII**YbQuHd?K85+ z0AbZa(`0{wY3-;RH63D%X)<)IjkxZW;s4-eV1--MitIsYiUb8Wbk1~?Z9-munDWdJ zO!b$RX5?i{vASUQAh(Qf(DT%M7WA3XD36skr##QILP=|3CdKk;{#>Ta%cMCyv!cQkso(tNv>7S>ugJ2e$PFQt!UmeKW6&a1TZ_sU?9PT)PW24iJBX zVt85?SXBeb4A5UA5NP!){vxdlYU<}7XIjee30|LO3X&X{d^MbjHj->X8%+KN{$oY5 z2*vpc!sXyVOp$wWSb4YiBL6+BxLV?3v$hEwZq91YrcBMq!JiK)SIQ@2sXrF_gJ^%U zTBIvIZS>_^nU@B47?#Ts0Tq5M6@oTh<22}T9RHG+bAyq?%}m)Lb+uXjpRrgOk;WCG z;o;7%ZW0KG18b zYj5;r|13VSB}VH974q$J^$~L}TRbt!#jG;vQ5W*?a4~V?QeekBZn-7nZ!rE>G$vj1gs&Nba;pi-qQ4rVEP%U7GZkb)nG_kTa z3Xphg2dK{)u zul;*;)hS$=E-YC&`o<3gLeDX#X{|lJ(^k?N4Wpy)`;tV>k?*vRdE2;W`t|8aosg7k zZNK25ONAJI(z1VZvvLdsG(>J?tGMoOw$V@%gz;4z%>}j#W|6poPWo!hsSt95G!XZd zTgIbHLFwLa1afdV7ZjndKbY>5E&c-UN66rE}6^YGxMuUJv)FOxgg zYMEWOY~?qI6wt%1HCAFymdwe&?VshCSNc_K=qwz;W>4;f;~!K<>UNDdD0>8vbbIO8 zdkZcpxIN`Zio6}qEdMppJea~*`e*B2h=^vXeYm_Rd4w1*y51vabf+ylFtz)k(83t4 zt;Xy?0j1)wSic@Y-}A(+ma4zi29?Be zk0Hox0AaUo2_XnfS6f3Ym`vV6siE{mfzWd_67pHc`i~&P6Z7Vpdbg$eTx&y;Fqm&7 zZ1nJ~Lf}dGX170U`iwX7=4ydcs&0d$z#vjeOIKA-g>h-z)tJ#mN@!_Sj`#Pb#lpKt z@A&%Mp4yp0IbyDPoEud_z+Qfe{{8Y4rRH*brr2$c#d$3C7qUEKIpyqbSAl|C$Q5Zu zBO~4EQ5jo&@5UW{ed#X`l@pIgSo2*y76{~KSu{NA&7}9mpWAXWU|a^qftI(6}Q00b-zRtf6hpz;@xdgo=;?PsGPV_vN5PS zYV}r-AH9kPmx@QH8F-WAl&fbpvOT`G-7SClf$qEQ+8+y8FK46Ss?%u%*uGd=x=mXY zj`RB3I@h(Myfnv1duR+f_qWfUxAhzdc&Ih25`JzNc64{5S)7Q-0ImV{^BiXQOHrtoN`-yh$a=kzx2J zW_wNCz)J1V$tmaOq6F9j0N>3|nc}c_R1RB7^@l@OjYExES^D1n9O_*LHU8ENCamvU zcS&c5euwzx(;P6>uo%y^o?PLu+3Eko206Wm$y2&jG^EejxB7DGrf4kh{xe9pUv#?i zGyO{P^wJc~W-4nkc;O%3qeeviW{&O2n0#OZ!(n&VbvTbkd&+k{HJy9; z$$3vjISrL%Yjw~M%?%Wc<<$!UNGQ261KjX_PrFeAZg1gtEt{%0InmN4 zJ44W-xSU*MIEY?vH=+nCcy!`X~>kZRgUy%YjuP(#Cxxt95o} zsxyMKSacx0r2^N-7!9J@gKc9;vekz4PjOMUCH^;_IhZUK!*L?dv``)ts7&ZHV;+7x z#9|%_rcVUXLj?-^>Z?I?37Lw!0cbm+OpegWw2)p9*D+XJ)vWr80e zsdUP#voTX6U9us|QncU0tF@k5)W2klPySY{E+xc9*0b78sGI7=JwMW#Mb#sW8EHk^ zgcHdy!xxcYDQ|Ynvw<7S0zACg#Mn`;WPGT}OuSnauh6w;3&qola-CxqLxpH5WbVpD zb{07pC_uJY1iQYK^5Sj1EPehP*3Y>sDWyEGcRcw7b6lZ4!D~wRx#H%o0`+jD>e=S1 z>~hLAO4hoX9V*evsyO0V*noXtKcf;-JK_z2qr5v)nPWh_F z52d;e304Cwgc&N3kAn(-Eu{bCM)NzSd}DQ+n5|~Ho}M|J(6EtV(K*;d?r;f+6_Fou z`JYZ+FI^`WXhO=r>A%%im-sXxjP|U3v(^bcuF#c$kx+{oq>?SyeLDx;hiY(ae^VV) z`34$?0+-?xRsOwiaZ#TbdyO${)V*&eO0+X%I{zM?uAPp1ihnc{>WrgEJx)zMMaOK1 zzhuT2sI&+y;@UTt9yX)=nc#AV+M<~n-jwLGdq=UmPAgha3?Rrm(pnBVE}8#Ad(`*f z{pY36wH|32`VWGv65EUfNqo1)N0kc1(_H%f=&Y}$*{L_wp|(kKpm5 z9A|QM^`|?y07<1UA@r~dA_dz$nYeXB*21(@vGmgWOcj+`<*B~L0)3=dT!U0L-fARL zn6$(J;*NAzLtXy9eukqsn5=gO5_V!bT?q}eWY;Jp*YO zJ3S+66}o?Yx^VP81-PlS9vVOhW5B+AG=PM5f&&;02sEI7aCqv!iqX_Exuevu;H5UI zsx{%A9N$LOn30s)NJF&h_@q`aZ_VsX4vOx5QNJnYGgA3&ieux0bm+^|N#DiVqjQz% zwFrFS(;C4O$GS3sN|g4#+s=sRy;q;Z(i4AhLsSDU1$QUvT@iahM%pflV>;Y(-uqHayGC1d6_UJn0!#K=PNzTA(O1z zOlag}^|I=R<|HXaczKKVpZF=}o}kT5WA(*}`(2Guf$|mep;qMNHb5TC-ThdYoeWw=hl53|TF%ikB z1OB00V@0D*xkn~{OED^If6J}Sn&wHBqs4Ceai84fEWQrXR_2W2Suq zQHHWSNo@R0KkN5@I0{tG*d}uPI*$t1?U>?1?>Wr*=3JP6{r1zVWYbc9rh?exNhWDd z1h4Fr|ArYgR#?U$bS{)*cEEkgZN%#uet5Sf(_ts*LWk4mlEwDgEq*t!+w2@B=;Pjo zGA|vJSCRt3qv_J&!YlrQxc1MXTiSv#NS+R2p89(qfVf=`8BO8E;^`d)_Lx_TuQmH1 zULibh{xI#_?VD8hSc=7T&drGBZFhb%TK4#AKCQG~Vf=%EEfwAQkmrzn$g=6TME1jv z6jk`SktR)tRb1*4E8Gz~R2o4V?<>-0j-7WX?=+5Q(6>%3+6HpYc`J*orjvVKQxqB{ ztycTiZTT5U)9`8UJF#gHZ!>UKB6zjnKOLjBX5H$ienSUKZ=&T-@d@_%O`f{poawz9 zSZRMT$Swmoq>#WX6*4!|D1!lh;7)9vh>6)kDlvnb3>P~%a-|IfFodA@&&CLZgF_le zZy=Cm1&(h4q_j%!YTW1*u6^M+w*h|cco|l*v{5FrdUzj`nM8wg^zAR!^)}8H4D@7m zUreONpq36MHDyy~`a*bM6AG>>y>wa#?-joq__mF;aE#cf3WAd7p#Vm&KeMGQj$WdiBc`h6d}GmFgu*_ecVOVVcmvrno6CvI21FUwIEof$Ho;{HV(7Z6p*;>!DJIS zlj+4&Udw0e#*?cd6Rj@Juw2UQ{sk2aiX8TnL7N8qQD$$T(g{>O2Qr4XK?R;(;<;Is z)!tTY6R&(_H891AR1Q#H%bYxavtnyoCMkOhM{k3k z$Uc&L`crF%?cieJ;5696q(G7}Ge9c`v6@Ba5;qDd9u$HYz(L0y1dn-etbC_#_u}o* zyPueckH0#krgCVX)c(LKvuq(&t~c1enwq`k8LG)nc0jzxup(WNVGQYKqX;-P-8xj5 z69{~QL<{yzAf%{ki3&{+V@Nb3=_Non8#bhnd3V^d=%c^*MYJ<1SCF>c#SzhupB`V~ zugy}F9DLtRKS@GE;mP7N$`SY`bDo4BszSRvRdb^v)S^!ha zsOaL^LP@`OP1Np95(M|i3Ypp5LqujBD|&jFwk?V(q@ABJxWfiinJQo?M~T}7;S~5( zRMd)cZzO&n#s%}TGD)ycoUT;Q-14$|u98nBpE{J!k-nbizUM7oH6f6^!xTq#Lx|Z$ z=~Kj^gT2O5yZT?P?k3L?VJhys$JCdAeM153n3@ZL-e8FE=eE)CZ`Z~~(=5vbogKKy zjmUKgWyvDqzv(sMy+)A~&P&2f46RYaokgO$OSR4tV9{z^e&ek$kaO-KOCiHsJ7Dw7 zhX2*B$BL{&VnLPO1A19r`kdgKGTq(+Y1*A{A3Am%G~d!<%lb|{#xnfUB+_B8Pnl&e zYscB%Jl^`HMXvNG4pC-@cT{U-C*i}|GHXZgMVg<@H)^$ed^>(m+>s7DsN~o5$lpmQ zOnECF@I|zcz#r(Xt+=@N%c_22iJzFLig%d|!^Tp}e1f#Q|F^8TEE@bm1UA&hIW}$@ z<2SiPBEv^OhY)l%kc1%9AZu*QJq^APG73Rbit~#CIFTDRX`|Yh;T@HJnT?4bR59GPoM4il2!Dv3^um?Y}UCZ2Wn2Vh)|({w@#x%_zXjiU(FITN?~@bRAwasgOF?a5@( z`qYyLejiHLPKc_hZ@>KG!F_Q<@YM1_u`WZ<&l0{TR7HCO46VjK3FSt1NerzzLVZv(WlG~GIpeM|x(a_(IQZdhrtQuo!O`N)b{(Z$% z9q#hE`RC?X*o$(k+;$8C<@|%V@Eh!J3{WXreacf2MXhDZ9ZWrw7)}F@elK;T@JZ%3LqUk!@oy=9` zzAzhgX^FO*?z_G0WiVY^{^?lO5ZLkWgG!G9x}ynXIxliD`Vq5A2QS;7?e#msk{&VM zeXx_T!vUVAQw93*L6XzMi-(Z4?zi9T5BTOD?$LG%5)bEHG&IJ3i8%6I*7DQYcR zn)*E47aXD`wGSlL*b`-j)Vn`Hh1NrD;J)N@7~&xn`PyZ& zgLd(!75DF|+WQwiZyryAbSzI|zMYj4I!0=Sd!k57@zH@>qj=yed?2{>iB~gaI6!b) zaH4|`O5YkmNv>Xo1YJac5PU?J5=I4ff6d}nvz~*o1(-=qslU}h>*^14BPp3N@GfXf z9FAUJ-GE;{&JMVEcGmHgk@FxJ62UbqLlfhL)wraoy6pO3=)p6)}}m_ zKf-!3KMwmq`#)H72_(9lqV&l-Gl5+Vu64uz#om1YMV&2d!f!H?1Zn znh`9fs+B|kPk5f+frc~diV$^n(BjpJ!jskP6N)UQh<-v$4D#xzQN>SBKb({h2cNrG zu46QFCWXpWU0#T~nWb`cf|NPBd;73t<8b3Fdsfq~d-vA%2|K9fNj&#AG(pWT>Fkaf z)~{%4mdHpN>Tr98@6))t)-m2*)nAc~th(AcqWTk(6<3B5J|%FTtU6{6ct(CIQ{e!r z3LVI+#~?-Zf_}IGyjEVBDH`K`(qqZBW%=8i7i!){weP+^;a7YXt*mguP4lGCJLVJ= z_WQ%!!2jAllzsavZ?isr?yPA=gfa;{SuN`eI+U@&(`dMTBP8ln#EW*{g zj=J-e?9R1n289hye}DT&i)MEZpV$}NvWS=Qy1??R9kktzq{9vce?q3f3mGWW{~mLu zRAGMl=`;=zk)@G*SD;;>o#RJZn*J`q+1FzIEp)QZe&HLX8usKYKG6~-*>(~O42hX6 zem_rcK~V0~Tnq`r=5Q+=l_W;jDZlxeGJ+RLc*Th@wjUIjz~MKJk-}3^Ix)JUchjN_ zr(Zt4UTNw~uZY?rG0U!fT`s(GtK1Q{5kzJi9 zUM1o4(32sXY}rQ+*yN$6!V3+pB7I6dkcI)#Rm}mA#sg>Xt>9f6oRiVz7l5O&egfR& z;3ps?LkO!=))FsyR3S{xl>nnx#}CE+q97-~lxM3`JYEnPm6peqG4x?1Kkw&fauUJ+ z4Kn8X|9fPNsQBN=81V7`1v17EOU4-fHyQKaWXxY!IR9UhF;EiszaV2|`IMkRkrQHz zfgWd{zU%P~7trt+qV}IOO}aZZpo0v843`MsG3T&ZN%USdfnwT;!0#J4EbWP`H%nB}>tP$DLDr<%(qJ`TF~nzU{PU z>}SvwP9^1dWm2duN1A=24lMk@hi_e&n&rqLd(KH##};X&?09o2c1dMl`r&vL4jM^D z;vq&izYV~cXrpFUMvr7SIW@A|LicI-V@q{({QYzP%8QY~)M|gIKDgSbCj=Vna%Wt5 z>9|CngK|RJVy5>yk8Rg5Nl;B<`EL5^_l%B=+Z+A538cZ73ynluw(zP3Pad=poDb1j zbuYifu_@`8y?d;uv*c%OP0Jnh095=w?G?gG`z}X>FP>42d4D?Elp(oq%rCGbPqRyT ztH&TXA&cl}3v0zh@T9K;D<=HD&I?>4iht{t+_M17=n`zoD6~}>;Bsl9u|B7Y(>aWXbfT$#I%U@$LpDveA ze2-?t5#xK{EbS5Re0fEE>3I{~=PQj?D#we3YICH-?Z^FrNz3|^l3E*vP6WjUclRLi5#<`P+h--DT6aeiQLJ$QwB0 zzVQp^D#u#ARiL zm!th_AkjJdM6n3sJS7XK%LzrB6T2+)x@!1wk}29BI^E7Zh^7$}+t}D&J{p7%XWMBvMESL|dNiNC zds-o?SS1!MwEg}P=O<=s_WCNzw{IIt>A6gNTjIhcT_(rg-(fs^_jgDPi2eYS18wyy zlK`?n#IBAPw@W1d!-&C8b}G2DbXh%eJ62r0rd@chi<&VQ5xVfM3$V!Ga`&w}Hc`xX#0;h%;bsgeh?TF2&TbvgSU8 zrsUXwYS#Y@6a#(mIYzz4-ub}kvG0_?Bk!tJ)CWwN$+Ik-dR1b4mtdW5Rflhjj*QT_ z=FAVXD}F&SXNffncv(7CCB>h=$pR?`mAyM%#GLbh55pOaha0V7Wrs6#+)p4;s|tD9 z522F^Gal<>@JgAt*z)?g9qk@K;s3+n{f8U5dsA6w!%((LYeEA#vbf62?aK)(kz@e@ z!_aUw%0`l!n+gn(vxFEG#xn($s9X8DCH&`_!UUm)|8*%whCUk_hA<(ipxi4<{>mOn z65}Q+BnFMg=LKhK=L(ZcY3IPnn}#09iGyv%bA?N>6xi*?9Qh@*I$V|nyXalzp}I`c zyj_E*B4hS7);4YNz6aNajMee5io0XdEK`LYu=a#d#961$A zUvuzr;au>OCOnxWS^bTK)9i9p1#Q%8|FE;dhyr`OZo0U>vBcx!Y}b9ykxBEG%X(_7 zJ^nSDPO{n*Qjg^H>~ehtri-A%9|IE?aFr+hNIdqXlbqsBVi)4QvKP-p#h4RYiQb54 zcHMt{66$p}`V(UQ6EZwqtf6OT??VAL`DzRgGmw<%%voRD* zs-}VM$~h$XKqS%-HwH}`a_}AALr+FrhR4({W$iy>aFbQ>PhW)*=Gh?ictgRa0bD~i zv<>VG1~Gs-9&SHR5sqw-!DI2lU>$rxJm!rW{@88t-8}1!Tb!+*@ky5UJsOGkOBHYU zdhojX+hn-BDZAw&B%z-*$e&>l)^iDThON||t#nir)*loyppb-)oPA;tl5 zq0q!t1zB>i(#*$E;>1=U(7a^4hL6Pw<1k_qY23*GuT~(- z)_%nDbhnf;%Po~%5k2;oM9lv3ey^ID5~Q{^vC}b{ajQ=rmV(EPoR{e_DwaEVtb`d) zCCccq`~1a;)o~(011c|ypZTfJeAN?0_7lPeH*0yH=4VXZ0bw5;-9Lqt!44>O!4L=| z1if!;XAV0ka6#vf2geEPL1BY-A>g@&iw0SA2#}&cd@cx2lPfdLnFL}hs=(2puP0QR zkiq*OM$G6y6+tV`xud7(_f6ulgMwG4F;;7X4dqoODoCXpG?raw{0;Ao==RD|dXbsU zp%q+wF16{1$4{U(`4h_HkeFY;^59D92R+Nw^+cA}mx@7&pJVta>dee@ZVuB`Jvb_z zGm3luA4bgT-y1QmdmWlNBS932Pwog~Y8PXmZ82yiF57Oz!{%Z@4Y}6J9+Yl5e}bY# z+TPXi)E|^Bk@*%-Vt68uhQ)v@TXt9g?j&UqI+lYli(HXh%Y`n>D!Tm{I|S2M4y^&*$Jou`l zQ2U}Za#mYsq#XAWUPfAfGQSAda688g&KZi{mc5EZM=h@{tu9)W8 zcUS$h%H=eGHvsmNyjK7g15#X^*moanpAPiw2(W2sK;!{6gV%`?j!hmDfI@5#wuVGd zC|pEuoufdzZLs{Ku;QJ18Bd330>1UFt3AkcqfCsK>CP2C4Lb74msPK>x7WFTz87ag z@bsa@LP%+6(gLIIMX8HS+SBk4=nM-*RHSjLLUAW8Yfaa1O5F(f84L{+RaYF_yDf2b zi^TSp{JU2EF#2wmBPWyuMRD(m<8Em~bf1v@4LCMK`~5y50Fa5Bl%|Yj;5^G zg{7AsUcF7-9qXMOg15Unr~*gRw&h7k;OfqoS#!GL1YB7s?LarnZ1r$sS1O2uYkT3% zpO8oMvW!j!_7jf$p4}hW7@#zpP&Vgag)P8xQRnA1*S@&s@Bl)vYqElI*dQ!NWEDV zzIaQ6*O0+L`8*!}{K*f4p0FJWT;Vem35Lg3P`8w^5;E&G6r&Hq=zQj>t*WIA)at%D zn-4tB{O6vOXuTZ>HE!Ga#ZTs%lU@Z@46{(`u_D(m1A>>Da}q43L#!4?K^{~ZiOZ)_TfkAxFa)FTW$V?R(aY_8j1LTB> z>xL%1l~ie`jUeWDsM|X;8ZoeIrCxHBFo# zHSyH;3J7D#cCLGo(w;t=Iw@&!)Y-4uK3!M{yoJgX(=VnhUc0=hOeQ5){7&`-Hh_aee*nU#28ky7GHmSlwfNxnXATJYn5pU3lOvp)b>x zdR@O_(;m0yN_K9_w6F@1++^-x)n`|^#J z;LJrMON&>)vsQe8Z*j+;BFUOS;i78i7N(r1Le;9B-6lKb?BPgW7+F03OgSP0Z#bqx zD&Np*u_cFt#Y;=EA=zbfmTlwWv<}lJw*oyS#bi-xkscMdM1RM3qqN6fdKjE&K3*m+ zgrGvKI^`k-iJixuJ9`k1S3j4Zg9nAHm{~#L zze`70NDxYq_?t5b0g4Cq?Hni_fMNif`ow@dDj|o!hVJlL=&KexDU#LUwWq!8h9?!* zC=opx&j|oKCz`3nJ>HMJgeyv~*AmqsX^VHf##3lNej+Lmzw=`JrPA8~gPPBqk|c<8 zFZh^bxFJRrmC1ypByY%{D~i(y-}f$46!j>fRuqC1T#Zir=x>d*u3m~AvR^q@m>^4H znf@9Lj%k}wX)6Byy=%Jku1>h$e2~t91{6vL7(}Gc}T7%5nA=F4aWc5J2@~F zJWK36H%_BX*9RcwlTuxPhzE(`KBt^Pw4{L63^T71Os}7h2c)ik!oa5E;C0KT`oc5$16Zro|_Dh4um^{trK;%tFO~_gdPX^>5Jn@ z5moJ0e0J2hXNjPo78Ph8Gg5l}!u;!wCDmDKv9zxLMNt0|KUq<`Ete-fbTM_2J;ax7 zAFt$O_501oQ92{Fm+R5fy@JgI?7DgERqvMJg0b*d*CG#KN+t{!Md$Ku$`~&83HMR( z=o*L9!s1ny$wOmbGfT;pRfnpc^j0>{#57HSWw;cwIiGoG)~wCMB`Vo479HI+aQ<{Q(JiS8DIyMk^{RcN@`gb>`7uQw` zGM6~0CB%)kaQD~0GPpQhM=Mq`|95UoB)w9f@5naxNo4C!4u+|oC(Pgt z$smWQ@*;u;U#P};0mg~uDu$v?>V8K@7^bwbvWclQdQc#p}1lz zLeCfdWOBbqPj3*64jvp{kdgWgkoomA{xxPm(H6X&VJD?v(u%bqu;UJBA^(Pr3H^mtb6o|Ne=yH2y9ZUiH#T8sB1?0c9y! zJuu^W1y+-MFAIw03J^~v!a7^8*TAYaA$>%RE8j7Am&`_?Ywk`Xk<1HL4QI1=Tt3Zs z9gTdLP?p7G$19c`%{mbapNGGGHCpBO>a|0In@ejLi|h^CZ&iwS$AoY=_Mnq@IJ%a2 z^$R(eC+VHyG+g=^eiS`48ln_mo6d{nh!?oi)WBV&(_U|8gq!_Ia8pPT?jv0>dVH4T z5bUy8$!;61eI6Ec{LgX>c2f2)<(OZ4BoJ&f{6FNFLD`3}MY=*gG=6GPVN2yiiX%<_ zHhUExKRssUnf1z~QT&&dF10%>3h-6em*VHCKMU(x<5OiNEgy%~^<3iFNWAhv?0B^N z*5gf5ToL}@;o2zmt=55D?&HhX?;7VlEOYb83gpYGSFtV1-lKhSI-s8bSA1*0mpw&W zAowRY#)VTjn+Eb=hXA-Sm!Kz&K->RX+k%B-a)4rTL5U4(Yw7*gKP4!XMhqnq69Gs$ zg@BU|j3EULRVTcZV96DX^q=Gyna#h1V;suZdP09fyluB-7O)Sj6zYdMgwF`U`;0cZ zn1MQC21AMEs<7t-H(?-}0#x}I%816Y>m{9u0NW&*qjeV(HTPB|gOd(eaTe2)< z?@A7qz2xg^0m3tHP|^~LRRwBFUuQd@U;lE%G zq9}aL*LUW2@!kY{WKMy-YmSuXlYNJw^rWrK7Ck1z?RPomYPFNlCPYJAF!+Q-T30$F zu~Nu12Wku<5CdW{T(h#UL1ibJZpy-g zk4QCZr)H^A;(R_&K7=dj6>^O?y$ME_Kha#EQvcMZ*lS=n)#gx~x?);d``jkMDmFnY zSk3BwyP$z9*IKU=!uc`IezEI1X5>npP?`s<1=&H3eE~Rt3kn9fH|RZrqli@O_a1E9 zG=o^z(Q_OIQTQN5!6Nd4q$ zfkgUyz>7i4s*_7;De}$?Qn7**(nAeZ0-&lbMfT&z$5A$&fb+8cKu!4kzxb^fPY-kv&jo) zVl#+4eE!Ykumm9_hI%6L6LN6%q5aFKZLboR7dcG6D7q66oe4G;|JJQy&TBJCs=;{M zDIq#692($%MiVxFs;)zGUzPqr6XDB2lxO4O-K$qnTa_|1m)~4{PmeTRc`+H(wrRJR zvQP~VK2EO4+kF3myF5D95l;#cIp!nxrK(P{`aQ?YReg9{vhKozP1!w~h~gZAPi2g! z2t>HhZuz53wC@jKAdM8N@W0-PJkN_8CNu4-c9va%dyc{^RZC5=(faT%<@v*!FT1@G zv2DNRI=>!Dx=xg!YYsw-wYs=Ki)jWwyg)3%PJFOOp}&5TvEN)!FOSX78FadUhn@P68tYTB3o(v5+GjT^%F zhiaPg7(EBz%@2Tam1c7>0o&CBW;CE=09o_PpBhj_1V3|R{*iGYm)-hJM(az`rDKa5 zd1R(Kr5W^#E`eMKA>K>AHx67|y#&N{g;yzb#HDVIR8+-ylfKwlrMy}j1=3*iV^7^W zvR0miA}UKV60QXmh>I_qkxz{VK5#>{XIzgRE9@osls@K}r&e6}IB`oo+h3Qjm7id$ zAzk%d^c)^Wq{ZMEmqKL*MG|S%!5)7pL|KgGW9W|?ZmlGXNxWPxbcN1e`<1OdXH7F6 zV6N>PpP{-kJ@S=5J27<2Oi5OOR^MTIM$(oK{`1A;yNPK#xF1JN| z07|B%kyCBWHEgm-Cxk1@KFYslZDk9-ROj7rU2!n3``bHhAgYc7@2tQ&*$&6nByYwY z&DV@$!1I-mk7xA$(FX0QmZtODR^j~lcy|K5dKttU#N85;??;S6k_+C;U-n9wFOO`( z%2mkZ6k08`bvr}ojB#*SrisK)$Zro+DvhLGSdv;xOjJ5lDEUwEnEn469)s+2^`VP! zjC&dGTGS}Ib@BQ;$H9$_N8O989h8=7RP#!F$Uc02vsEL<7IGxQeAk@jmN!`buaYqU8 zOS^W{5(Lf1Svq?xx`;Oqi_y;v_=0GZAxvR{T(Ycoc6-n|0)SFtr;+)9WfNrk81ZN$l}wGdo%P&VIFK3 zUU$a9UbCJlp;k-vA6OT@6LtQ-ffoo0SY&6)GwxHni z_J!w+Z(L7MAbEz-&0D>sHnXa>lOScRAlf66xlSDG?IR|WDI_)eK1<-$+kO}Ow+VeI zo78T!Ul+)P4&Jx#lu7fASOxG@u=^S%)eCt<6Uk*gN*K#ZnmI^o>wC)j&vZ;Tpkpe3 zr(-Un#x<#>^JK!P)4x_S*57zfW(euNN;>d72Vri+&zic`Ce`K%6TUSbpAm&75Ui z_4v_Pg@MTXq||7Yr$NFoTX#Pd4g%_|HrmAPs^*iWrX%5peGo{zlvmu5(a6IB9Y?p9 z>sQAs?YJC_I{qZb>TaHwZ3^Rspid|WT=M(o*OlkdMnEGj^$ z;6nWYUk&Pf2#4~p1eNg6SoDp&4c#f59)BpI9JwQ8Ap4JUjHBJ|QxI+?a|Y@xj%D|@ zj=6+P$d$aPm!BS-MW(DmPj`Pp?k}L4!|pAI3KXJ5=7b&!T=MdTETdD_*xtlgd{cT} z(AGK|IOl@Lq~UcmtFwG7npC|=l1I{i6Cd!f=|NpBpllL7thZ|s{bxC*v2=&NtPm;V_Q-GM z*f7GGx9F0C^To7R{A$xBog)<;$h`e>EjU%ht;?3zN6ZZlF8CSaNqses$fSaOEHIkP z!H9aZ3x}tJi?Hae1hH5gn9cH*kL4SIG!R-H%U6N_{uz$>k`7z762y(q;KA$3k1iAA z({k#{E$Jb!x}(6^8gEYO8y9M|Xt0wr&+^D}GQcowhm0y;@nNKk>raTnm!n~BXmgwY z-Bl?kT*QXqd$OE|LsTQrufW{Wy~Qnjn6QzLI?UH-mbk4mnX^|Dn2q%+mkiGTSvf}I zALN)a8WlV5_h02$`f^R0C!gbX%qBgSoK3kX^O!dOlF)?CyXOhLwI~^DI$cPd_O%d+ z8$>UiOTImgFxyV+x}B_fp*pfGc*tr`)TDIk`ryv%&lbmYx0l#Y$3|JAbo&b=Vj}fc z2rt}!erzMvWPE)7VE)=y#Saz{eh|y=kv5?$z#w$LTt$DBYwXSR69Cs6Vak zLG#*${S<<2GR>pt_pjEj`i`i6Hg`8>*?w_#Xp>S_TYR3SXK-Gx$05<{^*YNG6JPKhxr^Ckba6Gi z;$COl((m+-67*UMHHsQ0eZzJJ9!O9JHqL)5-aPjz&0J}Q0M733uG;=iy@sQNS>k?= zJJFoZ4p+Kc2`^`jg8A-r%iK)|T!%=-&|NGW^Wg|%Izg=8bLan<8ndebo|H6gAdjwl z=^`4;a|zsG0P0Q6kxSF;*J6VaUsD24RZW6&8LG){^7IVKoD8bHie~3aaOEKfXCqem z{7umU&MlG)oytj#wfZn)3!?Fa4V{mSO^i?{H@YhWfisZ*#({Mo zy<_WC-q{lFmC!%)hkPx+f?pl&SEk(9E4|hE6d`FWAI+hwObLB@ftLudhS$S6%7Zn3F6h;L1Qv*?xhap}ssP^xNhy%`C=j61U=E2+ zim5=#^Qf~erOA568$B3kvmAC65TqB|;7-;LVd=53iu683@pWd+NNihtT=Y#((>f|P zS-@17M7&4?Y>ZPu4r$!#U@@t%2+vBxLQH#wSvdG^Uq83*%s8Y1F6W`w!Wrm$Ye20Q znWU#ON~7_CN-F>9B?6&96NBU6&XYcVanW=0I=m^*{h5bf6e3>|PPt5`q&OyjMX_w> z-jMAZZ6Wq?qm({jX5 z4BzqL?5$`~mF?7yBx;ZxsobC}j~FwsGgxp)G0MqhG`>1N5u1i*iWmNxQ|Cus!tq9B zMVQ~yC;M`D=QO(SpRIlrn?LBB>_O?u%!tx3<-*+QwHlvVWI3{IZ$2SDE{2>nR%w0{ zeChD&75gTu-1m&^Q=M` zDFCF_m?sLovltjiE(2>u?`*Bt5Pnn>qv>{*82#Ko(IV|9z56>CvpL0o`8K(n=$CBX zJ}ehQuuVIp7eHspG`P@U4eQ>C&N|!Y{KdsMTwAPC7=Lx|nJPQ+Lx?`H^35RpV@#5B z*8v~w%><2$OW5msTiWr3$Po4U^rj{WoAPoa|KQcT#g@-uY{;-xhvKx@4%#Gfrn1|b zLpV5$BO2((Hx+**4r;d`mmP?$<5Znlw1+-GBAstph;@j*x_iyZmc)S%kPn8$(Pjeaf}9S!&9nw8gDk zMV@M9gH5WQ$g)Djb(jYQiyv9&-F%Ye6|)@yPiYlb5Eii06{CsjMU$kITt_Rs&+`#t ziqzL)C@Cn0*uoRkwP5{tI-(3f!w(N)BHw7M=8H*`f;VEh7@6O>7&pMhl>QgFn7qGn zF+9I#ZO#&)ii6>p)vew?3@1nOrdY#|M_yE zg|v&26G;NkArurIU&d>)U+?kqLzBlQlwU5o58N-8B3`pBvA-j*ZuOF7r%P0SQ+9d3 z@-`fEQOgzMb35KI&Yt;*@r?Z_VAQ5vwDfKSld8oGvpX_AViWC$z8radBWIbFrWtg< zUg&kQ#vi$SRB&&r`K&6&5_7vkJ5Ip4?!ob?*D3Pl#hFs5wK5?v3Oi4=6nvYNB7+mA z|51xEY&b9QCoN_Pf~)g`|A-}!_q#~@sR+Z?^LLx&W{Xt%4AVyJTNgT87z0GTKNV}( z)9sk}B~U@F!{u!I=O^MH1XrkL4O^?uMz+)x8_IQoyUBQl)FOUqF|ykqQ*O0)|9Cc5 zV?~t1VN?EUH2vOg``u)TVoYt*L*h_8Sj14xM%Gw)fLp|?scL4sHFu(uJz1~sqnbj4 z8LnY{ace(#oA}Y-;(+l{G{e@%_~COrNnuDn3qeF~bN zYFDAa+IGvX9a$pk@Fy!~2+$Cjp#-4h4o+^hTkzPRgKYU=@M$-T7r;IV4Ql9nX*X+f zit?$Y6{W@B$ZfXhnWi4X+O->4Wh@SBY}=Wz0{ok-a>RI2KJ zamYGtQGym%Y{%G;wwIo$;&3`lk(wm9^aDCL%%NLR5KjNsRt!JZiivW3^(QMP2W!PB zSZEr1w%n66N_yKEO*>MmD3-~^OYW#p);eEYMjCM8{A<2HYccI>n&;H=Cy`B-AK$FL zeid?1$gXk^FF{x9-eWPz5!+j@Gs&-!O(Dj#5_)}ivLt@zVy^dhHkyu#;-TwVOE>xG zoaUFfuC>X#-k@;GvhpA1zQc&_8A~0bcSSbDXE6tHg6kaVV=_p1?crK4j~@;)97^C8tDU@>{6eEplmxS726m@GqTOxcCeqrK8V zMzoe=tC>$mgVTu1`gxz*sqd6dog21Iegx*+c|RlDd}?yxq{h@B!AOb2ulBCfOChbr zbCQY5ta}a?%qYISFOTSNjv=(}dNoWxwT&C24G3U7FG16D3?Xg_Ur$8wp%i%Jn{nwcs zc&D5EkE6zJ5LMg?cd$%l(dNI^F}5zJC)?4vV0>eaTwUtA_?A5#np=+TY&F?X@H?)~ ztOohy<3MBfy%J*T({;)zxuvAwrlk4!@mv04^d=`?FLS{Jt~LHyj4AW3B;%41654F& zL-8$H^-t)GRQl8zQH&g3q5NHp>5c(nj1*RkvFJs9|6Pnx7GB%=w_;4N4u7h2(x(2Q zjhy5)wTlpOnmmH<_>K=v!KHd6PaPZ9ed*oMJ`Ah`b$h|6hIY?018MU|wrgGKJ zCIxHHlwY^qN{B@cU20R|SgOt>u9@5qKvAze)uim|7yru1fHK423r&{OGS4g+C(4yB zxZD>xB&;$=cAuMa_7H43zgf?c zx5o7k?7QVt;%w<0gXfC#8pEwq3XG5GeOJ>7f}@nR_(7zPFaab2f$o+Dof&ijbHkTP z>UVHfV2s-zV9exRnXvhJ^#QI9PueK{eXjEMb5cl?8N52}67;yR zuqV68w)TYHMjnk3#}gNP51q?t!(_F_@j3Qo0^l`})%pe8a;&(~H>LMlw@>V*dH=Au z%TicP?Mr{#H}+nh%?l%WzmqWt#*}xDuaZ0ZIr-gTvMwIFV$Q4|pyIyP)*7qz@D){d;BF-;d>ErUlRiQ7yg^{G5L z$skt5Gc&t`X**jirYgC@H6gQ;w=L=>l%mQgzd~`^_5AkmFmJAHSZcM^y=uiseV__E zBf#T~sh)XYVjOpGOZRMG+hu~cOYX7p86F7lR5&T`Q>F$}?`EreR>5Yjx9!6gX;#%f zo2cRrwqSy_adT{aF&oWAHX%F>WMl>&+NO`6g(({)wcB*vdX=3NrTwbBOO{*4tPhVc zZfsc;N#&I6HnFxSW~I_UQcxd}soJ{C9M$V#9#KH?*J_M(`0K)(sm4jRMmEDmk>5Vn zBr{ZR`e&z*(-FQt8*MYDj3u=T77%w;npq0?CzjeZe@b&o?Nty{6IbM*9GEZjN@4&X zQwv$|C+CC``#NEdaKJUfJOx(tEqV4OS}?R0#R6Gsv6;k6C80_rqq-h1%;$oNaVl@P zghhcDRMQ~^pguB4wBel!LRWK@ z=MtbGi2wI)Or5gPv)qC9&O(#v7KZMfh!q%P$#Kk22)t-zTz*VEUQH2$x8u?B;}n&2 zL%SyWJx#_|O0-FIwvPH+v&IVB-1)YAwS-?K4mm;PX5W*PH6$-rBu$^F27{i(|bLAcJ67@6&+I7e)xhbi>BG;nNl90j{yPU(x zF_H%w2Twj1nHqacEG@}}aaVs1UgyuL7om;qLdVlE44boF7l#}!1ykyvm_q3bi?}t! z4tSWpJ!?4RY(62lml!YFx}$kg8)(ssL#vIk znXT=H?@ds+p($M^rB-s^RxOW|BW$XWX*JT9qlY!C5>l-0p4euqmz#u7o+E7Z@YK3I zQzsQJJ|Bx4&wt+bpUIdTe@Dht{u?qTkscBV!hqP@k#;rGtiTC2-mcR1r+~=-cRf;u zfE+^V64mt=57SSe(|o`9<>K6co|qSai94f?wo;{kd$P(4hrrhFGJ5|xMPifRh!ll8=&I42}PgUWc{8?ExK%GiF_-@Ie<3MYNhV_>ux z=+1_kUK3>Mc&wm)^5j$n*;dDX>xp@Z(KF>Q`hs$bIfEYzWC!h^c1Ok?#*psk;|;&= z?6xC_0m*Bb-3@u6opH~+0QdZ8<$FM#<-iZNVY(fI&DfThEY3f9e$sw)eofWEel&c; zl6L+jm$-&qlQ27T%E`TBBhjTt0WPpLMi-cF)JH>FeB119OcR0CDA`o3ElBn44;sE3 z7|W`3R~fC^%{ed4Ew!d2uQjT}#^NC+9h&{E*Za`_R_6B0Xs7BLvM`-Q$^efEw5m6; zz{v;Xx@3>N#WL+Dh3|t*Jx3D5ex6@=zxtyG(nb_zM9xGFXFFHa*evq$xTq*WW`t(& z^Npt-(d}u=dtXnQK7)-+(u5=6?%seo8wFWy3%0zV5ZLVQv(c;xZrka>)=U{YQJ_iIy&!rq3X;Q-5cBpq`~C%UPXI z-ZAS$>+P-&g9hcj@N4Yny$1IxC9cq<_ZvMzUq#hQss)IN!@1gyuhJ-NemL8%(hgY+ z;S4b-gIB2XzQk)2(^nE?1zAP{E@7uFHEpOOZM#${UqBWjNekR4nw&dNre~8lpgzy= z61XvFd7cK!E}?9*Qe$q2VaYEzCR=^3Reh%>|6=G!EQ=ImwYQ^612fuHue3+AEWzU( zWeQ=W(DK)M*A-;M!{J2Ok&_lAUgX*ty)?jGb0EW$2U}z|IuWuP)M=@U@)w*A?2$MVnd#>l2-cV(hUnlJUp=OztYJFss zc{=wQVQkF#Xw%l_LA1>AT+N+#Y6K_nFJr=p_m!C7B z7UU3#(HkPO=m$MwJsbo`%;AuFg!0n&N7#=9LK}}RZ7#t-CPe}79UWo_VWX^tJKLi+Zu<($uUgeKj z$m=Dx><-19MWV|7Ly4&WJv2s4?8g6lXpH!u&=~OX{{=M06pO}~{x=%)-)PMLZ)i+I zZa!`b=zBhdF%t0nm(dtBpDdr3bC#{ubo)biBA8O(B(K z==ePHH6d@Y5O3>@W6Z}|#hUo*IFK1zf$5NRsEpVzG)CKFOUFPLk5c18c2xn_0Rp#V zRh+$gzv4pmhR6DCch%;70sr&c#f#0sT~<8rZFK$sV=na)=q5#(SVezdj&ThMO|cL_ zr<#nCz9w^A`F{5hN_L;xO+U;kC!t|G3-xJ5O(u0QEYvCs^zMEWW4itjV?etqYyMkN zo9FXh4!PUH1e0y5bmB8CyCWsOOs^vNdJj-9J_uj9)135FOlG=G;-*;c(;kAcFp_5} zu@KrB?@yNgJ4huZy&j?~o;p=-E80cYUedv;ACqIrGSYIDz+bv72wY*aSo*(`{)7RT z0Zc*l;V||@I z-MBKNf+9MUCLn7dD0gg}spGy~UuWz*B1LXE7JaX)CCd6QaX z+(x|4v|Vf!I7DoO!_l&IgD5+54wX0uV4jS zOa`Jbg*+d&>a2_F5MA4U-rq7jmZGp5dY1~OS8Q+H0b3Q;B>O>X`#Twv?WX=(iTb16 z4_0Va-x5tgb`|3zVGMK=eKtWxi_7$0j%-)ZgIbTi(0d$b2qx9_`l1 z$herNll?fT)NdCmi2_r=vPT^~)4rp;O!ai)Y}YD#;rtx3E2Q0v%>QZuGWH=u(sK|o zz~n}e@`MX^l8_gqEYuH9oXlnA_1X{-M}2BsQ$I(|L-$W)%*$9qmD_GbW|x*9_a=g0++)3*tjey|NZ@AL-Gp(H?37a zcuzFegp7%wqDo4faw}VJxPzY3V{$OR!iL3DNI{wxJ8E$mQrli5bvUQsDkWTn#reUU zX}l&Gt!nTr5#?)V50GZ(MrOg63OnV-_T#A)bJFSS)7z2_Rf}*xNig7kui-WT_jo_6AF9|i=9?F_WqU=5ti;FqM{S4#L~vKIb{lI^ zutnhvpqwE^rTxMN22Zs9q{i^z%mMKsi-$HxKMbOWq0}pTz9;tSw!bxe8+)HfcTU(U4&TKIK}VbI?SPhbq@ z{NBXJUYZPgMPNk=;#{f-U`vz3&_1;J`1W4#=O<(B^ZIkK75q4cxy(tPQ}`ai@QtNH zBHVb{u;2FThZV4U^Vn-29s?@^Tx^S<3_e9UTOYq;48>O}3x16|@ zC3*jdro(&^Me2^+^Yo{ezbSTK5%t)xU^LgYt~Gz1&%OcHJcXM@APQitzP=#beB2=3 z8G|5z#l(`tl0YCOkbK+>0`NFq)56W-p`6u_&5aGs{dKu7_6d!0Cm zM}9}d%coUm^+0iWqnn)H(X~paYR}FZy;uJJ68GMgJYOzfaZ51-XH0K{;2~AV%7!^B z?WF)AyB3^UT#4f|rgtRq=rS+CPZNUH_2X0-sbf37VSSL}B>W%49OTJgbI~@~atkh6 z9h)Eqg&{CvLX!XJ9Q-zXWW%s_CjdgwT0@Z9@Pr4V*=UIJ9V(q(hpAimaeiB7R4*6s z@0PsHlSN+VD!8122$~#Fu^g%;)`X+Ygk{Ndv2TG;n+yV696i=0VE9nhk_8AU{tx!< z11QRD{TFqUu>}EXkR%z&NN6$=CFdpwl`J$tG6G5zL?mZWa?XNeMNu+>q$WqnS;>OH zoYly$OZj8=^^wR(N)d)NDW-U_+4)vuAyYu%Gct%x~ajp8nu zZ|X)&y%R~Ozr&{?G&@pJm$I$ltC=iPd=rQGE&Q{lKUDiE9O12IW7%!dtn zRDtS&&PE}$!5BgC4SH5JXl?~Qal`-w6oTHWn@bbe6j>!-Mer_^XgCw9r9?!^T9}59 z4_3;zVJHJIrsYpC#)UCMJiO;L!4njWi?}Km-bIj^XR6dkj22Jhs|?oVPbt@>gjFeU zsMk1V;qRiqS_Ck)i$Ezy@NSiS@hNS|l23}E-_*-*!fMt|JDx3lVXV*%AG{vx@3&*m zfB0lwD7r+J+K;vkdMUP581l5Qqe`{PRcgQXb?c+Om|z5U02rYjSX`bf&`bJe`9ng0 zKRu=lRB{c8<~qXg@m-M{#}f*XNrPln1}~}5N_+bI(2FtE~DBG zGeH2qNdL*2oujD~Nhy~S-@XScpsCK*%T=m47~3iIzUi9jY%tJ!*WobZb||U^UPwGe zI#|@*tp*2~+IMOSE&+JO%n&Jl_Q5J|W^rlg6xq8skcWp|XY6quPAu}Q$1c2{6u){! zwSUqGFMrn=31hbfqe95zqFy6N!TJ$E7k$t<+!_R8;PP0X|NHal_rbUjHc&qTam>pp zyduMdm=vAig;H|*bNPA+R#e?VycNl-g6gFj>!M{RH^^8bLU&9QXQ(YF>mA-Jm0^DQ zRN*S4XVtTJjqNJ!%WH*?*uJ%E@GwfXarS1TtfT3xPLRFEoBsW>QdIej4nDWjZybm4 zRb)R{Ap0C?9eq|Km5!%tb0KgtVygJ?Rz4K8d?ZYM{tZItS^!e->yo2=(vcG$x17rL z?*FKVjM}=+sH;4?R;zq{!o{$vTTrY)`}?{s0o_Y?mG?MJ|lTnKe3+S{08YLeXD$>Z7=~%#+WJDmCH6Db^0KR3U(A` z!*1bnbfCDLj~hVC(Tw^m*njMw3HrMWzhX!xzHXbDE_?9V+w!8{|7(8WLyc#73J>Wd zIwg*7e(c;W?NWBaqs<%EHS_U-#?dR&2Y=68HfFx1e0`hlzH-(-b1~kO2);~YG>&z* zlz_9LKQyWpC~zVr*rNC8E7}5zWW3hnK{~jhn3*6ZF(TxNx$^dR?XW;=u6$h_ZQ92a ztaOV%b-%s>UJR#Fc0{dgk)J8Vxp70W_{=|%yLW+Em+%FPxR#eBh!@^*kw+EGj?D53 zW7J}-H~bJlV=-2L!(#G$GW;dX#1e0BkbD&UcV z!Ug2fC>sd<{i$rAXF!9^7QznZQllS8kYPfRVRZsI>U;wieZsp7)n+!+ifMgmT>4AJ zvAQajk*V0G-RgbFSie-)wXVnfR&-sBAf%D=6GbuN z?^+DYkPoWcGyMg8?I8cS^ypN@Wlx78(Xt`->%V9*Wc4^1pN7eD6daG(C+whV^9{24^QT}sS&Ug+G%>$F{rq#W!PtS|s8zI>Wj;$PX zvpT^F>$w})xhUtkd4CnVu-Ay!#4?az6xEAcisCY@{_a1J(#U{Ho=nnxN|ExU0W-dIzQ0$M@`ReOT7rYC4AM(h7Uib)1iOxF8XNN=;l&4avQ zixYyrQMt^lZQZ#iJH=;+%r38UP9xqD$Tvk;Zbg<_X>#}WP#JxZMK|T0)2AGtNAvhh zm>qONr3#znp_I%9pA3gZ@Ji{7y<_!)lc51IPx}NB3r6+tu%FkMcuVO_t?R~WvqavF zcuO+1OgFX}@l>?f4$?^mi;9OQWp}aQyFSO^7dfSM<+OU?5Jbs@;(5O|t^5dA&)Ltw zRa9~wE4o-R(I=;_F7)dJ_q=qh0&gi|i|m{Waz<*$1U5OKzuN*2Ar_cB12Te?Z^o61Cz2I@9R$>`D@A9 zvVQ#1C33pMsT?mz85Of*BUoRnFdA@FE_b7rRmCb?cJAuHwa4RfR5sF466>hse7G{I!Vt9E0%#pN&230)|!^ zjAq3}$jS@lm#21^R^sCiwlAJ;^5CES3W=@~l~S1|yKW|dxa&n8KNfOIbn)?^KuKe! z#QCc;QQ^mBL;wsT0x^Sj$rWEZF?1^IIZ1&;FddovEa|Qa-X5aSS9N!PMK}0+oScJ_ znb=dx5{w*Ykr;~p;`I|$3(9?t>-K52&ba$o@${Abz%;$}e&e!-dxNvCFKFc4lwT7N zE$hg^-8z`Vr(NlHBV< zR${&}A$p&NSebkg{1DCZjcz?Hco{F-iP#S-^J_xl0hPeEj9msoM2hjc2))NS@}HcT z!T;HbS&#B=heCfS5HiP}w3;*D#)}f|yZ4NdKHmO~2-D8d*~fn4H|#hf`c?YWmez@8 zAuDbnk*dN21jF3(#NS<|yxC_M4yDeW61tD3KT)ywbglmk@FMvcptz`Se^=>J?!XPV zzm7rw@INCl=!3%zxD9xFApl2u^g`rCTXVA7;IxWB49*7PFu-bcbWmAC+f)l~0b)eP z`X9p7r}Pau)i{kNahuYXh&U9qI4#pg+er%+wiy(VDr*7xtjYR9Ll(ISUi5owB&hK$ z=#!j=k?326TEsdEFdd`yNTP=Y?v0xn$L|z>}>HXrLVi-jYXdd*?4+h zjzrt>ydex-JUXGr02@2FD@A~hVwV3jVe?(NbL>L3)TPnG5~6DJ|71x!Z()n z-_X<*Un@BJ44Q<8u=r2vd8;^fgX8c!bPqN=^EI0v2}<|KgbqrYy7SDqo_$~aa?r9dsThDf-bdDREg_}4xqmw(>R5dhOj#b{gA=B-C==FF#Sf29&2P-vG^$+Xh3Ao`N?}M~8j*7IPJCZDP zGh|j{+;(m#htas@AZc|W&?z}8AFj@}kRl4_%j*dzOG^RWlG6ixstBh7U@2m3Dge%t z!41rL0XPgJa3RoO0EQA6x6#JJQAfza!ti=t&`n)^Kb7b%%~SPp4=S%EbJ)*zPB6&yA&Rq1me5h(4N z{TE8i(&*<36lN1!XA$(jP-1j9WByQLHi+wlR)2_|ZJKZA(qqI1jx~FKZ++|}NIg~V zbNs5b!#?zAT1oo=%To$^p!hTxH2uk_cYIc>ut<_jiSc!|%aP}<7kMQ2sT7G%bNF4m z>$)aIZ5Pnc$3UAhW&RU1~?4p^BE2-L5CQ;`buAxae>|t zw~CarzPRbFZ8(~&ew9dLp=R`g9HDASyWc96$}_Z=2*v17?rJ@sqbC#QW}M7S0%Lq~hOguqhJu0V7FhpIhynjy zAj%#wcsX7jj?pWf;l561;eD#VQER#W@=K5Z4BgnNeon3T{`4k`W#Yv7e)R6SfX=@G zVl;4b{s3aAUZ_h@$4MZb9FeI|is8&O*@aN+PZeVvYC*R^e5)kkx zEqDd>cEOn5vm2Qrvo=SY&b-ilsR*iJIJqQ6o#n~{k+k{S+SfDi7G$J;Ko2^tBPnNs z7=w&nv_L!rzX>JpKYrUW8C8FslGcFONWqs!w)m)}!+%yVUu0In&GU}PLk3yCwhGhryzq2XV)w0*$Tw9I8$l($QGOUD|?Ik^8=iXTL$d za;V5*I&)xk7c)CR!w`^_h(WX-#X?yC@&1E|0r_5ZZ3O@F+^%A*Vu3aTaL@}0O`k%* z8!S-sWI*CK56rkxdKChkSjsE|F4e+n_^ULXN^N>IoH3j$&~Y`81)FG5VMY4zJzU&BQo?sNAOVMx2|6c#b2 zdlxYF44Qn2KkuYq_Uf!WS!sBvp{kRu&R?d8Pvp6T3K*umM{QrMv!=k_GKv!ytcjZg z+f}+EQ&?OSB0*)rjGGJ}u(Jb^t;@9#2e-2_98R(fG>(#r_h*7<;&z`6c=% znOi17jdgVrcS}uG`)YTmY%BCmvp@9n?nORp_y=Y}$(dl2^^c9Fd+(BySub!p&#(>h ze=@17s*Cx?T=7j6wGrilnfc|H+Wvau(7nl&N7>SEY$5q@l1Qw}d;Hp*h3|!K*RofJ7Gy7m4I%c{RrBE7mRQLZ)q_-F z`Eq+^T3WK<`^ieWlEo1E1Owg!LH!<%F9(j@#M-KthikA)g|nf>7)&U7f&BsEK^Q;* z^9t0FyNS#RUOc>L|_%&>*qeBYCOd47%;-)m>a4ANe`Gg=r`_h*L@kOP%mK2 z8d=wrD0xuwUm-EtKLbsx^O^f{;eAi3@FRF0*b78ADfm5j(0`&+0S}ww#`1pnt1uRr zKjjqP3>gejPe-oC&H^Nc8z321#9lT@}w8QJJIm?qh zPz?JT@YZ_8_3NC@g}>M^D!aZmOf9cqRrksmbH-f%j|ydk&s4S-`|$jW!c@!qWj?36 z-sSgr2xBR`mVZ7d^xxms)&PT!zwkq%=g#aeo}qRHGNsm&n`fF*rUSqD;u@#aIKX|D$;&yDZ78*%cOjNd~p^h zXS?y_Pq$X#$zmpV5;`{8Jz8S_xx0R3C7fM;qW1Z(G=Cg`0=cF@;AT%2$jwDBa55>l zX5iI66c&qUd;89jU`Er`ewNG8ufK3=iYU29nwoBQW3Iyf%M+qPuHp5`C;qPN!qauL zS01EPjun2p-)yXDPfAvNHOqQWaprzNC*|?07Tn-^#hY(*553YG9_{zZYVz^ZP)JgM!c=7Y^9p>B0Td@3$xD?gTWi z9|S&u7@(R19tNPAgFZ(9-QlvV6AQZPfbj5#gPngXr6Vvq0(pfh&#m1c230)>i)`0U zFF}PCt9X~C<}md4-D8REYpAdty-4|0-cB;jd{2aR(DG{j%o(~4ZJl3Eq)RkU<9 zw@-Q9@wF4`<48laDQig;>0!GdUEbjTMM+_dPJ8ahO0A=WQN>+hlTBeBJUhBxtRD^w zRg*imCO4p;qTL2A1PR9YRJ>we;3?{ao~;M&WDx9Hj~oR%avx;4_cbWLx6F;nVR+Y8Y6ST@yKBtUvv!WA^Vp3}c_8Yb(7IgG=Ud!jF-ewfo{j-xq`?o2V@eUVMw~ zz55%aCWZN_fHX~Actc^B;}g2oL; ztVgzM&ZT+>54)VQB8th^EF6}iL{FZ7%y*ltIkwpGec8nv(Yu`Z;nY$B(s~E4+?u^Y zm0@E4Ub<7N8*MRfIMefumLuBQx92&L{zsfzWp9>kSed@GP;?%@TfNEaW;*xpg_zmk z{C>0$LsVQmoLXphb#7V-S8ZnC*XcGiyuLlHo@H6= ztrsls+;yTwf&-@}4>BOUjv+)#J*pU0Cn+5ll zg|z99r_X&#t$u^p`3ojQhSWtLxk{s-G%5y7^$$&KyPbzpCPMM?uwze2be`=Y*cO*= zS2|-{G~`-w&a5dqOHNRRQ%cJku~E^6u)+Z8CodmENJ&Rhf&=alRsNzg9xQUy+O|vg zeBbFw^rP}e<-8OE>62=btpwz#JPRr|I5-%p*s+Hh|8e*F3|zIsAqm>bptvS1u?XA1 zaYl<%;3YIJ5F;mCPRRh>puqx!DYjc+zu{)V4iL@47NpkWmeY%xXj5sa<8Px)MC_aV zuEUhZshM_z^yvFU12vT29Pa+7gVO8zIvPB^quhe-`f>ki!x$BB-P)_b^&O}u6=SrE zR4H$i$rBui=%7;3QG>S_tDtc>f+3r!d(i#zR$KFxQc9GkO1k!)TAy(~g*V z7Lh@PcAlFVkV($0rYc=eMKw?Q=v8elGbbYqE9agzTIb%Ybj4V(pcQJnvV3?xGhPQreJcnWqd z>bkR=XzzyKqyPLU>3%xXRIPv38FHcGkZi}RS_W>*JZ$reGdMl-v%04w;VYfy;rpw4 zP!>hL4n_U@qJrHoFeu2@PVW_t9~99u5fGD~W~e`4jB1lr-? z)c|1|p^xyU)fZe5uitmwir;9fI#>6kr5n`WH>ouoFEWxC603kcEc_PTk;v@<`$e1U zcztS04)fpzrk@<$P-wi(baZ+Y-iFJmE?-Sai*nbe?oJ8Ile8yhZz=O=zKi1>E2?Cf z5X%>~;#xU8`D*$bcR^32C(%z!Z)*@)I895mI?07Im9E!bX0BMLrRz*X)+DsC zu(0RB@l{mV3HePhUJnh~gyXolf!j!&cJ$H)P6hCKfrSh76c9wmh9!V+T8vwFZ}wIh zHGYC#`9!BO-Dk0Ze;Uz;RNB)`@2Dwg-d27!uw1JrDoTvxnyzGeEPbiT;LKLMmK8q9v*L(v z7n%p}AhTd(#SMB~$XlR8a&Jj|SBF)_Qa-6qYA0M;3Ve2{;f*E4Tj;4TI>a07I6%Oy zu}FqN67qxCEeEuRst(JQrP} z3s-LwZHn($^-Q1%?a^cHozOJOw_VfyypLKtOG>J-8Om2)Dq(1(rd&7_AU48#fnPrreLwaxLA=U+! zw)P~GB1XPRsz>9B3y5#5y@@)z^kLNh*@yWJvh}gtS&V=n%{KMoZ0lZhh4)46h96Pg z9-Jlq{hq)D)-ovBn%>}2_IYdFQLjRis1Aqzq+8ASHm8Hc#qiotDKS4j-Lr z-Kl~$(H5JSz~IeeMcPB$^s@G+E-qL5@M9lY)xRK)Gh}0aI0x+n7gLT&8BQn;8(j!Ab%ad{sJ)8L z1@`n#fp}Ebu$PunmI0QMoq&=$zX|yz52K~txe|f$eXAlCpHb5SA|=9bEa~TfD;)x} ziondfVK>l7aH+%4(74)Sxb;yMI z7Hb*1C+=QkBaanmI6a7;lFUHl?XUuu)b+@#=8M=t{0RFs5LTwMr-M zK;0+V8xj0O-;7WFUo6$JVMpVB)wQZFKBU=s&@xI2jSPWr;k~iVBn3y{tJOR?bZwo? z{w*zF5_sV4#d|yP3*}6Pk!m+MBIa7Qq-3w)?N_EO#wnS?ow^jAUGBYTYO4FGBe;|- zsari4>=<=*5#vKM^~FiSN_kP4?{;suqa*Y8V@pKm53;4?ACiUeHKps*p#xhiOKpFDN2zFvzUp(de-l{lkY@ zih<7iuNf6v#V9}F{uds`ZLV@mYP8hc37V|{GrO+2prN^eq^`r{dPrHkYjheB+^5?w zqa~apn(D4@JTEacEAbs?c-n;dF~YS4qWl}g%1?1F+>fC8H;7v3udT{orR0rwbvup| z!+y2xIX|cEyC<-5eL~?$60KF6JcW6T;ep7}Ql^p8ka*_%PL7>;6=2;T&RYYoqJ#1&3sunnr$Bp(}#-%}|JtoZ# zzD3VZqRvmHAV>+{!b;Oo+b4wA3d6Uszpxi0)J7ywvCZU|D|RwPBMtemXHtO42d}MRh-f_ND;kkW{jQ`H8r0|wKg;f+}q4byo(C>dD-o$i8oB`XIcYcW+0vM-JC z3Xb%MdAs!Ix7Io2MQH`*+Pr!EYf?sX?7U%9)TDdvsf+LctsUgHK_SQkYW= zb&X>>sI*y*o90PdhbsrEOM~0peJtI)zj&CBshUL6*W=Z}|4BP|i>GY?xZ8u|LG56T z;9inSp0`}dt{~t_xlMiNE7DuvVq;MTn&poTv}y*l4`1e4KEf)`&tu+xhS8Xkv8B9< z_F<_12Onm?B{1;x4f91xS&4EfW1PgKc#R3gt-WU%Don&Pl*xE_dzI=xU2;EU=GV@A zHQqTm85ZzZ%{q(nC%tcEmSyL%8}se$AvRdY2080^4}Q$VC?xbbo?WM#mDktXIkca;Xtq`ad=q7uxrn6VIuSHrrz$UMX^a!nCPRF zJ-d#z=Qi%1R7(!I{V$JGEevVBCC_}WHJ9p(Z+iPE;r*I6AfbNaL}dJqptmqcaj14W zGOCS?nIIy={?#sSKJ6=-L zUUf2XSIySY>h05=JF9I8u%#AT3VYHMbAo8>;Kt@Ar-|`aCx&jl1!(D zK&G8Ho!x0n8hO+8(zbyHmxC|bl%4Xp@5;T2>;0S~kEpdroW2__^>GS+%aBsjGr$hG zW7m^PuhX;gAm2CPd^|>zVCXIckFmz=BP8O7y`Td7LjJ^#_|4Gtg>;23oVpJ&w~r}y z9(0Y#*)P7x`#dwroVoqk*`MjYMMZa)Ad-5av|F@{Rra>_-+>s4b9*J?FO5oOzQM8g zwI7u%dw!tw4K^sRwSKl!+mJN#{MaPAr!il43a4Kg{@FgJZdY$FfQ7{LwyUImx#}ZQ zBhA2DKTVI!V^2n%UdQwSL?x7yk(vf>BA?uU2ES<8n%Ayc!ofd>Va3MaM`l-J^+ucm+E`hDbqbXu>ds zZVkMPLYJr3I^^ybqZ(steKJyL^%re^6wJU}`%Uq zv6%VGJH=V-xUpiS2=1lLX;cRtQUSmQ)Ig$CTM{>H$^=afCLlY7f)s2m2uJ-B&s$K5;UX0sJ@i%sH4yC>s`&^4~ zNOY<|Jtohs)+Sp@-Roo{BvviT5*sUcv7=1Q~gW!z3^A(TaNc6?QHmW z@SljvQDQFpssRNg0U@IUx4I!P8ayhS}j4O>Pg^*Vc1 z#bbJ@O4zHLMlEJTqN1f*FYmtfn|r|CiYXad%l%$$K0?D~ZKU1lR)RRRZ}8>SjAXp9 z#dzwL(_mu~B(IvJ5#GR9LZ=(Y9tD_NXtyXKT4o)(C7w~Zf+a&gmO1&eV1cx|>$u$4 z|3=IB^#aMnXGMMPHwRHR@q(%L6-M`nnNo1?L_W9Hvc?-0?NwLV9ZOw&;Y|kPF%z=2I$ga;e-rv z2pJ~~E9DLBNOz}&MGHX>bpvBz*zO-XjAwBim+q82wY^nD%0NS5;O{(4Ot?!y8U_LU z8Dc0Skt+umd|=ayLy%P5H128EAzKXZKXPTn%k*Rd9pjM7FFlVZV?~*TREem&^Xm7* z3*A1U4smRKw+}WnRX3$R*IWf>W_MG+9oso=QYAu6<~VqUP`Ern6tBGNWJW4a=55i( zhG%j9a^LSEs1g&IBXUcs-+V&w6GEJC1=wee!148OCG|gkvU@{oPVL~_{@Tu{FXfZc zXXEW&7%`8}*c$J8FGQkdW{L~aTfEKegz3r!L`tcAt~~mN;1>?^3ywc4!W!k-aV(c0 z#YwDfb3%w@n?-g>kauke1fPu3d=@DiRJr{X!%y;fY_!@clU20nkq2eu>+GmAg%+QK zGEoo8-nHEw6xnWMdGyz2I*pUM0Z~vBhM#ZRLrR)aGcG+mZF^&_sUPN z_px=7Szl?tTeTfinB7SxIZ#NJ9n0+D#8H%vMBS6;LoLhQ+?#V5p|~TdZ*d87C_~hD2E<-A8&30pm+pb7|NzcMd#>k!i2fxfaJE-$MvH z8+q{NIotALD_G+m{u<)5JY*GI{~%%VR9w3GaXr^$-ryDfrQo+W@nl*kJB{~6LJbXU z$i=JpzJJm?fGxv@yhH^V$4oPF?`z~0kdwklWuwZi0>YxFY({_FvGhyOUY4@p4?9P#vyc1)pD|OwCui>(A!miS?*B!)fsX+ zgt0MewOoy<*x{dfa+FQs?I^0<+<;JIlE64Cxh`Z8{L0OUUUG?n;p+R+Wa)y}Vy$ITA z&&671F>G=_^BNmkb4mlz4uOFSc&D4BM(usm`W_$JoEn?jSfbvKk$R84*Z(A94(}7X zA8{{FTUM)Fc^kAQtAF`!(HpFn>t$UpMjX^h>wbN z;Qe!}R&4miZ3VTOdW5OGxkOTFlY3;EV25jq6@iX^nc`?sY?8qHF^%99$7PQfE>R@1 z08C1KoB0SiSrgIzh{j4*0e0AOfP+a;8p4=BqnkyGl78Fqv>Few$TmymTY zlz;VsR){#98jv#@Bu3KL+^PvUS;Nz30>f-nA8<_E7%+Ifmc1ir1#uP?To?Kc!hvC! z6`MuP7=*-e)0jy!Q%+L@>l;|RLo2ypoNK%JNnxc*7!~kg+PTr0_F@@;%?*P&hi>Yi zT`&w>Dhk7G(x0KP74!N)iyWxEqy)tbf)}?iI}mT?Q^Oq$_cn%P;Vz^{Kur+>T6|?l zF)*{pE{vTJD85+apdks~0S^_7ROm2-=`eY0%gUq`j0*?4m~(s@=tsSbnD%%Ic?>@$B227uDtsR z-;~UkvFKejq+4zlmqb*na;1yyu5p+vv!1^2pO)&02_ue0wjDYVEsmX_qWqKwJS3v` zf1>>NKKaiU*3Z?S`iJnv-|qA3{1Lr>w$Z(=2aK2KVyS*}5H#WA&pX@IyDOd`1ZLd} z8%Nzfyn2wOV354D$?V^+H1aX!BB*x9;QZ>Hf#7qgavMgRHGm;5X~+b zd@}Mt`nK) z3KGsf`8RREel2byx-V+Rit3nMhuitUbYhBcs$jYxm^+k{ACQxrGvt~5VcS$DmQ-0L zCmsTD=KLax#w;oo9C~ta4<5kLGvTHkADHHLzNq%7NwRiTeO7J8;n8En_<8=aIUr(~ z{x>3~u>k6NzL7TpT%W!eR2DVtzt2AveQIVdoGpmS)N8%Y# z{gVpMPKpbr^@BoX4n}yK(0DE-^Dvi%cb@RuVgr{jpo@JG{0%}XO*(FzLh>?wU&)Q> zG*f2VAfR0k7f0kZ!S2t|up~uJGvaHw{C#WejL#ks1?9^?^HymKGK#WrMY%M*zppDd zr%9LiL@90E@&8<-(OTOnAh`^P7#pYHGj)Dbd^#J<2y*80@1~grlFe-Lq?ko29Ta9! z0#T2T*4Po|&ea^&mZpUBmQtFn9~-i=e<(L=kOeKJcSh`A_^T+t@8{xTvXj8{1(*7n z>uPq1ujNSzznn3>S`_JjSbAUDbj<6NPCmr{(yDnYbATkLt&E2q zt5@-UjTb6Dgdt7>-VtXjtT01x#r`40v|Te@%Dg`}l&Z#lPfX&Lzj!m&=uVy?Y-{4ONodtx9DS_xrJoC<7wTbf76VVSje}>Q8$$?6w08nhJjI8 zhj`%rj*4SZ2KKsgXKMe!E*W;xp8S{FH&a~MFge)TSr}fbrm3()K-%+_sx%R3{cQn0 zJ5fGtdI+2-C(u>tfTsk?CwMxeuihb7KO)OTz4OVns)*=k1foxDG8cTc7%Z{08v0F? zr~@Yr=mzwwudQSU!CsJyj3MbiN2_FJz`V{mVp1fs#jo>YFwxy^%)Tzvk(TmwEA7j5<2?F~`3Vt`OBn`(z&8#?!PUv~A!BY8 zc1UYa%uhm{={A~homs2#|E&ze(vK^v@c3|ZY;257?(Uas;|Sgvp&bXax@-RZC{&HM zfdzE~NF=r427*`;I!zDQfydO(yO;!im+;f93Jx^`p-#>oN^~{xe4vDQP0w zf$F!?q|sp$bp}CcFl07O4uk|i3&a4XM=n58rP=Ze!aXo)o9$!Ac&rBnKT1lR=(rlx z*;lBnJn@3q<1cq|xTxD_3217Ja(`dMF~mJ6$HX98^rY&o^P;{=EbysfPHk61?{1;< zVbOSe3CLe*O>O8Ob0)fEs#L|yT)Tu}D3KO5Lp3eS(uEc=(8pvwP7s}dET~U87MZ+C zO$&!S86&fCtX(+!rZf?3R~>ekWdZ(7Niji*_mZa+y zIJH<`?CnXNj#vb=Ysk?5(&CR|Yrp3nrTPAy=5g{F$BjA}tReoqXKOIk)>Nq6IdgW*FG>7)h*evT&zO@#7!g>(v;Kf5*43&$aE>M>6{<->u02 z6mr+~%C!1fr}D4~90N72rP>drdTO+;j~~zs%if}6%a5j~T^&QA9DdT@X8yr9HCssg zG12&Lm2^w~(rbbMu}otvc6r+vd=n=K<(&|jHw}(oEhF8U`>8ubKW*{*{L=mOmkUF+ z0Jyiemm) zabUhWh&2uU9zSmTJ;4cPeO{IWAq)kv8o985h;oH5Nnk=$CM?9x5ZXs^Q0lqH6akHX zB*-fQi^FCIHTtVMs(J)2;G6tn^rWyDvi}&q$)jlNy1924k}NdYS#PI+dFZ8Gfar{c}rX7n?_8I(K(z|EP7Yu zlg*X}dkOB-5K%~MeTHmiUo*!ba}~q1vMB2h&{I$K8frn{QJ|b^@}<8^QEJ=&@ZncfXoWWPPoDc>$h^+vJnRcBLJ&<_upQOm!j+c}KyIwY*cmv;;(Xe0doQ zm1Ez9zZpf&=;w+#Z?}_UVSOJBXMHmU45V%eV z=1qAPZU!8NLIf4W5r#lOK#3g8#g&JJQs(Q^FHt$dFqkL+BW3&^FjF8|d$^I_!y(k} z+)8P)9;oxq0nu5jW81s+rv9w*Ni10V67_^}P23%w^XFB=ZX zc%kRISR1*p2}Ta#3hGC4QO`%q@sWN%%&eV7C7++|pXi05%*@Pn%@u3q31qZbS?L~U ztmMbLP;0;OBFNNuPM5r2LY(m^mFaHyxK5}AFVP#RXtWGdARm+$L95htx_`QoSI!t< zY%HUe^!VG?v!y>}n0DkdI@@RWxQks@`6-32y_3O`oK7>$At_@1XBg%>WnrQVR{-Xn zJN^#_pl>e|-@L$wl?I)s|Kh@cclFCP@y{*{IOTZSm9b$&j1a_qLn_edDSyQ%jwgvN zQd5mh127Es4{X+z`<$3tD{^Wz?|hZpUJf^uRTe8E<%P-a_gru`eA1^ZP3c3Xw>&GS zC{`JAEMVl)4v%-Fw#Ae{f29CCj0R??hfH@#^=#3aEs1p=K1WTxv{#AF{a+*IoJte7 z(ijo9zt4RA8w(TDV))(Zpi8aaUOG3Q{?dhMDd2j70D%V+HQGds`cCAGZQ!=v>{td4 zV?@=XZ58hGl9S+@#dqQ1jK+ybGO&s!8rgtmz&?)(q=)}N(j)C{&rX6oY3he!h?b?f z@bL4@*FV zo5AO8F;EFus?r4Tghe%Y0Qlg=pg~*$V|F%A{E%p*w44C@UoedDHKCgKi@!?8{q*z_ z`o*R^DCdBkkRJu<{H6|BJH6qbyg9DhC0x|Sbf<|#Yny8$SLD8CC!0W77&+(k1F2B*qE4>>K}N@p6e#mG9EAf0_z_xj1V^R3(S&E7%_KIxvGxTd!bFv z_Bo170Y@@tAFJ;Tdy7cdI!M5bEpF=kTNj4Un+3Qqzy8^Ux%KZ{7|_!U_L-=JX9Jik z5kqKZNckVG0IcOQj0*xz*kI&2XgAgdzlIJuK?T%MKqHaz6+#|yP*1y{WI=jyk#ZA?Cq-Cv z|4xO8hl5PX=J2{!&-Q0}oL5y6mLGrm!LKIXXTfFKnDYYjU1@Z$8{uXr4N)qycvf97 z&!d7~vysSb?JzFmCq8YEO1xVgIc37pTEv8{UoNDSn__X7;La~ULFK4r2x@D1PSbkj ziq!n=3&DG7N*cE!3LDq{Vqv672GbNMz^gCC*&wJa+hQz~&ZsegqN&#j*h)*D%#*Gg z^}TwR7%j!V{Bh>{3F-~k`kTbC-o@3F)r{8qpBIAOSGZdiQEA8s-~Ua8ar{$-Is9DV zZGJ93yqm~GS=7QQq&eqQCKaM6LRWdxuEo%^i{tr~44>zmA8g(a#>kihV1@J}?r1Ka6+1m7Va#$+W|$0a zJnTH#4Cl!gI;z+WnTF`%6)ZS69(9x5tY&Em-HomH_J21wq8qh1R=FBJ%2$0$W2-tB zDlqn^3Zn}0m>@Zd?&jwPudX0z4z^J+i{Q_-4;DK5Z!S#@fNVg}vAlv%hRC7PhEm)d z7b9K)C#F7|K5{}Ql~ghXTQ@+j=c$x3>esx{GYFmxOsOYcS}9g1dGVIAtNet!{#qhJ zm7hq(%(DE`eSAUwd$-F?>}7}58=A}>(Ce2cC6z|7ILbV%xyEPAY%^z(p7e^WI*K3pV_tUwA~v)oqp=pH&~soZ zD2Y1Cy(&Lw8WG0L!=auNSz{_{L_29m#sF)TCP|Boyi;-m-qLCuqpPZk+h8;;1t)D6 z78K08|FjFiU%D@-M5WJrBD5&weIhkf4zxk5Od%$^&av^!>#KMRx*L6|{>{h!GutPJ z#e8Wh+Df?I{{;$jh9ZOtI8o|f?7gNER!f&o@ck2oq0$NBm8L($%~6j1I^aAvBS94} zC~c6e`QjuZ<<(T|iJlRCWAOJ|hZlQp#@_k3>7kZa$U2R|Sm*1Ltx`Oh!pF(Mx~#)C zrYVL>c$UBngi7nji?7h2|3k@^1J5|Y^vRLoy);v@dvr;VOs*cD{`lv zC67LM>0YpFE{R_xmZj)HoSSjjHj79(miK(FC7R>Nkt=n7A70mo>Hl4YG2+!Dy;Na* zl}bMK25MQ+xvA?m#5wMKW7sC2jz0vrzB1W$LzL1NzMCJk*E4>Dm|CtQhA^Ty*l*sl z9XO$sC4ZR*N2@1S@zNA0B)TQbH!k1?6yN*NVApNi*|L59V+{0Pub8<45A zHD77aV98zRC{4(^UV!-(V1%kpBP1nhCx3DG7Tv#dVfOzG3By60D7jB0<5ZV7 zpyL0Y%Y%H7$6Z7=Rg3K7FntokrMof=+Dgz-Vy-BDjBQg@ zokXk7g^iy1JZ9-qrW~*5Xo7zp{2L5|O3-_CRczuTouv!seF0^Gx;mEy=507F-CPW# z8s~OYr|NymEBRv+3LL}=t$5*gR#!irocUNnDc$%x8{oCmOhQQfYhu}4PNr`{?K2;` zv438qysrOBaV_L%baHQ|Bf3nGvuqw`Mr9s{HP!@0#eoLzKLGHEaVKUzCV^=0U=!0| zd|`J@U??VG&nTFgGqWxF5t#N&~v#6a9+ZGNgRTltkA` z*hm1nVMoDj>cv|#E3=^yiP2AN{?O}T=5(mWXBZFPgdz!UZ#ii4KqV7QRPX)m1b^>B zsdQQ#ofKpGI@_^G90GCh(a6mh*$c1&H8`L@UpKN98QIbe+AyVIi&foV(f39>C&Wt& zlDPoZ{3t@c+y>tV8J4dJ%V&lnV5Rs!HR34M?WP{Lju#QpNr~7^&5{#OXjv@FmkcVC z-;#f1@ZfbXZu8Jf9nI%d^}UtZlptTb4&Hc22n%BGI+nN5TIy%+u}k)E$Y|N{`r=`h zQ}bS&6di#u?l|}_Jg>uDkz5PX@zSPalV)@mf2&Ox%0*k>0h=sVZ7IRN^Zwn2LAD@; zQ~KQ{4~>(8jw!cS|IlFy*{uKfbQr?S$I}^V|5b+>h#)U~-2Clc``eJebeO!CE$j7v z!(r++p}gwhe$|$C*YB1DxEb{gg9$%^%%;7BFP~YC6JMXq@3=ixDy_bjDo#>@U`@-z zZN&UNJ6I;tBE7Iaths;#qC=21 zb5kQk?y($d2ZPYWsTl)l`zG#ZQvHhh6M_QvMm z6Kzf+`W|6?+kZr2fP9aqP57UX7;t7D4wLQ=-MA%DTiF1qhKsJ@0==d>6nrO9NT-W1 zGrOSzPY?1cEy3eZv9PUxVDZqd@rE&}MJb6Qq85wGEWVd|X3mIYb6nc$Z6Vi1r1ooM z4jz*C6#4tq+~4-`8+89X{p`Y>P`BQ%_oKhpTG}>Qog*<;sZ!SRn{P{>(KgO}ZKEVa zm9u$$YExlT<1(3i z#hmCgIJgj47!-$Fa)XT+Q2elTCD!%;*C6K?NZ2Q8Y~mWkIYU9bdk}2ept`td8YS@& zw!vGp(~Od6k4#CLxD!=0I_ctZ#{I%Nzf(jx zz&LO1(!PjAQRDvx62sARZC^=SONshWw<5&bI;Fv*|FafC$&8;u7#>^PE&7FJ?c(tw zZs}67x&AMVTFE#5Cuku>H6?CQ9B}~Lgv0syL+i0$HilJNAIW!-B1P8`B-)i7^Klc< zXhz5~n~s!se4X^EppRMIPIte5$Q&n_S=2|3YVHK)*j9PA%D(Y9lPz^}8MD2Wkhc{R z%zF|KR<4)0y0DH>In3L9;;=H3b0>z27PQ{dKtL=&OCo#hLjg9=UU>uAp8~oj(aP`@5tAgmuGh>sF}wtX#I%l&fsGabC2 zY_E7=P5as=?@Ik$oBZGz|$+TJcPY^C78hFdM zoQgcInT?=7UBp}0ot-SHx+jixVx0P@v2X!SaXzS?08<1%XnGFtg<&%40Rjl^udm>_1Flkc!`m-Vm9F}Mq`5mOF zUPoL)h3e9z-%Q|9aH=p55#>*Z2WQlAqkJjPq08EVj>GERU>d^>W!V)rNG)0pkeQS%2)#A|!s3;VL zoj-Si2?@+a?BpOWE|0xh1!k@Yc7ucE?=J8agD&FdG6lG`jRL!IF8qp;W%P%ttW zFX?Ty4BIlC=-}JsaC;#4~80w=x~yw7!+bTu$Gs>*?RW`nkdN zabW(vymZ~LpyG$uKtHZ-4PzQDF(`|`D#zsB--Q_Mzl0dzJLo7LFn)|Mt;;Ugh@NN` z$``qX$H2MGnsXC6zVqDNQbHrk7AwSb{Id}A>|BV+H;gtSZS}a37{by;RBZPIxr5If z-*HAq|29E|>l%KHyKF4+p4*pbx&Z;GCP*=U`j{Wxxzzbm^0?SxX*KV6A!hn_AqKP% zTQ`bvuew<5MD)JE^Y&O^Yh>MvB(L}HuE^PBFXRumbo(6h{hCZ#LV{fIE9&bphP7Vd zd=*F1%H9N7*Tm9%kMgz;$8$@_+v*kUxNl13YqxOiorOetswa=nlD25)k!eW}Fz(uX z!h9||Ct}1`Du1+V{{~r^D$MpTu}ZVWu`&5)BgXr1lm1jo)A>!8Xz@alI{~yw7S;xP z^baFu<;zaR6^`*O@hTNEdBrXZZQcHG24fr?hU1PE*|5qZiN04Dj%3mA7R80vUa@G8 zcWhi2I~ad6q|SUw)>dqBD22d%@!Ako^Hf?aq@s=~Kl)H^KjBBj*%9Xtl-_;Cp9A-V zrb3>6eR$-bO?lst=ncHGXvs$9M7`y4-od4{-?z zM4o!xH=hfV)Vh;X*1OM<95i4PZoDkGyLw<;W0XAArQ~$CkKu9Ren@nnmuBX3WE>Ap z-wEhalX}O}J!J&|Ro}M@##ZKn887>jC`;_y$ZG3^zbi4Q ze^p{0|FaVFc)srQmZSDn+9(;&b8=M7;vMghJT*mJ8TlawZ}u_6{6OU<;grVeKYrD_<&v{=8h%)FBmm{~ z-x)tD@cXXO9zE@R!Sxxl4q2Qv?`iQ{&NZi?98an3sMO&lO!T1|ZXDTGJFzXL+8Qtc@#Fg;S= zvaz{0i?dgmNvdi1g3>Eub#+x=J+IZ*0-@bALt=DM$M`UoA8BG>-^5nwZ{->s-6Z<( zf@iTPRK_2l5pjXN@tIXP7hK}F98OEwq5qA}Vrcx$lV6pery``3V}6NXg_s=x@@*R~ zZ6CbrZDNd|dEr066A~rym1qu9V|P%7%3*zbS+D2V;mvOlWlbNFU~t#_##44q;k8%i zC4JdU4|@4D(w0_;SGT55!sp&Q zg2o*FDT!ka1YTy>QU655TzLB)xg`IV_38bYK*PviWK_j>BVy>>fI;=;@Ys+SH?z6g z<4gH&K5MvK?~2Oe^BGJ3hibh&_-{5lE$ew zmiMtP->A;mkY^n#H0hXmf!p^rHCp;>#wFQk`r_Y>7^r_^{K^KW0EC*q$6e*zh`EC` zVs8I?BPRZY5KF{}^J^C^(7uYXx;b!7J8^TWeI!`I!#DuGe|yprAx;f@8I<~VX!t8n zD=Xn3^7CqqS4AXsQN{vi^%EdI*TN3 z3o9$h7qEc4chc$dDxtF1+4F*aQz}P%{bvR^3n7fV*Xq}N$5kiI-Hll{nW;lSO=0NR z1Bz)>bru=hFtsVeE&l{9-mzykM~Dq+*+SHEi&I_Qy%&eYKDvKb zVhUWT&9O>MBrau#%@MRd>&D{a4Udx{(6sAjstM7eWs=7*{h56ZW15Y4zJvdSEI>u% zqB$^MLo=CJ#nNTJ)2#2xW=aUD+rA2n?R9)0d>K=~yAF6Z_*}y%dWyd@v`~3xE;0V` zd{zH-mAE`HW}JiMlV>OVjYr9}Y2D-xhXa|SH=|7vqSVyauP@^DmeZfC3T-~&U%vBr zl9oYiVzKVjv|<3Vcx%16SvCp>%tMaIWpwwnc_ghKR2N%L&W0soE;xe16;Kib=vS~# zivuVIIOYma05hd^IEaI0D1y%sVAV9f1P3A#?i>uOAK{XLUMOf9u!dv95u_%;oGocg zHznESB{^5kL&vWc#+QE>FAgI~ZXQobZ$pX&Fh6%<^PswFVw0y2U2~(zO|(tZ>)$W0 zt}4)&7mhQeAiRwjc~xuTVq<(*{MfjNm(H2F;wZ^4DCW@}V@sl6)!~XJ2qE6hhwSup z4cz`2c`=&>KT?Y;hK=r5wt3`m3F~c5yloRuF25ftB}f`3#F^{#vD!=LmB=4XOz2-u zOs7ECU2SrFbZsf-pO~22N^GDyUy~eRF$ONI&o>L8p*;31VP>I=3r(WZxDpBmD>42A8hqHW;|6E#;X+{;mx!FuWICGUR@J4)zIrCXFl0wB5x70VQU0~q< z7`m@N=(P2bq_4KK$VxvEVPzH`?P1X#@221=Y)d)lr=GX3!n-?t&s^9){rRrmqj~dz z$7gFjl9*{;FVR!Ll7)^M_(p&1fZbTW=jNXs()&t?b1fh5Xu0iXN7^J}?OXh-Frs;{ z-yko}8alp-T!$4p{01?-ec5I`Hv|=s7qR&|miWQnkr)?^v8=0x1~0w!k`D}RU(slh z2)Tx5O~fhWDifc8E8twC+u=9^HhJ4S63iaY{fJ1I<`uvdgj@2T1|6Qmu|U#X7P5IIyyYkd*a3khy7K=Ssc1l5LcBCc7^ zRCC9CU?amz5kj85Pap;p>(dpT9)Y}cs!{8fM0l1r-&094^hJ@N;BO0`^DzzOHPu(y z{0pdsH`0D`P-_t2$M<5Hm?0skeGYvE!Z+WCFD=kL-fjOHWAoWOE@_ZP&6O^r2sYg3 z6pLI?DyTJ9iBW|el-A}OGb!=Fepg}!M_Z9`3+3XoqttwK9cV^PG>v9jH`w7SjLy#N z=4Zu&_};#K%A;G-7@R{u&W@1dvH+!2RSQCh(85A76rfIxxb)>kid*8qq?Mp`Sqcs& zmlf}b`t^OIj<82fl5SvK>eFN*YK~Pp&;Mz_T4|&M9@dG;#X2!kzdJGE2Qg0kGBGt2 ztG_I25ddA+2?+@L_PTi--;Oe^jnZk2Ba$tK%LbxP&&kq(8w68tSPG zCfdbup83u%h6~?S=}v!|{SxxLqvDoI)>q(17dr?i$!*}7QhZzFBf=7+uSI|?DTnYB zi5FwEAQCO*69!QkN)Rcg>F1V)tb74Qo|IqB8UKW0-lZ}KRRpR0yf)`d5UJdWNDbX1 zr8K{Hu$$tOqYKXd&G^~jUh-0vO6MiUo@MK z@0VShG!nO=_zQ}$>6Ah>wwJ40#hDDYKnK_$b1N*A4OX)L-=Ic(sJg~j*Y{rw4^4wE ztlvl4x$fPj7k+y3BG%wkZn-5$Mb%WIRc$O-ZCNQLfH^LN>ZEPG&du52JGE3o%M_)g zBbwz}2cCaz;VvqUwg+F}o7E0a-y{3SU&JX$BNE)42EdW_AnNb~B#(&@;CNiE(8 zwWyJ)^Wba$h1LNU1DM&pw@(ys5O>#1_J&=IWSY8W-6kln+co2z1+d*ZaW18Y4c$~& zQQbXFnN976h0waX;XXfR;NY$7?mLO~@nzggM278|f6I*jAv*OXvC2-}eU`R3(-x)3 z)_u>m*9m!3UzewkxD?%RJrnPxsumB7xXcO%#M%{L08S!Q;4f8F1*}9)sFyk{*QzwF zbU7z9(@sJ_4N-?v^F~}!D-9$SFy8sMwtWz9?k9{@K1Z{Y*XW?W`Z1+Oq(&bPt z!kAJ2l0iHorj&TISOd0hY2c_tDUZ>N#_CDf4^rW$;u$^#8Ro&XXtP46%*eZ)34Im@ z6aoVzk>jC)4;`mH5tzEM_f{t3UMRT%@L4T-5x#dTbh*m*N@`cPzYs4HB^cyIPv&gM z!K|H3o0@Z}h9<7T>Y??+dAdg&-EhrOj%BeE1E!2#4^3jV;u)vb&pmBx^{?Q2rJNdm zc*(jNe$kTB<2LvDc_A1Poj;Wr?NpPPUrus=Ffq&d9!86F_tqF37nXU%x)t1RQaI*X zI;F%8DcQSdZ;*>gSg8FQCZ-r{TgW*wj6kOpIsEaL<_m+*lHL3I)&$GOUkjI;Ir1HK zqeYv-#?feM-%iNPhtF02YbNIAQP>NjFg*UFTHZtJ7f7Bexazj!)<+l1toPLZd@+jN z_7=>nZi1L#X29Jrdk<;yfTkOMdl^r2FJicu1+>NAHN51Ghe9 zGy^JrCgJR-$23v-ks)_;<~K^lz7h-Evbrjz@$W_cXa*cZ&${o2wPG>HM`M1E&4x14 zM%;Q09|vbt_-++am<#!fFL*1yl)6psc|&h6y4^pGJ8UKSdvE^wRD;c%rsU$#khX3Q zs#d~2315cH0O~ub)r&5^Y)Cd++ii<4(Gq++6Dw^g_Ix?`Pme214VvbNWg~?yd$J!| z#G`ZN(!X0qt&t1jJz|T~00qf&Fq`fC=!}sj?yT<~YqXSa2Vvbp*PO1VhDG--6S90_ z7+$g%d=oVl+~9?(sP%1XaeuN%%+T|0S1`VmZ%ks{`ykLu_@z~mQoRKkq}!N&_fU2C zjidTjUz)#BrJoOr(bms6P4vrPhJ~GOLrYb>@oePCKM^rXp%hAvZ-a3d=p{0W=-80H zfpNW7r3YntFK}FIa*U5-9$i~>E@=4KH5}PmZz?wFV&|DCwKpO<6QgD24^dXh>qcoWf^G8E{upiiDRfS~i zY(NU7r_tgG2i*rraj;|zFk*lXCnlahg7>Tx!Ou?y1B+5=hLVP&W>}=cx{-;2W6yW*h?3%d&AFHD!rCn!bS)Yman zy!o27deSe%^!VNmR7%=jq`yC&>+!^Q3~Q{fnrHe{!{CP76&_hmipT)GLy}*aV*`AU z#W>0Joifw4N3S$Dx$99h3(ouuL=zGEd`KtpK_(zNJ#&*?$r-5mCiro#8N`kiUPLG0 ztKK@>BmDC1FCm8K??Oyc&%(J7vw9SvC5%5X==j``XUMc}b5K1|pp&VEj#Q&Xf&cy1 zjKEAYRj5F;tp{=e-bBA#+0)AuXjs1XVbbanO8HhfC!M{hrcz&u$UBG%5m9dTr}TrP z9vQ0Gn9G!GZ(9E0!_4FcL3V01j*XK&T;1x#leD|6{3h~k>-Lx_D@lG`V_9RJx+C0b zcS}_@GGtEnG+_M6Lp|aQs%y7)$^^Ex@mwC5dBS(O3u_YuFr8@7S2W+o7roh0W*yGg zOh#t#Hy%dgUwN28vluh(ti7W{ZF;I538d@11HQFayz+80stf&$-pDP+#A2seT7@~| zGtnsH$aB4Lt6zPxjK4Cr?CvWvzPyBzcKFWNW8(I%Wi8lJoSNMKv zf%VoXXXTyZeR$R&z8eV8rGv*2M-9B+L?wl?AaMQ%KFp|ON-@-NjVG?=x;~?nMTr-! zrhSHWWsejL{s#|}=XCDFoL2l>AcjrAwJ^5fg|eojg?>jqiH{zqhpH0jdk2YPRN#l{ zsHf@Sfe)$nXlDcYr8ud3*E{zvetwXmqn5vxfX{FJd7#Uf!cdZS%J$iJVF4nD~ zebT|S7KPFuUuCt5N9A;Qh-bzAfx{H&_kAWlqR{!3PifNH(pdjP^ODggsutDxCr{ku z=NeV?vRQ{}m#Dh!;d;UDq(ArHylF`0nv`*3npL)O;InC`s=ZPKf0wS8JrsXsQ{J}c z29OND9X%R$jW;}1k?oUk58!&xYP|J9%lF8jz&ID~DYr$WcW87}~##!lMcz4~Xa8pyWm5N1dxWp{RSi0ZGu4q^$>9lkYHf}iMvAirF$y+-a zwjr3`be%roeHfPjX+@))kk{|($u4;-|w>x+(3#eXtuCz z^vaL86MBrl9%4_Un~aRx&Z+XHTe+Ot>UrrC+^94iEVp<$| zsxUIk`@2kWPF|g(yvb%WNdU<3mC>R;VJ}ZR1fHm8PKK&?PST*ZG9& zqWH|8e98{u-aB%tWDx&b9ELf9Z|&X0*u(?=1n1r=iQCPrq=FZ3+qa3yH^YReva=Hh z-7Okp!*|d-ogHAGcsHwjR?j|mv`YUL+27m7wlL17Udw z?h1?yPy&L_PK)LrO)MM0bmfT)K$Sl1jjk62mVPMoAl_C_9Pi*ythBppse6$;q^#mHcmw)&$mMZ4UIe zT&!Q=WC<)3)##mZRAj6)(x(uDzF@+a%jRzz2_qRd%x2ZwoM>Ga8Z;XSsF7t+y$=~mzS7=L=S%MR5=e)?@htRC8rzZ)(3m$Uf;jDw+Jk zBUDMxh>ekm0McZ5#*_V-3Unu-)+D72$SGa$y*p>cr zMSyFfU1`3YGL7o&V~u+FQaE?GL8Tr_mA~=4SG6!3s1G6F5p@KdItQ5z0-n2qu+z6D z5!Fyq>Y*?0<}Xa`x#=nn_oscL=gyvDEsHV`Ozt+%-l4N_pWLYyx?3a2DDS8R!`U98 zsCxIWJPd_afL8naP$NDrN5Z~*MA-eDR*np6`(*JC;K^PVbfN>rIo7o&n9-klcsjHf zfjIptT;D?%OcezPX-S&mw zaTuH5ahQd*kQ}cpI#=k|(qybWe< zMKQ`#574q8)@dfBHziMJ-7pB!Ui9IOH}@gb_vXjuGe6l##}v`NzK|#Lb>2gX;Fh}m zgVFA%lc4@x);|5vQevgfm*&*5^+iINci?ZMPvkff127DX_OS@5M@ zLeNO33vHRkna1gij-vB24MHxI>wl+2Y49Fl?iWrZ4(QN!BQNFKL<+N&6yqSUL`;24 z=buE3=yFno5)Tfx=vG%0R2Y%tf+Q2DZlbew0T)Gc_Mi~mZ`5^PW&HJOYkw9h_B)AL zUj6GT(baoZ4!A8LWIIl)n34-onvB_~K@t6QlrANBxBiY*Vsv5kpm$+}&Y-dsrW+Tl z#wdZDVh0)n!y++*Y^$Is4;w;{Hq-nFn(iXRl(=z8Kt|E}QkY7>ITNFZ4vX$7;^oPP zrF3DHSXli9;^INVgb`ivrC|n@X9Z09CM&MP2K90qxLJRjBqtFTcJ;S@=;LN<<>^mO zqWI9w+s@5f*udZ2PKpiYYUN;e&B@));jdrOv~zRtc9dchy?IMi^#496=H}m^81VQ1 z2cQ@$EEHq)|Dc%v2gUsV2E{ZMmf&U-7SlzDGZXO9fP>hl=jAu)Rli|F}0u(`&znXlO8t>X~@fWqBI*Fsp*okpSz`Q zOBSYoAm}Hdb+eX(*o3Wr%6*MU)YGlQOTWeYscEN7zqTb$uGmIHl~7wtj{|PTmoIoa+UcV5UWCL0K9O)loehCb=M}O_02`?Lexkac} z^}K{1@qWX>{?$?#>v~#Ia@P&2_}xFWnCYp?hV?5^6&or+Uh~u5_;*YG)M9jZffkeU z4=pBCaOAPf*S`9`yDuMaH*w{Ty5ZGta`(~PtFxD$Z|HnNuLt)R?{q@rlp6;$loHH4 zo&W7&deoJugr`($@C=j)3YUWe(OJxaP{L)iE6;T5r(;5i+^W+C!&@+AMo)+KEofYv z=wwJoZ7`rpOltt=e-0&4U>4AwWV)wVQ)FPB%SRtIb#wW~-?f;X9SKVV_#&APQMq)) zA6!h{qkY%Inge*ahCjzrdfbOwtT_-RP!bmfgblu8+~pL|(mkIj0vx4))=q?mJBrss z=3pHdF%5*=ohYu82;{ofY=hUKA#7lf?L}m?KjErH$bdNTkEX&%=@}z?0;W15yuj5s4GaVIYP&t{Bv!-s<{$R5=|oWVTelbn5S zPL=tW9+Fz}RWo|R*KY4j(}otTtO@-jrdM-NIR@4`0Q;V))FS^S1 zrXr#;jEc60PSgx$X6ad&O~6L`1B_ug{yAWH8HK?cI52WZT7&3BC#6!pG89Bwzgt|t z8xPFo`B3ylXyx@C$>DW1CH4zHm3Z}*nksfqDu{(=2{#BIe7^<^D3m@rO2CtXvH_28 zKE`ldb@6zh9`Fw#odrpgBbO|sj%psF*w_lwX|2Pe0GU~saDGKlG6<0sFG|)NB z?!XfYB7oeOpSzpY`Q6&Lo7L9_U1e%^ir{REP+#$`SqpiGt~4pg)oD__fR9yU&h|&a zW!6VI4Vw`!tqU2Ci;keZ36x&zYfd*FTT-aah2(7mI3_kTha0EX%SLflpAQ{> z28nD8h4gO-kVn3A^I~?++4B1=Iz(wT{J<$g(!N(|wN>V{f=|(+ldL?Pfzv`M;$ZQ} z2Jgz}T_s^cwTmqzb+;9n;u2$v<8GZq$uhR8DM5{B9)XCCS)l@`CqFME$EIFD`~?Ec zm_Pd>b2DJuA=jrsq7X__F;Lv=4NO5)HGjZ#A^K2oZ;Jfx44`iqU3LDOgn8;Evc z)JoHeK`&9jk%D6a1f(#zX)l0kF%3BqY%+3<@8UtX<-s)T#LD_T5;8A~ra0T#vJFt|T*$$v^lcmx@W`k-skf@ zQ zh?)Es`A??NC~6IA<|G~7!;n~Qq7qR;DGb*K)Y8f#XWm}>(U-I8T?Mmb&dPFc_S7hO zmQ~a6t2zJ;ePVdmP%ly!f>&EIMM}uY<-LbWAj1n3vF*gE$NTc>$L2rO7!lVCLz)}@ zw-r_(LF(Z_Ye|Fy-k_NIJQRhE!eMzUFkbTIHNc-IC~rRh2kcKM{GQun=ms~>7Eng5 zo$+8k9EMPiyj;N>Yu~t@4+s2%jlt8ali;WAXL8ST@xp7QEn-5Yh%3xOdUv zdV1_X+j3HwQ69CtV3oW&vheH7M}cN=o69lLoWy-FhB6o>PT;YGtN(& z!^Ia0Dko1+HXBugIJgjrnriu)J=*=PQ=@UO2JA5rm@(00H&M)1muY3m`K{QNzc zsH+}EmXx?uSbJakKGa!g@fN+?6{GHw0}p*juvA&Fi}y=pg|~a(LSuIW(7(^y?ut zb>^)Q!Vru2&P*Zud0VIlvnET_ooxz(U?TFvz|mb0#pNdk%g-Q-6qE+^0t22fn1p_x zfDqU;CKlJgPDjw?FoNIt7?3+X*9bti!lRTCUo@Uhd!eB6A-dxIb#mLc^u`+7h|=9M z@9go%&m*8FpnSU_=wscLu{OJTCCf|q$-O*DDtU|(JmgGvOnh$pt567dc+iAgthxSF zFG)n+`rMBJ--4&x6;Phu!o!ate)^1S+-K|-eB&lS?o>P+@lp8F6Cza^y|Fl2pfgXW z?rIeh)uJDgyOZZ^|6MM2(ucQOcsl)8=B?2aX7(00W!_#3kS&XOa%0~lnrB7)-zUz*I}$KjBwvwQ5M?cfVyg(PA%5`C zUmBrK4v{ttL_TrO1A4gZS%XeCZjhkF%SWDd4olKa#~}41rAcb$&223vd3*WRO6}&E zZ0vmn4R^_19z+qIj0}O=MXtn%?|Dl`37K4K^?|u+`C^xZYtQ|d_07+DF-ZF124%v@ z7s1EwCLttoa(@akk-oenPc8>%%7oOc!;|FtiB0+_V*qqIsvz_#4O(%dJmv}^aqqzy z-EDSH75TXKm8p6-{eZ2&Q%(r;)qupc5au(3Cb8&L-5XpfoANV93n#nXm!VmK#(te! z5CJ@lha>|Qkg12cv%j`={rq5Sf7g(+d3XJAz9GitS#qmTd1t7O_!RGXM}?_5_?vFnDL8YCs*(T%m>(2>3=N9yiPDw`Ad*tx;q)OXmpY` z$s$mk<{-l#7T3h>#|F>5`t)A}nXCU0WWK2BY+=|_ZJHAg?n)HEN@wngTuTr%8qhH8 zq1i!|3++*4ps2+j)@cuD!6#K1B>db3JRmHv#|^Yw(TgrM%3}JP>LT^`==%D?(euSq zeA7zrQWG~z6QNX^;y4W8x^}9-OOs3jNL=+loFjAIZOcp2&Ww&H`tP&r%s$AhkVDr! z?QddW6$8WA3{-|p-f>ZuYJXt$<>Yu=EBf(+8(^0WuDd~y5^Bk)t_y>_B^qkq*V*v{HQQz2=B0d{V@sJ#M!w8I_#c1_o))Vv_m!EO2drp>H7{)${zU-D z5VhS+=FG|e+WyK;P|_3hIdGV`@KGaWt=M zN;6I%#u*O158GeAW-Pdqb$zAZij5_nFo7+=D{|-Uel`v ziF(|zcVk{sO)u>Yhg6I1ESD7H(b2{wi>DRzabNI!N^o8B=!&PH?K78f-gw6*;MhYvA5FoPFk&mH-=SnvxZ;_%r^5kBnsgoxNk4LFRB{6bh-zTdXd z`%yb5AwBu8d*xTYOx0(K>1C`^EZdj|8o1h1I*p3zEgtLR(NE6plnJK=8uc{|>p^=R zlK<3W{OBs3OTxlCYZP4)AmVUG9nEk6JBb1>BT$Zt`>$2Gz)7#+(Vto7ZJoYKK z+GRmc4xPes!e^k=)P#gJI4|#DG#|rXDAK;xuVaPzRp!n+LO0~^BGFbr>ky% z!TXT1m2i}I)}-!o>}GjaKQ$5a6SBqm6B6H3WfYwVveUqWDYlm5vOU(teS{6@pXa5| zhoTw?M1p+uxjc6cncDyE7-6ppL2w>BOue3dsU&VH~je_R1+F8%*BN>o_g}5oX_e{EZ1a7H#QeI6_fzF5W|AH?V z2_bUiZysWpXn>jZOjr;^E}BX5|J-xI#;iq)-|ya9VzqVNUyB_?VY+8Bqz$ zjG+HlM=rm|+f~h0o%DcF%lR^8vR3o9zwGy_^XjzYlYRjMPN zZcm}O!F{+0|A*s?*|vLEO6!taaaOn0hbM#ePD9Gpq02=|6JcK2K6lDm%19EfkV`UJ zmf^`!lbBhGZ=ku%EU-uT=-<1v!9q7a=5}Twk|s6N@$i=J>wE%tws%tXjD2xW8(wkX zz9$VppnQrH1v62$&VnG2E6joC9P+x2a(ln>l#s8w+Rzziz$szvp^Gr+FUcl$Ez66T z`@V>$JpKFJ9KRjpK5DswK~-dmcOWiuB1GxT=VOFH35H$Bwn^5(>yzXAn`0sG=(3T! za)vh3bQ}gW-ClV7NfLq>&qjIKmGM$C(YQ09ad_ZFk&E=kw;t*voi%Xi7f|v6$rk4I zmwrX({03Pnu$c464%{}nm#S<+6RJ&QK@yH0+ZsS5L4Rc?xD-X4%pv4d zA$84bJWoyIcXS+k5rU<#u+tdeuzsJt6wv3V91!+B-;DmT8UsCq8$t_Z1X5O+z*bqk zCZUJ=LhbwQFVUCJd^UXfbOuI^<$0R?DJtIon4h3%>#vqIS00sE<O-0w?P?8sFoTm#>4o`OOn9}S8aY1q5k)W4_txW+m5b}} zoY&#_yJ=+uoW!tCZT>6o{M;m%rcnX=S(QD`QF}A0dOLW&vLrjkK@oV|@$%P1|`9w6H7%j zN%gXIs*Rb&Jg{o}r@`*4+bq(*LGruvw&2+aIQOptgR^hl{7wtWud~GA^-yNBBABSA zLpk2}1d^<@M2`S_(99>llt#6VoSQbK3m-@!*jJR4^B5`)EFR&*jSF$aPN6CGV2UIM z(K!lh{OvN;W13-flVK&RrbT-C8Ln*24ob;sd!=Puy_b!5KTfuHXHW}K_4GQ;>Bo4G z22VU3eOoEQO_)or0A?CUHlura3g&GwwpABs$pvr{um*$S3R=LHE>}&J@#vpOUu&s= zx7I%Qy|hIC(xWjASB){rkd2v{imKJBs+D#W*WR(Y zRB@BW-5*6R>;bvcKt78w>mDzq(YE>j*t^T9DA)FH{6lw#bb|~fF(4r+9ny`6lnf;y zC?G8$BV7U_APik14F)2O^dOQV9fGv9&pGb6_ujwzzW*o=%Pk^qv|#C?QRS)$<5wXd=n$ zzp^p@W5A73-cb{{aA^8E$4e@UI=~Jx+UOUdH9^d3=WI#7=bRdk{|UM2F_4v3q?GAk z7DrAs)+Ejb>oirJTsJzQ(5wr!ZykMK9C6J6XhQ4G?7Fz=(Te%7AvmZUgS6LhCL{?4 zw>KMdNS`x7>V--^_NxAI@T2%%x5SIa+dapICpl}TZk^XP&KJ5yn%9(i=UsDPpU`j& zW?I=T(PKGth0|_E`PZUimIYpxJdyga+pV0{c*Sz5JkSJABwvzxWtQ1cdXa#<$B^S=S97 zl+7!QZhjVj`|LDzkiW?fB>w_IClChBS`hxI!KhC53bFO_4f8lH96Ht#=SjJ5jH3SC zYq-8K2Zdq}*K>U~;!wM-m>R9th51heUUYYEDeTik7w6&(mNA{f;gQ07U1G&uPnJym4mGJw94OY@x{ubP7-%(O1Dm zUt}Oz4l1nNpjeBY2kbmh;zn~7zoU8Jy=4H`8YT6lp-!$g`1I?sD|Trbv>z0XP$^h} zkYuy0V(=!)j4d_(z0XaVV&UAQZ zx{IAK62Ln}%3`8xk14zq+hrV!N*~t0{ z@wl_9i}e%Ycvk#wWd6<9C(r{!#FASO4_p{}$@l7^d%L%Ql%6o&{}g20qCmw72r@L8 z;_M@pNrHvnZsx8rw|*eYk6$M!^|EAp+IO6rvd!@fl(CxqSzABFF!aYPDOss@pk9Tv zE)v_ve+;_UuVpbJ=oT_2zGlMuSgN8b84OyI3l)t zauMA7+!Luf;l$V{KXVNnpAN_2gi*&T92*I#`B&ZdsFyIf;b&&oP3)a@kN$^by6vD#TSD^eUmDK$tAh` z{2dmPN8TTR5KCGnNNcg4=RgHM&Lco%3$w7cbBE z{ChMf`)B;)Wg4sH$|lGgp-+1*Sm#-$N4ag+?V>}x^wq&q+;Q56TCX%oN;Y%F|WB@9Rfol|^8!ulZmTWn{jx0!fjCALV*)&0l zYZ~_3_fwDt-M-14*xx)3a|zZC{51b~i&7{v2K2*?Ri`@wwGw;+sYj^LDY|xgFnjG^ zhRiSE0T?oXyE6dKdyDH}LF*krlZQ({tNz)uc2W4EuGjg3)oiAqK?kABhexD~@N>I$ zkF4g}s|CI<`sb=-Z(Jsw>&H3!8Zj?jd)USM<@!0PJVQ^W+5Fo zYsj4cGGydT8BJwDB1WpxR)#U0$f|u=!dKF8)M+l*f{G61Ql>_V<<7c0LQ&U+x_>5B zVR?(zC6Rb)>r@3ZDhP&x*f5Uix&Nqmtd(6_ys6LZg;P#QpE_CS5i^aps1rKBktGx@l zYb>o^a*?zPiCyewGqk)R>hiyNraEPI-BokvnyAzh`Ar@IR{fl|Xq|miQp@LGA_Z^t z1Vj#ud{qr>k{`+2VJTX2Hrd9+j}lst?Ou=%9b7SfYFhVl^sJxN3bBsgo9G_NGoZeZ z^K4_y>aargb4&K}E&(SzEA{T%^g)O);9bo%U6Q&VR%L$MxYw6{pz%nbN!|qSOL!TQ5-3% zX_&Yl?&HZ0&#?569Uv z=(qK|DkCPsWtXJI`k}&RgQC8~728QI>jl^GPBndR$zd;=k$D6dnHC}2Z&$qr=b>}7 zE8Ptt7$UOfk0rr@?0I~J6H21kxAtR;wH>&xm>!vKvzCaO=u!^xGNe}LWUshw+byBFfT0E|X%2X-W<`@CQhX?; zlfg}LltdVqaUfyQjt$0|_akU(6iCs7s>z$sKw$`N7#vE1N%8?!+y5G^$PhwReph5P zD(Fp0KH}K8UgvI4vLy0NeErg^)KAYd{zH|!Y-!su?kwzaf@fI8msJ21N+ahCuhOjj z4Uti^=x!W-`&UIK@jodtLjO=?*d{~oH~dwRG3Rv0qIRW`(Q2^T#vQ(i(~ro1)ofSH z7Qdn|VB>C%Veiq~&f@Wrh_#*AK+&6|_Vg5bZAxwLkRlaAK)oQzneP$c*c?yrAhn9Q zyx`dWy$t@b6=^=+i_#90HAEJFU+UnC)C*hrnOM)GF=oQ+#YY`N3I9fsVatT=!6x4J zu}MvwagE!}P1I@4NNxG(*Gkk@zH)5a|zR{uR=R*^b@*_D*(~nz5Wbj~O|b^(EVi!_niwuRXHiQRQ6rS~*{q zduMo@Y|vh)Rn$1`8?ieaC`}pEw6I;gb@ir>VBU6%k@{>FrO5Xz%dem8gBF^6Y!qD1Q}$(Z}W4|8{k5AC{*Xa3NnRZpz0BhrV}3xvGM$ckl_b} zjF*SnNCQUsO+`lij0z1*n#-OQ&?^B3j{8!yC^hvSuMbS8sOD$kMM!h*6tBQ_{xaUA zqbGK$s;yn#7MDyoi0H;H;H)&OJslQZd~cE2tai+Ush=V8nyXypIUR1L9chjj-zmFdJli;sL|gpB)#gK;`WVp=p2}?x?nmDJ7UBc&AFA}e$sPB2zoIBC zRRBOiB8&p^gW2Qg_eh?swrAQZ_Y=N%Xg`O>(ptjKHmO5ECRbPj?rp?AfdmOdkQPC2 zUbR$&crl<_*kD`^Ge4^(N?9-EdOs{=iL3lHKg9X4%=5O>PM6EISJ~H(pTtnaWzPij z^<>XU?^t#JB4pl)6ES5Pmr+P~tE#W*P}gF(%UbwgIPw@Xk#und$vQ7y_0So9scUD} znJ2{*9%tMuyfZEc5=?tbA4MPpA)BH&VzYK>oh&J%D#hj&P+@3B^AOtF20tH zQ~zWtNG(MjvF-{PG!yuNd?6;qr( zO6&3ouhN2akNnH=nib4LnNIq4uGF*bUevq7x+}*Jr|Mei)?#)61W=RPyxe;)3SseaR)=4@II=;~9caGT}%;of*Ze06Ws(>gQd* zp$lsTSM608mO>P8Oh+8SBw^$bsGUL%FwA1?_54V`Us>v8;(_J6W=?tJOi+1te8Y3HnYYIYQ_NE2AG*P*#8Yfh=p0=_92#wyI9a#ff1Qmpv&T~(&nd?%6At+J zZ|{jkp(J)|C+Rjc{Vx1b^tcT-mfc8hYj3e9&5A3u=Bf5lopcd;&v^NkmBBzNKI%^sBT!n7qTaXIkXzooswlOrtvg zal-F@%-~4fqS>Nr+>DssM+6>bbyKyX;V0xIl;C}?xbP)9D~Ol2fZPHKGF>MV$#v(jc5Vz)sUK8!Q};P5{0mYWK7X^R2R$PCr8&h+<5!B;hdya8X`uVQ_m?ZSrQVeM=jl#Fmk`&Qlf zH-t&8P@ao3)P1%lDY7t!S5KP@u{$ti4 z=#gl9;48-OuNrsDEtV(@8D~v6zf<+JF$IX<8*!$bP<@#8n<67+H?%O76c}2eo;`M3 z{cB8Hb+NHxZwM|fFG?f!4@E|&tnoPbo(!!3WFI@mxX@xRnB~Fx?g!(G-jOY04szm= z%LZLX-cvNOZaY^k>#;sQcZYyq&tuYHhP6lr@7STH5!qmes}7zPw>lUtL-H;<)uS&Y zn^+SoD?i*E5f_&03#1acz?HGw6^|*o!I+nlERb4kPveu!T#B60QFzA2^Gp-fYfswG zj{eX;e+2ZR5&|Gc`I6J)7iN%fGQ?#1V)R1`{UXyXl40Ml+YAkm8Kr(Doc;nhA4kIV z%j^vhO03{e1ic3ig^i#wL&&fF@CZBv^sS5o!WA|~_$-V||c z>E#lJJm>yQ4gws7vD+OAZ8Z*gw8fc)xAn!@6J8cG8K=<@5ke}O@?A?h;Gfr*gezU7 zKIJ-ez`pUs^%8wvW_IpWTy=>??3(EsT9}-C{Zo>OtjQaj_xw~&^!eFQ#Lez%+hlRF zNjv!t#ip41h1gBV8ROYQ@$be+lxdv^gJmmIW|(EW;``+qhAj)=Wp|#-wbP z5N@7Ki)YOVnKm*oL(r)YF@PcsfIqEg`v{9Cz6^pjtLKN3-Z_Bl6QVl_K-L$XLhO4`ZgwcZY*5wyv4TeJN(V zw#{Y}R8DYVR$i)6#*uVwQU+;B$Fut=Ta%M~hW;rY-V|d0OB935XE=al(puig-TOG> zw<^z>(>|VN#>-&!&|cE@97LpvP4m|9Zf(yb#1@EI24hG4coO&da^ilemZ#VdGp!2W z9F45vpOlP|SK@cZzfm%Gn2opP1&n3x>*EQgUCZ=rW}kS@9NEOkySH$@Y?g+HI^9{7 zKCCeNUa_>s#`@jUO4nNEpT8*@D0DA3y(4$lU#lJr;QgpCO+fa; z=vB$xR?1*PGoi)nyKm(+{V=T=v9IdE%@~`kr34Gc^Bewx3)y4-z8Qe#=EFAfFz^wFG*(A8Fi_{G|AiS#v|+eruD-M z?opw1_LSC@+!VWm3byM)OYEUrq$++YZ_83Ak8XcAt*CQ&v{l!bHuG-cPe=wJF_lVF zze6&&^mpr=Egxj|J|wu>wo*eJKTvaTG14_znDkyP~GAQ|?#%tZGU>*Ye>00Tdk zu{?X>n@V>L4FnfAr-#xeWcfatd>ED(=5PGR-reo~_`PSo7XtdKeSHP$c+zs9qwiXMyRH=~V$z z9r)WQKhkXX8Q;X7Cr4o?IolPoc&ZYb4W>4M$a7E_&c1c)@Lj5#JF<0dPhH2^Mf%R? zrM(xorlnTpLrwx^`FV%ZiL{zCvcN0@4bZBuBIKz+2rmpnjrOxYn~lapE0kE_2F$}@b+Z_?VdSU$W;M#Y*KrSt~lEW%HFeJKUvrN-oQVpx74GsEK> z0MESK`N=?(tQxma-v9;Alpy#Cp_~pLlzDn<$TWXyx+nOBS(`d07wgp@FD3Sl8TLqc zenJ8mO?5LhVp>8Mia2Rjn~euk?W-HR!G6cakF{7_!->b*McGnO!mB^tJpffgS&q?? zs8`#P!X|ZdD#G$9rZA2uJ(Kf;`HD`d(w*u~nSW3+{UVn*^jdKtaiFo^FF3y3ZkX=# zdyieBjkFf0ojO})%n-?SUAYi{(}MG|p2qqHvrM(;=($Xop-EkzJ><&yyIz5>{4utpeqtXy<3bfg@DTE$0SoHPJ8!(?E zNG@T7A`o65O(JRNElio8Kz--V%AI}291Sp}PCj|i!QyT>qd0~IzBG*YdbGyBsIa_C zCCN#Z<$qPYeTyeWy{+b+ZRke- z?TyRSQ5#-ss>pshx2j9-R$N}Pc!o=*`Au?48@faBhnoDfnbY>A9?m0jCM9?@wpUs- z7cS4}2x<%J2&&_4<@=~LC;LET?xSZoXb)i~qjfc8E}mluymlR1$O(O_X>nakL$eiz zh)pYijX+^C38IcKoYp766N8i+N}VYkonQ7bEQSUx%(v!l^%lL_Dev@Qyn@J26B5Xw` zzEj?L9L4@NW9{S<^Gx}n=&>n5HDU{kZRbee>+zoIy8E{s(8A*f3yl?KGJYmN@F3yEH&4UONzd$5N9Dw^#?m~WGJPNF_P%PS zoi8I5s{GmZ&98=6vU`F?17I6`TatU;Y;DP(U0V-nhAcCP)g6pA2*cJZ5=f;WKbAwu zbpx3nF%%YE){;Dg$b7x-xuq3)M7RUxa*1BdxY&;~JJ;y~64=MW1l0jHNt#A|nX~|@M zT1=czF|1bQ^9#Y~;878RBK1FFG8x{tdn1XsZBgk_&1FhPjp%MCe^9xhyThC^q#7$s zi4oom(mHf2BW1KQ4Va3RR#wUCje@$j;~q>qaG9V-nf=0KYR_+M;kEU)`~Qi_?5g?C zHMue3tQD+9)adYpQo85+r~rA3LddCKrTJ+PFD8J4;{$nNkTdj?1~j}ZKZSh+oegAp z&j7dqj$A?31x~%LDR7C-Bms=1dV5ore*@^M|VN zQnz2FHbi4Gm<(_*yZH8P5k+df89q%;=a|vYD93`7IG)MH#SS*UUJv8ua8vzL*0vmP zJDMS!SC!Jz2dwjaC2;|_qZRB~cZY+EH_hsiF4ZnRdz*tV+5^j?GUhga(eL)CA1Tz;K*i;Q-Gm zgsU_*ZP4vnM@%KrKOC9mhtHi~H{E?&y^kxQhzuZ|FM4+ZgT+b z?v}{sJ*$i7EPWIC@@>CxqzK5 zjs-xks`l-Bp2c7E85T$;?c0~#Xil2vCs?ezIf`^0t`tkdVzbSxvz4pup|=z3zqg8e zyCF4=yHipHUs3kLaUk$bne&Jk$!pIVcLzP9;0HhCqf052ntGF(_=2pZB^Hj8`{By) z$9rF*8)n_};kIPto%XlEY^>BSfPnZD4(+IXKa7gLa%aFuqdMqGhu-PS_`_I#BU1qi z0m=f4SZ76@mn`Tg9mtsf6O!S{F!~%8V=%1m83`59bSm(SodO>l#C4U{Nc~5p5nd0< zytBi~;uz&pJ07Pl*{KT>RIu^f!zyuC@wZNUYy9d;Zk5#V^=apB$efqUh}(q`X0;-x z!c}T2J-iMXUlkA);6Xb{twC^aeGu+NGcw}^$|TqH*!Pam|g3YwmJI$A}dPzR4~R$3{5&ABv1~1Mvo* zz45c3kfZ%)7fq~7=99r^{tYMt8*A!DyfEmct)Fd&KT0-#%$)@9z&f?xiGN{jlONnG zyIf%W?sn`hz8le5Hex^AB2pH)t`jSoBV;duKyrphI773 zmfyUB4E|oVNAt$#CxkUAP;IveL!0tRgkwE79aPm^G>|5o;wVIILA~}kcwzF^$?})p z@{o~A?xskT7S9?DQs}*J)THMAejizH?f37reVWb90`lHeRNp}7$~aB@i}SwVAMWz| z&Zr9x(6x2P^d|(gwb)zO-bq?|-Y3#0(sPoTX}E{O+QvtHI*&{~hga#lsvi^~Y)3!k z+I67)7>Eugg3pXdYZ%UZx5bp)UU^SLF)tYc%$I^k3FPNb3-pOZZB{3gzk{ zrW^d)S>p6vtl#MU#v4Z1;39m! znrm01mFZMK-F^+so}w#4V53_OM-aBmnff}^>$b!r%b+ul`PaZa^hl*g{q%WBp+q|1 z=$##|!p)m&JFMo6wqtZub)-sC+S!3F4bKY&@FX~6|DBE*6R>w3qlYaQvi$GMF=7(` zSIRMx5JCvwV|)k(1OmYXUyuYm2sZd8EGi~~m4FGM2Cw(P7xwS3@xUKUOu__HPazNy zfW;*J<@NtRa*QOH1Sl#hEi5iAD#a!uA`M=PNkWt)6=5({F)39sVG+pB3kVq@20&x1 zfBVP(lVkp$9P@uxj;UiSr_Y^(Dj=!9Ds&i_z9{nZd}llvXnF>Plu#RB%GXFP1$kZ! z-s(*>so*V9D*Fj3zd5@Qemv|+Lv0jRB+;}iLGtZ^IK5I4F+OKC5|hvaiJ1i@2EBUJ zA)xWz3FsM`B*F~(;Gv59Eb~t~CWXNr^hTm74HZF5W-w&D2pl(PU>ew{~C{vJoW>s(0866PDEu%)G@F@&xn58rPaRVtOp%3jnSs6a$|Y`q5`-b2pzv#H3carv^m15Hz9%HL$Hyv4Q% z$m!QV`jhRzdKAc|v~lPJG)`xmv~c@2tZ>HJ}4_>w5N15CuPLO(P~zM z`mOz)L=(3&HBu2tzCaf_&mmefibz1ynL#ZiJXb=*vN0W30|1X*MVM)G9jSHQ5ehDI<@ z6Si^lJP2dra$&_HeQn}XBDBA{2OJYXD(WD=jdw`b! z$In->a^gr%J)a7)J5y2Qex2ym*X78BfFKYe8tS&_=e2?mAUH>cu-+G-!vl~L3#Woy zJVG$j_!k`0bBt&B=~;{O$}WwC*Pf=p3tJY&W>ePPe);ypT%}Ij7oOSmFRxd*5ZZcCd0hsy~*~7mdPCxx*CE{W}_C z`U{PDlC%=e&UwxMU}Iwg@gmP5$`wlI{_MVluqB@S%DS_KjJ~NYBj`z>ZvP`j_uti+ z%OaFUcVZGj$7_y7T9{8GdT4e>9s*2h=gCcR(9wp+h#9nYXA9}*Wiz*i@%x(HH`WlW znam|MIBEBD^-yR2Lyd9E1*4(-VQB%O*hZO-@f1yz)QpA=Zwy|_#B4u}ipy~oa!(0- z@e{K1#d$3{KZ1^W1dhZ6sV3|ZDkHFUE#Lv2Nxz1VN)ID=``M_0fh8SEW?k34@lCMwXS2|3 z65o?M2(Mht`(zfe(b9Rfz!831aMugNlzzXAo@8jbbWT}CBtqLH(@2*< z^=tJ9);Xz)+%>r~v3=)Di=ao;U$5ha;`_r~2PS<)a}QIFpKs3hD@R76meR3(QTj6h z2B3T1Qv-nuB&1pcj5%~Mz-kCHt?z-lyA{;f^|E-dq3Ebys9iVSwVYdq z`ZaSQuam*Sz5L3X8?p)~#z-wDTpXt4I0fc&xIzI)1%mwK(p$S4-so#7CMgRUn_PCX^BfVW zP3)3S=vpv^^OkD1xz7X1#Yi1oh3FC5zYN{;14(^SzQMW$DqlG5CBIc zA<#9(f?OQxuR7ER)c6nWGTV0Q<*Nq*U0x{0xuouVjHulV-!Nu=pKa<*FQmTCHx@B< znSO0DQs)e%idoV0K%C4&n*XNaIGruO_0-MmYY&erm1Y-?EW=In6AodId_9gH=e*_{ z*%-cRF`wj1!#B-f<6c}8ssrwG-ZiS+5?2&f=>>JrbJ_V_Nt24~4cUK{ff3{`#B1=J>V{{t?G4m8;amH>XsC+1H&hb6j)2o!crg zc%Ltzt{lL0_<32(KF0aB7mcG%@0a<4;WHErNxCL>WAWZfn5~kB%)pI_?j*Wu>#3tZ z`095y22A#O!#B)T&ECUxaB*wFFmy6KoEe_vXflzL5EncNW~&9Cw2TorYsXZ+7!Kzr z4wGguu_;RiKS!ztqkwhFRxzk*Td3*)z;w%{QPdZ~U3AE1*`;41?3LW3}{ z7(xdHX+*-Ft(BkYVT#!xf?WP0$H>)PMLChEwxf`S_@HW#Uu9u|&BNOP#^O$bECX#Q zsFvaD@hMXeLFXcJ?Iyywy;v^G-E);KLpDBRE3Uk2#+VpYVIb!IZQfvduS0Swz@&N7 zu1;!Gw5daV%jQjXfFY(1!NT9@nA29oa~{<#z7OVQq$c^&9-AGsSOpdt&fFy?O&LR) z&^be#Yepg4;Jb>X9#%*Z!l{T*K^6`QGi{-yAa{xnC6P`T!g9*{8y!Oot`Sfvp%l>w z{!}GDYyIxrzsL&_gLY%gn{AA?-cwg^8h!lDjafn->RvQ0>>)AiC{klb0`oqPM4)R#V z%ieowCs{e;$ApMaw8Z0-mD~vswW6wx6ATmhH#V@YqSJE%?>30c*?`QX9;g(6cMm-- z+X4iUe$R=6Q+ROtjt-4rj}jC)X10aU^&=uRJnA?JOQc%GMQugJ38KBkEKbDdvLTPo z`Z{c8vby1EPw&Ddeb%N;Oi}rD0KhYS^$xKmY#bTLx6E&}h0P5YF?2&tX)BxM)9_46 z3}HV&e-}}pnEw|W)0T;C0muBEje(-sm_ES9z=kOkgM+qQL=PUWNg!%RHnmA>6I#u- z-1ob+2VE5M3pl1o>WTy&6bd{vfQqqLYO|zbG;}%7IE8K+&yHu~aKu#K-P9C3FFg)t zE>VUdI4n|~X}~o!(Zq)e%cI_QR!9Kl3vzMs+Ms*yWk@a8)bZwuaY7;cgZ_Fh+NG~H z`3O%aI!uy!2+l{TFE{8Le&h!?A=m^*ChOY_{WGm@q$$bJXk)bEA8HIN1gxJ(xFtuS z5PdX63E~8FwFY|MD2P~4A(6fYQ)XYu-Mw{d47%@lMn267_6Z>CElu59v2@RuS(YEE zt?tro%fuL7!TA!uX2>?1`zIQs>hU_K!VFsokL%N;YV$;iN~y~bvc02Gb0!bu?!+|S zyPuHa-_V$s$J6S}xN58`L&MHrw>eGB^640x!5wA&;@+nMvs4P(7{2XNVd%l7be6jU z&{oOr1FSQT|0>2*{9TMu`Zr>X=5JyQ9`&Ok%;gx+`>Ztln`qXAwg%8G&uHIFKNI^` zs)4s3M5Mq>ZHS;qEa*?hr0YTyc@B*=tQkAd`k<_?S{m^pq&&wDYA*w(VOi>0AUXCvUo<@MoSH zIIfQwe<$)Faf}i~p^6nYj1mRY#-6Y^w>9d2Uo$c=SaG%UJg5lZ$NJFsse+TI%W|qV zDqw6bR7D*@ZKF3$=u7Efe#tXetwWm%wwY3H;9$pmIW@E9Rcs3IVhaD^#el-HN~MHP z5}_|V?5Q{#Fbyo}9u{NVeSMa*W{&nZAgs-=!T8}kZ2~bR?>&ETs}OA==74;hiPydl z4AIU|ud?>s_CU+6i{J&|Lb9PmC$B?$ZdRNx&Y{AxNHBS23)-z-P*yeI?G~r$Kd(r= zjiLy*sGZ=5yB$p~{A2TIj7KH~qsZ`!uER2c{M?lb@tc$CH+W)-npT4u?qgopai)1+ z0F01)Sd1{dse?VfpLz(cn2U#~@)3^pKL4@;O1C9@A-V;3L{4w^cI2e&e%bnQwk9mF zHkA7KmFy1pJ49ctyD-9N0p0OaDpV|XKLd~PA0Y;^(>9X zg97qJ{(kfZB00|SIF2jd8*rkwi&wA!mMU3_EoCiSInnk4=S!h3hzQRk6GW9vM6nhK zb?^E=RwiP1;LGKEyx00(64YkfitL5dS4Gf^aOV+TJ3+ltrb>vU)d45!Lh7uzDN8vN z3uK8<%!8HkgH{GJnr@pnhlJ)Na%Hblh32>>>Q8Z4;oqfay#Ms{&AC$IwEw}`jzy=0 za?ebfLR(CBz|eyNP}f8jbZ|rH0i$69bgx=`kQmZKZ)3^PixMEVDg2U?eyvc@d3Epu zj6z@}Wzt1n)vTN^XNc~wZyjiSBAx*=ljZj^>Mj-ikeQSi%VBIzSyvEN`kDnr+|d+u zS+dkndAQzAie^Z|Epeoz$I*Kvou0_Uw3wqn>&1r2?1%f#HlrAuCR!9i$)5@>Yd8C) zWubxV=WmlTSOrDtO=P0HA6>Vg&2;M+#XQEWbFHnTYcoO71}H4h zKUI<9++f41xRRFle3&UVb+ywuuw+w}_DNn7J-LOI(K8iBr$(<=*K8AjkpDq}A~QBt zw)8Trwap^INJ|&D(R^ASMir{luLHl7<3$~x>gSvQE5E(#)~WUpT*)Ri+Wiu`&nV&- zHonP#H^slFnk;Jhft(Gyh0~tyvu2Pm z@R1HBjC&=tX!8NZ5af&-LEk`J7VLL}gt0Kbm@?j-@r-OuM}!Jt7>3VA?jNqt)xF|` zj+&D#88{Xb?!vd3_PkNsxh!7*2U@BxeGHc=6T|JfPwn+D2b0$J zSm&)t^m24Eyl($hjd`9Qn8eTNFZ{j$o=V+xjZlTaPvZ00OaI-V6nOQ+NJpmb;2UE) zKL%EFI-qA!!6S9agb+Hc0^W^cDvqVSwUKhRV%r{i*^@xNHUuh?*h>O>9KR|X{9KKY zp37X=m`W1wD851#X>7C9mdnNJt)tSI7t>INv{15ZcJi(+K_`OtIu?tZX;x2UAMrxxT4}kO@mgzJpyVw!n}eq-&M^$ zLQPfR#{7W2ffAE&V-G73Ake@UdZq?CKM%gxa?$tc-~2sbE1<9947Ak&&Qvi9GcCZD zR-BRWDfI{+0|l0#!p?F>5ndVontF7HZ3BrIA6rd5{*3mI?=2TziA!~gtQ6-^ zkAzScY8%q8MU+H-$kdE^!w#ATi4az!_cO19BmUq_HGluX4dXR2g-hXOhUU(>UVIOh zzrm+(C0I`Lr#^5EDMOgH!m8OdOm@h$lv)hY+L{Zvg*fER|e(j-J-{ier! zy+J*!r4WUHHWlP5bXN-Z;{PbzOq5&LVx0*JwthE|qv{;ultp$B^5Q^NUOI)r0j7HC z+#LE%ItVO~j#~??@noH=bNbZ$dbvDF`guHr2IhQpa{4kp`-TI zWmWm4siDCITu*0piqe&Bu0>~sW`~a-fBIHa@cIfYiU3Rl{>INSYhJPMcLo0^L>tA` zOQtlSPynopIly4p3O>)Lz-&I3-|B(t-xFOx2XcNL5KlA~hODgMx7s_M*< zEYpS&E;P66CCp9mt?x*hp{h?XG9BJDMg=6E_S{$_D*=0(dxeAb%h)PX=FCzD|MFuJ zz!DXd|6C|>)#Ol+KlvE2$Nep?iQce;0qX$=1SV@C|CWy#1bI#Tg3`?F0^Fxg<5byr ziW{2azDn+suigXVc3?;xHO}T|AR1~(Q&As1>gk)1FV1~W!63P>j1exP-z$^gcMpHv zRS!o_-^#fo*{~QNn!iN-$=~k!1ZX_a7F4$gVbZzkBitaF%U}ZqgA>8H!}JKyv4b$w z>q&Z$Yv1xd7bQ}{?Cd(k!;ZwGE_|j=~?3 zHlrz*d{oxEFdGMIbhV#Q)OM9+k>!G#LcxFJW7Pi*AM@$C0oMB>EN$vJCrkx6JY=*+ zJ@GfrL4>p)%+@LB7iP{B`)ufKU${nu=#O&ZHBha5tF&g8=5@oNyPr&)??5QZ7EaO zVMNFP)XJCMI$#I$CuHDtTnE9iKVp^Z(|7hUb899`h}4#j=gFkRiz*F{dnU)xKP0QJ z6KIqo3?w~isA3Z*9*FjR%M|dVyV8G;##BdE<45XCv}kT+%8maLwpf(}?cIu^~pmqou1q zArDu!H&0ABFXLvBD3hp@u(3c@gc&??ufH#HOouvQOcVg#ttt0L@xBgk!ns>6p>h_2{0Nk)F+Nj-0+Zv_K- z7!+(bEY_d9lQ0a2PsdzhR-!#^IwK91{v6=c4Y9ZJ`EDJsJxdg;y1QZhW_f)l^Xr=i zO6ow!OnloLiZ3>;J??GKVjpE#bqEBCupQ2gDkCi2J!YfAO;F@g8?*TdTuB1jEscVs zMGI3E{200`X+P*Vc$;%fHqL`JYZU5t$0@42F5_h?1p1F+cxEcSO{vwxaw=khBMmWl z!R{A0%wV($8e{#w`kgXJfcxrp9vI!-X7XzyHUa+xT7=2gMx zJIvDKQ1PZI?iGy283} z;#k;kay^6KVzp~NcyPlIx4bn4?3eLj;7;QKZ_6{(#Z8EYdTZl?(}E`S^diR)(9=Ka zZQ^E_4835vG(Gw|AVc7|VX7lRy*rQ9v^ZMU%-7pSiLlVLp;->56EAdfeJJAE_3^Ez zc&l|D_cgNiu;4kGwYWY8u@-C<>>3sbR!J3M&AVqE*%Bz9#)R(O#;{(o2``$UEIaw{ z3?o^0Ii*@SM;|gf9FpKN_hQ#1O{x74KgROE`Z0}PuGR(*vR;%XCA4nyVfB=7Nz#xb zF}m|%;9j7i0XP;Gcw+%*B2xjmUre;89rj!17fixIgH(Wq{8d*AAx2ZonED{(%Z3DI zyYe(I?w^1ReF=9&KIE{NJ)z9rXw-_m5gWF3RKX%Zjzt&3{!jP4_4(ak@pts zq^U>w+C4&kxa+8sh@=kJH>b)yGN`aI7lv&+v%%*RD8qFP^$8!q+BHe&3jwNt1Gtgp zm?7-Md@YX7aDgt&I*vyK?blP{-&MW2UZ_|{@_|&hHwSb|>kd>-#Y85bg2pwUk%-jx z2|0NAd!ytb;*l}SlajHbj%uo6FTH;?rFztaik}!FZIO?;unKN1%Q|w%7fh*kHyG(+&AH4>M9h15 zaK=y6b&`_Jca#7-^(ApDyjb$?-LkgIIl9EUO=10XE>>J%e`3o+Yq|$C+}Pw}9SDK~ zM7X1_IWyb9aCU8{t90@A@yTp6h=wE)3gzjLB1JdT%@S5EP7P@~u*<0s6z*j4N!WYI z{I(r5E0-kpj_C42{M4zT7kgg1`{z#N>RzZ`XHD+ujUV^P$tq3nH+;j0A!n7qqtUnL;`P;9Qi#5&yzt z7{&P+q9+rECb&8bGjG&!#Tu;y@k@yrA@<}f8p?46)0*16Kpyj6TJX+RoyfFn`X~vg z#JDZmb zz*_jd_sO{s|8S&0(bx_C)ViY#WGnLK=Pwg26dzwMDYP;x_N-hk@*k_)*$VXyx~YV7 zm{=dYYF6Sr56?GX=2_#IIIQ-4p5I7!d*Ab1QT&FMJ>_vQj50e>r!#m)mTb;NmBg{r z>xB@f?Yg;wjTf${+OoW~dqD-9w!YQ*sYDNP7KRK)%UX6_%4*3ahFEvUT3tprAHKjf zg)(x1OTP1N92YW=$<+G@3|H}`SVPbFM8Y|`xULY#c*w^R9J+sbOw-3_pgk$3JwBZK zxNWJeM)DMSXXyZ_E2+{8YO%XRSJQ%<{ceGIEnS4~2~~3+mR{m~W7^Va$q2oCtz2Xg zS870-IL8pNaj3sk!`)|y9UHf*-Ya7GH#nx{FK|pA6_nR}j7EAfdFuFrriV}0@}AI= z^g{~Ro`B0({4HJon9Q+}xe2NpL3`WpZh#Z`$-FGTg4=0UII*_zp>4cn5I4R2T)N`? zn|mv@b_e1jm8ayLNst~%%#x{w$-5SM>wT=OoRN50aapGqsX6n4?hBgj9v!vURN9w_ z)7eu!$SHDOlX@R9yvw(GQXzWzW576+_sLe2Vckm^;+TcmxZ z%a%zsp&YrhRVV42vllOXzSBy)yED4E8P&JMMiT$s=kBN14Xr0${c_KXx_)pZ2ljMf zSA8^HfABjUqiurJ26PPT{iCP`SWkL$`n(NO{OQVU4!;0-50niDCp`zW{3@($3_0#j`>ycgu*&u#C8mkszptu zy9{X-9EpvkVs#b?W6&EqXgkKQ|6ZD!#D>*y#kt52%lHC{X|9{%^hKGmWp=_Ka0g^A z8z>UIcr;25;-2~I+f%q6{RTdQf4XdfGeaYK>y zQ7=H=h{U3!I zKEZ+B;XWQqR2f&(Tz%j~;UFh;- zq)WQ}aW{wJFlz4N-ha?zHpAnWOdcASD;(-gs(!2Bcehz!A`O=%yId{H|9(>(G&QSa zPA)|%Oe-zOe}{f8YZ-cdzu-%|Tiqjx_A06moaw;2feWjji5=-f-y**DdADqLlpSC3 zn3%NU9c;LKVr0#QI(x1;3sEFrn!eM-bZ1+BpiHRRt!Qo5F|qQx54MAPD6v}>IIWeC z4dl-rD{8sqJw2<)0?K%Ad2=F=k0T>-(nW3V+lXDItSsDZcK?H^@ z!IqqD$vFqfNkMWFBnzVCj7kzgV9r8k_MSa^KkswSb57M;b?UA8pww8SGI!r=t!rKX z-!+O#~hiN&~R%(dm6-%xrW^PUDpBP;(;J;qo6-}#u>KlvDPw`dizOgqM%c!J0WUZo_4$J3RyDqVMz-5 z!=vP_-uQVTgMLl{+o+qO7OJHMj&Rp-?}ZFEL-w+Qv^&qsOdU7MxqSX6$n5VPmgO4q zaqO=%9`bj+jX>Mzmbz#oi9Oha!d{>O>Td}qo&%RK0&E7H#JCNIArfFJLIi-SvT(9M z*8nXxT^JJi!h2XI6$djujT>tyKe|*Ls^KtDr)kNHWlbZ%9zSDN8?_n|TbP7LeHz`? z_^lMHPi!ymc1R?p%kL0{PbZUH6z$zUUR%-*n8;n74x+4(39_k|0x;JX9=FUr8I(YN zpl)mQw3P3d}!jr&IKx5`#wfZANDW!_#k92Q}H^#C7tLdUui=jyua)=e0jXE4@LFUg-*qBs}yJ7~kn?I)w=C{u{XzwNR-7FKMZm$X z<=^A``UeMskuqg!OcQ?qGA(wINpm;QN(LDJ2FPdw zK!$5Y=$soV=G=!fv-=l7roUIp<^=6wSwoY*-MYgx&j1Y~RV?hpwY3&DZvr-ZOy$Id z?L;+C{O0GI4B5Zc-qn1PS@HM`Dj7Vjr{>TqFiC#15IfTMu#^O<6dm%Ao@6L!!7oTUdU?eS#?b{5Dgg5>LRD2y*?}}MQCE+nie{Lf z(>o`~9Lz~l#B8j++~!hYX>eW&GJ#`YeZ2;L&;h4f3@4H@Ge_QQ3_4>qr6lC5kH`YFh${1jwz&t!Te zGkL2Z{z;?##%Ok~T~`)r$~q43#>~jw?5|0AB_oCwrEPBM?EE^PXFA%Th$4$X3EnF~ zd~6wx?ZJ{=C|GHIk2*2~yFzMf~LoW6nE-4EH^TQr8bo})vfHK3CbFK*@aGw|T>L(F0f(q&Da z#wlUB^)tnoHW7cg=5hbp%#?_}5?ky8Bn(eU;zV18Q`kU-$=ra6==Qftw+DK7tD#y& z^qE6THRIYDu>I%Hls261sZZV~Zk}rr68t<*vU~wUY|zYpm%CcNgwMjZ@=Un@kO8jC zJE`GK@}yPHZt?t{-_Ucj)Ng_K|1QYr{#}sa)r&)0nFdF=nZJ&4k$2#?B^iFKnt80m zbu@R!jPT$F)mHGAu7>23^T|lJ-raZHEQei$^JD8D30#pP%8M7C|nW zD%HR8*V{KYvwdck`<|A0j&~`@g#HIXM)iC>F7qqXjYD|4ITfFZPmSF`NwNz{`0-)M zyLp0wP+hSO`5h$|EjH|nfFvzm_rC%%kvLbC7Be(u(vhWDZN2faVX+}2Lo+m7SZ6re zQ7-vmJZbpfaxu$pvD<)T4t!-YAIXvKud`aQlj+vZRJq}(vbV}~pQ&%{+00Ger)(nl zSddjU=Mc-#T8o^}sYyEd4m%si zq#JzwvWt#}eZlBP+4fy~L_?s}%8#yJgp9~szOC9~5ogQ6s$?}FWEyWo^yDPW41E2Z z${(TY$S8_^pXw zdCC?MDkW4#GdSAgq}$tzu5+Fg?M*$5nQqt~VhiaAp`Nf$OdHpLz$WfP71SLe9yc4Vx0lFhA0bwl z-qgeX6C#82?}*HvP(RaGL1FlU+V^SS{Ku8bQ*3%T1En{=N|KBQ%bjhniLb;{5!1aC z{Ct(S`iH?}!<*-o>`nXm$!B!;AKaxTKHB(nJgkSV&#C^>Ai_~soA7!*>Izf4QT$!m z)3otiQjR7fn(FzQbCn{iy(f=O?RsaAZYh@ZM6J4fxat-PIoRx7*ZZzyP&a) z(*QNGh0xP4XMZc>`XWuJ+Ra`s_O86i9H!uOBb&i#lCL~8J@R_zg$aUcOLncvPf6KC zv_)moUu8`((RbzH_ZaYPeMUc3{E^+0*v_R zE2$IfUGjVbihHOeBJi|SlhNb`d-_7N?=#yNLaiYPcIG#ueVH@8uXYhXY;S(D3z#>q z8(=NC*;=Obkt^Ye_&3MROzq?Wk(DZP_1ijOG=q%cj2{wqh#lp>l@hsi(}db+mxLri zh=_EG66gv|+wx0f_j*$A^bY>Wo~gFP%VM`KmzaL>{h-$?nYQwqVafw5(m?w-iDc;- z;Kw-q^katq=YCA$-~E^mv_l%P*EI4S?L>qg%Kh|X0(;r8+hEHHIZIxDzM(IejLjCgR$zW3D0PiY6QLDV_kWJP!y`3Yg%^1Z3nP8kc8c zl>$s~T`~riZ*nYzqtw`15bAe`aNM>I4!$60;8)2L7Awlgb9`DV<`BVNL|Z0aL~VG# zR(|rD{t%RtB24~GmYbomJ|8z`{aVz^Ldrs$(nJ>XsfXpVgD3z$F(gt|4@pc$hKx0) zR&qS~s)01yPHnd_Ehb&^Nc>*`G8FBJXpPA`l{OYf7IzeV^~iEtG5U)*T5r=cItf** zBm}8<-ee!Idcld_U0v`f!XM$xz~$~HdXbd=P+Ma1pM*>X^qO1a!`lZ~Q^k^Al+UY0 z1UX-zD>w?nvj{GyKhA4#rd}PU0O99AiXaWv6cDJxjZ^2L3t#$P`+S+#QmoCJg^;V*E!QZ&V@N>>p zGd^$FvWG&Age37JF`aJ_VAq+<5!9>qr8jGPw$A2NLrPv?KwGyPSqtvMacR}i0D^!? z^AASGEiR$2!nKPw(dYidj{_nUn!yU^lYAv+U$HaaBwuMFJ*$ivKhf(WKi?spms?8; zYDrp-pS|NF`6wAKf&Vn>3Q@9aTW^sz98a{EzN)RL*$|hFDV1*0E$zZ!Xl}!`aN9^? zwiM6FHyK}3HZJWxTsO;4NTqmtYWwT{nwe22;uj?&6l2aqb39CN=RHA&QOQRJC4-hW zn*>T!_3u=Vo^t(okY2bix(3}zCpHcKUbK=nbuIh4WJaay zl7{eRFpc_E9|FXusoEf-mc%f<5A>-0XMRR(PK)6W9FOYDs?w(#$mgmqw%tyQU`1w@ zO`G6i!~Ob4=qrOO+^9w0xc|nIG4`c>wD^Z51MK@iS|s<+)th2r9LbHj;~b}pDkMr3 zFO@5XZ2i1ej|~MMZ1u70d4k}M6!fANT&DoJX0Y<({xRDEvNNth@&Ck66EQf|hY zW1B@7nRF%pj>Bdc)PYbgPoYDUb^dMJu)5{9ynX4?+kKYJPd*mpnf1SD%I0q?`A?LL z?0=?Y==JtAth5Iey`i|2cA>Z5WGBbmaB4j&IO!-cLecaxu`%`(_q)RQqw+$=>DOL1 zHCehlX&kEcc^r7L?y@CN?cp537fpSxM&D0zsc65+^||nDA9SA70WsK<6Auq9@1$ok zF4KD>o$z9DMD7f4d~?$T@ub~+>~jIA6tt(n5=W5_Q4lb-=6XSf#$PR&TeLSLduVP6 zt;D=wIJMcU=oiraVc66b)!Eg%Vl`SR8^B_rC(&dFp^xij@K4;9);v~8&zQA;x>uJN z8L}f+oTy(%YabOH(AB_n@OE|lkaFYOvGAu2L&12+Q@r8x9pcstL~EzY>yJH?<7>$_ z0t=5f`?`Guk{+%am)#d8s}h=F%q$W$993`q=1?E({NBE|K@@ts6#u@vqWsur?Z8zp z7fKfkHi#ZeTu)&Ls)2f~w5OMPchfMpRK)IyX&vcWR-{2{FRaV_sRD!=Q}`z_#pF{(|}D=k~fj#kZJ8+W~&p8Rm;`& z;*-Ep(o}ocC@*gTcbKfO^wCX-G#1Z51*34^9Re!!=uRVlfdW zk&kJPvd~1n`7~RBU%Ut@x51=GmEub34>CfXgQMzRBGesBA5&mxmax0<0sr&}{9b^Q8Qc8H$+YJ9sNq5VbAN|SAWt8~!oCTsaHD1YHO21+RPGmS z)(g$B)}$Yh4U&zYK~Egl>Y|^;%n#FTu_7_DZofn%biGr~j_bicsxZ#PsLN$t#U3U0pe+a@?6Rx*$i7RZ{Ym|>OtQsf$BxXLvxm2l zD5Q5$ae(bD_q@hD8d-qVKg_wTK98y?%t}6`l!{u2+qnfF2$Z(?yf!=DA!uS|uEe6e z^km0HZlPXDCyll3l0^7m?>6Fp@sbu+lVZJ52q^EAbpP5cY^^iYln-!XAG7_ohgY(sk{d67ze=o8HQMkgp8 z>T!KdftTe4)z_o2}{>b_qM{BWcdMI_VEaZVLlIxOva zfgq}@Etr#xGQ!ByQ!X5%RiLJ7LoB1yzSa?})itp{&ZpSXL1kD%+*IP!Q1aYs7=;w` zGhZ}WQw>Yg_*5u6yIEffVbh~N*ZvM6d#{1*Fpd*T#4x%3y;mm5xSBXtS#c2E;0`VT0~o}tO^r9J_;-J!dkSm#=AdVuyK=^BUmIux#CLly9x`mMXDKS!6D<^ zx~XrH>nTJIj*n)=UZcP_5M?|CTG`2E6U=2tgZa1!@HUvxJhe>)%sG+oWS?~3{5vLd zznF*EAaqEvR+q96?Q9V0CiD}N!J)`WB($bO5sOFa5kpuCGLRFAZ{Ao3i|2%ia*)*_ zkZY-qCdy0v6`Jb0m}P}HW<{k%=P`>~P1UFk%=bnN3_dY?l={8WRb5%aSL1JJbv-IG z(-obmYff^c@LR4^ufriPE(|LQDj2+xLc{ijk!o8RuhcssxsQN}<|1Hy_KDSM?JDBi zq6&TFSJ3;d{2sGQ;x$uvz`+;Y>cL*BV}F}-csa}S6brh#$2QRusrmfzJKbTQ)cvLW zcxexAx7DNVqQ%hMh-GeSY@5u%u@)T5X`=Uw2>6YQrUKB)r(_)vxe&8{wt+QL^9Ln^ zkmtf1O}L<`$k@gVi+abqB0y6krH18#itAwgbZU>$ ztdpyV8FUf%)H%&_o~Bd4g+TyzXBV#1dx*DJxXxMnUZq*=Zf?v6qBW7MEkdFHZU>kutGwB zq9LoCgu((`>^zSD_S@?17gQUq1|%&c6Vv%}?j7VEP=lhWJc>CCKqx6n#DW%a%mk1U z(F-NR6;{yE`*5IMa$gR?<51MtNs*cl({1blLprr!Vb6kyS~>K26M_uarE!et#r$}m zk?{`n#F}0+mHq7ytCaEojU=Opew?MS`A{P#5G30vnhIFj$JSneyMBd@Uyuw)1O7LT zj8+d0b)J2e4fQxN4{Tgr4vaVh_FwH3fI)=vgvxcQ^KEphi+QMfvaF+fhk6q;eWeMC zhjl?~cg&TT$|K6R_YLZz7lt2<-FCK#TydfIXPk7QnbPko1^sG!n>Wwh;R7|`mFEG8j&d8%o7m0)TsamlX3MJ+lL!-zq+W){VAi9RE(#RCb+~?npXicdlJgs9 zly{tgein(ik$ze)shETB69M}j0Urw+P6q=DK}AE|cJScN-Wl6zz|mI!rhygsLlwhE ztPDTp3km0)?HNeL_R%pYnq`(NQJp?H)n?-z1iA6QYBHgjpGgA>kIDu!Pl&p~#PQeP zY)q$__Ex#ZPbcrn-f%dmOegTyPmelufj`IlT23CuLo0H|Mft42Bj)_*!n&d2_42+} z(S`m3Pq5B|im9$#9PU9*t(+=rI4S-al05UqnXd-;&(56~#L^mcH3-f=wf)4}8ag8| z`a{Jc#=%MqzHMD-&lTdP&3jxTdt!{?c`62Fw*^^7_!Mjj*%cOC?UK#MK8L5EpL zKJl^R5>Z43*t+Xlf+$F$J`tEN$C49!MMX^`i*W20CZie9ii|l9pgkz~!s$|fvz5nD zqjR8>F=@8v0dLCJ5%3|EE-lndWTg>wbgvWCjjofuEa)j>F@V=BQc;KmOejOt?#YIO z1o}HQb%h!*l{0-dH8=$pjIySJfgYLEP|zo1eGLXCWMT7jFl-cn4s}%tZ6Sx;I+jh@ zF3wyoa9^cI3Wo5>6`-)e%!3eR2X5fpAlGtTlKvoNibhQ1V}5^6Ovumg>}~Dp>0)c; z?oCXn;OgRG=i-1FD)jk%Qk(&ToRQ}*(^dm_O?Kj|E;~ecl1D4Dk)>B1n{00f=aIM}A%SEJ&3fg@5k-P`hOse%VBy-_$JDV> zaTF(A%6BEJarmE_jLJVX8ItjXk~`rpIjWps0@=MV>pwY}=){d(R@7Y~wZC~XIoZ~D z0`9%j4Tpt#RZW?4$TBxIWn4|irxd#H{%FerU9TX0nuUD{%!KA&_D<^S%HT+$%Ws`% zaN^nxuf(aXA-pN=KXpA=An4HBHaUn)K=(E4HkDQMJaVcqCc9{Iyd|YzMGd}PBx9{x z;Bu?2q{X6oL(yO+`gFa!egprh8k5gJN|%>Wd_?w-;n5qWD@#8#R<^O3Yns|I+pct8 z&_7duqIPpck|^F+r}64|Z3?r-qwds$7}aUQBEejWePmcUGcB?pag5edEOxxOAn+PY zPZpLNqKv0!hmwpy^XLl;cHpKf3X1ITztG}$)cg~a;rt28q$CxU3&gLA!-cj1!KsvI5muzIKcogtBfA8Y&mfBZG#^#ewNrq35P31>*DC!ivTE`YL+-%gDX&x|mdBrpvbcHrbep%X?*#!@2 z9w2AhCy5Tj*X5oPpMWyIL1ku8c%9?uUc*h~vhHMKw^==gLGG^ohP@p$aoBrus&B+- zp?gz2^3|u~7udf;m;R&q$?9DkfEXEgV6w@T2KGoOBwq%JEmJh*6U1=DfJ6p=Y zLw6B9L9Bl`IxCjz3PQ+NDl(|-o(H5$56H2)oQTx(=TYiR&uvaUIr&husK2mWeRz6s zwpp|_vf8yBF`ed46^(av)`l$N@P6VJ7L6aK9{yjdGL=W7{0v2<)x;kzr_Eu>f=C@` zrUA|6psyEn`?iOzxw+ED3*VWf@QHVHM5%csJLKPAV^xjZix!@pW zl!45hm1s+wI!LS5tQqIURT8L(?HA+MohobhKCI!CxQH7d$Q_AhBv+oD18iQ9Sf2lIK zKYpn)yc!5JKv1n}U&u|RhTH-_X zVWM_S0?6N38J%N$X=ry+<@+4TIgghNW3RzMnMAeRI_#$_BUFzAe^dDVRpE&t3e$`s za1Bi+c!CH}HQvYg?B}O}7zxJQ*TUKZF|fCVj9GrrF%A?i&JFejTa44OAXVPmf%0>* z5ZiB*xILD0X}z@Tz8N6E=^VFetrJuTFHhtH62T$cAC~p_Mkgk%6)zYdcbiveLO?gi+?VX%<(vGyaa! zcYVIJ@{;1e-PZa@iS*ofQSPQ9Nton9EO=92uf3pLHK*zQC`A9SunZ&%LKsUZ%hRrl z@g^e&4PJ{ZZ%{glESICFF6Xw&;&8LmhXhOW08VI#E#*rE$#F{M?+}Y@Uwds6!R;-H zn4)4HIp!65S_P1c)kL|;H4w+Ylb8TjI4cEU$B*y#HFqZkENv~t{j%O8%REu}m?`ZOr zM*bjl$6#}n3WdM$3CP?ly&c~b&Ym!R5)h?{BZMn4&OTIL181QJmj<9O8blAgO2{Xk zGu**q!!@MA_R6;>Yyz!wWCmmgh|8&%p>#l&ahLEl?86PblPYWlnZ@|B{r%AyR25o2 z)WCGVF6@&7!u_62|16a_(^GA&HeK2GE+CJ<;&3^Q{gMxfNAUz+h7-U8nO&V&zhe(0X#Dsc0Cvh{R9X58aqZto(;jUtUP7Lbs ztJV#@4c5`ZOyyb1!voY1fMf#caijn*ql>W(qP^ht<_R9!Clq1n;C)-GqoR!YOI*s& z6C8$C<1sG)HIRV1_r~eu`j5fs-yxOypLKLlykXRXH`NK#=d6>ZOI{%E*@eDdtuWQH z1-A|va0%J#LNvzn;y;$ju-;4i7gnws_2ak1$mu~UCuLQYmPxdt56|k@MIES>*i2gj^cfuP9bV0 z*#M^TO}&!P({a`gT?7fct)KV#LtVo-S$=-z0yPPR^pieB0>pOL{N72-+d6qHH~eNZ z-Q~Bo!uP+_l8Ws1=c8C?gEiOJ`HybF!vbQ9%B?Ji-JnY*5Nscxi`Q9{_Nl=^iWEek zETDY}eF%aVNf1Z`UcNwdM=#@2AgcHdgcfx`-wsGBT}~auL_QfWF5||CoFs~0bVN|< zJ`8lU?Xq>eM!t@oc~^=36`O0#f){gVO+mf3#aE@XQgi9$t9*tEo;>~QMtgU92&+c& z&3c@E;xb|rb4amqvg_R4D)mvd7>t)cMrvbQ>IG(}>lK&0{8+T0%{r)DBr%C}3H+j8 z(0065N??lUFNjEd_T$)g%5Ljq%$X9IKby*+!7g}R68)Uu`K|+GQ@Os=89B(v^NfiC zrQVx7f|oDryf@om%Is_4Rkt3boXcl6cAhy~nJP3JM9dm7m2dhKFdmOwRH3q$!?g1V zFbYF-u=LE7*43|7t-eYO41i)-B7E+(af=ohm^@0t@xw0}GhtyHuzvDw3+f%}j-iSB zf?ZGHc$^n#XS%#$8^{m0bCOdp=e2uMT*5LMVrj_4K=8o>B^U4w2OEY*_@Ob5vI>$s z&SeiGZr&<zim5D-21Kf)G%3m^Shp`giPqCnBonnE!_k; z;flwGqC#y=N#vrp%zE9WEyJ~GO5SaMd$w^U4FVro*06PZlc9F+0n_>npkK@FHmGbFC{x+`yUNZp_PP%2PsA>eogAyUFq9;LXGZ;XY*^GtvVr}a# z)!%-}`@FlZMHS%|h*!)GfeZ7ztM=xQlqH;_Y^j3WJL)^*iSKdYyv;?{)iGo9MQ~MV z;lZbrHZ4Pd6lH-3lBxuuJQ!X5lM{CM#TJ>50-Zn)%=P_=7>US+{+wn>2uev1ibN3G zQ@7ECb;^bbEftUat1J`sH(AE$@3PEGJD>k7%P0s9Os3X>*J?)iL5VJBZ_dfPfX8~Y zTO1cG=Fn}vfYt1?4T)@*iU5hyh@F**Y^^0qRcFfpV)p`2^cv=xeP2Y@B9J#cSyKA( z`U36nRkPw^?VyQCb~U>?>t4<>-cZJiBt;H^IUBl|a;NvkOs{X4)<(WyaCI4D_%c?B zO$b%g&czH{(5p&-_O(P|%0Mlj7v9gePT-~9lq#D{PXs@&Fc8F=eeunwp*|Lcyt*z^ z$2#Gp%kNfG7OPjIPSI41@xI$}rgK%E;KxY+MN&F&Iv!yBC>=EHab$0gf3m)Iw0T%$ z_lWv7q~?*dpv3Qx@MYO1oqwZcZg4zmI8F%B#nK!Gv`pHb`xDhyAD9MmOc0Oi4Z^h_ zv(_PBD$;k7Gh2O`@E28yUWH8TjQ=lenNHmd2TWmyVKyRK7dK^_14S4uxPMtHomXe0 zx%hGb-j@0&?ObCusV`cRp22xmU(bIz8M8DQ&*mk29zG&8A|39!cjmZHZWh#A44b58 z%m~gTWsj9R9yXP?trmE9w1UPGvYxy8Fw}hi@z0KTm*bnl<=KArt%BnrGzgdE_Yc3gPpE0F2OhD(uoayXE;_hH4 zzh{7^CEGG4w)dLFx5*X9Z~8gT8N2MN(^_)noA4<%Ykkol^hj?iRLZ~6cDH^^5B}&m zQ7QfBGgjKs+Hn_wRH{D8QoH=39`gFEnpp|!TIJza-IDxbyms`qlZ)-eT@cUwnO~^P zrW^W&qpwbA&PD~|_VpA>&Uc7e0s68SK#0imgaxvJskpxS0?44SI_9F}yh~I@5_YZo zrl@G1gy6Sd$G9&9T#8{ymu{q#{U z1hJX$7g8of^?@8VCW*Eq`*ZqTJmT~5j4q{U{&nZ-VkNN_OXy*`L{N|NSSV4 z^`iWz3%cAlI6Q^!(Gd`*2p`Jpy~espB7vX=<$+-$Cstgm4<3N#?3Qulx*`qurL z*?PrSzWUYgKT8oJ32LAWaIPTJ`etKlKIoZPKk23}RWD{a>13C3Zo~r|p5HtQM8>b^ z5r@N)Xnf?sTuLg6wwvBZdEf#I@2T<^Et59KHcki3ubz%h{Q6fFwdm)gB%hW>`h;#x zJZ-c!Pywyu(khpi=uQ!@<|ggLB7st`S6WsEFjEq26?SD@H@ zX*@aoE?l+!^;o>x7tZk^R%S;1x3XZ~`Pud(ld+G0uYMAxCiIOm&XsTKR4V03K!GE< z9bUEzsXtcc+r*?pT9I8j?+jgWI4fh<)z{^B@gCw&oIA<$J}%!G`vB>u4W(i512=C^ z?*~V{OS@N{18twFuE0_5HWcGeZ(PTt;O6b1hQ%wb6X(UYGK#Ozw%~)inkSF&aQrtn zr^nBZN?{?hX}rdz#MLhcs^kxDz3ItM{Q*pwR!oMsvIlmOc{1PvuYBx<%v`Dt#j35x znXF{QnJn90DP;~jD3O5Yji*c*JKC=Z^5qX;`cmftek!nWe`czGDqZ{#aHRz{u$RB& zQpE&ut;J7Ds@86tH;dU9lWP2dgJ^V{v{YrX;eAt`l>(MmmOt($grw-cySv zQMH@>tz{|~k5WR=Zgze{^Nr)WFtLE6AaZ~xx~R=~!H8xGxfy3ImU`rmHxA}XoR!Mb zhKZ8pM+d?Pg5~}QeGo96LjCdEmYrNKJc}(OGMzy;E#pq za?e}^-sj~EWfdf6wGqVS7yF0sk&l&!)H%&324#5sB?`rDRkg2CqCo|B#n#AGTP;_D zg_5Kt_BQptmajZ-73hP(yr&Z#VM3qA61A@~-^8~6iznmOQFOxwy)<9GyGq*fJ7iG4 zseM`ec&)4j8d=5l4^L*BP5H-4(qKIvR-mACCmr~JX()ZUhbh#bBEZcQ~Y@Rme*rm{Nnd9f6NW5eq0iU&M{;jZ<57$j~xoY6x zQv}t8=gK3f*5HJZ9A|^~)slg)OEVZF?oKgN@9Kyjsa(rx7}9F$_MukQ(ASOYj3`RG zA3fNSeAo7!O<#wW;b;~)<7 zN7E)gxG)BxBiZn4riMEABd<-LPW?u(rjBPdd>^{2&6^YH6Ic+B;=M#lOC3$0&>B8} zSY)22;B-y(Vu5JEHzPBOS;({8Q?mg#%Aw;k;!1S(6&sfT@EWRToE3)9P&4>WXPn&( zTh?i9p8n$9+-l$G>inKiCeWpK(IVnYm8EU<22bORO45*U?>IS$3o%SNzkZ!~E^L2idWvQZo#b2ijQ1gPal@*`(+NABYNr682vUej<(ft zdXPe!YKjmsl-n9wQIPy{69$eWsi8l=q2LuF_mfcL_?gMp{#n`81w06#8#o~REIDWg zQv0-A*no~KCvtPNw0@0&S7lL&zNpMeE%yqE=2Gp{#dTdZ%TwAIJ-h1O@5%iUxTJD4 zo2N#ZTi2(>j=6Ue1v-3CQ7%8{qPO)U^v$|iXw3w1G7tb_Bz&=$|s zIpeHpRWCECc{xGg>E;jwr5;s?sm)rtifo{c9LT(L(0~OSr0E>6QiG&6$3#Xm{L{BR zO{)*P`@O*!Zm_3ToiMDF@lCfabH@z$ZTT!fed2vd-$NGoJoTB@2~)Z%Z4IU+vVB5! zJSdBxqmb~ah7_$lJE*aOr4;hW8^nEpKH-lP(HJ(V(^N0~{`l@gP}C}<^s>6X!#^PO zKOklL0V#v?zanL{8mLEp^wbv8^}TiJE%+`mxI;^1mPl}qqY54b%!BdB3(;4_4H<pc_KmePeNX8p-pF}KK)Me*aFprcgc-bdcdqxS4KP{kri4Fb)1rX5O@jOc& zye4$*Q?CF{20X;TlmUA;AO|PFfDJ+go08IYD8Rifwr`548iRc325HY`mxbbq=O zZE`mFu(Ms8-nV#5NV9dE%+8^Fwf|x{l3a3m`s~15q>!U{wBWfR+Y`JN_W;^hS{t@p ztBK-6)+8s){-Dcpi55j*BCQWPLa(EMRoh`0Mr$eyx>GM@A@(R*6UuAJHbJIw@;L1g z?s3OQ$3-4}V%d#W{U>^-nVUw=Jqjx4%e`aGoALuo_cPE#lxmED_Q|Dp1^1<11|7$W zWiGAQH>j@EYY^o)bOtI}lW6-v)%72+@r!akmuFKFk4BGLT1tD#ojU+@aRK1CDz5AxBHK15!sq!h7R)gi#tm|hZ zsfyLr{@)=%V4xEQ<)%OWxbBEX#yat!skwX+WSFDV(YKwVyt0;2fm14cmT%u_JRi3`3a&00#VmPXb- z8KuG%y>=aeg#ypGJeF`P_O#0DzM$h*r+bo6d*S)=#a^{p*16rm$i=&rq zdr~$2osv-j9>rxp*f3yaK*P@^s|1?ME`M_fP%FYxsdjSbA7-AciG0t%+icm-pMVv) zBB)4C6gD9lDfmOzU$lI%F)NR3JbMgz_{MG z_%j>v6|0Ci42#@F1hWx^{;MUU9QE>VmJH-XUqFebuSHvvt@$DYL*AL32Rq1>ko|LFw_moX{SJ?$# z4dF@>S9vp)d%6rY+B;cvtGBYS4WZ$<`QU!U+s>3l|MV?nFWQjCuHATw`#U`=stR|K zPyz#m68$9Oi-7Ct(SBAAgQdA^LP`wRcW;(++wn5d7!i@&Vz_mD4h@f}Yl-3|>%2a? zM|CYx*fPwZT~_Hh=xqR&(VB@$2)f3t&ZKj;g~BHsC-(jt12as^IbH#S`O-5=cl7d~%4TGbBNP}-x6 z%e|$AHCyu{9bs*f=bZhb__Ky@cU0y3!ymp@`zOj+Hb3Ugj)uKmgAP~Gev(=-pB0&X zLmGmO)_9Mk{3*95^9~I;d38Bu2UlWR&J^a*wnKN+-2|@&Xa%flB_&haCdOh04{6bp zT+}JLcF8y;51#e%hKxQs#pt$=otx?um>(mqD!s)nfLEw+n3n`bfso!;Vd=12#@_Ee zS^iq=ylhAG*85=#)BFg#zwLzL+54N(K@VgK6VCA_Xk9J0Sc?n@vv70*@nm_B!WfXa zgi)Wd2?|i6{Q2;cY9qk{aCw6QiaVAR&%q*qe|ZwVRG*lC?J`9GHil9M)lLoya+XU~ zjDQdvYH$jKz)R2TOQj94W?=pU7|;w<2LzIb8{>U0Y?dc*OI01%f|m;ru(|>H zG)60;B1`|V+d;pBwV&=P8fY|NuJBtTtPQi5(D0%7RuZCt_MjC7l&JtYtcn6 z-p9IKQ8T9`vJV(nGGuJ_8%6=}5YzM0K3TkyRSl_+>yWxnC+Xum&e8n(RZ~ZEqx;jL z7lUGwL>cpYU+&1fE_~*Tq){5^bX>27uZs>T**vgvN=@r|+e_%9F{w ze(`s(kvcK=Qwr!r16RtviFEi?jr+o*xU+UR-dkRlinS+8i^{Hgeefs+(IMgV6*A_H ztp}gd1*OM3Uhy@*8g;gNmGbs^1HsKm4|iN@Ux?dT$q1@0f^mROc|NJYd8k6DeM4k) zUrFA_C}h@&%G5l^$tjrz%k2t4rCSTbD!$0?eM|`;tD7R7BX89riniuD&+A~@`UfM^ z`nBznkvaL3k&$7al)7YO+*t-2N8H8neljwZKN*>rUyMwH!nJ$sZX-i@mH7{6YmWn* zGS>D9*pO*SuCG!@AcEjjZldZG-}hT3dS}~XF!e~~i4Hw{4{OF}YUCSAEc+X;0a4Ye z9T(eRJ9Y1I%lbHO)9Y{~eU*j9bE;vleH*+q2nM^)I@^AE+ZDBpu}{t)WhNi8AW`*l zpC9;1OGA-*$1a2aLq!HvanI?l{QXK_N1{zyk-iQc411eVjEU)Cqm+@Q-yw`29EsBt z-rjk5CR9lXhv4|$=$3ZaxWI3Y7MqhkJ{7hp(mNhGKZpa9pDC9!NMAlb8%%wJX)Lg| zgE2xWG^OCn*tGD>PVL##H|JfmxDv_h2XFr2$n2Hfz%@P(@2nOPfz?!7k4*oe$m~Pf z_RdNQ8*{7rIdD0xWzE0(wKgaRL^ zFlAGUYjLnf%gFm2F=L&(A^MD3Mxz(}UWyRu2GQ`vzs!*B(8YU&kS^hLD9j$E8)UM5VklDml1TS* zO+dku?}9Z017n|m0iv#DXnhrO45)LPZBtR=5{7aSf$7xG9T$`rlxKKb8d`e19y|n! z6#9{>Kg(r$D&Uj)vzCFv=HTFG$pQNL15B4z{!d3H zFAE%E!3iZ6Pf8o&$SJC(J!xIyeTR2!USYk@?ZPbBFIB#@r5x7b)Y`#O*xewB$y`V5+ z=5wblX}9J{lVbCHXwY{)_pTpR%BYvjvq4@B3V~OIF65t#@z19WsjyiE zcGKZ#j_poMVbmJQJ+v;`iB8?ZXFHitqozxiHK7--#!C~y?A^nnh&AtYmbn=2s*hRU zWm|<1P%MkHr|s=GWzHz6VKLX2jqCTmCftb$v7h+jG2JvN z|CDUVEp9R;TEpF17J=gb3(F(R(>zt4~YzPP1Fi z+mkXh@i-w((Ung5cZebl$TCT}E?eY(OV^z3r{i@ITRzVX*4k(KN?W=(=UF#y^@QXF z4O|bzQlct{1)7%B*tl*$3Gz;>`2zIrgooD}v`+2Di$Skz!J|kR& z={EM(mGU@AT_EYoT+v$8s8{_PB;!rvj>4fGgp(!|=t+IBEJHUC%t0}nAjO92ZHF%MX>RaJ>O zgv~J0#>TvWBy*`E8C^#KJP~t219tgflU@V6U1beG#DN5>CBN18=r!Z}9~MIUJD5~I z;8uHL`yuS>*zxt#dusFAc2!=Cu9)dCuyPC%J=}7H#4Ky#N0vZ#>>Z>9kKxnx~_#>$Q>@Baf zD8aH8Pin1yc-{R?ba_yNnHMJ~!(8@RKu^RxmgHT0mdRE*Z1&&c>`i1s3bzz}iYr;Z zrbO_y!ppuiCGb9EY^R9!re@9{@fSInE}q${mxx~*TV!XpEkrOGNKIAjl9oM9)uYy5 zu}?M3%Aq&9u{;=yzG8xT(}KM4{9XU~qiMr8t%KdCSuK0~tFHn)ZRp*8*nYN$7lF?c zD*7F*I5Fpk!SWH2nD57mPyF;D0p`;MkICmm?wH3NlQN%aYF&h!PUB>?_ZK|PJh$Ds z6I0=RQMct!Qoj$QOlaK~xxiYz2tOzdUVVDQ@P3nl?(RE;)}ldf^EC}ZKIh;UGtu%H z2m){Sq_BX9i2#69*~)V#|=|exfqdm-6O@t7)aLt z9b%^pnk|<*4+@tlwFaNoQCHYn7tD*Xyv2e&A!54QEPh-BJ9p&We!A$LP0qiPyvIte zhJzjXW6}83?QqCAZ&*Ue0JbZ;rCs)Ik#YVrqo~_?7xwkDJCDDy*3v*IjJ;S`V^Imt zEEM>59B_)MEF(HaP#JiBlSxs;Q(()=ZGzTi>xwoNmNF>DJpdW!5$PiBske~%ajzCW%P-H;28VhX78l#spkV@(u8YSlb8jhQZwp8%; z`eqzr!=6UCdX(<8voZBGCPS;Wv&~aaz9uMRo|o}1v7Gd$be^}8Mv7E8d(;!UzBK;y z<10;8u)$CU>GRFwoc(2Evec2}HWJ4z)==h1Rw4{_%SbN>!BIYRI%!8A>08fhI(Oyq z-p8lVM@8%vx9aELk5k$j`UGux6*0bqu{wrs*%zhi(qNGnveh|lq63-P(EV;Ycn+EF z5vr%bT6FtyK~-Nd7>S2{I~U+FXF4cL zK|*uAYBiKKDIsWS1T_FKM9bt&WP-jxWffs|0n7CC8+H~g(ZP=`>MI$Jho8sj6AbQ% zs8oa7vVTEjev6(L(H=tmfyn&Dka^dPHVwoTrGe!ELPoTyXhuIQ)d6Gl;|DX$q7#Kl zy4V0haI2%mQ7y~#bHylxpU^9LSuH9eFbI-gl5gi0+1y z{Q`rz<55M0_Y*N2P5*Um!Jm?|%dds$cgEj*MU+T*sycr3a?y5&afh|hr70-#4_;pm z)q;&B{0BmYSG8O%#$mCAVRap=ri3|L|C6BEye*1TxL?3!q0Bh};^Q9|Jy)CCe!$C5 zl{7oA_Hx~4oTMlef5)cG+4D%2MlkxkK8E+0#ozjMK2iX&h}?d|vCsDIaA5O0Gx$xt zy3wTfoxO}GJMRZZKegtqV^z6c3iYNmXNRYor&At+Z%uf*Vu+J}CMTaFqV~d{(_(Rz^xoMPC6h3g$5C8*y5T%7;U2!P zeSg3Gn!c|`;wMe|a7ga)|Dhj~72>B~D^W%?S1Hm@L}xqh1;7C(A#exD`OyTZCf0TdYH-A^elSKl8HLE zfVNl`>2vpD8raAUpZ}UlPkAj;uas#(xu; z`m^taMNca~&224Bu(~pUa9a67SqhjeSN!H~`7?>*vPA$J$`JsR% z!Gy$&!GxG!63XiKtlWkA3ZV+Cy@6DP0N+(A3@d-N`IME$-2lG~bBU}S1JM;k;1zXe zoLzs6VT%EI{%7zn7W&vLja7&3b|O_KPkm){jLhyr+x29oB0{c^wex*ZeNL5G`f!r= zl2xfS=3X&&D{^tz+$5s?H9Z3j4GWl zTynM7zg3)AU1jaQvrTZBQKLFR`sPy`E4`M>%S!^Xuh$rB_1U*f(pK)|$+@$o`q1h- zT0F1%sJ4%!#4KHR{J3v-2c%M}l*pc2CR5H4ua@6b&@tt|XNV`5CWr8S#rh$eF|LiC zXMd$9yiS0WjQeA}(oBO!lDtLhG50w_N!(lM8K3g$iAry@z9Dxr z+!`MZ;dyT;LqK+HQeWoXNf}0HA%s-<@mfKDU7tlWdzST8dE95Sh?gv^7CmEgWmg;>Yd^BJ0aO2(shm0)2+zr`nDpx2b(PZkQ@Z_1TnVFdeC zYDjZFj&K396r{uA2yo5`1FxpWK59V{QCw=GDocAq(P_`CnWg}4sS-sAG~rOsnt1R9 zf}0u=k(L(#Mo@HeP#A`3`FAU-7=?RvF*L-VjUf`fHf=VFg+q*jn5GJxgl{W*8yg9( z?JZ1jHa9`xv%zN1Bvlf6V76rvE>fC1M*R@Ra$hEX*Ph;iSjS#jPwkg6rWQ$pjTF6d zaCZ~Y@!Q+D3{z>~*NlS7byPP@Cl8Y>A@ApB`Tn?(U!F8SOymfYmUGOSHow8mfS3#= z{lO@=_zKuDS8v+AE;;AdR~nB^sv4e)^n17+HhR70%y#Hyvyq)o5Rw~#n!Y<)_ij`9 zO^g3n7{jK*oLKk25&y{-7Z zHjY;9J?O^K4YDiDiP6d(80Sxdbhg)1aHQGeWG;A*g0sIQ%gQ$nUs-kMrDawo(J3e@ zofH?ia>>~OCMWL+y~(RlZ*EOD7^}?Zz@Q`z>xgL19hk|0-8h?_d-Y&K$9aK_QJUtQ zfBbGmF5jS|Z$fNDT<5`sQi&(_yHS_tu3RIQb$g@1scfdM61eJ28?F>!`j2MQF;mz`LkcFNx)G5rrKmxWPrdTuC(ae8L&WsuP3;cx?&>53+}iy$D& z5sglCfKOW1B4m*rovPY9BLs{J!8kxGX3!Ym0k?-nQ>MUm8E=hGZD_^xYo}B@B*sh< ztJTnKy*3q6+~5r|3A#CTZ|DdqGQ~r=X7s~f@9O20kE%Y!N>dEJA73TSp0R+P$-FAP zyHiZYSW8KSBGq<*+R6)f5K>vdbQuh(o2e#NW}+Mg~1Jl;@00 zRWJm-m*N+)8gwDF94Wdi3fv6W-+-;3R59JUxVO|YL z!Scx_Z)50Hrr9qYFjZD;oi2cw|cP`Bze+6Vvz7|%7StIdG7fQCw0d*%cgB z9>F!+(w9mVk>hL^b8n9S0?06B_pc*9<_k>3Rpv{Trpeb+$;a`9w&}@CN8L)CYKb+` zYPx2qqtiyQ_i>_1jX%ffRsHy8&fvqz5b+(pFH-ydEM3X79-T+jV^*2O8qNku)bvwU zEDU}aUxzVSaxP`@C3N-l(tLLz_qw817t*o5QLh+j%v>21yV#s!+U&^g_8Pg;U)#yY1!CA@@CNsk(f zkk_@yhfWQ&_iT}{#t$DJzXp+bUp^M3}sJ8uF+$H0gznYsMs_io-*!GDJm6G^9@`4WjYNKB|;KNf@ zGF`j}Eq;^3L5NCA;2Mo8D>RhzDp6u8@`I5PT?J9bb5|r7ADycpTyaPTXR}JGf;?0` zH>ixC_6m<(C7;xlNy@O$F;J&6nEHjs?7E2=4XI!8^z(B_pmvtxTU5QRz6(2+6uAu< zPW2t10o$DvraJ!f#9B94KvekoPgf#8gGd4KjDA4ThmZ8G>(7bE2TV?s@R}K0in~Ak6gcm8doR~TU@qM! zHg+lA@Ao*{t-aMdy>6TTw;%I!$~-bBC5d`SM%12y%yfB1-)ZS~pDdGm?quPI5K-n& zeqlGms=O)I+eL#Pk)52on{`ZhmyN6J3wYAvFr`LrIHoVVZg?BC}`OgtT9AAQL6F$fV`^i)oTHZi&tO!qKe zBOLzBuhM(%;~W!5M;1eALcs+dwW2TivBZ{vb?vQMtyDLOG91ZHKqigrA;F}GBC-#Z zK76{R6qA5b%h^OY8#{nI9#C(} z@Px!#LKsMOgLU5wg`4qkxZGFl z0GK2Rhk#@|o%JO7GMI^V0lWLBV19?T#GZ7RlN0QN{xa=*Kz-Q+odX?=7lomNI}Z1S z8t`DWl&oUL7>OB(RJzH4dLy89lYn!Ai08VF+6YHF6}l;UIx{dDo|G8lhR3t};C+zh zal;rQYl^?-=l2#cY-DJ?(Q9S#g?NU9Lv31WMA5$g)1KL+sL1XmjUd#N#b9Em^Z(JTWkH%ACL}x@56*pg|3Xj^o_RFpXw)<0Zs?ybz%MQ zb2n=%ymfTS6y!{8IYe;}solJqsBE{dY$?XPxjr}z_S$n2E5bxBaNHMaPMN}5lrD3p z8-x_r#I9X{CX)}b?$%-bF}Ye5pKocHsu4<*;Vj_u!Pxuh;I=x1lWyaMl?l;J+x zIq&sa0B0EsxrXOuoyOE8;PUa%5;lsdcRJ$7J^QmZHM{;}8VZ_b*TPbJ*KDE@#9 zG*tWnX$d>PDdq|ivRr6jXkf^#(&aq?zXx|sc#or}i;^#3HHID<>m_i9$(bU>emGPK z(?nI~N)MQ;!i}M!nUNnZf`gB`gIIaiHk1 zAjO#c8|r3qrj z9TY@7SEY)IcnSq}jK9g<9`v^0-|ZN;G*L;n7=*tuiKhDhS2;%V|95hXwDf<;G3Y=3 zzaYmDpye2X|0l=%KRM?AiX4L$4i$Ti)K&C;tUj!0Jgxa@gki*k#8&&z&%Ymk6xQ_w z+E`FTSaVr-FC>m^Q?!F#dHOWP-|^VMNOpIc792Pfehe%0jA0*17Gt~k(ZNWa!i+!5mw%%|(NL}U zw61=yq7ZPZ3RjfsJb5u@Z2x?dtg*~MxlFphL*7u4*v}&CB9!+=g9e4Z;UGvGZJwGv zn+arn0N!{u;dbt$(HnzrarUs>M_k{;Jewyw{^tu4_bW z#by#+U$JOUA2MF_VliXCKz#zO0$=3rB#-{-#%M&L-I$Ny^h(Q7t8#@W!^`#)W3&!< zajt$V>Gi1}iW3*P5;@Eoz}Z7=C6)LGO|(y>F!p_BdNt5ce0|SMb=eas5B#ZiHQ?8GwuG4K^Wq1C%Y{X6) zc33quPM_RDO<_9nphhO=)2g>y^-N_jg=rxB>(=j{uQ1t+`s$0mGfu4)@B}w2HVyV3 zy%?OvSGwaLjuU(>`ds>ICVrZCPT*SdTx@4xHnJ7d{iQ&ERR*VYvm_K;frtcM&#Oi%muj{`7`;`9vxKl7z(&( z#C>dUuTq8GP3L`kk{-nrl@Lv#+pA}mVHQC_p=)nMU7V5S(V5$Oh!vXauAsU__sWy!zq>Jr zKOj#_IhvD%Ebm`_TQk5mF@$TWTm_7=5r)4O36Nr#NQ7YFKr(06Lf2~J5&EFhCkbTv z&lFg;gD3BKT3@P6SLoTmR8s_025xArURsv?%t7H>bfSdBrYCTh15^7Cs4F8|OYNSp zsm4jnJryn}*?huydPa!kBH_{NZKY*rzTR2{dGr1!(F|7ER{Z`!sh8ZFM1~d7v#=$P zE9VjCx|Rm|@4K0vZ$XhPed``slp+VEg~~&$@qT}EW1gVhm?G?Fm@!Xm;4;bcU`Z+y zlJe-*;*UEXRvLVTHO-S%MEk9VT_t#o@g+04o4V>|VM5%B?0UWfHd4$pKMspaQ#UC3 zn55X&2JVYeiuKpnynfwMPRC^t*p(Qy)$H$xQ|o{Eu(+tOh&qt9?FslkRCEQ26u?Ic zqO*0s><;pU5tdlZwYGAeLk`pwRoa%oxYmdFw^C_WIUE%>r;GBky+A~zk(j3-0mR9b zcm^b-OK1N#*_io%urW>)Mlgi9+M3p%Y7Ft6$L;bLxgJ7ZDfH1`sp($K6-Q-ftXZ6Z z55gx`G~B{fG+enrQbt7$Mgrl6im1{eG2u{GKwb?_w+i}g+`eM9zd8W-(x`5gnGsuU z!J1VUk~W6JY!zK{vMa4V_7S#_Y(Z&o|1QQ1m8N`QYNDqMP=!4=tNtn0_=U_foUf(n<<4;i_q zI3!i@uV74G{a?WtTJS}bmOzS|Et05C>DE?39Sajvu|um7nvjzxBUY`;d| zggV2e3UlmjWI-7}LE971sbt9dE8T*gGY2!=f5!0tYy<;iUFf+{s1`&;mn~b37lRfW zn{u^Jfi9Qo3Cx0UnC74&@H2Z{;ry@;DGo;jA-GB&PimVI2f~n`pJ7-8eGr$4L}H*} zNX%>EXhj07?Kp>d2Vrh^x< zWAIXVxp28KWS2Ma(c7nkjPihsh6uf<6%O|VlZxlE;6IAmY{M$|CA8bSkzHD>Zf%b! z3*(TF;J93aUJy(tf=Wts)F287S6V#iD9JA!L=bo&zcRtULYROM)dYW!OL722h?fLt zF$z?Wm|3=5#PF*&0=E67Mt0c*ilySySd7wHL<-!Vs?+O!0UV0WNdqc!>A{tVImT0j z89|7n@dzM31#6Aw2Jhc%(9kjBPThK7F9dz-9u zIvB}KzPTIxCak(WY+Ih%@zM!{4X?)(N1M6QcplqP-cykFE*m@-JAb0;PK4|J%$#xn z!ag~3cmb&Z+n|IzdG@Lx9%(dTsDlGQS77vk z38tVpJhI9(?KA12mixztZnY@`AGi=~M%TYC4!hXYWOI$1A#RsNOefJ0G4`Rs7`I9~;NveaCPhV~fV0djADZf({UQmTKnDU( zWtfVvWt3%ERpI^y!K~j$Eky-vhy_JX=2tBeSgP6W%DkF<22+?oLgKoJa!3HRTvsck z1WPxGH~(xa6+mXZ)ZKT*zpxo<>)>XQfPO|r@X-*Jlm&}QzyYn!7{E;d+Z>#vZ^`TS zH1O{@3pT}3;BJbaNeD#Uqlen~UQF#O@Rgg4ip~eneiXS(EsB$QNHsmA!Peo!xD)zPq@=YmcbFrh02yL zedbFq*576f*iX($d0EYjDd7wkiD)ZRxUAXKbu)eDs0x=*{bb^hYZ&xM^Cb=kR!m$b z*7^NYR7+K&0DQyO-D{sB%MN2(6GHYLe%HfBQ*R9OWv|yRgcf+vgiduYo*5GK_zDNo zoV}Me{K;@*{j2fRQ!uT`1#GIgNX!IKS^X>W`a4jU(wc_}k`-vB2!wJ|3|L)3@byqJ^7Ib~O^s-@VUk>w1L1!}W2gZd!!vCWe;B==xBcWz%w>Q% z#fQN%<=h^=lsS;&|EoXtLa=$51#+lQ zMxS5ejx#;4MMbI(bbSY4hPjPkQ$A!8sJlP(L<5kUlq7d z%>sbK(TVq^M(~ol4go|1#Fg9^GbIxRsq>E&iEcRD?+^QS?s|<+DupJ#BJ|_vxvzny z6J3;*hKn?R)0_5ox@7TXR|=)w&F3j>rbo+{rj$csP4$&BkA&ULUT_$`7h%I?x_Co} z?Dq=?(c%9I8uPYDZVVfwL}P?ut-Y-A!&GD7XrGMQX4{@-^f)XRkewwX7&lY1dKMQd zEl>0XFTaM3Bx8=eAkc^zxfv7}v@07HziOV6v_LBFuWJ~w^_llOFUKSN!_glV8ainl zb2r%i?(I?cW8aDkCjd^u#;Ijz05pvxocpL={${DI5Qr3s;s7r;R z=b1ek<@A&qfq7p-eJ_Jo(zcDg-M&QJ!)U_TYRMu#bArdsiMNn1iN5@L{W#?G35#Fo z>E-<@M|w7=FYj3F)q;=nzVW((ab#X!O%NJIcT$5j8oHeN*Jr^aKyLh!HqfKV;0{3N z1VDlv&>JMelda;eNJouSvs+a#&+Ue{ zdy4P1GyMEbTw)77`ZTG>20e48^t8ot&}_`34ka*~EfhTUvUYJUL%cJ@G^s}$d3mVQ z44X&X4K_NPIt%3#JBB3;)h)<=d+M${soV0(&X&cjDlMguMr-cTsOuJdoL%yasLNHeE`0*uJO{%PS4b(M13^W}zZ1NRI@g zST9dYzQy~SLXmin9W%E=5@;5*B_eEiwrr>DVKU z%Gl4{R8^t{Y1|PS`Tt~N#Ajcth>tUH!{YB@wKhT@#!kFo*PtA zq*s+4Im}t=N0U!#uw1OptTLkNktmpO>-+XK8$YR}IpXr=^XfIf&`yR>&4Y?5>ZV4? zz%6e~Y`Dl1Xj;{LKL2}O>HK;%463_GVeu3&UGOWK`d=Y99&!V4DgTC#fVpt>S8fy_ zgdi&yfe!X`#)UHw05^Qm-om9m-#r;0$Vzp)Us6YoV@?XT5 z7p$`XCB|q7=Rt%aozw}@guk#3Jh-7QRU^JnQSnB)I6f_PAFUG8!D2o4LTL9{&FId| z$>ir@1AH#5XBaA9C-=*gO1BDx-)s-++fV)|@!=l9;rk}NV;XY_l^rpYTDd*UBCLob{k3jM#w|s;XIT+BkuxXLyA4I{ z*Ttg==+Yc_`Pr5%s}An8vtNiAJjgo`>>}&**U~yAp*_lL9OY$TP9&9yKwSoe$!4gP z>_923dc9rKJ}GIScm{U9f;f#6l~;Vsk+`Y_{Z)z1GMe(f-q&BG-}Vv`ep4giNIdDb z=eUj+Ck+njDa9$V|I}jS6-J?T8VYFO75;S%H zx+WF>`6RU-2aLn7PZ%w9%P{&Ahf@$hnD|BIh@2waRM(j+Ie1DtS~G#lUPHUJU~{dZ zkt9Ik-Mv(H9_l*iyleK2YcN7FBlBB0ymGJVUM5pB6t?1}E5;S~LW~Wm`vfc{>=l)C znR$vECh3?)bp+9r2Sf@?Xxe-Eg6L*Z839s!VUoP4>`5(5SA}ckex6g?+dPy=8KaxW z7#AxNkE-~E4#7$~15?Vo#*=$hYNUL3?cO!Djb%Tc8gZhtM%hiKT(f&V6^G-St(KE{ zl^qsuz8s482gLToEL?)vja^msb0KEdXdOYHzWug4t!!QVziKf;Jw#cnWry;^QYPWw z(PR;BI0sc@C+q`K?$^t5F!0AseCK^->!rN-E?NyDFUp25o zO<7gZ(OM6~*e$J@PhjNqRu}7^!*V-ZNpX3qt4-Z8-nk{Xd3y+6Ux{TAyZ*wTfYV0U zPl;wIC;R?E*>TlIDXq_+V9d|!`_<1S^lpn%_U?^-SU4mUjXkve)_aF9G`zgONh1>e z=zg`Gv#458u(K6A*bgfv7Kyt|DG2w492V1jKh~8=YpkV+lahBMLjA^KC}h!Cj95gU z@7H0=d#1E@dKglSJ_@5d22kjrSG{-ck1 z4hB>OfC?FaAI9Lb=GQfY`Rn1S3!5EAj&OUPIwg$`oCB6%CaM0kNFbP(85VLS z-yH{|W-bw=+|)(RR|nq82VY9$Vg@B+%=%jqwiyt9E@aB%1;2Tp!S+-6Tv|SEYO(e9 zHE!~`o{2XtSW--zC94|5x7}|WS9KRcg+me_t(Gd!&gXW5(0ugEm}*=#Je$MKHzdm~ zC~vQ(vn8(@+G_fFUsm-=`nsRy1+u#|v;x6|UXql~{#11bJ(2T~x|F=0t=+*)BvuBB zn>E34ib)!E^X9A0E4WMW3}MP#?yA%Y`b%6ioj`3Yl)4MmXe#m`ai^C8qt;z zu7Qw_`ADP#QWw(E(iQoDIa!YOGtJ{H3 zfajNn(O%=WSF((9D}4g!Q~foR;q_F?l4CVq%ZfO?A088Y;~KsmCZ1fpafkS!KQN5_ zCR#o9-|2Ft7fcc$EusJr<3K#FfhI$!QaYjFV#a{QBLX-vF(%-=KsiYPgdsXKmq+Tb z_~3s5#^fu!_PtancJr8Wl1|7O68V|C4{WCjVNOsFql^Yov*>&zC=vI?A8Y)1M!<+b z51QoR0o!O{>V?VLP+0VnV}%PhU-Li;uP&0u4FMI!F9?HtF&2KnrWUE4?E&#j)v2|w z5JNX9E_svYrQ?RJ6k_?hA!QEUiju-N!h?S2SlY!$1&=JBbwUCpK1rtU-*4!fi)*H& zWiltXR0|POE<0MK{R47;I%Rd3UAm1adwJ)IeO_-1hQQiOyl*@wil@G()2GEtLELCB zCh0$UF&4kQm~B|g#~Y1dqvtC~q%b#To*W`cCBl$UkpV6S_QimeF%dHf5nnI@)nf%I z6UN|Mz9>%#ZBBt}RoVHxQjA~o>AmJc-zceFe%ohF(Q99TLA2w_l8#XOUrh0+>7~w&vjkO`- zR_J@(#$83*S}7MA^<`MlVTaDXLwv22w`Y4W*9u)fbk37oq$u* zMDPOa1L4lZBW{evMjs@|ZsDw^j>SQ-RLSpawp+Qe|5r3-SAAW-?VoDQH;u3HHVPL3 z-7n=T9xW2u;z(@` zoMH6iZ45iHP@AnUtAT+K1t%B6R!NC}Q`4bRtkgu(O{zbX3-;8t+Pp;R^uIFQOr6oX zYJSD(q__bGWEW|R%4goo#S7$xu91}dj8b#ITdnOhHpCSpg&oLph&O@kRmYT3E@S`d zOd}A2YRq6z=*YRp4g*TH|PF>z0#s6v7-sUue}wDhST zXwZeW;l7*<^J`tdcU?&ToGAV$evS4wHAGsl$FY>dv8dm!p9N3ng|^xFvE7H{X>|>; z#yeFaFGwp!ctrHa2!eY+91ZV$6fZj+>KT zd-8i>IV2$!=*4a2K7H7T&mryREWt^+9=*7rc(di}$wsz@16chC6aM}_1c_EO)|dup z7_>`)Ufn$YH8JyRC)Wimhf<6TAg*~61{%gNUC9WrW(VseJgTB+JGw}&@D`EMjETC^ z>^cosk0gh(VQySx;&#^8Khc=ksvV5y&Zp*5dbZ!IlSVJ)r;BA{lfh*Fs*XfmhD#D$ zOc5U@rX-$O%nIH+Xe&ky27rpEP%SOA8-oA?mcM>|3^iA9`Z-n~A21Tu@~0b9@CQUY z>cIXv@1Ze#-SZ(AOqlmt;Ef}%9)AI81ChAAwf*GD4!60V$BJzVLiw0y8uK7?^(Py{ zx5Wn7nETmNWX)J+JORRvE_xvo46IM3DGW- zQ%q9zFuE(dA^*z85Tz5oU;UDpn?AFDX|fZ`CizZr$?|e*E$pyD zP09@4NCIo!pq0U;VsSw*HYGX7jOGUx!zLqc=m<{08PAUoN$X{gx*cCpipH4M`aj|+ zKb2Fvi}T{TSX$s|?*7SiP7z5*qp~w6kxZUmE634g*b_gk=c)w!CLVr4B}S8ST)0C)>^=RQ@pu%fxUEu! z{I?skcl2U9MKm=&KCv5XL|uRMpqzC7t|qB|)ms>dp2bN`(iQ}SB%VqLhfhcV9{c`Y+QiTKdpYVb4tdfX5 z+ToEx5rHrBDmRQ55XaAnY#X+Yj=I->B;fWRziQq={P>8q^-laWFqe4&u6xYwja~EJ zeEa@6|4b}QzVenCQYRvPkon%v=w|W3j8_>eb`0_FKAsq_J4exSIzS98pMjz|H~2?P zcK)>YxgrB^8Av}W@{7a0t%%$1X<&9$t|hcEWs`7}kI&;r^dvEeU8mtrQviu|ZEt!W z7yozJvzK3j*!Q^#u5S#s-zADTl*|2mGjB2k&yZTV)D*)M0R3QLGk{GTlM^|emY0Y2 zxiHY@Bu|jyjVZvdgrg_efL1vy$O)3=py%c{Nr?7TV*n@yj(CAJ_g6Fq6VaCfhADAH zVwKoU;@sM!exot6ztEU{OR-xjxi2KpXiSyATJu5wBIMMNu}gK*5D`>5_fm`)glWK2 zmDL{|v&sC`vYwIx0c$i*s?Go~+zDdwfN}!6sR;1m=NaaN_=iyE8)HA`@Q`{tV1MDB zGIf44u}$0S1yzp8U8i7=Ce`Ad>73`K_cy+mF`Ei`LS&HgwltFh=Qn{XDMDwVmwkXpE`&Q9IYV ztA8@=PcgfY0o~vhX{hVUd7+MVI+oE_cpIw0K{y|M-uT$l_mJ@q2&uc-OfO=xs~KardSY5b?M^PABAM96 zuQQ(^)sOWum`UH^A7sp(FgnG9vxDgMyuG zB|Z+uF!?~R6L(M#O`G#!{&QA)ZA3>E&j>Qg2INP`N=k}!`5x|l@PosHkikFjCB(^9JPEt8HL2W>4XJpT!%N2aTC=M{3D(U! znsD#N(yrM(4Q~*UgUBFG%72`k+pf;XDm8oL`0eCW(0wcX1~IQcH=Z7eUw)2^eK{I> z8I!S1b+P{kcsKunz(ML-l>t?US^Kb$Y^E zm(PNytgfAD2UQ}e1RKK|lJG8vPmMZIBy96)+PDdQ7Iy!8V9Xl1{11^mLs?Z%x*Ap` zLQ%hm$lRWP0b_br8Imk68QqD@W1F42@`Oo=(lA#beDOF=g@cK3q;mI2ZI~2Ug zViqu32DUR)F)5SMemx<8|HMNFyqIR-#R$gK;T4&*gY@U3#Sg3h@?uC#CncDhyW777uT*Nd^Ojt5 zaleXq#jk0ywqv$dUNc=Maf_?z?z8*zmM$f=>F-(?zQL+`MxG)Zjmj4CCg>JvW5Cu1 za0xMfEdaor%dp4`Dt?!6Mpe*L{rfS~ctsPTg-zdDRP4D4>eB=W;+?~veH3jQ#c*Yl`~~cK7=o?#OThp zs&>+>lG^VDx~Hq4-xvQ|Fh=NaU<~)rU%{9U{~3&_&e&CA9x1TsT&Tw!_>}ro?$c9& zFme%1ocEHGUw7B`J()*m)rTOIynViLv2XVf+j5*MX6=FX=|o%v;4dr((8!aR^EK-> zezNhX4kfPHr?}j8kMApWHDc86;|-wc@=mkW0d?pXc&p~r_eyL0JJJXW^~H2w>{gQo zh7m~2{*?68JJt=!HsMKR=%=-*Z1;0droFFM_4|>B(66BEKa`lgAEs(G?DfIXA>Vml zW4pyLCpSug&ZG%tyc_(aQPbO#ifDogUjYD{S`!}g2}E`C{VTqpxvQ~!S##hm$7+tq z`LK)x5MvZa_G`&LG4h2!61+)6kXW}b?SHw?Y+H6dSY~g`Il15^t9;`6CXpNTt`Nqp z-Z0j7YZs!@SOW80U$ac3Q`!6VGvl$obDv!KY3zB($?=lM{b9)L0%rqGW+4SDyId>e z`!=qEjJB*dr(FN&3${qE5Qa2`3JD84hG_Qe-nZw%WEcC|#=}L=Up9k77i2#A?aV0z z^v>|dcz;8>Q5aAh35*0m=24aT5b zQW6l@wBURaylSVJpMG(M50BtmXHgtfb-G_OR71JA8!0}~6mZwlUP5Jdkg7IOOt4wk z_TMZnLM<@LvkwXsCVw@&ZFwub$BLm`5?2;6_NtcrgIcv1`$`9aRE5R5IV!n(*9Z;9 zFc;mWG~;?saAq~2hU2Me-vE6PWh;rO~o7Ns*DD-aIr2j*d*4V19I2w8m2|G zgzJoS$s&z2p+5CkVYGp&=QV%H$Ku^aboSH^Y;s0d^z3zu*%}7!Eu*$3GRB=7^v??MZa73tp~n9 z=$@dxm@12B{s>WZE%1-Kx;j;2aMi)du>ZZE`s#GG%028wdYF8yqr@#g83sCCjPa4z zCS+Wqrkc#wrq_vY9VJ;?Q@uTLu=SBv$n3qtl`jWd7rPoic2t!uW?Cz`Tv>has&n#ZnTJU8a>Ng2g8Pr&b)Sx$Wr{Y7cwlI3CO#rqK3-MG z4o;1$a4Zw@6rxnd)w*jZ|8}G49*n&5IWeCopP!fxzkwksfBN^m0bPvB2>R+lD9_BPHd7bdrVCjFRKcvTlCby0s$j2T7tlSEbBHI@N4*~@u)Gqe9Zq@e3&IYgn_ zeewa3XJ&P^##t%(LvHRrAW9eR5o2~vleb1M>jQ=QXXL<1=%E7Md=MSw;8GCSufPCt z5S5duw|;#+9hUX$rrOOweR^UR_gPn*r|u=8w}Uic5DoV?g;ah12VKI_Qk&-&Wb20g zG|i78RMN~*!K+(YlV*4!@tmw=ta!PZwW4}>KMC_G7qy9vr6^MWMUDBPEp?)v)>a?d z4|(_|zl^&-CA2n-9dX@mp_=3Adlq7>RDrVXaz*fO&BUCoKvVwETHO%7p~mMqR=}_< zrlI)u@T@RCI8txA;B`yRwKNL0r9p2>R*qT&jnUWC@g>YUy?{F_q&C(=k(N-7dQt2x zt*@FhFDNQ`mj$fO!pB@Y3%Xkgs2-cVfDw+drqBiQRw-6X!aZq=b^FJ47PVhxSO?&~ zc=bpX!$&+S8Dz(kX18JDPs-XlK69<^tGUU4C}N=W3o2$k0UGf{BoHDvsWS6G0zD7m z<%*$+PMNz?{!(MaT?*9xj>ceEziy}s&8AzT*dKW4!njk8pw1vZ+Oqg@-;LnQ`H9|c zU^QHbXhozHc_ex+TQca=1xqKC2|1hms=D4+%Px{D7|b+BO;zS<}m_-&Z>*P4)~lt^GaQ8jAUbdhe7nl>|rZuq$(wLW|v=l@UigGftdq zIv7@@s2g@_DUEE@Zw)aa?7h74bA#jK^3tV`V+a3piNaFFR*1pW8WoTuiebGoLC@ z49Xm6-a-8#o(;;b|-gSDRnvS(8eJrkNJI$l(Rjtdl;-drPfPe3zE`-&?Q#gu@Gh8MbIUaQ0pQZJNqn!pG3m{#1t<9(cym zk2>c2IeO%)A1aDNm92P2lR7m;DAZ`2uF8|jw{p+Yp`PrS(X2*^5%%Sq;_*RfXbKIv zZs<0JXy1w`j1_K+Pd`?vbLb>f)aZpe*WCEN8TVBnz;?GvXt|3OGRzhr5Eea89qal1 zhSkgdOwk1l(4Iu@|8qu1crwfD$w=bP8E+dueBzR4ZUN`ok?1Y`f50&=o8d_Yxv07g(cSYpR8fw+?a{+z#FrxvG# z=uNjAH;s?t7DSmvxP@jbDRhboXmTb2p$Jl9Z~`usjr)xF=E}2D$gbyk{Sx8 zR(w&We`RA--{0xL-4I#Ipxj6f0BFoihk3f84XVfWE5y%@z?(vb$z7y+EjdExt^8ln zm>>zR1w%OP4SpFEDc{1Qik4Dm0Iy|?s49EiAa%{L3!dWkV1U1$&CCFM0p^sG-DB9u zNo^w#gcfE*@MabbLwKnsK)I_fL>y%Q6Mz791v=~LJ2mQ%U=6&p1#=4eS}(HTHsbdk1k8H;w+Ci-m5dYzHW3@=N@eQB{qnmN83lJK znZJ}d8+{DtxTLDRJ&+qPaG{VY7s0*smITH0?`q6Kns-NkseuBXL>WVEN9ijwTz2LR zntRrF6jJ>qPybDgVK`0gpQJ+MyzzQNCgjrefM(qD8di9(n#j%*_M0d9{Gb0zjj^nE zNzJ|aVfCQb)J_9yI;$*;FvxoFpvEt^?TYAqmm~p*AHN#W_r%0VOSp&;Vfvkx-GI}3 za0z;rt8*t{YRxxp{XF)r*=s3;j^JW}=H-H5_uC@gog)*P=X~LxR_Mo^Pfr3IJ z^k6zzsbVpLLu@fE5{M9tgeoeb4@{b9>)0EOO|sYmJoTAJaANXh4s<) zl~Y8bH@DYDqF}>Zm8yq4|E(9S0}kRfC`1DT$6=XcIO&v>j@kIL=ZSRv@W8orPnz;N z4l7r7co5ZesdGVlDi&dVSAo63JN5LY>EOu+V~gA7aN2%m_F%da!S=D2l4*Fw(!66qYpnI2D~0O+G)s#te8G9m+%6OupQ^B#K66pCZWU4{42~|ZKoKLE zN74^In;(+~lP%xO)z=yl=fd1b4^W>-7w=34Unh~;Sq7Msx;SMe$r>)lk%v3i$#xXn zUq73>5z|L2EwUE>g7MVxd-afP%Im3WZ%3c+btg1;`Nxd1N8jzv?~{q}gWG(YL-iI5PV9D$wekuEuz4lXeJI-;!QeYd`PMQcP75r@ zB)+TZevk3^+k7hgmenBTtciUkaeNDoh#GnZM}YQJ4yUn0n`k!jVoWo7MXs?KbmC$} z7>T25`kUg?% zw`-*}@SlV&D_jImKlU~x$e_3`eNe);uZQJf`<2cQe#o&~$$Oa+qw_32(E>yF0N6`~0QqtgxHZ;^ifX8Bj2xm>({Z5%~&N{4#+ zUF?yFbbq10wb`l`;}e(9y%AeJ_v55R+Q~{K@~;+RcdsWrlvo#87;2A_a1n&wAN?p1 zQ@a@Lk2>#k8uRQqJSTe3)GWxd6ZWk-j_inS0?zVCDcdXMx21e zzyg5|a8j!Y23Q)S;3WKz2|h_^8bXyT+7DL;CCF$6sImN>BDQ38L-By1qzuQRA#;&i zeXuB*cE{rX!QOobMV)PZqi-@IL7-7Ua?TmaIp^F&l?)9^1__dbfaD}JNX|(>GAfxS zh#*-&MIFJ@)~)xCDM!03rTx)+@3q(Wv(60LFfvP4^;V|8 z^3EqA4f8dUHHJn-%`c3)$s|=`XZYv`E9-2UH0zg3=h+s_nQR9l|KY_Xt15|twJ3^G zo*k~Eo}q_<7Za&H)x$x_P36^Y7r!I-rjsocc7YDn@ozVYq`rXk7@j+Hiw$dXu>3fWDUnTX#2e)1N(YmWq34 z)bq%?BV0wDDp5wF$x zVY&x;TmiOh0aS(;J~s@k$9M;op;>9A z4S7Y9qTHIRmY0$IoVG&jmDa&h^W+;D|Bo)FO=^WCc0)M!;)LNL(oJh_adoxtxt>_t zd=0DG0D+vW#g(b&_dWb$2+?1vviE?10XMW3L$x$nr&uV8kJmpQDJ=HQiFDN2L-jx% z2fCvdn`;SiS4-B2bzxgWBV_NW|B|D`S7R({lEV2cRtW{h)c(*ppw1arj^{Ilbbo#j z80WcZ^!(N*%y5g=HkM>Jv)o>)gH|ji7|lR!&qXQ#W6HLkxJloJF-{6^wiEQKS;npG zF}`)PXpXmDOAX{}1_S08w5D9jUn_4}Gmlb~x9I=KzaaCVC zqFva8mifRe#kwejUl}OLSn)tU7Bwvt_icTjY8^1y(l>gs)RH<)el(_Y~=aZ$XVA z{w-Nz)^5oMdF$7K<;pbX+J>$!(B#1}NH9l4d=i&J)xoddlNmWZncRb)tbx*?nSvnC z8xf~{By~OiusQRm=lK3M2UnbwM+$G75#9;+5Nyid*~l?%@=TLA7PguPnKJDl6m3_m1CT^yI{_Mp;FZwsu<2rZPT%kLi^KVXw98Us z%T5uxwQBl?BxS4Li(F)~BxlkLagV;nw0veapc$*^f>Oe&#p|5l=Ia1Dp&Y_DfCFE#t9$HbPLlhZ=N%>Dz-5RGizXMxd8QT!n7o2u0+23=BW5+9C}QxR9n@b`o_ z{&<-_#i>_{d3&_0s*1R(P1gRMI0xI8TPiY4@Wn$gR9O!+6t@3oIOZ5UztgD`RZ4$A zX!V10dupWk1&S}Hb(jRAPRLw-(=m7cYdYq~-mT!Wlj@PYGyMKPAQqiJZOkS?+%GLp zXWJ+c^0w&xJ1^!leIJpJt=G1+J&o$*E4DH{?k4RHp!q8>_?X_jMdsj?bt;&{4cf2H zLxz}3vn-*o9Xf{L|L%)r@F^Pw@6_wy*?ZSUw%F;O-0$xdnu@5>$nV^73@m=7qgi)Z zrG0(!sIj9ebzp+b`EvcSOq=WVF7i6wEUz#4kZbD?$X@VHjHc$H#p^bEF?%r? zGlanr)-f4g;DP?ob>nP#Gn2<;KfjYwIr%pYRdzT8^m~_t6uu~&@F{q=Yay3RI?9Y4 ztBC=25b7M*JdW5&$NBnbaP|OwT`W^`9;bVv6lF4Bg$6D<7l|{Q_;0f%};R zayCUT8S{Qih`%qc8*STNG5oh1!wdz9Q%@~uVdk@Nol(WNPU@JbId!pS*3<~lNxe7K z2LW+&FYq!z?tH*tw^3&5jZ%6+(02m5uQb#)HRVC-5X|9bD;C0+cM@2foG%2^4FDdq zsRhLar2%MESR_jk7(_i#VZ&$-iVMO(WlanIf62!P3jF_ykHG|548NclBqaqM^4I_W zZ{TAHP<#x*|KVf)4eDoP%P!q_CdocZ-DbL^jzlyODuz38|* za7Y+PAeZ4OK=nT=(E&+gUQm_@2w3)Mx)_mc;09v?ttSJ)4g_2f5y=RlA`s7HpNbIA zKoGF4g-|D9>&WR^Soq!ObUCw?u!%#lUwB?Ag^a4ZfBP}g3*zZd$YY2uU+WwQp-Dwn zv?x1BtR=0foJfbyze3ZJ3)N=5nJYv2kz{c75jq-@R?uHxf#^Lgmih`uYEHo9G!)Wj z7c(|Hk^0^~)jGE*MS|$zK58fnN|0ISZPWiJLFNJ_$k?2|_55YCJ&_Jo$1AXlSM8>9 zpgPzc*G;_{nQXvGx#WuRYI6U_9vk^DrY~i9-Eyq%-zCBL9O=eXzg!cPAVaGYe00Sg z&(RTGVAU2J;X^HW9gx_QN7geUl#KPX?~W%Ko$Y!^Fz<&7AL{lPj?tgNue+sLKZSDg zxCOsWNZZ(Dh<(Vfkpkt2vX&3|Zvh!+D*d%o3b9HiJro#&>Y4m2-F^%FQ=s2Y1BL;b zp6V~?p;P1fs!%4y3y~Bh-Ykl4GW)N6Zc8`14Yo~BF_>nQCelakd;}Zrdvqn9jA*9z z-6l>v$-m#2NSWz#R5Rug)x zmmeTx8xLsrSIle~OgsG~2-S9#mn5_&EJB4BZrgm2;^z@x~b#*sH-H_hMZJY_ph*>SVq4NsMghH&rZAWA@g< zT=uYBHVTHRN)D!qj3g4a3aG*DVrYb@VL`*aI>LAHda=Iw)^{DhUYS3Z`RtPSQhxu9 z3SqH-z;viSXC3(#k**&!La(q^h*<43*yk&7;)r$CT$wBy4z9ip=Hr$ZLDQnRQSI{R zYiX2^c^uJ}CP1(N^o?j}jdDXo4PI-OjMC96kOaw1os{m)w_QfUa^6flXETXmx?AE7 zYRlk`$qAFoN%&^mNmFPmb%{Ih`RMkX$mvy+ZQ^|j@!2ZQzM-ZyR@?|zC#`ppWwKd4 z6@olmdy4tNP2w1Nc5Y})^KkFuhbi8ctF>;KLE66Qbj-{5{2Nl7h7sh8cUdtLhYRCx zqTvUmK*_m~%>6< zrK3g1E)~bV``me8@cHrFXVc5pm$LDGNP6F;;D;kKh%l8*6>{IDXxp}>LDeyuxBlnn z%jJ$)RtqF7d&-|j<8F74ot+~x5~hsiqV~Yk$&Uqspr}srzvC(BceDD4DNJHUSTf$nB|q3r~7-D z#O4#d?UibecXe^t1Fa>SMF**!vo*}K%B1D%b$!SDkKtUW=(rU^qm(S|Bx!dI@=q&a z*n&OMG~$7EZSA@)w0}UTf7WIqMD;?oSP-)GU^IsoeI*(JeLg}#7K3K&=7;JS_5|os+4qMhRrcmrHNjwr_Z2Nal1%Q9}nV-0v}?rMe+1x|FUC%3;jRh zF`Q&#`f@+>?Wpu(+lQ#~{Cx6$@cuFsFc$|*h~DRbKsXu}w6zF|FQ@@}D?@z2o)qs- zKBm+@$`TDT1TsQ8r;FjjgQ@n0XN~od-l@G5M}PXOPlXb5LJiatofUsTh}3IrC~|dL zREyE?b(Q@xzW8czS*3)=ozUfZQW~Ktt#`$4GlFLgX^{Fy*%9h&x4;avj1Z_nq>#asF8u~4&cGa{ z*nh{z6xNvBZ*b3jS#JAvRMC=S;YGOaDMn$_6t#O4L(^{4r|-zI+!dgy#kgaVsk? z4b_3-x)rABsmkkto?bB2O&3iTg~RNsDbZ2&h{??G5S_jN66tLDgHLHLn&RgPMf(-( za&#+H#Q(Sbn2|5}>_6MC$7Y!5%VfSt$^QW{P@EJ9U5%d*TOa{;(C(L-`Gx1cicV@$ zrin1AQQVspNU3(bRA$ESSlLtG0ljv`)S8J2W^vDb$zX_QIImw+8MS4s?`G63?BZHv%^mtget?{(V_r-%5oBx>##-M- zrOsj%fZBlScrJ`(yQMz?R#cFw`^^ubegUvnX%IbSxCj`)PN{V~6U2r_5%SN1%;Q1a z9|nQSAybdE)RjPQVcWj|GJt+6Lz4}`OU4qy^QCu0+vz50BO=Wczeh7YVWE{ITkC3a zx5#SSFxq8jS%2}|Aikx^xu#UYAa{~8$1q{-=BM>0KBn>G71*Ttg#kfl6NX^QIu#$? z)TTg-I}l{dE2kW^sap%Xys8Yi6`MF_1;uYDN#a?S#U{~Ui2~jl3Rz-bMzHsU(6IM_ zACjH{bWT-H6+RUeau=L6Rl$cfcn8$Y6!0gaz%xMk7>dS7z6yGK-hhIwKPX#oy&UG? zS90s6u-^PE~0U zlq@iV@InF&t+SbwjQy8(H{8e1rwa%kejzC03?1>OJsMkuG3Y|NdO{;p*@;<3AcFrKmnos(-9#dv?^Q2TEtT>KFbx_LhZ$Bn& z$RUC1#|L?#Obk}1?=tUU<#Ioxl_uuT!avvwJfijq!wzf{s#j#xRa?AVeOJc1KXyn) zR?S6G%WR=R2T9an9C*derX#n>4k(v7f^OuzNqL!%^*HwP97OPZB?vlXBSOlM8eM3)!+)CTQM51WI`XBg z3T)Rw^8zJ=vwk=)r!Af@#5sw=f4E zwU)kOvpI~*L(9bY#>roF3`&mKKRl^Y9jc%Cxl8}$#FtJFCC5NT)O|?oOz*hotMqG< z3hky<>)Y8-v!)|nH^iq^8x5C20t|x2U_U2_wdfbuXNt|@o#g%j#{k|ymGA>6Y=D*n zqVpw4=eT-X_02^H(dP%|mz*}jBmfX+l@WQRQ=-J#f*W$%1z8FrM$f*>w(0fd_XV2o zT?c=U4bz~T5|~$DAG%+2wX!so1v0|1L=KV`1thOpunSFy~O$hGEmi_cBAiO+Nl)LckLxrc#V9klffuNMHxmZGgzuC#i`s(D$U0;=go|6LYAVkO2KU(o7y&Xr9 zQ*`%|WlnJ7H-mQ9!42Gj;ISq+ls}`~h|m~9#p9p66->LT;VBwPreLi`I7K~HD>G)H z=gr>I^dydF>0Z8+2x(~Eqh!7LLJM41in~u7_CjUgr>SL-GNg;Wf zf1shne*={)`d=x>=ic1qY#9?SS<-#PI2|nRb*PFmeVsQ$2#gj*RM8Pl8!gyHO zAm@(r1x3fKH6E!GNZ=2Ua3&D&oz(c%SmnLY$-Wrrt`yvS&7;Z1gi*ir2P6_@#}LSW z`1o#!Ecq?Db$Y3x{>nZ(lzC;rU-aG!%?6r z{IDZ==oIo#bj-(vu26F9+ar2nYB{JuLy?VIFe zfv|w0kFY?96)1$LWIF29p#W+%1VR%Nln~S`sA2=vaz{n{CJZE^5tcckXB|s~dt6Sl z=nqR}aT27aUwrTE>J(ptwrdu}>m-@yWNC9(k|u4nVa(~1`O}{j`eW`(2C7<9#}S~F7>ftqF-l*G zu#{9+@%JA!8f9@r-}iQM<~Af$(xLhsFA-A}2gX4zMw8nbsrgnZ>nPj+vlTEj5Z@ep zcb1#HHr{juB7dljeOtJ^n6V{3JH1J3GZM(@W~dgj53hgPUwrZ{2%IBcU9mTiwm9Vo z=3s38I5RB5gJ3}L(M)mO%J3|Oz5xQY5W((v>zjrac19=$Lu)S^5~2{jNB%ZvDKnCt0baPYpJ5- zw8N!Qj_Yx1_|F+=;>s8*oG?=qpqmXQ)3YC+sdsfwq-cC)nS5_`i-^I2&LJNRSLoo{ zbiFxB-=kcTd4V_BlHnXkqtPQXDSSg9e7psOK*rYpm5&jn^Okw}@WUE$#raGG?7)1U zxfN3E1hw@(z1(7Mz!On+&mD-a!mu8AV;E;-!wmSCbtv0HH@Q%X$~r+r(iod-9sO{P*Um8?ARAI}vvYb8J0BBJqA;Du zZbz&TaVYC@S~oR+6nEj7fZ7+=M&s%GdD}c@;Sf{tAmRz7)>QguJ_g6}Z$9QTK#AOi zCZ>88!Mrat4gK5y!pEpBho=0%4%Zzh_O5s>n>i^fS|T)WsRM0YIxf9mHuBbq&)uL1 z`+m)rh#i(%Pr8YE4h@Hb82FyZm z?-w%pS~b}d)IGoz@TZbdFoqCJBUBDB@YFY_Pg$~jS~Yb3lT6Rp2mPw4odJFLraz*} zyGV{RYnQsZmgurEnxN+T@K|Q-OCt^j)YzD#DP*-t)mx~yr>_#0l#92wyklRqY1Mau79?#sONGOhI3Ov>PO3Hq>Ju9xOo`3EG5Q_Nrs=rPS4 zikrGRWbg4yRnuN+R@st*f|zA5`HxL?Hk?~TW>$%`UXvBh8ffIAOH+3$g!?E=bw6v&!2h>O;~$ z-~d(h76UET2DiZAJ!U8b^j(2sj3vwOFN+OCf0#L%37DDbbC#UiN|Enu=r_U>E2^*N z$X?sfra#I*am~CI?q{|2dLdUNQL6VT$mUAs)v6|T25H*YtE0T$mQ9m56aaEdm`*6f zw*)&Ah#;7fls*6pAIEt8d*0IT!DJvrh3hU_!^zgHd}I5{SsL?*1*2}Qnaro0?4z=j ztbv*m85mftX4J$jPr#|!HpQb&-xO04?1t?oHKsF=MBYBrT4o-u+-Udjc1#GiGPdBC zH>cm;2yThAn4SC(F^-!h*lkTJ+Q8lkBHKu~z{b{~3CoU;$g=3?3goBzxiqO5YwqIq zXz8{m!eeL6s|`dt=yAk-xq2)(R9``PRSYA+Vf!BSHbG9RX+XX-JAQK8a_%V z{T3eb7S6w^p#-@X>%N9i;v+#7LZFS{-EBnG&_LY-b>n~{givP{kj+4?Z9t_0I$AL} z!_XHZ9B#Ca9DEfgOvppO;(Ae&1zR+4D+!>kna!xv`XzJ4=_iN^c z$N4?Dj8Zb{M_2M+*1oJ#H$=`kP>au2-;dtAF*qpPwRNss@?_aiQRi6k(caHjDmbt& z+me??67Ot%#QhllosZElrh2*Zso1D6X?d>v(}AW#zdY^JP$w5NWA2JGcs1Ogn-8HB z4Wcz^eH37U#)R4fRRk1zbkQ`QPFAqp0$)!i7Bj`40Xj#F%uvbA3F*G0;~(ag(}|_L zyzD@$34XX~<}|%N=2@MUpgi^iY??O_*}|rTwyYOLv@w4`7=d?I-Ia-YXn;S7?gsBE zG|<6LiUFq-K1vIqguW13{0L?!IF5=4+fR!Lr$2V8tp;mPJNL?ouaM={}PBZ z?^j8jy)pg%Sj9XBKan$uCXNnlQ}}Qoct7Gb8grq@WjT9zFI}$OES&@O=)iuCfES6-AV)bUIZY`g32z z1{r1MW`Z3R*ciEYk;i)gB z^rW<^qElx>Sq4hX=(po0z;|hv89OM_fDa2)+7Z-k(jb(YAPxz!nBZSd`lyK?{dlD@ z-YOkWl!7kd#LBGZlj-PsKh2Qe=P0k0 zCUNK_N1t2Bsf(5>0e||(=Js^y2jF^rwgqqCGo~5p)5xohm`vY=^QMlchxxvwnRGA80;+B8-roMI7Z#U)z%8fY> zN@r%lAv$n)NXZ*+*BpP~SwZ_cpISbc;<6QFn2g2tN0c?#tru@we8zosi^!Ar&x5QMXmp$vS5+0=dD8=8s6=YIw$o6(xI27E_*&^+CLTY$hYqhCsY+ z-TLLbK2TPyNFE-O?8O+CEzmj5P6;mCmJL(LI5%LgV0*zOy|;{Zf(#xL+EM^35qpRp zP{N>ka{2O>CDn6X#WK}A`^E|CvcCfi;+Il?xfW&)c1Rv}* z@h?8goFovPBzR;G$~8cqr_>zRV&(^GlCmSXR6sS8!BLMF>OMf;5dh@i&%@0Bs0t4) zu%o%i`2#Y>pVzk!78vD%l3stl{s;cTW<(@I$~-Jrk<(e83Ncc`%w=TI6b zGS8|Z?OV97p69R2(Z~5;=`r!L0=O%Y-;xt>4hp147Y@+d?9|n=mEnkUA>vGnR+s~= zSKQ^h&b-4i3pjDJ8TR4WsSYQYUQk2+&4gZciYQ%vjaxQ(0l*0V+=zy|noTSq_Y%RIe6O5{sjR*RH? zV40paX0>gX$i*_9D3)Bic>ns#w_D=V6HA|nUp{+($vgR0N%Tm)m#g(AWj`%OgdPks zU!Wq(uczEmHs*-ja7z3oD4Tb)83i5&hGy3Sa(er`lW?EHih#u!Cc zu*}2CX4{Ql6%rD6wd9%CHGM_7avz?omU&vMuXpD&VVSd~4+RDCt@F-G!Pxl7ywX@| zMLs8IzuEjR&t8%93dX0zv}`noG`E7kDh82Nat;QGIhSPX>|b*K0nrf*-5$>W12Uet z%*m+ik&Dyl;QUhA+Vo{}luPjm%iHCF*`4#iw{>Svi_z+@_G-@8-n4X`dyGiGEJL0$ zBuNc%c5@z)or8S&$<{=GNpy{>DF$zv^p67~7x-&_Z~ne6G1}9y&Mo%ChF<)Xl)wC# zggNW~{$>J432zxS8>23)K_~V&{g`vJf^@00vJ85F# z&pQ;bpX_2s31i^3-_n!y@2vw{kq|Dpf@1-TL}9ob10`4t@{_=KGK9Svu2#yQZ$myM zAS^X%oHW(&5gKpk;M7E4sh{i2;u!6DWZ$mAnC)HbICGtOQPWSn$aG(rq6`sVWy1$L zH^B-EF6Sapc{x6O086N&*4ulrmg2w*1DhFeF2aIlp?0-#6S_De^=8a`stwA$@n!VS zX5V@sI-gC5z#mUqrw%8Cv;mGR0>zYmq$J5vjgo{yLK6~AeXeF)jk~v z(&v$u$hM7O$|&bdJBh)*@S=*|Ag`{|huFRiTNiRo8RvRJNVim9qzawYvpO8w;p$&i zCZRh8jq#t%V2j-YqSUX)4+BUi-q1{%v`?&%np@Nj-i@|!*eZ)6`@Hc$V&cd0z4@NI z)+(gEjG16#1LspckGqDdyB4;3Ebd+MI$dil-jmP36Z}e*x2flwYH--Z>5GP`@WMTJ z#f@mm^K9B~dbe8W4~U@3EPZ>JK(Tp6%MY(cjMNq@H=2%gnQZ!4cL?cq4=`YY)jcXmRdGPdjp8fj4ZI5VD<4 z=*jD%Uksa!k6(WcE@F{jzgWLM8w5)&-oY5Isphradn;uVp0(>+guWFC4R_~G#xTt} zEG)F|?6HhX{lky(gQJb%5z#b7E{)LE`}(lh+h=%~ygreN3g)^NZf%pjQ*lz6yPVL= zlt!KvgXTFq_oT9!_h-hDsp_1$BPb;^5nUnD_wwPl=LJlJO7B^Pp>;C_MHqgQOp6sg zyRkhLLUhu9m;S*{!ct&^AXFfff-9vVSGF6I=xLmx=EnuaszF`(xSYHS)&r(1_)nid zAXW_~xmq9I_tlfHq#*aVO~l_kt~5uZBwlAzlYaV0*`rdY)F_YfD|Na0k2I?4w(9#) zTc(#vk_;Ft<_jSxKn62Jis;n^t=Y%&$GW@lmp*ThK1BPk_FpZ@=sWpmnHQ+41JS}- zPY!je0DDOJ3H^_nsIr>8zWxLd{*(YU2wlwi4&B0X0~8e$(2s1RV7vKGehk=83Z-=c z?34*@6hdDZ4qJFa|zm^YKH_T%0(8e-wGjb|0cOgGk?3i^tD)V&>B zPhVY{b3d!v%xR;B*&wyXJZai^+XPWaYSym!=5cj%#SY8i9!^1N3#m8*JYK!bBV5(l z%bpL!cZGVvWC`#oHp-6%c9pNKBOi@pl>goipf&~hSoxr}obtaFWSX=sIWep$c$iY= z%!T3@ap-k82>E|`?;@MD-^sPaGcZxN{wc^@Rc423;u{xiPx+5yPR>__QOHqu|2rYG z>7zm@0Hz|0{hImm!qqO|(KjAFmli9_Ad$qMI`V<-A1&L4RcuHYs-y4a-`^cyLI;C& z3xuBg*d39m&ROy+cN;Wzc#lCIla1dusgz(W6%WI`B2cUkZL`Hn?&Tz&#h0`B6&kC` zW7V7&?7*7scpcOukdMZY<3PT#Nb{+-sOJH*Gt!wo$dpgJN1zr*FMNF3y2@)oXZXm@ zbHgq;Gx5~MB1l+8K5a{yU!#10^yzDVYpD+7bE1J-jvsmt)&rPGmBX~|xi++}JM5?4 zPYk!8`r)`jD*#HzIp6055K8 zr-_0LJHpGLUiZO$B`Z6rm33nCfeD$9$~uSnM0bD9Rs@zAG&d*@FNA@uuj5kc<+Drd z%3nU8AA8MNVXSWjmC-J9-hG&R-?J}6S^I$@soIIP$#rQ55s_n+U#(g7BSWrtaB5>b z!O4B(clC_+*uX^H=9tI31)=`(p)&OU@MA>kkw>Ce9BlJOZcHnlsR_QwBcY9{*Ayif z5+Ay+K2DV5T2Fop7vQHAd?E9Lbo)dmOPERBwml#o>G+WQ&Sb%#e9TgIMM>iW2p*HG z_6^zucNo)eJ|+QjsdlY+4PCG;&gds_9SgV_y_?BZLz@;6cesLqEGNI*Z+ud&`|=9?1KFzxr28BA0!MJH8a^*6J|Gee=fpipg1l9C1^!kS6-;!@;L zH?8_2cD*~j|=opV)s*9=i?C`Q;Kjt)#z%9-yHvM1E zZPFD&6V@7esxfctRmBbddL^&TF(YPl!nA)uzW21Yi)i&eW_nCvkJ7@w>M=iza;x7O z2Q>|NB{ivZ*kB)5P!{+@HmZ3r>k0j;l-ow7H+tFheQP7B#KSlpJF*(-ODU5kajS@l znJ90Rp!}GQ!6(b5+GzvNxe>vip&>zzqeAx_M|GcpuPzI8kdUqB(5C0xXy z-ja}>SmPwmrcS?xko8D`1%B**FkQ>VP0iStYpRVK%avtw^q952+NV50g>GrWkub&k zFyD_qrBW4i*Zs0)Z47uGmMO!fv4_KYBwD>P#j1Fi^;~7c*+Fyq5kdPx+hLAF#zrvh zL7|B3huI!BZ`WILH!zD$Gj#Gd&yyciIuAZ&UcLzev&?(8!;AfO5EaR|TRJ$M6qnmm z)!ydRYMyYnlv@G@MQD3vi0RM8@(o;k;HqN9J`!A4%J+y=HSB3fPzO@?GMhkcr3d+ZT@j$CT)0qB1Rub294h z8p{1zczTG|8=a_c{SS!Ue-FrX`mH!z;*4Aots7&FkUXgVEg=ih!Cd zV2RXIMM}nz{-PZ&OMhoYPSq?1N)rlahVBF0OAstVWvSP*w3=&(I*CO;c3eMP$;;8u z6gQbJBixKFRqL#-xJo`dt06`-_Y?5@SZX+jxO!J<%=U&(snE`^ql)ur^nP|f&k~_k5)|sFh(zkbJIa}K z?_j9H1+`eRxD!ed@>8FuNZrqhglTpr5^jh|PTsows5Qza1VNQJ(jdPJsBNg)oKVE1 zNfMzLcwsGWO7(;B`qJc)Cax>byOCQi#W{ZpGFd1=X6Q8YNo}Ps%uR5i#AfJQIcvvJ zu|)lRJ6kJn^gv7+<8ISm1uQ1Td;dyQMZ^lLKXrYz#VOc z=+v;o-n_Cd()e6&HnV7sOgL^Z9Mx5uXkFXe^XfM>sXoD&`k^aj>!>(=({L0;Q}A>b^jbp+XdR2Zk6C@Dsj!M@*0B zSNq^({t46c=BHvYmdbYfR2!9B-%gVTmFSG6TWkDoi_tQ+$U9La%Pzr0?y+$gW-HMz zeQeJp9AGbvM-eilda3GKLgN@Z!gukKCdqX%3f{IhW9c0}s}uo~vtNPnkQ&w@s{4;* z*f1L^PN?=CcFmCD#YYP5;M6(hk-Fr^-E80~r0qj81$T9!_JzFP@9(89*D#vIYyMY) zjEbB4{lxj&$M2w{e#~R(T(e6HHepNuwICw|1R2Dk#s;lE!i(vKivsqeCjem7cOrjk zMMY_coNr=Rs#K1%{@hCSeHC)FiCj1MrDD`PdvZgQe}tliu3-=&DuxVp>Ipg;FRs=% zw|Pvtklw3!ui>0{=B@tl!j2E0InkLNWyN{u*tW&HqGB$@=i;qSrXz-7sY9_#mBZ88 z{z9mD{PV>?)~~kxf^Afg`&MK^MoE~7DQJ!g}G92-kG~6E%HBdlh0WSk_u`P zSFGomKHWZHJ)eHW-EGL$6NjJrD>d~T5w#og9P_($lQgG{UXovB)&>Xmv5Fv1OqB&r zhWQYyFHi3BC&r!>5eORVRr+OOC-joSR(h7;^wY!Bd)yw;74bhfiBDrzz7a|x+8w5t z8{*EmB!{A7`FiQW6_-qU>Fy!Jc>jLX+hQlnz>ISin@fD3mKA4q{TvY`b{11k-9dh1 zQ+^|s@4}hcoT+N+Q^WSwpve>I8JOVD|~m!F1>#5)AT+5FpB+B%0qvDtKG|4K(oC;W&yx zc5TNvQ-pWR(qIU*x!OK*Vio2z7cugA8y_(3=Z&?ishqHn!;+tI zaLdiFdY~gkJMI1Li-DIBpCo5>c+snpa~t>hmDy|;tG9JUt32>(mkTlyq^)fv4Ozas zt;MWiS4em!n`V;=qQl!Nm(6_rA!%T&h1S#rJY?r13jRk^1&E9qfTSrw92*Q)3As^Y z--660W%-tzJY}p?<7=+_dR*`1d9jTvMomdE({2P>TjqH^Yu@^}kqkjUOZ_sp_y-Fi z=X*bK+F!j7=Ig9^Lc>YxIz^BqiA$j~9V#^9;|_J5e`1Ngc}#A|L3OOrltAu4{T=gn@$0I#SVqIh&b6)&0-m#fBQieXRZBr8 zl|FlgH0Enk@PUnI_Fg{XJCBOS5>G?wxriilPQQgER#I_ryB1?YKI<15SXiBx_UyR6D;8DG$usr7Dx5zd_Qkcy!z zyVtEx)k?$}C&&C!aig~na1Mbz&62O?v#GRi6PEmua<2nLRp z@ZqfGycU5#&c(95zW2El#m^yBu^+=O(FORN`IW&0#hp{?gkG%!?IK%9^Sfl@i?}De zE6xRt2YnM(F1sW;Kfg)ntT?|n`Xq*3BlpzML)-br_;czpo5no1XIZh@hMOq+tw9Tu*SHWh*TG>`=x)L)1UYLrG#PpTHG2UQknkBaq! zoE7**eSPvty}Al`9ETaDEk4H`GeEc4RnKz^Q~{X6)UG@Wj4t{&MMg<+M3#(#<4!_t zqgU^pCLt`k308S0rH+>Yh|KO?wUq!^Oj5V^jkj8=EvYS@su`blVav@l>4jQJE6VYl zCoI*-8&XatOBhYs6|wXD{2q)Z2ignzyG3L$rst0_-E|lcJp~!DhlcnZBjE3#z)kaD z)^uCzNa0Dv12zY$r|}z3In77%bMxk`ah{0bwbjs}8jP)sp*blx%HuA3Q%lIDG`BIz)mP?z!~XT_pB)>;zF!2%ZC8*f(vO~OCJuCv@9AH_ z+TO(?2j0Z?l8Ys8D&I8|<7&d}i?pfSa)n%;*ac)nk|H=}*C5?2ukF zgL7r>6Q0ieSE?m!mdRmHAo#_z(TQm-M4G=q7e>#*PY{+) zci|s3j!)p9uTg>7j}X_G#d+!~bG*0Ce4fl^i2>;i4uiVutg(zxH4ZxB*W{<*QkjeRaZmInia47!?G z!-!{PW=#E@-DfKZCM>@7njMmJFDcS&bT~pp(eg2p(Z-o_Ec|`_V_8tE=U0h3fYH1b5TeWN|9*;(^H>;OJYG#;1Oy} ztHw|*|Kwx_ha#v4DFk&Jj2hS@ov76H*@e7GRgAOPqr@#rJfWKQnKo4e;;t_=lEh*k zz#I>qex$3~RO&JhmaMGha9>p}#paTdRa+_~8(H>i)_-r(s?%$3Rvn7J8S4v0)2?K4 zVdvKtnv|uFN{Tfzm?%k;1>BNB6JKo9a}{cyESmsaSKeXPy%8LaAfN_M!Owk?xX6c? z3gKvHqTd3qJBPDM=a81MAD+Bd;ZXrOI>+0mwaz9Rtyha5-xabX38OQo2<5WGD&Z&a z$<-PgOfpZdtn=QaP0`s1+^$s6Zzzt|n>IDEtf0US5haxs9(VG`lxO75Jz(*DR_-_^anM?Uqf*t-)#y=(`yfv1C^}v4wFIuP zmbP|=`wrwuPt^Ct7a03w%lfLbq(g#-6Z|$Otiqe`ByXQTdG^DMd*oh)ki2Fa-40T2 z+zs1(=FJD<46;v=2|Jnr`$mCSQc8jnIf!KES{*(5E`vzAGQ7}8Fl9Ks3l26V$AJIB z`k~jlRwl_U?^olZM#ByV$D2PL8FeKQs3+tZ^z+;gZ3YCaDu+w@Z%1YuI5KH}J2Js> zw&zCHNFO=6Ssos>VvE~lbx27UffRO>BXg!r;Cm8P_5|abowbzUgY5i8p$y!y)97{K zZjX84=M#5IZ;uKgFxA@Qr*Auc)>LdO6285qdyEDuoys&hlwg4YS{b-azXXQNC=Wd3 zoV~FPT=mC+;W7O}vEIzLZ^nba-JgyO{68ES+%m*tqeypt5Mb>C!e<(u_1#zk(R~yn zlQuajo|0*zsjCDPR#SYehX|mI`!`00;J(UEFkZ^q0B8P9I}0!GEtx(e1*)IDB=LhX ztA)pxDo*`PjQ5|LR~XmG$G_&n(8_qoBcSp{l6NMF2zJEEltdye2x3QL zO}M=_{|w1o{xc*)^#(vPph9?3EGV$u>+=fVeSws!q~U&M9kNsgOmd0$EBLaYC62tS z1?6lV#zq6!Kd<`sbhw3}Lj?I{=lNQ)&bb8X^atGYlWQ-u_5Rmp{vxC~QEcz5b>)kg!i~2ZsgHOHf1Ap$bgHnmCL+{GN%dhM! zG=+`!P02*>JL8RfJ^b}mjoCJaA{6)D80*-6xj!I5gYPvsJ{ODt?UToo#cfPrXk>4D7_uiTjTJ{C9?NUoOmU)OneWl{ecVi)OO-Hzd zIA10AQtRE3_g#%!zEOgVM4pq!wcVtOq7=e%SJd%cMU57Nm- zFf}kMU`1w029I!11KG`FK_UcC)B2;DHdK)uDNH8yLF>08(`fi#IWmluksiKPp1EOF z33nNr+^w1YOeSv1JF61qhIrer7?*51ubMNQ;~l+(3J+9<5aO`BW%;@)%LyeB!m!oV zl6@8Hvonbf27&=nOcMt*?7SZ*;5P|^Z%-70A)J^pLX9BqPFON3q`wBljts!ZXev~A zf{_(vR)nL>U_b?kh}owAALb)MK~q7CSv-V2WS(D}QymK;0==}i9azwxKjX?wp3m5gT}D}hxZcAH{u=lnknh6}^TAN!@tgCK z!z>Y<9NGitBDC+rf5BoDsNyNENn-~l#aS=5|9}+WeQpDPXq$A=nL$YD4W;4N<|J{@ zBCagy*zV{{S;m86^`~DCHhHf+T~}eUaO3}ry}JOadVBZBzv)gl3W(CNDM{%T*fg7# zP+B%9Eec3WZRr+~?nYWgy1QEm0V$CVk@H*IbG~)&_ul!>+?oGx<~Mgd9+7e8IETgh ztk1Ka_v@itXt!sLcSnugFuxl*r+ReMPu5qnw|Bi?^?f6^jN7$qJ0OWYO%8K=OM zjWb!$IiY|ku-WF6apW^Djkgv~mq}`)8(4+FF!P-;)D!U(e_G=wckBTzBd7w0g?S)3 zZh?s@V4ntUSc70L1F#mbc`#9kDKMlPjIHJ;(gSF^eb_iTc&iA>kE%g?GPp4#+TgD^ z9?4&9)H7QwfE)yx7vg`$$%u-I{=dh`i2M^L13v!0z{yacIT?!o7V24%8zL$E1k#xOuOSr(*`JE1iQI8GjD)&avg+g_(4;CO`E$i}=( zJVIkTp~g%+o%J`V#t>&PvK>b}CRAq}sVoH9@fQKz?hOqG?Z&?b% z#^Cdo5!UhQ@50w(J`d%YbulUYwl_2&S{C+CMqdw|=ZeMt(#kw3Y0=d-*9~tOrjN9H z`CF7}=^pr;5G#)r+KS^!r7|S7xrKsQ^h<*hbZ!QZ!8FHie(g5Ko|yjpxTT` zMOGdN6*M|Pi>*Y{S63zAwVNl4&UtLQ;{E!4I#En#y^ATImR{jl_dE^Y$yER8$sl@H zh_LCm)Jb!g)p7E7z#lA1l*OLF+r^I4;Vn>qrKqcu!sGSkM)j~{W ztF0K%(a9eW(SpK5qkx#g-=fTlEUtWo;w_ckUw&_;`sA1#&jssva;Xs0^P5w_=U?oW z4TGY($H6gfI=^dcc&%eQY&y;LMik!hWfQWH)$^rGXcT^kS~~H|i2d#9ii)w{qzs|b z&HR6mGAPa+g=gp{{lEY)k`%P@{cbY{FasK(8_`_r>@B|5-e8VeOu99xqxRyY5G&zR z)ol|xYm7lRq=TE#SJ|=}I^xp`rMO{d22{NIXtoHGe2~5S?PJxJuII|_-CA`wL{v%I zqkn$&GE~=~<7ml`*I*zAb352#CLtklfk+Yvdb*W@EWuO+U?U*eK*nkdAk(bv*-jSt zmii@w{-UUnYwrEmZPVeXB{B=ThBZxCo)1QWJ?fwT6TN8=M;4qq{ zX{Y?>1)SNs4PFzERVJzOsS2MQb9u)QHk8nij7*y1hvoADAxU){D-rW8N*1;RJS0HN zWDxK$Zo8c7TJezfQg7k4*`|;?}Z|C243N&;OA(^R`NPG3BV_xPHelnF6e97J!b-IkRat|%&; z{UQ56Wyvq&@N>cKoF66dBvE2QF?K8l(yN+G9n>fTdELTaqRf0of23Q}@Xao<)*Z3! zU0pn!s!=b;A+eLfEZlX7jUst3?FzTAr*uk7cYTvS%9uFrPE9a{gu=~;757i~C-Bg#G8JO10D4{Vk-55soea!eh#0MWzh7Obw2Bo~S&xbW91f15h z`KX@sw-$N_j2dr|HndY1pUmy-uWs~|%YR-yGYK-xi60rGm~CHS^8HbwR&j690i`md zSK^Eecwh4aR-#i_?%@uhVt`_3Vdg>zy-;XV21B+W0N`4LvZA^Zy0Qf*Y*7RVSD*tb z=y{MFkW5Naxz-b~g^^m&HE-X*``HhAHY>E7sxcb*wztHi3;#4_XcaguTYlOe3<-T;^KrKsRH^6x4Ij%FwE)PM%<~`Je0eLM2b3vWrr&Z7%1-^_UM9F zMlbzr6-$LUmF)|%{i05OT=C<4IB`M$aF4RguHSL1Mq7d%Qkc%bM;;}CQ@h69~8(FR$%yx*ZZ^l#8VL0|w0Y=?+3fSBuQ zuw6KaKp_z=6k3C}3yUvfJ@b_z>EBcfpYk!w5k10iw@227{q#b!mZl^d3@&x(} zG%*J#iV|XD=E@BUD@u~rKu*lVO+&+n-`*s%-{fWMVK5XaB^j+eF1{z?;R18?fpyK# z)lQAyDjG&64sj~G;p`mFrBK>w(V^xX{Om32L4e-XbafAt)BUwcLkWp(shDxZW_vx< zh&0jOrVwBt>yP9(3If!PpBG^)>H6nFmt{#Y(_z_Z92hHLouZWA_^PiXuwhq8#_+KwtIGsBr z#m~xIY(x*m;6x}Hh&W20b;zD*JuKQj*FTVlS&itmFdu7^6-Ve=hZQHA-fAKhr#G%) zEc)46WAnV%N#V-#vbaMPOoY+q%bCj#VJIZG4mb_}Zg04tH1YUWJNtN6_xW)#dAieU z=6k3wuy%fGyczoLqYJ&QEZ>@*s+!oKUYc9K{7fejbG3=oRMeB6FArV*h-8EG&NU1l zQWcZ?WK+1;<;!gd(A*{cAcrNrkZP1oQONS$*Y%5T{Mn;(Le$iC;%Ulq))UAvfokN-{0O~>-haF1F!F3$$;b~+wc5{I>?9-qYdVvtr$j``Tbf2pbwxjC_vZp8UMW%aYZ8I6-CcIlCJ9vEMS@2|eysNWhn>%; zIopMusEVIqQr1}JajDd$gjFeVYScW*#NS8%YUxi?mq17}iN{vl@0H_kM`4qTFFuKM z^xxCVZ#pe*Gl+eM%w}b}`qh#;(DYDTBfCuKgDzItU239RgX%B)NE`zGJtfm^zi1b* ztN(dA9R{@tlPdBZ-ZdPc-jxlBz2~4)CJ#s=2@zHR$?$98<`Q5O1R-qm>mr8vw3g}W z$70A@O&zfx3c0>1QyHtVuz0F#r!OjM@s-QMTE?wWcD@%jejh)g6rC4I zB*GGCjm<&U4r4+GOHGzoC}CAyQU`b`0OkVWdr;+IC2b|I+J)mkGB+XqS4&25M~1Ul zZ&-Z(>Bfuh3xgz4I$!7rX>``J)$O@_xh5q!)01zC3N_Wm5zC%3#+CTgx3=YtN_zqf zL4XAB{1O=vC90!jBH@3!c^Ntu<7hiWK>5gp;Sm;d^x>wO-SQ7+1EU=3^Y`)KSa{_= zm1mSlVHv@(tY@yfbw^0@%1GDWC3c*_FPh%?4b?l=n1vypCv%p95fCXA28{Dq&G7z@ z1$Gz@E$clX#2LfPKl0UR8eT&)7CTyJtoHcUl$V^pBsXapfK4S6JaW`W$6`Q`4Wu1l zVSf??ao_-nKGB0M1`QJYIXnF~?({n^1u?dwb;+m2=8vY*^bPqBlFZaUOEOa$qgA&j zn`~AT&96ToadRasK7SzBiD5NrF|oTwCtB3mp`3#{gg6 zjvk8N*(08CQua>-#2)F zR%y9SKlnf*>V));%5 zCyWVS%crgmob6?iEh9`pv%@zy?v?De7A7G+IM^6H#WqJx=J!I~h~WXDk57u#pK;(q zcgDYt&$wY{{J8N4gtmN;h5ee6E_yVuL;wIAsM>YX?J&g!;qAC@(sZc*>hN3EHdAM& zx!TjJ0QJa%Aq?rn)Y%V>M$In|x__nr0eQ=YQzlLsOOy8EknR_r)BdIX0i{VmcahNK z>imfA3U(Zya=BlkvAlH^+vxGvR4jhWc`{SZjU@WhMoxw@!k(m2V@F07BQn#(y=uvl2F7^L|KcJ~nY0XE!s3+J*WfADF_4A*LIHZ$id z30`i)vm$Ec3tjmz+BS-Ag9q_J_PPrtXHYn+g!VIOa8EAfqV}7Sk^SJ`hBfXYN^{an zb*mKV%=ppz$>)dPPmlE(o}O->uhd374v+u9UC5}UCG|~`&=>}$TY}s%!SC2#O+w9| zxn$tCp>suOFA4p``759N*NPYFDxgJXg;}{5f9Gs1Ofyg?-VQrV9Z2Q2t!Gt76&jxd8Z{ObE=QNuctC5S~WadCZ7t!TX?PP^DQJ}3|4#|C|(y(R4dx?zxQwY9}ScLFr3za z@BHp6^E%*=O77>|zF{8>>)t;;0V{U6$$BQV^U(I;-mfpGe#63S7`YcNpihjNVRnGq z>V*E;K>@*AnW^sX)A-o)r&sPUQJ2w7Ho)N-0rZt?|G9%?GXev9HIF4(n6q$xK8C?b+po{UdmZ)t=lTrq>A*8^s@t(3$+;&gvP=%4{7$>^(O2UlKB#f4W0sLm zcEEGOoN-_;+zV$n^NK>+Qxy=&XmSAp^Kv`{U2-X8l(C=FtGy<@)JJfZwkD>G3+Yhl zVjpOqT{EOU#+qy-=j$ns+c$_v51~v&TM6q8_diRBmYIp);r-(BbIk1wgo&h&Ut|0F zv~|xMd{JGXqBNJ(Q9QgJKOKBQcGY~857%5Qb{=phD`;NRiGV<;A?AcV3e}p>R=^i7 zqbEzqhJt!vUZ!kMIbR?CX?}35^y+u|MPI)M%|jVV_Wp9BbZ|m?-SNXBjC|b}mCakd zS{CC61qozSWd0?n9nT>} zfJc`AJ13QCTHYXe9e{3oejUATs99I zSHWdCoy#ms?{@Wbwh|3&5YZ+%WzyuE$TAXt^*zviudwBJu?VZ)Z49Q+G*H&+z-ox@ zz?7k_GG>wofhtTCtQ3wNdR`%AX`tMvCzdQ4+B@aIfHdiqa16=a$JuEzv|#+0I> zEp4Phq>I>+`|X;tXVz>x<^4FwUhRy6N)b3*ENi^$#s>Wy7xCD$?mB0s4oH(PZTt1S~$9Z(M@?p1i$eZ z(EDDcYEL7e1tm^?E<_u`r)>O2h#*o33#F@(kG-!JMIx{(ZMKaFLcOIH*mXQ&w-9!F zjhie?EwqL+i}0#yDHkjpA7|=#!oPJ%2BNp13dK2sn-zJ_uCNJeuKW6gQM7kQO2dlX zgMaf-b$HenalH3S#yDA+mbZf_ZMEoFYEV=UAV+N}z|CZV*0dmJbK#A7gCat<;0E}2 zyIl(DAk9(!dTyNpYfH!jeDjH@ve;T;ZC73 zP5O;J!wX`BnQ8G9R?;Nn#OE2~hn=*twh!;FUuS*5=PF0tm;UzoQPFAHdLa#*Z;rh` zDtI2umj9EFaZVC2$*6AJg4*2NYF0M-UHtwQg#r3Xrx?#cMSh>bIXR-f)WEiLL zbxB<4pfEacw_qWXKI58sW!%{2t3lcia;LBFIyrHKf4MTI8p5OLZWgwg)w2l@#a?>a zQpm&eV9^>$|01r@qosuoBVZTgr$=6kGelA;D}MJHF9L`T`jtgj1#MLP`9cc@1O2&R zF#i-|P+%Z6`t*qo^#V2o~aO1kDu2%LPehIs=?$EQNosnEv z9;o4Xa7#aNxw8^0{p&P+SxOLY-#kG|IOnC(xm$gyuSQO0v>qu8TCx?N(g*|A19^rqc zHO+b zbA!PMU;TGcyNC2$4Y@l}ao>auoK-LSO*_ zgpv@Uj+;h;d7DH+s8vmt6clt{=MDZIQkDvi<{c|FrP;I8>=h5Ys+Y6`%q2)2EDY@f z%%D~i=>L1)AbGjPb4gjaiTS2mH`vMNI>wqCutaZf6s*FD%^Xa1%UW}c`Ti{*lkPC= zd$ZN0@bjX>X~VzqV^pMYL~arYG@|r2!}02IQ(1K8-9nBeY3nirh94Z$y>OD~;KS(5BD&-_?* zZ1k5MeEsto?a&{9kN$ke{j*lagWgzvt1=KB@KQ_!x621}$O||I1roFgQoSB??ly+F z7;90At|?j>n^FHeKE@K`CAkt9OSEwj+%(uU;5aM4BN+A8VT!lIEvcuc9M5n}n5+4k zQdq{fu-V1k2T`PDO;es{#m`>!?6@EJwqN&q&*^H(cvKBURBDM#_VV z%ZEp?N6Hjk#p$E>H70|#MFuc zbKeRP$FNP0zVw2X%pUDnniI3whud$Kw&^Q>>oH+9SWt4}O0`g5CGx1homIOhJR~Z$3H77cLNFl{#Plg?K-s{jF?z}gsXT8{I zTljkO{(sVAk}HI>^2!@H&3Hxa@z3O%z5m)#-dnNN?BeWf3a2^`_H#>&BXV5XR_i;MSz0pueg(aD`52v~= zgCSg>#dYVSqtq`M)wp=9(6egrnrV4<*TnA24e{8Y;usU``x*VwXH;&@#5LCMl6YEb zsya7%yXD&nrJDQ*TlcT>*ioNYNTe5oOpwiurUxIAli9Cux-YQ}^Ujzx)THiur%|2? zxDL1)akTtW^VP5MnYn9y96Lt5^r(#g4Qa8&flshxB_z)IMp5BPKZh%yNsJVZr9B6R z+g7)Xv9X2hVQJ`Fl*i9&Uk>t>xl|-`&~yy^itTI0=EVp@%vw>xM1oop{WBNH#cK0L z>Qly7;0O&!81!pl<_IIG#aR-<>@qRYT15iTD$tw0++R;I;8B19uHXwyiDrsqUkU+P zCK&G)Ozqm#8@L9BvlZexnDr&0Lwl2XrD%{HH#c@GZLjkYn|-YDqke;5{+;ipMKvGp zm&XlXNloQTM8?xv6wq3fMYP3%DhvcEC;V^!mxaw^%T3F#V8^A%EioyH zsj`Y_ccSWpE%G}xk=3C0yi?7PDpi<4-mwk|AY^d;Bxr$VC#4Ygz(bO-9E@r-i*`X1pY^G&8RMPK_Z1 zY78gG^o#Rd>R%e_4oytdW6lDakYa<4U6C^0H7% zLt0imwm>;1rp!lDw=>%29Me~20_+3c(=d)#-@~E1k4f8^MNi9o(h|fjXuRQlFWzck z+<(A1HQk<0(>$qib1Cdx_B7^4(A5R=k1}nC|6Ywjk__eWa}Uici0Ce)p1!*IuzK_4 zq5N|UnpOdF|5s1$7gvuTs+XO4=B$iWKSIhS6!N)_#^Za;li>Tk zjLuTxv?}^JwMIdJ1tz*f&7LGPOdqV~bd$4MY;9=A@0n!mc|79Aq4j5RoR*7pts{DE zWK&QfDoc;kpGpaGE0~8(mciQk2wIDS-sS^|3K+2K2Y% z?|WbL!3+h^2XKxAj|ON0Edlu$EeQagz$-H5IQ3Y(x|M7fe<6!~?7vlGh8%L=iPcjL z(tZ=_UW8;zynGdsBXo$UMT1Vi5A(t3f$Q=}e=vuQ_BR?MBBYBkn+rC5aG3+d7(%pR zhECRE4DJ{Nt5b5vRxZJMm@lSbC1*a&9!ZOLVUmS(JkqeWTAvZ=fwaUf{ZBa;5%gZu zHIq-i8}>I~-jICJ{naV!DwICbdXU@Y;iI@YeZs+;wzVO z{i+%3lf7uK&Es83lX-Zbw82e7DFCsEm0L>O3?ee^SikEbsylACXkA}+;<^kA~r}9H$3Vkk70%0 z9WB)#-WrzMYB3s5T-%-ha%0N(9 z_|a5ye#d!sΞ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ƶ+ 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 bd=iEAMb$dM-{?iq^vpHT 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 diff --git a/tests/extras/datasets/video/data/video.mkv b/tests/extras/datasets/video/data/video.mkv deleted file mode 100644 index 2710c022ff5437adbc8633ac7a2250d7e20be853..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 55145 zcmZ6yW0Wq-ws!lLZQGn>*DTxiEZeqi+csy}wr$(C>#p_fbMM{XiI&+i+S5lLk?}WM z2DZpfArRpI3{=5@aBF=h2U|x=eSl!7Kd@l1KQN|}pXq=23J;P@rqln_M#*&i&kR|q zG6+qg+(fA=>_2S)Y5x~PSFW-A{}>SVWyXJbh(e`y|FEC`8UL4utJLxTd&{r?mxru8 zVESL)zsvtmR~N^kxR-+aRI^V-8TI04Hkm&19O{+2Km>-CN@@DX;l$IVOwis zLZN>>eSolj7?kdzBx=`D?Z2D+pEpRV`JYMTia$`GKak^4SbmR9aQ}^}F3CY)5D+6R zK*=UV)!fL~7O?w2rNp%Xz<%ukKxQhn|C1o9wD|*6>imIK+W)yb{DDK_RCQ(KMZ{R> zC6wgo1yqFqh6Uw7MgXdxJpi`pKhJ8w5FsVZw?-=@BpfAx@W~H$=NLc;1OPR==B_0W zYIgolXD5^se?Xx(i$4J12LSNU7`GAvKwG@sELmmjCnzv}RU}*Or9__xNIct;1OUI( zBW8|y6h|%+z&T+@kJc<}5P-6PsS@?GMLa)&k9WlQ+vKOzVQqqmpwicV_{c!?s8QC4 zi;hKYo@LTxI?FhIBhQ4_pT1kbvKakM$qQAFthZW#L0v8LhgUr^ORFPNgjF@}`UOl|MM`+EQ6+O@px>*cQ8 zCO-Snn^vFfHNH8ch8u>6zmiKX{uaYbH zFySLd0mxf6RcL5BubOM;tgDH1Yzip=iLO6j3-ekFxHt6s%Pk82-uy9rQfaoYX-4?&GmJtz0&vw-pEUP**kKrPda|%MwR9r+8XAS z#3NaWHbN` zPLIn%C!IGl@_&GraRQ2UREdfmG1T_6?$G}Uy;Oo`f{<~Wr6_CTdBt&Krty-e zpXL|&d#|>TRc5Rty$S}ayZjSPc zx#+MLWFEpeBAYH7M?S|(g|1$0-_R7}g!!!gtFfDUp1a6aaJ!{QZW2+PtC`!Yrc;}O zf6QRXEBAE+9)A$=+ktY_UTIS(x`qQcLWNX=(x$=}%N=w78+cZu3c9~%7NB*DLT3a5 z62eSoI6ZXi5ri96ZGuIFE}d8tJc!M6PW%@*5k+Ea(|JOG1xzM1VZ24_TUhoGb)E05 z$+<^tzM9HTaFOh@rr;cL&-Yvh!1Dwr#&r>BX+CvbB?o ztM&VdcE1-D&};KCTuSqIc9dUrLJqHoNI9z+zuE766Ad`dxwyEAULHmzuM!}3g3s6% zdu6Itrbl(cBolZqs)Gm=1pAwES|ru`SqR{owcYd{)lzaU8wd^4F;4*UunBCw)4gi} z&`4Xi5XZ%f+%P{8*=zF&!UG#cbcD9LO0(NPrs9o%yrqVqw&dNXQ}b#4&6K@BiZV82 z;QO4+>j}I#0U>AAfOdU2@vy-mAhSIq8JbEIpmwGkM>#8#r4G$4h^M8TofzZ5&Eke^fB!HZpU!0 z2-|rqc|FUrxT;=#%yZ5Dd?fm^4ZyP`bUM2V#2y56>f3<75ELpoR7QFEj|K|8Sddh5 zdwwjp-=Yu%HFnZLaLg2JcCQ$LUQxRluJ28qt~+qslLt9Px_21LR+qy(t{X3;f8`dF zesfgPSk_%YG==l@hns;DvTHWo)Re&GDEN$;oYEIY}u+|C#T zNccDqmxu^H)nKD1gH1>$jma$Ak!d$O5mRb2JUNN}qVHr#GuVl@wzw|p{v#54>?q;?+a?i}eLYs}DyW(I!18+j%E?sb z+3?MWS70?lpy`R_g@D79J{tkHDO%^TJyyAY8in^W~C)X!j&)sQeNKaO{O%#N5kXMBMv3ZGSSsA4{8(Jt?g2Tzddon-A-G!*!j z0(DV-+fp~;kd7$YayjqQ3EARsw|#i)dc+^Xdj_AdH9vhS-;pXM9ALONjyV7s5JH%y z6qI#5()&Vv3zPy8#cDngHe!u?5^Qv~7&F9o0VaXgQnt!a%;)D{4bM740hep zM{@FzTbUw5$iwgBG$s-%Hs=?0ao>G;CmmUq1myO=7CgW6mmI45L~QOdCXxiF&~O!Z4xhx1%O)C?r0Rk}wm)fRQ!W)7Jwc$UA+e zGB=sg3etC1J>N?JQLGn*LvMDT`8e1U7tOM4VF-5JBRz}-GIamOV<~DO-t<#Wzb=x+ z#&2NJ2xZiWX``X0Ygc(AK=-r_NO-yDvOyvOwB!r5pNo;=G1)XQ%}hnnnAll8fq-LQ z)H?9%7@g0pRL%-XEKrSad4H*%SDu;S7vI4fy#SFRVhhIRou{Re_oDpk7e(>youqEO zBHjSncu+FVAH2KqLG#76E*ZR)0zAY*L4U&u%jb|_y*r+H&_K(yd1>u&EMW?@&Jwq- zYH+()br{&C*nX8;+yc96l6!uI^;#@uD(wQOY)XjM^4KkdJ(2znI{|zNP!2Ael&AfpH6r zBsM&_xWs^v;~u1Evfw-2DO6L!6O?{nY9ejh(L=2?`Gasq$f7|UR8_gyI2qmeW6urH z{oijJnS^2OGZ~wjhR{BicalP_-vYmCY{=Mo6R0efnkZnx_!sHL>|4zy1p_RPNKAfT zimk4A*}D;NPSgU-UY{2-mET=FEQYr~L9k}pV6=e==|FX4f7{Mez@_c?TkeLz|m zk{C>If?gMS!eB|U{|X^fg3s5YG(2HeL*bhwTp5nABmGIMK|X*b$)RF!8P$!lQK2Da zVfpb6D=e|6sgjoSncw0^vtepLN&tyFPiR5k!#z*D3L?hK27}>zg@a(Uebx#{6{iEy zY2-=}Huma8ug#Uoz-Lw_p?(0?1jw!;c%6<5s{ro{$V+S;hELwnt<13$HGVe<3cw!5 zTBJ+-@vzz_iGULpYZ+v<&kj$$wq}dCuY)gi=@ugn;Cedy5UsSxx1gSh(!#5_EhuqN ztBPFXTybfy+V4j}*Z&I5CQv2SZH@;S!{YztB(xdKQ*gUt`V++oD-dqVTXi$E`7uHE zGZJ2K^7|&MaaVg2BgX7~d3EQZbMLF>{lkw@r7f?dd~Va}*r}aQ$Tro=%d;4T%+4X^ zghv^6ia*5>ItB|32z-X6?EBN%XxnRURd`~CWnU@UTfxFOyd1enxWZ~`hbZjhK^xlL z6hl_uDZ`J zsco$P#F;^{L{XmHi6c}7pAxm`_z`5)EbpkyF0#l4D=+412$<1Sc|3z452jYTz?{Wd zLVH6cG2qW#S1F8-)M$R0p8hE^>7dP^ zEc!r4P?$-Ne`C~-jskXh#2rDeJSEmqd7YiB9mB&<|4GRF8K&ywM+=QCOx~)&g&eQ0 zQvgT(B;@8tG2K9@RZI#o3WFX9?f4qgHEB08!+xd(&v%+E?w&qxSNRO`pw?NX7G_S# zF>p1XJ+M=P4Qrjy+DD`#5sx-|d`mk$@$#GJ?l^Dm8cbnZv*F)|?QkM16Jszi(6GIf z($=uOT}lQX({NUbzut_V0PFWWbw#nD$^dC->Q*>F0BAS3(iQ*tUWVU1H(d8CUIJ+! zWhq1YYHjRH%@B-)8kx4S3mz90BFZ>?Ey|Y1Gw+@6Z|`rA^EhDTeHCS;aGP+D1k$rV zSOrFK0yT6ggYObXA;jv)j^Uw;gO-QvNZf=sw@_KX_PHBwq?x?+EOe(xYZqd`wrW|Y z1t;Uz5#XLc>)Fz!w=Y>IRymPZAn&ZD*aO4q;+0I-4%$ZHa2NJ@O#4bqKHKRXi))oq z^WRr3HaP2DDDZBji;AXTZl}3-Jee40(vl1D{i-vX;4V4K zD7J|FrGya3c|TutXmCv#56bj_9fRYZS*n<1na%0oF##oY2u>x6utAI(24|NMfEyWH}oK{=c(AZny` zb60B?%TOrMlT;|h{mkb%AOmAgjS`B_-ZXo_L1r+m_wnV7zj6<$5`M1nl@w&-cZ0yy zioINMC2KoMEncXE+Y*{`J%=CE0%f=ZF?bo%D-}~jBTmw0Iv_DfTMW)yK2Q`VG65r> zu};P?b}Vdbv~vzfH=&Mi&p|3$MJu;VuJHau(J{92;#6;WU}z`GKdZ5j#1z9-Iko*MOZ|OQh!HofFZh>_T+N zaZfprwBxM0x@_r2Nx%9Ok``K{TVWpslO9QiV}R71e40p!yZao7+c%0Y3({^mg0Hc9 zLdH>v5^hP@my|k%$H7IKlql%_n8$gV0{H2cjl@iLh+TguxO>}K^3uF7xK3?efbH@#L8@=E zmcNWd!$1-Ny}oy+XqfW5(_fD1+1q*&mmzp0d zD-8%kfgviU3#iPU`cJ7JWz~N$X$VbMMW2CM}(Fu)=68Sq;bw;TRd+H70F++I7dyg zdBk91J--~=5-Rz=D?FP->dw-1dEz5I*UrU4Hcf=t*O1+~=CYNp^jEZ^q#>^ON9x;r=(x5Yso3iN2rK6jcx`OwM zvLo1){S)-lR+bFGe%fSPMA|XH->e!YJw9UH<+S%J|J=N!(lgGW?Qy5wr(sf-tpH== z;`0D#yxFIn9CHw?g^xxai1^UHaA<^+>_Nv=UO_R#TWXkZ8?WaGcLjIr*6QQb5?Jhel5)pxW{;#6*CuoDqKTd~Zn&gqp zYJsBcB*z4KHLBIXF6=GW!k-k(P4X^wlJ>f-zf952S_VvKfI8G}<%#I+)Ah;bL4m-Sr3Aq>U9jvyBC z@S@nwQ2U0F!&F}m6BZ7{uV#CXzQbgVkAm|%E49Enzk@+{hGQM220SJrvB-x8F#Csp z{-6Qk78uWXpEF~|FcVVcG`QS~Gc0r%tXFl&jYRKpKXBl}Sw3IA@Vyl_=Dm4~nh%|x zbw|7g?Sq=@x_FDDsR@qU%Z81;1i7mz{#f>XD@c_i4@(f@pwWY4{267QBT zjLQQJm$>od(~8AlUFEvg0A6To%V8RKY|1k#Lj4jlH`WBAn$GP{RaebX0Jd_R5Qj;y zU~Bq`XxHs?RF$^g!R)DtC$=~JkuqK2cIzoX>NRlS1NS>a6Mh2SrTOFdf!$86j>IoE zR)WiSAR0-c0@zVZ@LoC?C)WOT){)t|r84?V?Wj?ceec@{doj4+xMlUt(l|Fv2PwX8GzU zG@3f@r$=8yU~>i1kg(>QReO98vhyZ!X2|b7+V!cFPeKjmdd?12b8P}>EqL2+xa4&- z!UsbLKF7_mCq|z!(lC>Wo$!&VZ)RgR1$i7Q?0oz*tXI{6Uh~!2y>KqNR<<1dZoT78 ziq{*j;5ZQf`jl6uzYdR>H?l?)AxrfGx;lDtXSa!Texg+)nxA;~46`KUdCVwc^8i8z z#tg)tCJE$R%sEp``pDz`QC(C@60E(u4u{aN135B>FDnD9ZRfY0E_~$k$ikc<4k4s{ zBF4)ZMWAI)!90^6JFv)9)@kfU+vVtnZ&9n>Xk2(Ws+b^v;)6DHy8L~= zMaeoj+qeQIXGSXwZ^CKn-zs$+$Qw-qKE=ylu`PcjG3!J9z@BOUL(XqBpxdf)r9Y3ld>ouv&rT>}w%-p^JBLdr1Nq z&jMI5JoKSXWw;tu$)v2!w%N{#1o+duUQ<#@lU3s6Vt-0Eyrw37a}W3IzxG6&e7`gd zH(WInv<>?;g*(~yZPhIM<)EQnB5!^jS$XeL4tcQjAI-)Ogd{_rxp>I`eL-Q2Rcf%n zQAFD}+F10(JiA#{JrucYEGoAYQc?Ly>|`DHISKq78$v&69`MQ-7G?BOstbJ&MqMEo zYr6!?&9QW!hYDK@VyXzICoH;b9+?C9%9Y)U3Z-YpPO#B4?O0nJKP%IWlhNMMyJx-l z^FgMD_xOf$tn%S!Zec;rI6?#fBejb;tjFB3N`lLpy31!kp|71h(6)JPz5V}8lAi;I^AyA zB@=dII&aT=LXRr5(~+&cPw%W!VbiH<0A}ZHbqLX~G`EHcTV15IP;46nYXb$x;m~m) zQ&kPL2tSI?^Bf~SOUX>S?^BK9>)Ar8M5R&0WaOn7x_yBZ)Z}WV8-s&{-Sew$uBd+d z6CTv(xr6k8ukRe1yB(=wLp5*3B@z|<`3h_ftR7o4Q_X~5+7K8#VQ3|Z>VR<6=)h0n zkAWNBhI)RJeo@aVRsH$BZ%;7COksi8l+A$|ix;%ajBGed5o{c)so2>*amFnxdx+>2QYKth_$#k?u#3HE{ z4CI$&P#qaEM2*ezn$`re?}+8^d~kh}a@1OJPs2#wfs&^$AhyY0^vN3HzrkC#`AnU& z#x=GrU`rxnx|BJVt!X#QqSeo;k$tclF(Wue3`>jv@z6a)!$+bG`7+ri0c#d%!?gSW z*fj@UGd=em49h#|KS+v(RUt4{@7dKH<-Gp+$8}w0vN`gw$T`h3ImpLge@)SBrQOw| zC--!FYGDa2`cX#)ktt+?szxVIVdfLY$gNF4%Bp;3a1_3+N+5=1s2#7OeH5xj4LwQ> zB5>9Z9#PHFT6t)aE1v`jYnDn;)Vfl#svU{Cwv}+2j4Vm2IZUlpVuWga#Bx(oaY$gP z?tTiax%zIpW78+MYYTlUmgZ1vMob8$J4XC@+=7HAyFCvknIFQYK&B)Fr`_Sd9=AOo zUr^IbjmB{WK;r@uDa5M(UbA633k+=t^o_rMPJ&Cl;VvGcsCRMo>ES8$>Pu6es|r34raA9l{0PbVJ|x7jHM)JeE~hU6l<;sn zOH0ArgDf0jU=C)+rkbm0rq5B zCrzNXH=vo^RoPH;f1RVN4YV-Oo9wDtUIPX&W<~^+4#zuBoyqTixRO~qTQ@flTET&Z z^v*r2P7P8+#g9T!O`v9~<BcS8|BZS?NYHq(-O3c2S&MX?l`Txk=IxHAn;`({OXCV=W}=~_CV4>N{NKd&TPW|n6=iD&zH z!Nreseo`iV?D)LerRB0)^$Hv{$*5E|RSu-b)q>!qj-pm_;~}FdKYy9olS2=%Oh+nu zf9Uc6%8XO*wMl{?HGhrJDMH|8-FSnI&!Zgb99Ml)ah<(-DG~|y+#p$L^@7qm)%I~F zQkl0Q+q?%u6lB(^xNTKuuCIii318Y;g(%G4F|W)z6s-$5)kC%`@f(?zif1PPeIqu3 z#=t;mj=EUz9W`)%8rNEQ1bj_ZqEzR)c%MP@wCchOP}AluZZEwxdkD5HU+~EFYaAab$2>&tv$3@$P5fys(2>=*i%$@ zkC4@F?^W1pHO{r1IgiR5Uo}1;pg0aqMHBb-QR+oQA45G+BVFx?8fFKG2ucz$ ztkPa3WR@b}JoT;DEHE`U5`*8GWs_1`2%+rsT+Ezh(x0Wy0j@1Z;)kG&s=sP8@)^}B zxTTa*ew*1{JsE9)JfiN(+?-glQBvoaS~BJ6{qbwaq=EHBPSpIVmT~_CH;1F_a(_6^+$cN z#qm-sMHS+hmzi{>+2OQTb(Ib^U{;3)#*wDsFTxVF{Yk}fTueA|qy5EFPM(I2g00Dp zhJZ^uuZRqx-HL#6c7XmVJ2s8VjVBO|5^Bd$qGj%%z74XKoxp$H}f>S$lma3940ce=_SQ3No}dcQ>!)di0V9HB{tV_2TT z0?QeMk8#a;e&+o$gulS-usGvcyU>Xja=|i{O^=@{+q6ChXO<3;z?B1-Q{5iQT=Xn< zBNElB-Ncgk$~y!y$jAFKf+h(we87YB1$jrUPGz)v2630bDQq5UxrsaJ+P6S{EFT}o zm)3rBuD>}gnD2h%+BjiufZvOf-TMTRSjq{lW-X7T+dP1WH-@$?FS>4Xx{*Y*K zUL6C=8z(@J^0rvN+Jr8Ba_lA%w;gIjo5io?f1ThZc-3R~aGgG#H&{&nrW*3a@LKgN z>;rH&bkm9j0ztUSiG}D4H|X}$1KFC0pwvnW2}pbTK62aNAS@rx(Q8rd9&zweo)Mtm z0eniW#q~{TRTeDL&2%``0`*~^YcY67aklbAysMG?-J@t;hc}%_7Y}t(J-}kYE-sFU zR8D-#=u*mD9$7Z&z4o_k=87GfeyzlU zf#gN$gc<<>1$N5NcY9bKiA*dqT9ijuZDaXaoAlg5nf|%sS(U)a z$h5vwOZQIc8}HJ}ZPHlJsg0sOf0jYvdBe0(x? z8}$XVD_!}Xv(>+8d7tqWK&ej5>%8;MHXo6yTL66QVn9hvuN^^nsl?aX z6jC96IGsNyQIzE3I-%il+l0xqEtm~+DOWv^H0hYsAqg&u0q`d}aE=40e(yIz9F(eDJJUEF36v3ttBF{z{IlI3x# z=x$e})f-SlhavI&2e@%y9`Sz{?MDvAt6-iG%4d4QVfd;X=;_4sR7UOe31x4K|xd_YK~EW_-!FbN1S2n zn6v%D*SQ19<=R^RTT+c9O8(vM6Dee#(bQB~5s5M>x1k|Lj4bWX$O8PQ?RLg}W29oV zJvlyd{<|_TYF95C+#PWuv5*v5$s2?~eqr(L;Ltg%v6}G-kqKL3fe%AuXl>o|h8&>l zmQTKQBaIM(2%h!?=?-G(2y7v~9p<%Xsm`|8h5B5|lm;ZEP|~-Z=4Um+E6>Se>N_OR zME3*dm6FN)%dgKCU0l%dcbq9#!U5i^<&clcMR;lCZI~x4bi(5) z>BUfeV`<1WgsW62H$2&&645C6#jqX@Pe|@IIi821i)RX%#UI~<-Cb3#D>v+{u&mYS z1vzR-)sijV`pCb;HuJJQZMs*-H1)GjZ84OTD@(3`y4YmyYFyq7YHV32uSyhN>ox_3!Z3xCZ zG)XHB79B@(aFc!Sc=n3oWusf03JeoDQeepD&CIVp_6BKZ-=!?~4$`78t0wq<@baihHWnyXnVxv?~jq!b+K+KyP&VKFI z7IJ=BLggL!`iaH0lvC38(0DuMCiKIJzbWknW2D^Ei{x3f*H)$eu3-{+U=Z0l5PP54P(tS5pD>&&S_esma|yb zhA)>%C>Q3Hck#iixTKng*>T)2PawL$bb`M_+B)i|8M5@F`r^M_V-2K8vpK)>!^(N4 z!aS400BqGxCwSmg4a0ik9IU<&S(-9XfYF#hxfsJigX)+@rz-xzN6=@mMDthu4d1%~f@MdmMfB&VPQqE-I?!6UU%1M?v#(`~+nxOiUQ7{D2olDz^AcXekF9 zc-tO@`d$?Vt+H1LmX~Ho`s1BrFH)Kot^mQXHv-XC>(|W zZTDl&;BTqV*m}}AU5-h#WI2Y9Ui@uO;bKQZv|rcvG{eFTyzA`BiYuHxd3iNhL7-{@ zY#^IJD_arjXdM85WQM%QxO{3pJlf>U*)I&Rn9CIIE2{*uIN}?JgrNh?kWBU|g4KLU zkgY~2!#B%AQDRU^xvTeK==JOfbpPV>m{gF##2(2QIbSfH%B&DL$&UiAdVw$G>i|MG zH8{`Wu&5AkqW)fDd?DlQL@p?|4Mv{|=r#5~m`0=?`zKuS(<=fvbW<1k&bQa9r)(SQ zmYqW3i@&!66yM|o^Dc#I>Go^Hvy}O)yC7IGLVDS5c}(X{&!eo@uUaXupy+o`BoeR; zQHQuVH9DVQ!8k$-`JfM`Xb{cPN*=^tMo}2{%=M<_Q76m=5EVoH>XrzS>3$6w5IUYL zuWCmoNRrxzBLZhPA-3zKDUi#?U=`&{v3(sS4D)1lEsC*mct{POr>_WbX1x<6O@%~H zo;itHTH)~?1{BnDql)7nvt}|-Tu0fqmoxT$DSpZVW3!j2Cx@>R%8sqld$M$hSK^I$ zjA-|~K~?LFcwEw4wBg&SHRTA&Qa62mAXu{7#@N!y*!i77&3vH#xeeeQwrWyS3d?{d zTdc*J-Y9dDpo~f48(Ex}{l$XQT|-7RP8zPBbty${IBPtuX@q6vi5BKh!s!vL$vtz7W5 za})H}9_`?EjVU#QMIR~H5%(ueS@MvYi|VwrL8{#&yYRTF1zK8J)^}Jtkq=8g);jqb zvAYbRoiGaSNg4@oEzEWi5o6SN(Z6tAr`;c?bMdBQqj(|qx78FS-32{<#JatyjfAo% z8}fRHrkGx} zK{yuOG9SZ}!Ayg5fj8}hs_AI1c{W5t(!uVl=jWM=vA_7BJBU{4a>7q^c7*uKP%?A^r;ubwk<%$%! zx;8{5mZ)aK{T4_tmb*vOf(kq|()pKl*1A5-?Y@f>4vCZ0=vGc*LZOn{H8Y#rP{T)i zD}2%AORuzT6t1H76c{k2wy)$a`3aZK}y9qf?~JT4>%f22%~Uy?&^m16vFt)XqEG71UA9j zeKd=Q*M^wG2KFWj+}eti8{%qA+*;8ZsEF`jLY3btAUxu3pqZFb1msxMm$J=sSk!@v z%1?MS=NHQbfwVe!bHou#kz6QKmAGSl z`*~JSbW8 zGUABb-c^YtR!~cdonX3OC6!eC$y&C(Zim+WQZPN&d|>OT+!88Vp_8D{;uJD_+41XC><=XovImia?n(ND6^ZrLvn+|IJS5C+rX9~JORLRzBvt_yFFnCazQK-k_*vq2QK@_^8#m`dFRm^+w_iWAu z5%=gMuWHBNMT(jV`GqrJRIE&4`yIjdOt~-&w-~`w4HvHSwk>Q!-*IEM zbJ7hOBqX&`k_DPfdH4~~cUQjyU7bFDTo$}Mtdn6&!#`;_d*wO!t6dBx`yzpwznBd6 z`q5a@g7{{WM0890Y`-K)I6tI<)#jGOU@iy<-7t0t)eJ5>?FDZZ4feyCn)`87@YEcK zgnQECxeHQ8wUYj{aznqaWE70rTbVcIA4jX+YKYupx>T>Q7jiVa$0Ql5?l1c4fPhp( zhksb4yfYXs0A-_2r@bWvacjXq1_ohT?~yICJ@-VVD>HgM&mQj7HQu8ZCHVD&{w?2r z)UUqibN@;6{2S)YzUTalBb&6+m1b6dWTlTs)5BT)-)!{W6Fdsk;J>cy`}2F+qt|x; zB=XO9bpg**`}?x%`arolm+vL}%?g^7h97;~VSP4Ep5JFPTad7Sqq6W-lK$@N_!-9x z)PWJ~I-U{Rdt1|;)~Y1D&}Br)O1cp)(@npo0ck~Y4Ff~#UEq$jzDv!6dqofCQxfkH zi`z3M<)P-%I$#-Wb9AI{RUK$gv)?*^|HUaz1zN@aaRDzD+xXfFO|74OlU(QQRo;P- zhx&ecqaJfUjY^|q4ef*66`?xZL;M_w{O9YL9b9xK2G_K-@CL%_Li_@EFI}`9gPUI~ zx6+I9kpy7<%8=kS@mlj-?j+uRHAeMlcU5@@aR7gAB{!axJFSH1 zbAzAJ%O%bwNxaToZde5Nyx~-%Z*Kb?wzqQhNS|8q-`2X#dvi;(0t7I$V{I~e>WxMC zc(nB=dzWWJDyFkcOb+ja-n7TTiAc*#A+erdA(KE4s3?w3STGd6E$*B6`XyTO+wZgv zUQ*TwU$LY$!-41aVA#M)cI%EG(F9UX?v~5Q;;oFDcH_jr8GGQAmu-MI2US;pEogUPhx?`JvPH|cJ7Wmmsm|3VrL9N^2U>_agOEtxsw1fHa zDMD6vq|LRWn9N(z6$n&ox{+=%7Tc=g<|cbhSW876x31TCx>c>o?ruE%^7=RfoVukT zIp?0yq8pk+C5MFa!dpRW5Ly3_NJ6ri_`W`K8#RKgyhMlH(O}X(@QSLIjTWRuC_$1ot!! zum^i*f~}5e-tdcSdWVQIfEge~_3?>M=JX_Ek&ddfY6_b2NZie(=awj?3;eXkLvQEg z3*}`J#S;iA0-KyRYSbbE5l6hlxw(Xo4-i)iKIy7K@K(Ak6ia$X9Ujts?6yX1l0Vz%F2LSvOruqYn z{>>2t&H?%ViV=oJrfgmBz3sgWzE z8Tufp7XVgNIOh)(_iwR;z$vglSx@sK0x4mP46Fo7OXPBP4ZUSB+qIP(vF^7;6zEG9ds z7#Q7Wdy70Z$><>4YZes?Ty+V|uM?~>HB*2cVa^d8N(~{H^)c0qOmcVaw}_Lko3G%1 z7&FV$66>dT(svVEM(lSWH%hHkGp+`@B7dgCfp=?Ty2fO_u8KL#@~Q9AUC2g1aswJ? zaa;;FZuN%!Q{n`%0258Z@~piIZ@om8cv>Wd_*nU5qIm6B-!ob$?_dL?M)28bOO(JZ zK_UY{h%f(8=mgwC0;qc4FY@Zg#QgD<+a?3Rd$vNkZ2QdXD6V-nayG@*WR2J-Gx`B# za7qcGrx@ZcWTE}%NsGA@rle?trf*81^IWE%E?MKCJ`-ywF?bHEW5~RD7C}@H##C=<@w>N5nTM zewqyLzuUnblHzp4EA89jS*zD2_JE-}i=NfsBk!^KT}LRJ5-2cMQjo|P5Q6ZZ8vTHK zNaIg3SvX;gVv@n3^uL#Q{CQ`2waF+;$68gv9MCQgc(zvCnX9I1hT1eVmn{&a|EVch zc$WW`@OR%eBS<^cXufnJ;BosPPvAa!RG4MRIPAQp=!WqJ%J!~G~+)BFJ z^hLd)j-l@qfo+loSV(SYw0Wc7A}36}{HS2F``bjbB=YG{jX)yLKnT`<+FS!3A%Rpq zR}wFLNT!2*%4WB9{t+Jw#1n$br>Kjmsy%xC(nzINa)q7kBkY!8JHJJlC_1>FrnSf( zs|piO(1zP4&+tdIpYi3`5GA{V_`;j+z6J^DysMe#lim;>A<2YPnws{fZ-r zD=8T&{qY`8u4pjd0XOFA#DrH3OKZh)!8QgA^MGGAl_4FQG9g4~kiZo|VL)jbVJEfD z+*rH{UK?Rw#677Y(`xbix%!;YEgr3s3tJm|rO?@^b!%n@DQ`9r$K-wTVq3q83DXvi zZ?Ksu<)nD*iJ+J~)RFGnQ;saJeXrw_G9UWr^*x+ug4CUN06-vw)~Ej9UVvA~KB}JndNSoH%{>9@SRqa5 zGRO~Wf_+ZgilbQ3tdAe7#9Jb2+hesc-PmJds2+Qf{w@qunHJdRymfM%5!O??B9IwL zIAmVYeDl|+q74^lm9;oepHMyv>aei0NIlBqgY2<3a_v5=lwa59W-O(B+!LwQIe@=+s*k(X+IL zLbGGiZF5*E3s}jyc%X&fuB6SM6C3nAZMpBcIg1cAS37ORl`}fnwp{4Aiu>M)S4(+K z`Iw6Wwmmj4@aj*l(qHc9{E7lfG zWblPhT{WEZrJ68-9jId>XP!^(+-k%%_)*(t+bd9VJC6+jI9OvOxR$$ z@({0A4ncTjWV=!P=iE5G1nsCKa;!+R@CngH0Yc2nC;olkAZ%%H3-r}hb4|AO^WhRD zx_7tKo&6~!u7zkJpVhs~viaE`E2}8JTk+K*;d^dMU!{N4IFCj`Hj+m&eiNxCN`VWH z44ZsG>))aM{Wym)Q2LFMq*`>brj19}PY2_m-0WP))|QF~sDm8FSPOLoKHfH<+}m$@TqH3qv5} zgWjGfNlIK@anX;*^re7bR^zQTTx$za8VoEk5Cl;9oDTC3+i!p^C~&Ht->S4^{MxVs zj*eIUbTn)py0{xb-{R}qE6OvTM&|JRs#<*N@#<&08loaJF~h6ls=xik5PqgjXxp8_ zzqb@Z8H3)1v=YWWK3fQwk+vw?Q&cKI+p-G|t;DrfCZ}8{zfRl*mA{1Z@pJ0+BCNcR z(ckGY8s=Y?Yc*Mqf`Yqd{Bz<3c=%e)>R3szWs-hDR5qrCpzI)6|7xm26N7d&>*1`j z!ZF-MS%R2R_Q>UGouYFMJYOVq{mE#6Xv~VdtOlWQn_c^(%vF*Dr*kWhCQ=mJ=NOIr zH~S+W1pT%W+0CQue=6ToMOMpI_wqTp|BF|{N zKSG9}xYia0zc=d_!m=#a0IrW$1`zV^GYG=aWb$KV_cn*eg2fCC$Keec}N~QFOZg%Z<#AL`u;fYWa1^+KEBxe96TNZ zf+@Zcg{fpkKhUD(y-2nGvu9$i^z^(Jj`eH}s*^Lu^awb`wD0R;yQlSEP8WiE7JCi8 z$p{TqL8R$jZ|P>x(D099`FhGzl`MnzF!B7MD{OwsQNSMVG6~C`{-S_3P7+HU1kOgxjeU zp`-}%f@o(2v$-NGf`cVUf>S|)!iGt2K9SNj51K>zSU%Z$e7@RBOgQo&PUz=excyAydC*Orj?B36KMkK?wu5>@^qxi&zAc=D9* zSp{2pxbZE3pkj8!xLfWVqr(d%Ki?@TAAn3GKcL0=c1llcbB6%QBpL*i>;lsFo$@!( z5e%k-juD>C0^xW;01yzO{2%DqfITPxRZrNY$p*m>!SKwj&gNIf0Z~UfB>CYcrFuCd z=~N7t>nCMF#ew61o;IjSNm`a7(+!sxO~%8;q0IAa1_%DS*k zmZ-vm>EZVpxTp8kV6<>dWF~cH>*&GICiZCG z1b6(r4j<3^k?(IvdGqiF+s; z)rKpE3Gme$;$80+?6|~XIDbVnc%P5-<|Faq3~#y;;B4H;RFuLARdJJDw%F#0o%=Q5 z8glS9^Dg+Z)yS(=@%z$b12q@urn@4r^8Gs+n4G2YQ#n&_iSrPmPG4ub^R;;&c~M=y zSz@zRV9%8uy`?Y~C7$_{qTJ(w$94MIWn|7WIj2W@BZhueu1dSh%$O-F`(8uz*_VNP98r87vck zPR#B*Dp4tp(KAnQsCVwM3#~b)Qd}0lR{mo3*%FeIbFJ6>gDE$}pIBK!I#Dclrg4JC zZz9=yEwge_orbUT9`YU!5zrGx4(`7O(*5^e9`F|kG4+qHeZUbEkgBI;tm?=hi;>~k zQ9sf}E%55>ZDYU1BT|*2oiV7EF!?M5{*bdDL>*{{ek5^)t9&w4Do9F!B#@I8`xfbY z9h3BZqSxYsryxrJI#-&Lyz|)N3ocI8*^NoS8p&XfqQe|^?*;h8gb{jVZf?}XWs5^y z@4c*Xd_P!y6?TRxpH!ldC3d2jNHIOj9w|>mM9ya->020T$iM`1>DI7YnX~-F3=0uD zh*GcCxzSEF2X^=Zt2VZ8A@Nzk7 zr_>WidPU2$Jwj-DFSGw3r5;VR3|07R`z55j(wzx|b65cJIprKylf2PLnhJbo%-ZwD zoW%!!cNCO~6dBAOFTNiaJz_u)zN|@}O~p~Qd;39W3Vlrr$Dt=;6*?Tjn8i~kZ*I!D z>1@+Nh!K=qj=>iR?=H5GF_K{Nc9z#+aO17kB}XhyXxPJ0N&EfeYlIGn(G_~@oL32V zo3neyrHAZNu-G2-hQK5YyA*_qnBDgx{nLc~m%WBQSR}{5WA0+xi+He^s%Wv35X5&8 zs2mWW8VIrXkMB~z2~;6fkJo;{3@1sDRmYus;a4IT3*rz-_f3p95|1GxX0Hk4#mS8^ zPpQdy{s2Na=RNV|Cm^io)=_~|Bjw|SH%k2b>`fur(71HZksEu>u+COlb=ui?(sAE} zlUNR_wK_4To@~7_{?_EZt&g5mCZ{lmpvdF1?SadX8PwB-LYd zMYS5ULVNuiT}A*80~16N7P~(gG+dTN%Xe)aS=sktFA11KHnHJQFgXMDo&!XxkH-3? z%UO$e;-R65u!K+%xM&?zyDA#ujupsOduj06^7TIxZ~1W(+p#Ui{mKYfWNJ@aAUH->FAOCBhXHb1qJx0Nq;oAovkKLZoT8<}Gaz*0YwPj4C zn)QG~7NDQ;$aMm9_$$Gv9O*3BP-{?rxcPS`B8Lhmx4gO3F?X z*Q7e^lXj9)UBvnWh9Ko*ExA9t82&Oh;Nl%EDde_Kh3h^|5awmQSD%cbP1<4(0~>U2 zrlaBrm-@1geN@}Zd3fC(7egoSlM42O5&LB%OJwHio|t-vbKF(PTqLul(p#N0ckhndxyTOk;n{idTE_4~O{ugcLVh z($nzZ=Dz1!KryavuK?RHm3j#y|Arb%a9RxUs z+Wt>4K~#dVB6gTho;78`9Q7LQpefXn4J4cTv$% zuD8oHxs>UOlQ_IdWwRv-Fv;TjPm{k?W(+;=3ib!ZV4DPwU;d0O0~mANz~ym-bOz zQx%I}D)^CX236G*hWWTizl!1~xc?+ALFQhJ%Lty|d>1dp!BN61ydlu9r?Mcj#h8*~ z;_Z`3+_hy&hjhZDdV!3F*%d>PSt|x?GPrf1mZooef9d$sV|1RIFVm;XYzHoYS$+{c z2tNK}UL3LffRsm_(ADC~+*hX%%v+AqA%rF2U@$33GH!GZ@pbn8hrYxgxaTXIHWB>1 zz>>p9h_7}l3ZRr2e(HB2;!AER;Dn;T1NRzK_!VIjwz`M~GZGzfAEI~w7D?h=q69ZJ z8Y`lqdn_F>WNfHatraDRo0#nel{q#R!(B+tHy^yUm*ikWC#wF8jcPg|N9WRO?p(71 zR-u<{XZ`FD9CF!npqViVjB*PE$OJ-?{v#t1a0v}g)nf+JBWSt^4eLr<;BZ`!{`zpi zlAxCBjL)a16^YH4om}TF34|hJo%dP^dwQDW85!3mZE^skZ7zYRmL`37VTu3^D^>cf z>s-6zpB2To@J;cjc7H#sF7Wr<{^IZrG)^u3%pLF-zQzM3m@gLV_2EmRe>qVK%eJMT zNLduxi0?;1Hyd%&GpfbMqwY`vIo{|la?$roKl6aRpPX}%tKG_cXS7|G3xJ=D%g0Wl zjekSG^6@m44w6PZi^j;o~G3mOYxAJRR!( zqLQG)*dR9xS$e%xF`eFn!h2p|EB5LfE;*g^kitAbdcsZS;CCyCxP{ENyRir}tcF>5 z_wm|$T;KlSokRcU+>1m;%)yYB{!XXNEQHK7eL1u|lzYNQnduFOzqAWtX17vt~zg zs}l%6g?UI#uuBM^bE%iguw95I&~t;H0zBt63d9+@AJ9BR_;vlG)-9DKn!rP84(G@Kyf#X6!R82%l~=r^0o}y zvil&$1;+bnHX%UB&-4QrxG&%q8bHNGE(iC}z_j;*T$t62U4l(eAjW3F_{i~{ z1-cveW28jxF(kv5dZb3fH?Xz^JQXi=axw?SFjz@TW3v@9E5R(B1hPJxQx7K#e+1n@ zi`9$F;Q}c4HTFqfs2&&%ww1mWf@zKAVmw^KB=%kG(l| zExG;lJeoLICyU|64hoeDnT;otz;6-O5g0CiI&;6Bv$o0_THQum*lyYr1`Vpeg15_7 zjow?mr{fqWfNl&~SH{U=vUy?@LhHgLo~+NG3c`bC1LrN>zQ>Ehv7A~DqaY0M`SeCN zK}N;+zKVj#N-v!j8F5*6UY7-1ARp1?)qO3YAp^s}VATFa%#eEjXvhZKLmN}|gtG5! zP!1-ql(?4m6le4IkNmn`Ta zNy($UqYRV4T(4Jm%6M`=FX?SzaK4^Ohta=LC8!NLKu*2#t}5qiR@I;1gEn(W>XHj| zyKyuMifx5=Rzh&y#UN@1NM~rxp@a9;VS*rBufB!i)QMQttgGWk+1*?`nEFv&4^@Y4 z9(KptatPD{)4lofA({z18@!Zr!_8Q0 zZN@ci44|Gx;(dk;Oq9&(zMKq=I$^8e`h<{Wng^*J zrkvp60YgL{P&QeJf4A3(dZKJzl{-K`gM}dtiPOnAm_))P>dlQ+P=gb7C5iD2VEwc=Dpzyv1r2w%CE7X0TKo zp{&77E}unRIL$AN@EFMa?f}tb8i}!8U)(Uk|NJB$=%PSkOHXVJUQ45R*Mu5*#HNBT zK52hM5H#iJ&kS>R@%IOPwPHGU`raNxM27CKc@i!bu|JfrgBPKBTJvDBSdqcDRYr?{ ztzJH~_D)!!y1g6=?#ACJVLPJ!JYq3I^rF#;OkPad0tRz7BbSIHNB|i-XwNi@Ii`^4 zwb)n7VU0&xA>-VmYJVxMUCefodwp&Qhwb^GZFe>Du94;6=3Wa<#6e%V_oTIh_hQwG zdy$VofMXzJ$UpeE0ngBdR6XVP3NVcdKTjo#=COLfsaGRWz4g)KvRJTJRq2Ni-wFJM zO^$Pzh#>|jCG70inAryZc#G%JK6QC{H)N{GrN+natpC9&eKQg3z7PMpAN&_FZ{@lsWWJ8Ndkf@xjVlEW$MI{gIEds$kS5GulMhwRHI|sV%q+ce2O(e zj;v_tuhZcH{=>lj0Y;FD$W-()$^0c^Xx;X@zP91OV3+joIZIK90e=!S`+5RgNV2eq zaS-~`T+*|}vI$HMvkpqSkU@36XfdV))upe{_PZM+-@3MmD|mfjn5!&T`Drtw=8zKI z0k7Q|wnK7yOgvM=X?yR+9+VD_TVUK+_v`+8qg1u|cbYGb2d`A6ewvp-o9?G6&A(`> zI>^CyQa5JkbF(}iiyECpf01v{1+e{L=XDXL`8DTlG>tkzjuWY@TQF)K z2Py9G!k;juQnt|msL1(`>H{>>UhMYfiASGbaW}CtMUCxUoK3pT83I*90AZ2m-hnAP zzug*@1E=fFBSt;~&>6dDyby^>{=*{7yI^al-}?*q0tnZ zwSxX5ypit}nI5U`?*78b%L_p5%#-(cMtMNffV_;+`Uu5rZ6WA${OsJwmDg<2066PK zdlMTtMHIj}Jf^8}DzSU0WGhj|2H|E6larL;Pmb-db*+UK1VOdFaf&8*)()YpX$~n7 zw0*ln3`FTIYhRK053`T~B?B;V__D3uhE*ydykbh-DKx`kK|ep4y%8Zn^TVGh52ZKC zDQYW96<;ZG+L$&j9nK<|T1Tzmx%nN%#N( zb~)nkmN7htGCyVMzOuJzjz#F*jHmQZjkl6;o6}GBISUZ?y%u_Ak@;wvtj@Im4xK&@ zFid%aQ_IvzC+wIRn!(opE77kmb~SSUZt=zFJj(x-#6q%+b;$JrQ-=Dp;R6K70zy{) z!#@e|4!up))5G$ufBmA*wxI`)LMyHE3j&dWjp6nq{?N{GB+Q?&Xa*|2+U`3Od>9UX zIB?Vtl_X#5F9t2joN7$gTX6rsjmtMj~PU0S&c9^N5yrJhBu#FU&U7riTM)F^GCB=JWA00&j5TGpO- zXzWiAMY4v{)hXvDEm>_dLouMBy;Tt*;3I1sY;A2|Vl zEf{dBo_3T>;!*eDURSQdz($_B2pVM8v?NgdE*oyPH}j=*ZNYT|VEh_KVZbHw8cky+{*m|B!A14ADgW?pec_t2wo>;P*t9@5k3I^ccJy#400$<(^J> zn&l%tr<1PUfW;_HIO2Uh1wO^DF@@iD0i0dnbl-S&UcaH=k#xSq9eQ6zuMaI#Fs~9^n~DRNMuO*kO-*r8lbd10}hwZS3^<(B@`OX=nF5 zW50$lbgTwh69nfH%(SR~bxV+MLEF>YJMi$5DTz|ZsgGCon39Se_%iiR}S z1?x^Yp7TdkShZ|I8xRXgeT$(I`qFU5yM}@Zs?6aR?x;87hh@-jSI!XXO-9_=jC&?W z@_vXz9EW?6h9w|x2~K$Kg_Q#VoPdyX|Cq1>?7)zHx+(W`IAi!s#XBln1gW%!QTQIExJM8rK&?Aj&6JR z>WI)k(*b|{0qX38fu=vlf}KBUb*;&HoDsdGrtRcEBDC;IpD9*_x=XZpwK9xya`5 z9TomK1zHNNaRy6SL)}7O0j#a-zKy~Dm)ZI%;Uy^pebGkMJ$;F7(KmM?j_|6bgr7h9 zetm32!`n_#T|0=6`dL)H7y<+>R}fqJ3hAF#a0`9W0=MzYQhx9lIf+8QomXWZto8h) z(R*0-L-#ZzXv{;GH5sB6qpcOyfs7k)Zt&eEA@q5(eyn726Awtvm{uF?*$8 z2mgJw@o;%w<;rLT%{6CXBBN*;LV9?Euma}hS+@K)xXE7@yx0D9%^38xCAPzU+HgJ_ zeR`{r=rFtEtcXE~vsHNVo~{_NZ?ujN*a%4$xs9%;A4bf-T+wcj(H02pDup{dk>67j z>67webjU4^1eHG%u+VGh^FzEFbo(ErvX4pv2u0S7R~R6SM@zta}NDeKGFx4EY?4>PLbh!+vVKswKX>>GTcYPURDsZg-}vdYX+$79;I*{G^GWK%_iIg&rTCj3(0Hje%Tk^XznlNmB{$8tw(+cog z5GGNW2y@>1ZmwL^9}-Ku@XagxaOyhG6>8sfm7S6KZ!E<}Y~(lv26QJC1w4|_UeYP^ zU>b)-O`HK&hVCT>u!uF2$ZeM%Z^zcNzDFX{Q>T`tL^T&+5FP)d zTg{QidOGZx97uOw2TBF#XWm7d!9MS9=@C;CQ!aYuNn9XKY9Ku#`XI;M(xXTjOT3q&m}HV!$I#Gqj{Qf zpYDd|m(=F={pZ9RVf@w;LRZ*mm+*1JQ1tMjQvl-h0~o})G)Col zu6Wq~e}~tTPOUeaoUwDeT!J`nohVi=TWq-d`iC3R31hTP- z@i!!icGpeKVt|M;{5vA^!YE!FvR1N4&v8eI>&4!p#3st$6Y%A+aoe3#^!rtbE=_PO zIN*&Uc?&)h<$jrD3(to36(I8a&SIG?rp1{OGq+?YHn-f<95m`t>Vo8hB@W)IWudm- zxhotSw8&ep5!nC_lfv?5VpcOZc?buOL_lTh155VxbkHmkGg|hq-??YQGv$sbW++l* z+nZ1^|FV!!l>bOb1f0MWel}iCnHkFBc1k&k&{AB)m>8dMQV_W!?*ziNLWDWodHTpZ zpr-p(Zg{W=JE*-yE*11zKmwHH>0*>oEv&pL;Tc3qh%25o^K(VQ`J3M9(=R&{#soV| zU1h9e0z;RK$JH_x7ZSklao|74S6?Kv9o<^=*zC@+&M+>)LQ!rWZHiwUrYxUf`WHi+ zaJ}ty;*R?3(HTBH6ZLa3`lr%^(j~7WU5jQogYKkbN#$`QCCU#{m0lOX%bE5V_!#MA z4<`gnF|U_)hyFkru^rUg+a|lpU7FWRUqEbuTb+NwWn0it3`jA9>AcY6$RF4CH>1oA zh`-Xd9+e^=STkT0!cUy|o{~@XGtg##&+t-h;%}ndw2Bo3&EIfcM+>|%tm-hS65Qz5 zd*_y~bqLXzVjRt6VR*;)ae0mBa>0iTmGj0*+mAjH`cfKF9mlu7u_S^Tbk!UN8D|hz zOdh16Xq&81Gm2;%8X*ij9*d6$h(Wt0Qr9+^qhL%~?K)Z(90c)|A7^6=x=jyYj9Vap zD-eqJA2)V@GnhWAp3gdOx%8@XFfKjS?T77-zOm>I;vh_@!c&wB2EJx5nPKYRK6qC~@L)z%JH9h7 z&5q8Ldg&7&z}0tq$yYrG*{ugctrT0&=}_MmN8BG&bq^{gArlF@gZrA+x(x;UTH0i- zrE!0B#QI>k(_bq*?EOhshTvmCWdWEo&l5Fu`BK76DmO{gngETumT}tE&Ulr1W*K6u zp+P4?(0h;TY_`&L->hd>4OG1)jyWsXJD;0XrL;}95AF#cO`}=0eD>eC<9Kn8YPTXvKMO_7) z$l}p5TG`E~WNK0-`{E_Nxr{P`CwiAjxi+nt{$edHJ*y7IpB$KOPERi9xgz*cWu#X% zbTt{%P!Rd)mv(_ra{pjo1)RfdfBGd9Lw7HSi}<3D{rcci>5WownZ#$6XM+`#j78S|eQcs?!Vrl zD9y9S9H_1gVf^gB_CbMhEBtiGJck~RNQ|Y~ZN0TeMDj0lhQ7&-T<;`7PX4i2)% zf@oT7$Dez8c{SDzVh|?bM{xrZG^J_1H(7GQKyGsgq{m|;zAWkMUmz;TWQU-++XJ1d zpzv~+X!6#6aX!n44GWBju<2?<(t5uxBRPm$E*Nz~+!xq@BTpwJ{H)FHm5Vb3bI%H^ zMmcskrtW*B0S^D7Ic6QXDH?^swj;sM4>~&#%IF`<^ngoPaH<~5))OCN%3*-4sIUTcRAYu768lAV2rUrQck=fsuf*+CHuEuKXs4hG%X^S3{P?{QNg z*a?TPa*J&xt6wt9D3q1f^ZDN%&w;A96229|&91%m2qy%SU%SnhdC4h-MGjcVhPg)G zGqc0(28T(NVoN$4(+zDknbu{GQ?os4?-_C%*-SCf9>tis!$;)G@G!A;1e%A@j>QjY zcDU;HZn#}`8wptbIy4@H;W{s-zy1DpWsgKWwIq)GS}XgM_cHJ~9NWy2tVm8IO_kQw zcyl}@-i}RPtXwnyE!)ESP*s``{UHX{kb5(K%vArk=AQ%ju)rV^Pg#jJInVxp!Dni> zCH#bCDlfUQ^jHHZBJ~SPrDic3kVqa7%Jma8ur$CmEE!dgG8#q^KQ4<-lbT3rpgJMe zi5e*;%}r8iRK^d5Uz^Y^+*8r>mzoF;9?kmDmuZGR@^3ZH#%4Cp<=-)=$MRD#c#{)I zEJE=P-IlP~k6jkx)&3kT{vtC@h#2!RRBWVOSGS!3r~LgmC{SEKJ4E!IkSq1e{g}M;d$n&2vM* zY~o8wG>|05lQG+`@OGZTR5*(GQYgZyF?Sm4PGti3LloU#?b-V%F)?P66Og%|4<-*uo zqLLFr42i9kT0EJvTLg_2B+>?i%K5jbI>0@wF;$PvL`SSy`QJ+s#HOt&p@dc8vs-+f zeRI1JWh-#o@T&*$u`5uE^Dc5Us-I)B@9+#sZUpLuFu$e`yj1_3%+r;_Gmn@9o#%#x zZg{2)D>2HC>)gBSpt(#zMfsvh%-a1+>-qp3mDIu6FYK9!0%HW{OZ-*ivhvXI;7krqX&7-rEOT~QYB~gFL zmh+{stdThJOHW(~xgQ9|r`nqo*z9P=-3|K7GA*q~SsqokF}Hp5rWk_U!?H|ml*xYQ zyS{r4H{Y~i9*gj-n!mQSH)CM<^!WZ9gBdnd{e=b+Qz_JLIlvjHh!X`Td?Au(Y-oN& z=x?5YepRS7D976kPTRH@&Ou?r9;ZyYSaFR8fpWD+Ud*f3B}1L3*@ZQ1v6i*{t^M}n zugxJbN#5#RBiYw&nZ0K#Q#!g(V0)(K@AftIJ*=GmOQWuC2mw;|Szj1tj%MBl(RyX3==wUU%~8_yX{12RwJ=jiC+?D}QwZnV$tRI4PIFvf6P1JeUR zBKtt7fq%PY9qfAZNmp8LECOUK0E*t-9+J;Y-#h$2*;=v@d|dal#6?(xd}cP z;%+Kg0ZMDU&f%Ttmx9k3$&7bLQNR8C#g}UqA3nagg)qE8CVWKtOSV22zarrgU1}RoyUlA)2v5cIr=zY6@`2 z`emie8ZUj+t&v}sNq0Egc@g40Z1>#jQWO4NJMlqt#!vu5Hx~7B$-pI6aOTJ44?%v6 z#8vXD{kqCv*Ac-OWjpWII$B-=607Ib^ur{dokSH}QCPEE(VqJmfJqvw)!NYzp%P5| zDOTdy&szeMYy-)$76?V9fXKXl^-NWc)0b>^#q}%hPK?NW4TEAs(hPU4o5h8OJ#?0A zPB86KZYu)?*6#=hR7?IMI8_d_ru9e@s&p3mSt&B67-_wAeF%&6$8D~Y^hC$u&XN#Y z>Mw24O(s!0=pkn?em5EW;6&<$$IjAh!xZU_8@7uZDb$6d%YgtpVqQlC?mz^$cI*{{ z{rY^S(~STkY1G8j5#RWmDdn@&Hyp0UF^k8RdQUjU6e!Cn$e!sFz9&Q@#Z#9p-D}nB z6lIr;;0>?UE)LVf0IOQ3bg)Iu_BCliQc9>a!Dqq9HA6`61RTlhic>g7nf&m{N1tyqDMXk108HPg6ik&0VkxP-e)rO}5{IrVfwQndO zuG%+$EM()bSD+^F7D)jf5Hq3DcUP{xjQ*ENm|8B^WChpP^CHFm z3KY&gxv-G_q1P?1ixJQA{t9o@T4Eg`7M76?=I+0U(}m9zlgzOOwjSxUZsC+Van~G6 zc%GcQqJ-`8uU@_d-?r&ux1GJrn}zG-WL8$Y?tdjT;K4Wt8g zcUYsmB-$)t{*I`szH0cXqB$>75>eh~qy^fD`^@}l&SDsQE&MBXPI>Ixz8%j$r){m3 zfLK)2+b68YrmBg_+qe?EPbJl1Te6aU+KJoHxZc1N;ObZ6V3Qa)G29_=EU{zIJh;R) zv^VV0`BD9+$=Clcf{LA@K9R-VfX0Y1QiQ*GA_ra0PblJTMh4*_U^OjqK;b4$IyI48f7 zo5i2#V%84yR_X$&#kvj?HQXF<11@<2V!=G#ZJ#_z!(4;w69A%Cf9S9zCI19C+|m)1 z%CYz&d-9=$*uckjLmOp@Wp(RH{GgMOlIi(|3}bcgmA2VSrIkx#Na0bCXjFf$tP!fW zxLJ|!0f{=2s*3Jxm@!*QGyhjhMuqy493Nw^tq(>{^G7l8*vD8QF_&DT1O^ccwOPVW@m%H;_7RceNmm6{n9)-*bzz zFSajYk7?i0QFC^SC+liph_ku*Aog5X;dr^pPkjij+99bBT;89n9FVKvsqtLe)r-}x zAHTurHyG+$$~Dv}4%J}HuDtMY9^y(56=9$K(p_ntWFITL zawlmswYrj;{vg=RzG*2(mU=Rq)3_u(=!NSHLlC1seO5?mJ%McM6a`LfFIe^fM8F}jc6i_)E>8u;A~N&ED*L<^~g}> zrvs*C+64mx4r8~q;ay>LjkK8Q(P?}p+h3;WXl|pIjA(a8sS^Cgpml5=A%qik_vaPH`o@J zjMWSuojW6CLC^2X9m|jWRG+B@LD@hKzaI3+jvZKQVf$o^`yvbGAH)1yu6j)TTgN!B zAt~;U&1XY;6?WHmV@+3xdUpvwqk)nQs$itz$sI`vDFlWvBGY5C7Jqa1x=}(O5J+L_ z4aRS-wQ0HnICI+(m`G`02vqguy5;)zIj@}fWmPN9yGat6-4~7;UPMD&zX)DcEOeHtAo=6jCxNFNfQf^VIgH= zmdc1CL?YWVF(0X5(t~=G{}`|!FQI+fg<#g~f9X^+vI^m0MJ91!_#_ z%RbWj=2{KK5BBc?RbNZy=}7U~T#RfV-+yB2ntSNN2=osBdJWev#Dpz@9`Op}MEX#f zVhb9<-6^aO+R~m6ltBM$U2p_e18#5!En|PW^A8!J$xpN7rYHp#kwQz;D=DIUw5fMber-PvH@i zNS_VDP?reM0LbfFp%rj_C*4wDiNGF;#rNNQfwA67AE3l& z+5%^)G7f7{udoK4>Qr=!37qXjLVS=q_r|H$41^g@=*roxY=5YB_o96(^9!;HKRS3) z?r4UCjB)ZYH@fj94p9=RHkn_^!@-Sz_;DBD1PPZpIT0E-?hpj066x*KlO;?%~0uB>y|Ly$w7U=->S+6-(B$Fn(TcZTPPte<97*Esl_q5I3+n1Z@d2wr9fK0 zU%*IlF=h*5%#22^^Z}Zr8Xc{t?`V>wt_vpMFgE=NYVe9G$XX4&ij=R&__aq>bVB_qe zA8RL)^+=~R8Hp zI#pjc0U+=~_aQm^adiWdV*18>Idj2A3a9vgq)mhs#;ZrpxZZQoPKaM@_++xU-wt}V zLft-&{mq8;_NZ~aJ}}zS-XN?E6kk(oucA2vriLvwFYQugJv3myQ~_Y&^*cM z4E{$e-lonSJ|l(BX2D=^R$AtX_i5SO0CY31T*@~(K$IOAior|WQ&3yP0-WH7dWFM0 zB)~R5`1dO7H$SVX*o-O-s@r6Ap8vIaJe=!lEnU}&lDb>7qoE{*-;s9R5h$2z2 zC$jNq`%`u^&PN!P#R9yP8<}9bG1JmDwd@5b+*GjDcjg|^-&tXI3-&Q5_7NXgV3-js z4u*!1@%=v?pbq#Gcogz4_DHS9*45I-S(&}WUV;wTsHAriOl4x66~_ZQT)a5Gu1=Y` ze+Ds&!VGfjftZYyf-yH9^acd7+*)kb6u$XDb)kZk*Fj0w;TRNgvo&!k(v!r3inBk| zTEnX3b;laEoKt`a65eA#Gz+nq zJB(0sU&r^^3N3>tZ*xpL%)8SPLKZh*7Vl8I5S5~UlgLCzt(hb$+!2OhY;>t3Q5{e~ ztRn^uwo*cHW@dzk3b^pd8>v2X3WL?fsxck7T0dA-$0ub*P+&D&sbb#7M#U0Df|yXTgpe z=Qer0&JBlHdcWQp=4Ns9G(Lz{h}R;HT$xn`_y~yw3Lo*@vCc|XiSO~+X1cUT`VifL zx*(ftLi6;PtJIBeqIZ7|K)YgQ#kfm9&3q-(LK{Bp2>=Ksi=evX9b{3P4@Q- z+*u5I{a-_$T)8D(Y>L9#;Svaf^jTK3JzQ}#KW_+2^Z}kQgILOe5u_L5_a;BV-HQZ9 z_`55wKiLgPWWwu+ekvXnBk7~4*ZJamesBw45??P$|Gsl%O9#gus8MK83nJEGAn<)q zJ=QXTN^k=o;%j-H&KB{QhrZaXZ=-Ovbgy%1KVwE6Zn`X33YG#7{r>d>NI}|0AG~FW zN(0M#2Q6eOo85-JCV$BM7QAIg*1;P~l@0L8U^|U%Fu=DwM95_UJa`?Zw%Kx1f^JLozk3jtoAS_1dcJg zy?MzAq2ig>%L>$cEaM4GJ3xG2iTd1Of&-`InGIsau5Xagb@ul>tvQm;x6C30s>^`q zqVfP2W*+Jd1TYB8uwXu%)ubb z+_d!*4fhGbJu&iO0ssxfA8LQRj`C)UcR*-Jd?a!u;^0+24ECwE=tN+E)YhwaOD#;4 zgT}>!F!pVTSM+>ex~pjlRMM zd~2BI*7UX2uG%-1nH(~uHnEKEj8~S24>OI-(V%`xH9DQEdO?-Agph;;C!WO=;F!lf zefa61nA`zZDV^-sBdaaPT!odYu8X1HfiruHF_&*kKM^R=E6}GtFLq}~j0A{?7X>KG z9#DDPc}BBB9)$EO7VI=jRvk;ea=Q zJcm0|E|BuT&%(R|ui$l$)({8ex}x(*9@|yb@AMm9OsoflmL|WpeSh`+OThyAE`k`G<`ry0g68!4#5C z`i`FQ;n`tPi>og0Gm)=`XW?;}fra=SK4zT>IIKE>_v}qyhH$LaPeQkzYm)zL)p&*u zPLP$35nQm;a-^J<>D%hormjr%fM_eW4!iaZ)xJ#;3eKZ^hNY~+(S))9N)Pn6xaob4OcG2!Pw5cZp4A7$!GKu41qt5)Q^m8#dLjd>tar8lC)X%-wV2$N z2CeuXNuOD?9TQyPg79{Q)3kBg5iN4VaQ$&(56rKoLKCJw*dp)e&xtkEEYOEHJGsV} zaNLMN7|~r^D3=s7eJZEn-0R_|0Lv458Qj~F25zg->?+1@m?LH(yT#U|zg`L{ihqjw1%#K5JmJBGDXg z=DYNI()Hi>pda?x#)U~1&^jl9gaCeHEIMuY^nZ7ml{E1K${jD(ya#8Dr2^UtDadSh za_O4L#}A-kUK7qIIYK86`M!xgxL!Y6D(Pe z#>62FlZe8ccU9_qx?k_TnfN8}ID2pKx25ZH9Ge@bq$iTxW~r(3-6I`BS6sy|OPw5E zuJECD`7(r&jLFZY0!zj`!mcW_gHr}{SUGf_VO-h~v>uH>YoYxx8z=J%H;iRg{8gc--sEZ=Sfp`QLE9l zs?W=xkt53z4?+Jsd_-S+_MMHh!@4x8*kGq^l@;>LnMb4L(G@pbpwpfrY&eE8qG?Ua z*goL(fusM{>ehGUO)wN9k%m^i6ib-(k@hfz#tE9H=^Th7Np<_cB6_`wQG@ASk4$>|vKHvN^X4p346~hhXV+;`~x^gMsx}M0SQ1e67MaKBA zYUQ86P@V{=N{hsV+LAZy+L;pH7O|W%ljCz}E(Ib(;=Q;3_$H zaSp@sDQq2Xa0EV3>bllnFc9#U(%HWrcM)#myegau8EsCGo3juV7Z#%(9$sv#g(+XU zxt3LRC*WLRCR5r7zryZ9;#zexjYhY|?vPu(QgubluQICsxfKiau`6kotXno6&(ZJr zI?DH#$WjR>Hv|Rue%l?aZTWdK?xW!g*=9fp8m2q6Dx=l>tt=!Sg;YlKZ??s~ivNU) z!cYRCP*QThD$YiGPk4|2rdyprn8+|_fmWvNKLhXwSS9haCfsw!L1HNpf-N z4Ob#4_$eDGZ1Rl6*v3OYzM2w5b(q5tiX6}kC-(@1Q z5h;cwDFLPz1od+nuj)r&%4rzSND2~ih(V)M70G82Pe@G9G+0f^1C_V+a2+WSOKGn> zecfE;VTUUqy%TYlkEaEEXPDzceG4pAi{2L^7ZhK4t^uEWcGu zme>^`#M!$Jnyuo#v$qOkG~XAtW{ud0^o9gU^o+d*$T->As=}1L4ods4a?ozh7c-Sc zcG#yr(2J|B4n9FNpjW)nyk^P%hZ~5A$mY~)cv)KG49$6cNLdT7p(MeR7q}1qvKo=2 zG&n3lC`K=0gUxXxAB5MB@9>)cY08lBU%QdxAUI}R)>GjA-`HFW^f~|Dxe>QFOZKq% zzhsuB25$smA-uyT`AYi-Cv29{`F&h~*lj%oQ56e5aLwilZG-bupe`0}cx4x_WjT^= ze*_OXzSUm26DX_+a{e2rry79+rbe=5VPDYjUFi2*Q9%XXYPJxk(l}BfzSY!GBV4@U ziDuD1qHB}9mu+O=8o;>w1!BfOn+^4 z+cwFsu{kZ<`P9f>|DHL30IG3_U=i`@?2{;O{RO8SQqxMxj&yV7Bw!n=0 zqVT(?Ln}2szr1V~U}|Oa%#rcbqg~+bMZNvs`%mtfzSA zEQR8HT!XPlK@9aLmoVkW(H_=EtU_|G*bbodz8K>ycJ2&Jt@S*1 zY&sV>;+^MGz(Sx%&o7#HJb0MQ`+&9{D~50MdWC8{dAf4BR!W8iJB>sx>6bes{{dylqP35>$9asRRXszt^!(y2)J z(hBwj-vjdjEv}x>{NmT-7m6xEir;I@Pfz7zdPzM!R29t6`mtYV!gz5^v(iiGh%>3; z{GK*F4fl^!Twf_OfI_EsB6iNRnV><45SWI3ZGIgfijg!sZ}4_VB}F7QcysysH@HJL zietCT>H`@I2+q*Z87p6Rf(=1@*pEL=#y&4@J~YyX&V=IhkM|rbECF+}74qfWU=Xa| zu-?2#DN782f;ASQ0&#fNN?-gW_BOkoIq*}zD@whbxOn51Th33W$UlL`#t~vEZ;M3HEa}6PC?>o zR{CG&8t@4lz`q3OL1-TJ$Tc1c4-UiwBd=nS)v)pG z0Yv?Dk}#VXs_Q~0XyV9W26iJSpxdh)Ww0NCw{6Aa9lg}KdODV9M9~3d66(D)wbz{O z-w?{tyrqSiRLx~C8NZmgL0|*(F$XF?x)8WjW=YE`Xa7Vp#FpJ5nE?2{0Ppy3`Wq4< zVAnfplN@7k*upPBikq)jwjBw~s;+ghk;hq}brxUF_x7{X+OT`Qqir%y_{N3v z5OHFCi~G1e)qK#?cwyNu{7xwK6d{{eY>5h44F(f7CN02>j{hGOLNh#}hoX-5prYj5 zoPdiZ$Vja09vmaP&?z<>R+CWx58w>EGYc#m*^eeU*Es_=cnt=Af~= z0pDVFllZfuPEiAn_{k2Bc+6@VfVdJ%;Cr@r*BmSBQk`wv3vEC+)f_Bt?om;_PMw$bD3~=CUhgXBkmGI3_nE*C}nISM3Kj*#@mpC+& zPXQT&Qg6%H2i(TO>WKFx13w*Z)sZDcP;hkT9DIbuRpY!25EnTY489sM!H`qt7RQM< z1(kR@qyIYGn48`)<4(ZBS<4%)aYboxcMEi5J3}V$+!ai2(1I>8!>T~5(4}WJ@7a2GmYA>B_`QE_JN+O5-qdH!iI7?2?yc`v)?t+ zx#TF5 zu5JBY?n$@HQrPbRSVdE07;5L^d3Quem(wZ&_u&YvH=Jpe?eX*|y{IMogZUECPgW{I z1dL0Clbue+ndKrX|6f)9ASW_Tc*2*=Y`~TZ4uezNq!CBZdQDtM{cAlcSZL=){6?nGY72|1+UE)m^Lbu*YAsFh3`*C?YVg{g7cK*jnTl1?gIR&EWU6Ygz%hez^ibMCkjmn$F5%U@ckxMVfws$Q zR{pAHXSbDrARji5>!Vl34*#sM0EExz9)_n3+p#JJCDkUB;SVv`|R9yB{qI| zlr<61mxtKXK$){0>DbFfZi>*b9M!BQyQ#RadW%y>JuZqjlI^DeieO^dI*=ZadD!}G zgAb{vsW+p^G`US0!|tWSBva1FZ9!Tx?AvyWa;dKpZ7E}<<6w5VRF4jL!1~H_FNHbp z19DGH=onH-H=$3ay#KOdfdV<rih77-4a+`#QwHv(S$F3P2M<)ypW6L5o$*+<8o&)4jcxwe_*Wc8;_;~RF* zW$mK{J{B@knc1o~l2xMr2>WhNwiAFf#$vpFua0Ol?TjuXp500^*O9ynNt9D8QGrQqgiCOZ9}LRxe)+jxS_$ryK>En=wuBhHgHQ=sn0MQ9gM7aU4S&Y!2z{!Mpwi0 ztkE*qG~9&^GLeOy^%+KMr^H9SFUPvz(0H#D;`a#doEkSFdn4{9J|;AdQbuo>BMTc# z6zoMEydwZppjr*Ycr((zcdHi}##$`~d19gTNU2KAK+O}5&^s4y3i$Fcs(ZLTJ2lJw zHUWD)Kj1Ic>+8oq@0}2ZLP(K{c0S%IX|kTLB{9?Lr!o`F3}hr$643(nm6L#PK}QiQ1m7D|O+$Bf3pIfm_iM>Qqh0`m>{Yp# zY`>xYw>h)U6ZHWiffR*kYfW;~ZF0Nnn~@2R4!}i$(?x3dbPsq}&h?5J2UU#& zs4_B)y39ZIZXGPPzZj+m30AxN=2*9hQA5^Ty?gROhVG37uAWxH+HZ16 zQM!z0oIX^&P&mTLytmcgPMh#pdmm{`?XOB#-%C}46Ke4CctUh*wf@SUQi6dO;I^IW zenSHv%B7EHdXalKA*8RI)} z#Onh=yh9UZrnlVaNQOm_>2(f+cvi1@`*sMCXzH$)*~Iyuy7xrMy4q27)sl@Mkykp! z-qYk{t4J2iyV$r0h)c@Y7@r*hpzDnm;Vd80F8?aGAY-bM`*~V_s?_1$npXOB`r9r< zXRM+7#SFCbMqhi{UOb%~XgPBMYO*e_2wVUg^eq2!gZlX0g%G4hJJ+n&tGw;f30}_% z(||rEUcJ68>)=&{^i*GQ%#IpD4m;zM*qkD(c>0Qbc6tK&lzn@ISD3(FvwAz<7}X`L z{mGH8hiul5>_9M5QIDgk%cAI{F`nV5Nzq*z^0Co8ViP<;1bEL_a)_bgmL~sMC*-Hm zH4k$l*}hxdP&}Krk&8jH+&eZ#K_&3wEt49xY&9rk2r1;qOmb)RNLU$EC!w=%Qjq!!d+f$b+Kf+u_)Q@X-WF{WWkbzDQ23vYb08w^%eAkflLjnV@q;>V9B9P5gaOXor0{f^@4m|9@k@Y&i|rR~h7oZg zrJ(H-7ypCzT*Jds_hVml^D3Q+_+zgiS6|mP1+8|eeU~qRlVgV1${9NVK@~iL*u6hv z5;$9Z$MQX37O16yxNXKvX=uX7cID%YE7dArut2fnjM5*g{Rd{~Xs3eV-iUj#g}Jks zDty|5i|gltVY<7LtioS}$+phCYKBs{#z#8}ImsJ9J2@nvC1Yj zX6o-mxGU}Vg;jfL!|3+T;c1%5a9H&J!``K!{)}X2{n#r}&#dr)qht9&k`npmHgsfO ze#<1%3w$WdRj;S2Dud=GK|a?llx99a29IGyH;?07h-_g&~^ zyIvFqqKRzTmq=H5BLM(fZ4Txa0D>sHbB}m!cYy_-!$;a)nBet0us7 zC&I|O{yMYgCs>{AS&ctJ4_QLfbC;R)fVhN8x}L9)o=u%IJ5*R`I$RM19FGwM_~`&d zqb_K8brJh(7|{DNQ4cs!3;E+}4Q+fWoy+Lo^u--K#I(Hh^uf}cw{SYH* zj+sqF;l7WPDEOy(&Cs|h5Jg$zm=*WEtb1pb`y^KYai6DkHUlEbnF$CJwfZ)Jg(9N~ zoOYrPITx3-6!c(%NS%pomhQrS(jA{u}%|#X0||GiIwaRp-7zrx%*@ z>aKvOvu@fQ&{bF((_8X9&%mT^R8d-MKT8uNPX9*CY#p(eMcB6N->iZ^|fJb!ZGRqfZpw)!vXqoMEU z9&I+V^IHep0m0P#zkN1}>}bxG)=V4dNo& zC?%2DZ)c@oSx44|Tus4#>_@~D$e*GYILgAn-hh??J}rEzj@7asA4%G2|KT1%B(i?2k zf#s$oea=&;f!(e(7Y!($&_$h@x_0r<5e=eM6SwYI#wwHpD+{-I}YKkxpR9>pU8U9#bhFp%H5L0J!>q`z zP%o*$y{H2*Y;ksT*PBbc536DCBo+$D4}#3`s3ktLJd++%6<*oln5glEw{UQby>u-V z6lHsRz&|n{4aQ{1dGomMXQ)vo)ER%=N?4Gb7?k3g-xv5y%7K4G72UreJfFWvJ)mUp zN@vx(7B>52+6nV+Cq@XT7#v5hj{+-StV;Q3cZ6Hd9oBO)7w>)#hx&edOaI@WZCZ>E#EA>95^k>;wy?((&*&XIhrSc z#OAHryZB|AwLbBMYWvS)NCHh4DqD2NV@*-+T#u=MHLi$%L>Jr(j-idHC7brd{%=03 ze0Dvx)pW3h%odc?y-5&^pHRIaVUfALR?Lw*9jsT3$ zKM!C@+#A5!LhX0!A0@G>L1eDF3t%>vwoc;+oF;o6|0L6du-M47y;cnatT?wIApa&M z#nx&W9L(!_wAQW(w98XRPbNbP5g`+ZMU1Qa@gdzV)*-)y8+7UY0leRWq>2*^ID zpyO6IJZMt9GM2yh7X=bf1@PdAN7D7k2xtld#Iu%(;%l-qT~ZQolp%!=bk_zg`4ga% z5PZeKwHXKQgND~<0i4>vS z^EO#j9uVf+aKCWMh|3FaDeb*5_j!*GV=O^yeiyG^jp`D{@*#Y2Dz`El2&yiZtDX2O zeXPOSjqQCW0@+?#YbEIE4-B8>aO9qmj($;&_4s?xv1~;{{j$h|U3^1~$T!Fv_&3L*uRbV-#n_!3JZJzz z;>8N#1feithYCvVMU@v=6N2GT()3S7usO^l*W9>aq2%zw@tV^_70A^bj5D?v0;3{U zu3R9i#YNAIKuuPwN0V4yMlC{esrObnYB({C`%Mnr>qw8|P>v^aEQa820E5|em+C-q zoLrXl50S>JAeu`&84_MAA_dDr$$mX6w@!(&-)IT_W!!$>@*+9qU7tQCBXYeJ+cM_0 z$SxNI?n^`4sKaJHD_nEb9S>U&RkF4Lem)cUzQFzYbUBYYb*{G_2XH$V52)I;ExQ^I z>vo47Vd-wHE`#%Q0yiy|+9DM&U0>ttFY}Ky^dyPkRn83|xeN?31Vq-y&CJ{c+Mjd3 zUw~1H)23{BAH;b9K~-rr;$mu3RTGHG)WB=w4St+bn-s7eds|IC`3t_TUOPmW+)ZUa zgqTqf9EHYwkggN*iBO=(G5A-MM48L*Gt!I(UFx^%J{$e$Q0tm7$ z@v$ZdMCVzds1AM#o#PV69oDRO5BQ3#JClJo&FEUxdfdcvm=S%&HyQdS6nM>K;6`Z{9C5=`rx(5`az@$SHsOY^cZgr)74lC;wAJVl; z-Yb>l&iA-8e2}I}OC_@fCqwnBH&pH#cDyIrYV zFiwp$v#FKc;tPL}-mQi@;!n_2>7FryNEesyk7+tsV8=q_AQ5KIUWMs?RKTtB>L1Hqdo$86u4%h}H zia4s&26##hV2gVmVB4N?l-!dW_kBsOL0VB(1!uK1&e1U>dM1J}UWb2EJa^OC?V!Ro z5wAYqQP{Euje4ll(*X%&Kfn_9cM4mX$9j%YELMfwRwXKHJv=rHb|OA-PR9}j$^@0( zhFr}tl{yJyF`lOsa{p^```0R3ZI@r_Jxxr9mvs8l{3gw@e8W6T0wj=Khk=rg|AF7% z(rC$S&gIS6`RSHLZTmr1lh~bT@CcfZfCN=OAQFT%aj6c1nAVeWg@sh644Y7-2cLk3 zC_x#CA6lV`PH5~dH&e<*v&jnSVrd;z<>f(w6^MHrmJx}ehJN#E4g?a2a2tQ&v}aAK zmR34GXf=4h0`yg3kE0Y3f+celoIqo4d}gND-H+}=UxIs@5k&9m@;p)h6-LG6-~YJ^ z*xpY{DL)&RBd!3`AtR3$xX$(W0+}|L8_zPx^bVIKcE%3c+rtM|$4xt+CMng74FLZ|-wyawawDR#SD$bVl4= zKv4ANPM@>p1FlA@#^?J&d{~cxN?T%WpSf<)U4KqJ%Yqm93AyxK<2`Wz zEK&LIMTb$>rOl74f`rNG@=AbmOtNYTDC)~t+PQ$W~(HolleqkwQ(xTV@~c6 zz`Jo+%9t6_btXa2v;qiJI)HIUf+3Ar(yWY4Ilx|!6KUdlGxc09LHR7sHhD`^(nEQv z^;rA+Bd+X<9hTs(yx2G#St5@BhBybB`cZar^AE#?mJr~_LI`>}Cj5B7M}AKhAR92F zL7{;FC;$Ke0hvMM06`_c!&;NEn$D$LW^J3lS-Ivqe=p5kK-3xH6W#C7M8?mznIqIE z?H-|^t^oe4=50olNcn(V8nlF8{ z!zO(CdWLvo_ZQ2()BViHhLX-%+KjH9x>alMI~2;N{!D?m(*~vD>;P&oBo@T|6bH~V-cRuq=UrNNIEB67Q5J@v6(%_&+0O zKeTWIcC1eTJ|9fQ*9p{cc;%Tk&Krk&cs%W*Mld`D4A-Sa*?ry^-F>8}ow7?Z4+785 zk&x#bGSuy!fc0M8CfUjG-2nrwaVGWGXo!#Z@`xp#4!$*V2HE$@j}5{PwJ)8SC0pJ2 zFvQ$>KOaD7C=@KxweNM552ei;kCRoB=OWVg_2*~ z5N6O%xq^?U14(~yB0SLu@0q+liF_yUh$7ry?WEabkr&_HCQz_PU+;rzPV&`wu{F-K z@mGoTvO3;x@cSW_T~Aw-HX^TxNRfw!KTx|Jki1!?!dcj(Al87D*;V@X!9}^MhhfY&LCWOAL0$UfceXkpmB* ziZx?c+;M7II^AFKl^!H$a0Ab0CQt=lH)lpXHjz9OpGc~}*JD9NRkxu` z;zR=L5FsUHbObHG+5XP&l4kXsuiOxci;)y>R$747%Bu_SAESV|+tZWiDseg6Occ=O!j)6gl%8e(n$o?g>^Y3% zF(gbf1e7K(@W%vMxv-_r-gM*SXu9qFv`fI3tcBp_O$|pjJ&cm7+;21CjFGpZDOk$& zvJyDt87|u{F%W=wJCzW&K_uvV%Y`iZ0JbhI-Z^_CBVY-*qe0Js08jt`00GBA=m3Ew zzmd*yu?1Q{m;pa+b9BlK)5a{oN5ynB?yimE!~VxP7t1uzcT1l7)ex+i>$=KQ*puM& zEP4aGZ)?Y|T!M2U_kA;%#rV18w!Fu}c4U%`I>-q8)4_^5RYXsV8MRpYf} z4Bf|;I~9q2+{7Y)I6?W(q9w7b76P!@yoBAJ;32+1k3tq^iQJ>Z{)& z!_E(zVE6!7D1B5DSyA5o4ke7z(F9zD<$hP>7?i4aW+Z#@$k$D%Yb!f3L*C(nqi@mb zRlwAOH_rSqN>OkrXqxYnl4T|*iJ~WfvR)1r<%4V##NenR18v@NfpH!FZ?!|(FM7o- zB;e3hDRO9$X12~9);`WMHu3t5QD`)x!9;=&^hs*H+^Ts)m+D|;(wb|$VKiNaOQLtN z^R%)OMc6eh;GFu2&}_ajaz>{|g)bU?O4yQ&_%3lhzsyREnk%9-)b*^sYZ);quYSdnQ;+1xW_M=vB4Du1Bej#k=30#4P1IPyBLwZ6}gAaSA;f=S%clwTvJL_x zk1FmT-0LORw)U>87#tn_`zCKJK+iqjlV0}g42LbFwDe&cPII+Z(+^dzkQ11IT=9K> zN}fH5-Fuk7*t0TJu+3n|h@IOmlkNvEFz;KX{cU1Lr=GlntiIlo92O;(_e+~ez^1Xi z7I73#vbcce{Ae-J^JjzAhsB@(U_hfm&R?=yd~hK+qsjLnN&p7nzgRom&8=RKdh@iPs+Y5~dr z7>sD(N^>Q?OKH=L`^;j#d`d0v3`9Q)%&>QQs@FH|*5yzzRX_*$qd~2K0E_?t00E^z z?f~H>zr$LP#9^z(%#=}yUH5DRO5StilWrmQo#J!a7e(iNfL~h*gsYm9tiMlx;M^wR z6|>KOQ%&(BCm#}kpWfB^H62c_UYT-G8j;@%$0^WVjz++M$*+=c*?~2KmMI@+kkFkj z0~qDy(VzsZ=|rXhb_myIbdaa{u;8Lh&xK0)w0CPW!^ZVAJxlxYCkuvoo~)3BOitvD zxYdl7)khxO14YrI#&=+e(Wa8<6Kr6#5V62>Kce^R&;I41#j}*$%iq$A__Jum;Ii@y z5@-8lW@;{G@e|cmc0<1N>P)e`(>*ur3TO>JVLkP|vtC^?xQT2baufmf{L2(kbUX^{ z9G61`5HN7dNu-ek$l8l~p}eSAQ&ETER^y@RfjcCA#?d1y6Yqe zzX%7W|6p~Cbo0`#Ef5i#vpmSa5-z}V@!%G}lTaO}7qSH=k*ps${VN#TSC6i^G2JQJ z{xA}dNDP_DsHo0j<{>ioB8L7)gGp%JH7key{+X}YT)1;KL5i)fJR+k=!c_yMCfwGj z007Z|qe0Gr0H6Q>00G89@BslOzsVyVd!}u1R>gKB+q(j*UX&l{>Q%}SC2G^&hAfn< zw&H}B=ax-)%}%s1M|0;a0ydi9?>HE7w?$%UtU|B2dVy!(*?(Lc;P#8pOBkPGC5Wn) z$@}sfU>%0^Ey4`lQ`Fz{JlH~%W@YEMgZ`@53vHo1;BqblxL<2TsvU#2SHi$h7E-bD zberjr1m|=ZJjDoKESf{Y0oQLRALz`P?G z*)-`5?8u@2rF&UH3Z>DjfdIMSx_XI^McGB>$dX4^&wW?Bz_V}o`jKU>fyqktyPe{Q zes@m)WBFf-gAxc>){QnkCbVI*Nh5ld94$yLFX=vc@ixV`O$P<{pC6F*D1#8@+VFrC zFrz`cfdI4s0005ALGl41CBMi#*T7$aRC=&>YhC9U{YX=G(`3ppjcB-R(>XLx#EkwO zhCH$G4bP+ni$5XO3UQw{*bbk{4%Run{>W|WdcgUQW`mq%ANwMbwu*nS}}vUDh|(zX^vV*X8%D;C)l~8U9EvjAK#3`>NPp-D10+@ zZ$h^-xh^sK<}$|qWo`dFdwpZGLe!fRWEP*YZNx0(IhIz;U+@U5S`O zYUqt{96|Hs^gm#qyhp`OoQSy0@t_|SMv6Wt02?r)LAQYb$N&HU0k1*y0YN3dW!AzA z)|@JzapjG7zbt`|em{JCj@ZAfb<48Qp(t38I${B(jvtEq6^b^sCm?x*g$cL1D%G*| z9h~nj?LNm27L*6^e`0;Y8vM~EUw}6+y+TSB!H$F7QBQE!e4A`p0A{nap41JqR4F5` zFNz+os|6b=M10E;?wHI6L%M>fubM&kU+^Q_vX`RmTiQ~eZKRNb?6Z;D-xy`XN2@!v6 zxSf)uk&K9^hQMA9mIcg-0srCwyBbDdHV`5;LzAn2J0`WMs2jM5K?w=m)Yu^O~vI;f@P)KDP0m-97y8?XtYdn5!V4sA+U5 zZq?&9bNTC`o%SN>!bCG*#c<$Il(iiOeDM4H=Tm!ae51H4jPVvCKj!T;o-ykRh{+ z!bKS~WR@{9B_c!SDPyMMx%b=ed4A9LzrEJ$v-f%L&pzv%wf8ye^QO4dSN+x1iKfn7 zLS|(r=8Y_*mUHDpy8dk}qxG(I@^(S{Z%599Ua~& zCFWUO;ikUuNZbfT_1$0B=hk+cJSQr{c~%^o9`>&F zfF4%IqEUrDq3@{1hzn9$;KW9Z8cNYpQ^xu8m^=NsU)rab zfayR6Eg|KQp)rhU+l%co?hhs5K3Su+%kjSj1y@F@QWkq6rdBze%uZCQsz$%t1r#4q zB(;m*;EZ);R*Ux|U4XO+$?4M41WVDe!aShi2=n#{OUlgdz=TKU&a)@3bz-Eh+TPk6 zfNttYRQ%&vp5`2pWwh3$Z20VhG5zRehw2=-oP7125LA&4lqdeW?pmb^Lr$i&q z)}N%fd@P?YuVaSFJ(05;wv9iK8QUj75_=Ot*U(0 zFU(PECFuTJIUDCN9voZLGMyqLLWxXk@G~IED4jlDk>Ku;I){1(FKv~47NJOq3D1tI zd%&^Q;cdF8+$`x_GRUxyiBM017v)p<)Q}-PQcMGwg~jtfwYnUb!tn(PR!!5Fwk|bC zkX>u*56zz`T#orgYdks;B|IE=CFEu$&c2bdH}0vCo6&u7o07SCR5kbBq2^JcIcw;6 z`~ES*@_6r)6VVif!QzdD8|vC{2gR!oVq?3(9$|?nG|Mx8miHNd_&3+rv*sR0@kWwU zgvqn<%yO;sTz-O9Cb304rN<;>EvAmQqLz|kRnE0F-7tUhB$!=Niwxu340>Oth3))G z7BW&XF1Q63^cUc|sxF%4IZ81p9NclQdm=O%@&mW*{>5V}@*A!%qX&G`bS`T=HDLLt zMgpBkx*NUwCuR8E7sVkR!#F+Mrf;d*!O$k)+=2!D&=5Fb$vnUlNG|?0hk3?5^302; zuOcE9f=!FikdZK;{8F?wMOT?ctLeF$0Hh1E8`Qqii%ACv4~S09BJdw-k92>(u{&oi;> zum!8!#&Fk77p*PaTymGDMTuOaUv72=RQuWlvrwhg~n&8$(Fw5RmRc=e8(@s7p4v+Ok+C&myyI!b}T5$BC^HUX-J z>!Z!4%amKQXCa6yz2D>S>OH_H_pNwVrl3h`igy%5vc1;B9Bsst7~0*wu8<-WcrVA~ zsvdJchib^kVDfT@1zyn*Q^#b!gzz3yi0CB{qXsWiisw=(bA9cVpS`?|1)b0kCPMD5 zz~7Ko{Hbz&k><4XSBS6yFa26z+nAqC5UCv{GeX<2@$L-M<9+GO!=|yx+Q>V8*y?*6 zH`^j=xWCm{{DK+E>P*N`RhKnBWL8YywZ6&B+>hpis(e1-e>n0-&t>wa;qy7?$jioJ z>O_g~(|6Kp$>9O^`r*XWik^Zm*MzD@pnFm`Php=fE4NJ9%awh|GoCx64)^}S2{7Gd z_lHHEUs4dPke%-zQFxvfq$L7UXnzzcmUOiHXu&kb@4Z-(-t@#nR;pA76Lc@Gtx9$RBRI5KOS4$U;K|3As)N z&meR7T&Gl1v+B?bqD9*3i2M6E&$Ubh9mZ2h067L-y`+(W6uVD!tZ^9=kbY?zsv;KD z_|`TvTifb3^quvI4!A;RU2h= zwW-;)fJ$4=D~M%S%(9b2^r(q{$0det8k12kdP}7liXF_(+SQ&x$Pj*+NVr^o zVV`+ljaQY<2b0TlD->~&y>F&9UWol ze7Z?|baSphSgz>mQY*~N_b3$8d1EdHyr4U~6#L-7SZPbqXv_1T4X-vDf+Q@E8aNAu zAe!U)zA*)d+-2Pc$iF2G8qb+R1Q%2?{n0`q&1<`XqpL6tWx7s%mEbj3DU%DDi%#oPu9ekBXh^E!mEZzuQ zqopIgAzS_$gZ~3nrUa+2ktBX=ohbdwWiCR*nYewONEw)7R6Qk_JzR5RvVUjb1-lLp zJJa#4hMGwdMfNA=p~jnU+B4xK(<7g6EDBs~yJ@kDt&pa58_ls_-u$4jX4XIn4TE6Bhk@sP1n(@;>jc{a$txCt4V958ek=Yl;Hu0xkR6}OyX0ywSqhd+~ehmkZS&x`^gtoY}4w+@gsDXow>|b-)?800j}vT~s}GR$t=x`RA_-WYC?b2+#U?E|aFY z48Q7)*8Y0I$C!AYn!yjbS5{73#&SbhMVH~nPh;MaUv$`dc$)5G;?S$SNe$A$P^kKgwY41^{MBls%&Xvh;nBHF657)W`k@nN=@){0rrzVwh=V$*pz zg6(Ya+}l5F)>Jo#E;Oh|Sf4-UM68!a*PZSgC<^!O&lWtN}OYs50A5TQsKV zE3aVKwx7&A>lhu1P4ciQl=E;;hZTVqyYFAE@bi*9&xz`=Hl;y-c(3uC z%E%t`22GoeJYt`kK$!`L!8;KKpSTCJyS)tP9h_#x*d8(RX_-ksnA}{>nLx1nfPtuT z%c(9RYqSk#ngz~z%ltW=u$2+)kD?^Ly-kQySbbM=$2 zhp@&ZcVB76&)v4^J!d`)%%m_wQ|{?pR7!o!hepoI?~3lUVRi{{(XN~Tf1zp7^{#UL zTwr!emxr8R>kECNPqjLgKlih&e#=;v1toim4DyDz<`=NY5Huv8aBuj7YtURobMJ`A zqa-d7eVLJ4yx6EQX2oQ>3zM%y?Gtj)oWGktb>3kVRkc91sSF~V^eY(nF0+|)jj$id zY>E)mYY5B>4AMApM3)GTXn$wWjpH6T$Xw`hmK9~UUWi`xg@&{Dm5X^GIZ=v4oRE&P zf<-o3d|md$VC*0o@}8hU4cvgXBAS&?(p^5!$B$+qm&EDcM=^}5FZS0R(~>bJju}R) z9xB;%t$>SFu-odZCOw`q$V48tbZsXZpmgtv1a1tlQ(X=jiDB zZ8>NxJZa!^3a2*)2DqyN?r`a|P&2{b6}cVJofwY!6{&ghyYejX)_zap{?o4bBIjt` zOSD*BP0N24I z7#h*6E|7VF%Fn#m#7&qg*j+gglvDhi5>{Q5$LBs~%yUL+d=XCm9RMUOvFT{2GNB_a zcmm5sGz0#Kcl^;mnGnGMt2-8nrzQlN2>yn(;;sAAf7;4R4sWpDTcul!k(JynmH@69 zls{x=Q6Wh<7ZF*~j=ytqS}^fU8bvEa9YEKhsF~9sZ-b_aj*xtejlEyS@bT-bdvgLx z25kZw z5|UACu07`*s8@Vt6Rc>qUcf^V+0Io^^}clcDCmbqT-7LaS8M-ATRTr`MJpQpaiikW zP6tO5js9KqqF8l>s@VWmFkm$c|_JH4CH~X5w64O%YC*)J-E*CTlpkpbmxF{B#OCfLIFPAmr zb$uXkSySP38o#rpJjSHTjUmtvC#JR*q&tP9Ij_xbtWAD>J`Fv5`MTrRly)OI*ibsp zUKS}iHoA}*Oy)5e+c0MSA!>Q1@hvikoBbWViUBpWkRVR%ueg6q+wlwbU)Ab=!9EfA z|5f9AnEeI+m{|X-hx=dfk8kpS!N6ZfUqJP*qi=oZ3F7y3ct5GUJ?XjfQpyj*E@4|k z-^D9AQ8wwvS5ET`fjzL)nkafEJU_azj(6l5=4I(?V$C`q(nvkvq7*@M>fa0f;~M*a s7h=_KcnBc?0FZ9@PlL7+4b=XpO>&5a?1Vt-|CiBK;yo1p4ZSD+2c%=sC;$Ke diff --git a/tests/extras/datasets/video/data/video.mp4 b/tests/extras/datasets/video/data/video.mp4 deleted file mode 100644 index 4c4b974d92b150816de370068fbc97bd977a500e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 55132 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 diff --git a/tests/extras/datasets/video/test_sliced_video.py b/tests/extras/datasets/video/test_sliced_video.py deleted file mode 100644 index e2e4975d1a..0000000000 --- a/tests/extras/datasets/video/test_sliced_video.py +++ /dev/null @@ -1,56 +0,0 @@ -import numpy as np -from utils import TEST_HEIGHT, TEST_WIDTH - - -class TestSlicedVideo: - def test_slice_sequence_video_first(self, color_video): - """Test slicing and then indexing a SequenceVideo""" - slice_red_green = color_video[:2] - red = np.array(slice_red_green[0]) - assert red.shape == (TEST_HEIGHT, TEST_WIDTH, 3) - assert np.all(red[:, :, 0] == 255) - assert np.all(red[:, :, 1] == 0) - assert np.all(red[:, :, 2] == 0) - - def test_slice_sequence_video_last_as_index(self, color_video): - """Test slicing and then indexing a SequenceVideo""" - slice_blue_yellow_purple = color_video[2:5] - purple = np.array(slice_blue_yellow_purple[2]) - assert purple.shape == (TEST_HEIGHT, TEST_WIDTH, 3) - assert np.all(purple[:, :, 0] == 255) - assert np.all(purple[:, :, 1] == 0) - assert np.all(purple[:, :, 2] == 255) - - def test_slice_sequence_video_last_as_end(self, color_video): - """Test slicing and then indexing a SequenceVideo""" - slice_blue_yellow_purple = color_video[2:] - purple = np.array(slice_blue_yellow_purple[-1]) - assert purple.shape == (TEST_HEIGHT, TEST_WIDTH, 3) - assert np.all(purple[:, :, 0] == 255) - assert np.all(purple[:, :, 1] == 0) - assert np.all(purple[:, :, 2] == 255) - - def test_slice_sequence_attribute(self, color_video): - """Test that attributes from the base class are reachable from sliced views""" - slice_red_green = color_video[:2] - assert slice_red_green.fps == color_video.fps - - def test_slice_sliced_video(self, color_video): - """Test slicing and then indexing a SlicedVideo""" - slice_green_blue_yellow = color_video[1:4] - slice_green_blue = slice_green_blue_yellow[:-1] - blue = np.array(slice_green_blue[1]) - assert blue.shape == (TEST_HEIGHT, TEST_WIDTH, 3) - assert np.all(blue[:, :, 0] == 0) - assert np.all(blue[:, :, 1] == 0) - assert np.all(blue[:, :, 2] == 255) - - def test_slice_file_video_first(self, mp4_object): - """Test slicing and then indexing a FileVideo""" - sliced_video = mp4_object[:2] - assert np.all(np.array(sliced_video[0]) == np.array(mp4_object[0])) - - def test_slice_file_video_last(self, mp4_object): - """Test slicing and then indexing a FileVideo""" - sliced_video = mp4_object[-2:] - assert np.all(np.array(sliced_video[-1]) == np.array(mp4_object[-1])) diff --git a/tests/extras/datasets/video/test_video_dataset.py b/tests/extras/datasets/video/test_video_dataset.py deleted file mode 100644 index ceeb13929b..0000000000 --- a/tests/extras/datasets/video/test_video_dataset.py +++ /dev/null @@ -1,186 +0,0 @@ -import boto3 -import pytest -from moto import mock_s3 -from utils import TEST_FPS, assert_videos_equal - -from kedro.extras.datasets.video import VideoDataSet -from kedro.extras.datasets.video.video_dataset import FileVideo, SequenceVideo -from kedro.io import DatasetError - -S3_BUCKET_NAME = "test_bucket" -S3_KEY_PATH = "video" -S3_FULL_PATH = f"s3://{S3_BUCKET_NAME}/{S3_KEY_PATH}/" -AWS_CREDENTIALS = {"key": "FAKE_ACCESS_KEY", "secret": "FAKE_SECRET_KEY"} - - -@pytest.fixture -def tmp_filepath_mp4(tmp_path): - return (tmp_path / "test.mp4").as_posix() - - -@pytest.fixture -def tmp_filepath_avi(tmp_path): - return (tmp_path / "test.mjpeg").as_posix() - - -@pytest.fixture -def empty_dataset_mp4(tmp_filepath_mp4): - return VideoDataSet(filepath=tmp_filepath_mp4) - - -@pytest.fixture -def empty_dataset_avi(tmp_filepath_avi): - return VideoDataSet(filepath=tmp_filepath_avi) - - -@pytest.fixture -def mocked_s3_bucket(): - """Create a bucket for testing using moto.""" - with mock_s3(): - conn = boto3.client( - "s3", - region_name="us-east-1", - aws_access_key_id=AWS_CREDENTIALS["key"], - aws_secret_access_key=AWS_CREDENTIALS["secret"], - ) - conn.create_bucket(Bucket=S3_BUCKET_NAME) - yield conn - - -class TestVideoDataSet: - def test_load_mp4(self, filepath_mp4, mp4_object): - """Loading a mp4 dataset should create a FileVideo""" - ds = VideoDataSet(filepath_mp4) - loaded_video = ds.load() - assert_videos_equal(loaded_video, mp4_object) - - def test_save_and_load_mp4(self, empty_dataset_mp4, mp4_object): - """Test saving and reloading the data set.""" - empty_dataset_mp4.save(mp4_object) - reloaded_video = empty_dataset_mp4.load() - assert_videos_equal(mp4_object, reloaded_video) - assert reloaded_video.fourcc == empty_dataset_mp4._fourcc - - @pytest.mark.skip( - reason="Only one available codec that is typically installed when testing" - ) - def test_save_with_other_codec(self, tmp_filepath_mp4, mp4_object): - """Test saving the video with another codec than default.""" - save_fourcc = "xvid" - ds = VideoDataSet(filepath=tmp_filepath_mp4, fourcc=save_fourcc) - ds.save(mp4_object) - reloaded_video = ds.load() - assert reloaded_video.fourcc == save_fourcc - - def test_save_with_derived_codec(self, tmp_filepath_mp4, color_video): - """Test saving video by the codec specified in the video object""" - ds = VideoDataSet(filepath=tmp_filepath_mp4, fourcc=None) - ds.save(color_video) - reloaded_video = ds.load() - assert reloaded_video.fourcc == color_video.fourcc - - def test_saved_fps(self, empty_dataset_mp4, color_video): - """Verify that a saved video has the same framerate as specified in the video object""" - empty_dataset_mp4.save(color_video) - reloaded_video = empty_dataset_mp4.load() - assert reloaded_video.fps == TEST_FPS - - def test_save_sequence_video(self, color_video, empty_dataset_mp4): - """Test save (and load) a SequenceVideo object""" - empty_dataset_mp4.save(color_video) - reloaded_video = empty_dataset_mp4.load() - assert_videos_equal(color_video, reloaded_video) - - def test_save_generator_video( - self, color_video_generator, empty_dataset_mp4, color_video - ): - """Test save (and load) a GeneratorVideo object - - Since the GeneratorVideo is exhaused after saving the video to file we use - the SequenceVideo (color_video) which has the same frames to compare the - loaded video to. - """ - empty_dataset_mp4.save(color_video_generator) - reloaded_video = empty_dataset_mp4.load() - assert_videos_equal(color_video, reloaded_video) - - def test_exists(self, empty_dataset_mp4, mp4_object): - """Test `exists` method invocation for both existing and - nonexistent data set.""" - assert not empty_dataset_mp4.exists() - empty_dataset_mp4.save(mp4_object) - assert empty_dataset_mp4.exists() - - @pytest.mark.skip(reason="Can't deal with videos with missing time info") - def test_convert_video(self, empty_dataset_mp4, mjpeg_object): - """Load a file video in mjpeg format and save in mp4v""" - empty_dataset_mp4.save(mjpeg_object) - reloaded_video = empty_dataset_mp4.load() - assert_videos_equal(mjpeg_object, reloaded_video) - - def test_load_missing_file(self, empty_dataset_mp4): - """Check the error when trying to load missing file.""" - pattern = r"Failed while loading data from data set VideoDataSet\(.*\)" - with pytest.raises(DatasetError, match=pattern): - empty_dataset_mp4.load() - - def test_save_s3(self, mp4_object, mocked_s3_bucket, tmp_path): - """Test to save a VideoDataSet to S3 storage""" - video_name = "video.mp4" - - dataset = VideoDataSet( - filepath=S3_FULL_PATH + video_name, credentials=AWS_CREDENTIALS - ) - dataset.save(mp4_object) - - tmp_file = tmp_path / video_name - mocked_s3_bucket.download_file( - Bucket=S3_BUCKET_NAME, - Key=S3_KEY_PATH + "/" + video_name, - Filename=str(tmp_file), - ) - reloaded_video = FileVideo(str(tmp_file)) - assert_videos_equal(reloaded_video, mp4_object) - - @pytest.mark.xfail - @pytest.mark.parametrize( - "fourcc, suffix", - [ - ("mp4v", "mp4"), - ("mp4v", "mjpeg"), - ("mp4v", "avi"), - ("avc1", "mp4"), - ("avc1", "mjpeg"), - ("avc1", "avi"), - ("mjpg", "mp4"), - ("mjpg", "mjpeg"), - ("mjpg", "avi"), - ("xvid", "mp4"), - ("xvid", "mjpeg"), - ("xvid", "avi"), - ("x264", "mp4"), - ("x264", "mjpeg"), - ("x264", "avi"), - ("divx", "mp4"), - ("divx", "mjpeg"), - ("divx", "avi"), - ("fmp4", "mp4"), - ("fmp4", "mjpeg"), - ("fmp4", "avi"), - ], - ) - def test_video_codecs(self, fourcc, suffix, color_video): - """Test different codec and container combinations - - Some of these are expected to fail depending on what - codecs are installed on the machine. - """ - video_name = f"video.{suffix}" - video = SequenceVideo(color_video._frames, 25, fourcc) - ds = VideoDataSet(video_name, fourcc=None) - ds.save(video) - # We also need to verify that the correct codec was used - # since OpenCV silently (with a warning in the log) fall backs to - # another codec if one specified is not compatible with the container - reloaded_video = ds.load() - assert reloaded_video.fourcc == fourcc diff --git a/tests/extras/datasets/video/test_video_objects.py b/tests/extras/datasets/video/test_video_objects.py deleted file mode 100644 index 66a284fa60..0000000000 --- a/tests/extras/datasets/video/test_video_objects.py +++ /dev/null @@ -1,170 +0,0 @@ -import numpy as np -import pytest -from utils import ( - DEFAULT_FOURCC, - MJPEG_FOURCC, - MJPEG_FPS, - MJPEG_LEN, - MJPEG_SIZE, - MKV_FOURCC, - MKV_FPS, - MKV_LEN, - MKV_SIZE, - MP4_FOURCC, - MP4_FPS, - MP4_LEN, - MP4_SIZE, - TEST_FPS, - TEST_HEIGHT, - TEST_NUM_COLOR_FRAMES, - TEST_WIDTH, - assert_images_equal, -) - -from kedro.extras.datasets.video.video_dataset import ( - FileVideo, - GeneratorVideo, - SequenceVideo, -) - - -class TestSequenceVideo: - def test_sequence_video_indexing_first(self, color_video, red_frame): - """Test indexing a SequenceVideo""" - red = np.array(color_video[0]) - assert red.shape == (TEST_HEIGHT, TEST_WIDTH, 3) - assert np.all(red == red_frame) - - def test_sequence_video_indexing_last(self, color_video, purple_frame): - """Test indexing a SequenceVideo""" - purple = np.array(color_video[-1]) - assert purple.shape == (TEST_HEIGHT, TEST_WIDTH, 3) - assert np.all(purple == purple_frame) - - def test_sequence_video_iterable(self, color_video): - """Test iterating a SequenceVideo""" - for i, img in enumerate(map(np.array, color_video)): - assert np.all(img == np.array(color_video[i])) - assert i == TEST_NUM_COLOR_FRAMES - 1 - - def test_sequence_video_fps(self, color_video): - # Test the one set by the fixture - assert color_video.fps == TEST_FPS - - # Test creating with another fps - test_fps_new = 123 - color_video_new = SequenceVideo(color_video._frames, fps=test_fps_new) - assert color_video_new.fps == test_fps_new - - def test_sequence_video_len(self, color_video): - assert len(color_video) == TEST_NUM_COLOR_FRAMES - - def test_sequence_video_size(self, color_video): - assert color_video.size == (TEST_WIDTH, TEST_HEIGHT) - - def test_sequence_video_fourcc_default_value(self, color_video): - assert color_video.fourcc == DEFAULT_FOURCC - - def test_sequence_video_fourcc(self, color_video): - fourcc_new = "mjpg" - assert ( - DEFAULT_FOURCC != fourcc_new - ), "Test does not work if new test value is same as default" - color_video_new = SequenceVideo( - color_video._frames, fps=TEST_FPS, fourcc=fourcc_new - ) - assert color_video_new.fourcc == fourcc_new - - -class TestGeneratorVideo: - def test_generator_video_iterable(self, color_video_generator, color_video): - """Test iterating a GeneratorVideo - - The content of the mock GeneratorVideo should be the same as the SequenceVideo, - the content in the later is tested in other unit tests and can thus be trusted - """ - for i, img in enumerate(map(np.array, color_video_generator)): - assert np.all(img == np.array(color_video[i])) - assert i == TEST_NUM_COLOR_FRAMES - 1 - - def test_generator_video_fps(self, color_video_generator): - # Test the one set by the fixture - assert color_video_generator.fps == TEST_FPS - - # Test creating with another fps - test_fps_new = 123 - color_video_new = GeneratorVideo( - color_video_generator._gen, length=TEST_NUM_COLOR_FRAMES, fps=test_fps_new - ) - assert color_video_new.fps == test_fps_new - - def test_generator_video_len(self, color_video_generator): - assert len(color_video_generator) == TEST_NUM_COLOR_FRAMES - - def test_generator_video_size(self, color_video_generator): - assert color_video_generator.size == (TEST_WIDTH, TEST_HEIGHT) - - def test_generator_video_fourcc_default_value(self, color_video_generator): - assert color_video_generator.fourcc == DEFAULT_FOURCC - - def test_generator_video_fourcc(self, color_video_generator): - fourcc_new = "mjpg" - assert ( - DEFAULT_FOURCC != fourcc_new - ), "Test does not work if new test value is same as default" - color_video_new = GeneratorVideo( - color_video_generator._gen, - length=TEST_NUM_COLOR_FRAMES, - fps=TEST_FPS, - fourcc=fourcc_new, - ) - assert color_video_new.fourcc == fourcc_new - - -class TestFileVideo: - @pytest.mark.skip(reason="Can't deal with videos with missing time info") - def test_file_props_mjpeg(self, mjpeg_object): - assert mjpeg_object.fourcc == MJPEG_FOURCC - assert mjpeg_object.fps == MJPEG_FPS - assert mjpeg_object.size == MJPEG_SIZE - assert len(mjpeg_object) == MJPEG_LEN - - def test_file_props_mkv(self, mkv_object): - assert mkv_object.fourcc == MKV_FOURCC - assert mkv_object.fps == MKV_FPS - assert mkv_object.size == MKV_SIZE - assert len(mkv_object) == MKV_LEN - - def test_file_props_mp4(self, mp4_object): - assert mp4_object.fourcc == MP4_FOURCC - assert mp4_object.fps == MP4_FPS - assert mp4_object.size == MP4_SIZE - assert len(mp4_object) == MP4_LEN - - def test_file_index_first(self, color_video_object, red_frame): - assert_images_equal(color_video_object[0], red_frame) - - def test_file_index_last_by_index(self, color_video_object, purple_frame): - assert_images_equal(color_video_object[TEST_NUM_COLOR_FRAMES - 1], purple_frame) - - def test_file_index_last(self, color_video_object, purple_frame): - assert_images_equal(color_video_object[-1], purple_frame) - - def test_file_video_failed_capture(self, mocker): - """Validate good behavior on failed decode - - The best behavior in this case is not obvious, the len property of the - video object specifies more frames than is actually possible to decode. We - cannot know this in advance without spending loads of time to decode all frames - in order to count them.""" - mock_cv2 = mocker.patch("kedro.extras.datasets.video.video_dataset.cv2") - mock_cap = mock_cv2.VideoCapture.return_value = mocker.Mock() - mock_cap.get.return_value = 2 # Set the length of the video - ds = FileVideo("/a/b/c") - - mock_cap.read.return_value = True, np.zeros((1, 1)) - assert ds[0] - - mock_cap.read.return_value = False, None - with pytest.raises(IndexError): - ds[1] diff --git a/tests/extras/datasets/video/utils.py b/tests/extras/datasets/video/utils.py deleted file mode 100644 index 6b675aed2f..0000000000 --- a/tests/extras/datasets/video/utils.py +++ /dev/null @@ -1,49 +0,0 @@ -import itertools - -import numpy as np -from PIL import ImageChops - -TEST_WIDTH = 640 # Arbitrary value for testing -TEST_HEIGHT = 480 # Arbitrary value for testing -TEST_FPS = 1 # Arbitrary value for testing - -TEST_NUM_COLOR_FRAMES = ( - 5 # This should be the same as number of frames in conftest videos -) -DEFAULT_FOURCC = "mp4v" # The expected default fourcc value - -# This is video data extracted from the video files with ffmpeg command -MKV_SIZE = (640, 360) -MKV_FPS = 50 -MKV_FOURCC = "h264" -MKV_LEN = 109 # from ffprobe - -MP4_SIZE = (640, 360) -MP4_FPS = 50 -MP4_FOURCC = "avc1" -MP4_LEN = 109 # from ffprobe - -MJPEG_SIZE = (640, 360) -MJPEG_FPS = 25 # From ffprobe, not reported by ffmpeg command -# I'm not sure that MJPE is the correct fourcc code for -# mjpeg video since I cannot find any official reference to -# that code. This is however what the openCV VideoCapture -# reports for the video, so we leave it like this for now.. -MJPEG_FOURCC = "mjpe" -MJPEG_LEN = 24 # from ffprobe - - -def assert_images_equal(image_1, image_2): - """Assert that two images are approximately equal, allow for some - compression artifacts""" - assert image_1.size == image_2.size - diff = np.asarray(ImageChops.difference(image_1, image_2)) - assert np.mean(diff) < 5 - assert np.mean(diff > 50) < 0.01 # Max 1% of pixels - - -def assert_videos_equal(video_1, video_2): - assert len(video_1) == len(video_2) - - for image_1, image_2 in itertools.zip_longest(video_1, video_2): - assert_images_equal(image_1, image_2) diff --git a/tests/extras/datasets/yaml/__init__.py b/tests/extras/datasets/yaml/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/extras/datasets/yaml/test_yaml_dataset.py b/tests/extras/datasets/yaml/test_yaml_dataset.py deleted file mode 100644 index 432afaed3d..0000000000 --- a/tests/extras/datasets/yaml/test_yaml_dataset.py +++ /dev/null @@ -1,210 +0,0 @@ -from pathlib import Path, PurePosixPath - -import pandas as pd -import pytest -from fsspec.implementations.http import HTTPFileSystem -from fsspec.implementations.local import LocalFileSystem -from gcsfs import GCSFileSystem -from pandas.testing import assert_frame_equal -from s3fs.core import S3FileSystem - -from kedro.extras.datasets.yaml import YAMLDataSet -from kedro.io import DatasetError -from kedro.io.core import PROTOCOL_DELIMITER, Version - - -@pytest.fixture -def filepath_yaml(tmp_path): - return (tmp_path / "test.yaml").as_posix() - - -@pytest.fixture -def yaml_data_set(filepath_yaml, save_args, fs_args): - return YAMLDataSet(filepath=filepath_yaml, save_args=save_args, fs_args=fs_args) - - -@pytest.fixture -def versioned_yaml_data_set(filepath_yaml, load_version, save_version): - return YAMLDataSet( - filepath=filepath_yaml, version=Version(load_version, save_version) - ) - - -@pytest.fixture -def dummy_data(): - return {"col1": 1, "col2": 2, "col3": 3} - - -class TestYAMLDataSet: - def test_save_and_load(self, yaml_data_set, dummy_data): - """Test saving and reloading the data set.""" - yaml_data_set.save(dummy_data) - reloaded = yaml_data_set.load() - assert dummy_data == reloaded - assert yaml_data_set._fs_open_args_load == {} - assert yaml_data_set._fs_open_args_save == {"mode": "w"} - - def test_exists(self, yaml_data_set, dummy_data): - """Test `exists` method invocation for both existing and - nonexistent data set.""" - assert not yaml_data_set.exists() - yaml_data_set.save(dummy_data) - assert yaml_data_set.exists() - - @pytest.mark.parametrize( - "save_args", [{"k1": "v1", "index": "value"}], indirect=True - ) - def test_save_extra_params(self, yaml_data_set, save_args): - """Test overriding the default save arguments.""" - for key, value in save_args.items(): - assert yaml_data_set._save_args[key] == value - - @pytest.mark.parametrize( - "fs_args", - [{"open_args_load": {"mode": "rb", "compression": "gzip"}}], - indirect=True, - ) - def test_open_extra_args(self, yaml_data_set, fs_args): - assert yaml_data_set._fs_open_args_load == fs_args["open_args_load"] - assert yaml_data_set._fs_open_args_save == {"mode": "w"} # default unchanged - - def test_load_missing_file(self, yaml_data_set): - """Check the error when trying to load missing file.""" - pattern = r"Failed while loading data from data set YAMLDataSet\(.*\)" - with pytest.raises(DatasetError, match=pattern): - yaml_data_set.load() - - @pytest.mark.parametrize( - "filepath,instance_type", - [ - ("s3://bucket/file.yaml", S3FileSystem), - ("file:///tmp/test.yaml", LocalFileSystem), - ("/tmp/test.yaml", LocalFileSystem), - ("gcs://bucket/file.yaml", GCSFileSystem), - ("https://example.com/file.yaml", HTTPFileSystem), - ], - ) - def test_protocol_usage(self, filepath, instance_type): - data_set = YAMLDataSet(filepath=filepath) - assert isinstance(data_set._fs, instance_type) - - path = filepath.split(PROTOCOL_DELIMITER, 1)[-1] - - assert str(data_set._filepath) == path - assert isinstance(data_set._filepath, PurePosixPath) - - def test_catalog_release(self, mocker): - fs_mock = mocker.patch("fsspec.filesystem").return_value - filepath = "test.yaml" - data_set = YAMLDataSet(filepath=filepath) - data_set.release() - fs_mock.invalidate_cache.assert_called_once_with(filepath) - - def test_dataframe_support(self, yaml_data_set): - data = pd.DataFrame({"col1": [1, 2], "col2": [4, 5]}) - yaml_data_set.save(data.to_dict()) - reloaded = yaml_data_set.load() - assert isinstance(reloaded, dict) - - data_df = pd.DataFrame.from_dict(reloaded) - assert_frame_equal(data, data_df) - - -class TestYAMLDataSetVersioned: - def test_version_str_repr(self, load_version, save_version): - """Test that version is in string representation of the class instance - when applicable.""" - filepath = "test.yaml" - ds = YAMLDataSet(filepath=filepath) - ds_versioned = YAMLDataSet( - filepath=filepath, version=Version(load_version, save_version) - ) - assert filepath in str(ds) - assert "version" not in str(ds) - - assert filepath in str(ds_versioned) - ver_str = f"version=Version(load={load_version}, save='{save_version}')" - assert ver_str in str(ds_versioned) - assert "YAMLDataSet" in str(ds_versioned) - assert "YAMLDataSet" in str(ds) - assert "protocol" in str(ds_versioned) - assert "protocol" in str(ds) - # Default save_args - assert "save_args={'default_flow_style': False}" in str(ds) - assert "save_args={'default_flow_style': False}" in str(ds_versioned) - - def test_save_and_load(self, versioned_yaml_data_set, dummy_data): - """Test that saved and reloaded data matches the original one for - the versioned data set.""" - versioned_yaml_data_set.save(dummy_data) - reloaded = versioned_yaml_data_set.load() - assert dummy_data == reloaded - - def test_no_versions(self, versioned_yaml_data_set): - """Check the error if no versions are available for load.""" - pattern = r"Did not find any versions for YAMLDataSet\(.+\)" - with pytest.raises(DatasetError, match=pattern): - versioned_yaml_data_set.load() - - def test_exists(self, versioned_yaml_data_set, dummy_data): - """Test `exists` method invocation for versioned data set.""" - assert not versioned_yaml_data_set.exists() - versioned_yaml_data_set.save(dummy_data) - assert versioned_yaml_data_set.exists() - - def test_prevent_overwrite(self, versioned_yaml_data_set, dummy_data): - """Check the error when attempting to override the data set if the - corresponding yaml file for a given save version already exists.""" - versioned_yaml_data_set.save(dummy_data) - pattern = ( - r"Save path \'.+\' for YAMLDataSet\(.+\) must " - r"not exist if versioning is enabled\." - ) - with pytest.raises(DatasetError, match=pattern): - versioned_yaml_data_set.save(dummy_data) - - @pytest.mark.parametrize( - "load_version", ["2019-01-01T23.59.59.999Z"], indirect=True - ) - @pytest.mark.parametrize( - "save_version", ["2019-01-02T00.00.00.000Z"], indirect=True - ) - def test_save_version_warning( - self, versioned_yaml_data_set, load_version, save_version, dummy_data - ): - """Check the warning when saving to the path that differs from - the subsequent load path.""" - pattern = ( - rf"Save version '{save_version}' did not match load version " - rf"'{load_version}' for YAMLDataSet\(.+\)" - ) - with pytest.warns(UserWarning, match=pattern): - versioned_yaml_data_set.save(dummy_data) - - def test_http_filesystem_no_versioning(self): - pattern = "Versioning is not supported for HTTP protocols." - - with pytest.raises(DatasetError, match=pattern): - YAMLDataSet( - filepath="https://example.com/file.yaml", version=Version(None, None) - ) - - def test_versioning_existing_dataset( - self, yaml_data_set, versioned_yaml_data_set, dummy_data - ): - """Check the error when attempting to save a versioned dataset on top of an - already existing (non-versioned) dataset.""" - yaml_data_set.save(dummy_data) - assert yaml_data_set.exists() - assert yaml_data_set._filepath == versioned_yaml_data_set._filepath - pattern = ( - f"(?=.*file with the same name already exists in the directory)" - f"(?=.*{versioned_yaml_data_set._filepath.parent.as_posix()})" - ) - with pytest.raises(DatasetError, match=pattern): - versioned_yaml_data_set.save(dummy_data) - - # Remove non-versioned dataset and try again - Path(yaml_data_set._filepath.as_posix()).unlink() - versioned_yaml_data_set.save(dummy_data) - assert versioned_yaml_data_set.exists()