From d67319e699dc928e881159a1c3cc0189296864c1 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Sun, 11 Dec 2022 21:59:10 +0300 Subject: [PATCH 1/2] py: coverage, `get_mimetype_id` fix --- .codecov.yml | 14 ++ .github/workflows/py_analysis-coverage.yml | 271 +++++++++++++++++++++ .gitignore | 1 - .nextcloudignore | 1 + README.md | 7 +- nc_py_api/db_api.py | 2 + nc_py_api/db_connectors.py | 2 +- nc_py_api/db_requests.py | 2 +- nc_py_api/mimetype.py | 6 +- pyproject.toml | 3 +- setup.cfg | 7 + tests/nc_py_api/mimetype_test.py | 13 + tests/nc_py_api/occ_test.py | 53 ++++ 13 files changed, 374 insertions(+), 8 deletions(-) create mode 100644 .codecov.yml create mode 100644 tests/nc_py_api/mimetype_test.py create mode 100644 tests/nc_py_api/occ_test.py diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 00000000..cb401da7 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,14 @@ +comment: + require_changes: true + layout: "diff, files" + +coverage: + status: + project: + default: + target: auto + threshold: 2% + patch: + default: + target: auto + threshold: 2% diff --git a/.github/workflows/py_analysis-coverage.yml b/.github/workflows/py_analysis-coverage.yml index 27739eee..2d9552ba 100644 --- a/.github/workflows/py_analysis-coverage.yml +++ b/.github/workflows/py_analysis-coverage.yml @@ -18,6 +18,9 @@ on: - 'pyproject.toml' workflow_dispatch: +env: + APP_NAME: cloud_py_api + jobs: analysis: runs-on: macos-12 @@ -46,3 +49,271 @@ jobs: - name: Run Analysis run: pre-commit run --all-files --verbose --show-diff-on-failure + + tests-pgsql: + needs: [analysis] + runs-on: ubuntu-22.04 + name: ${{ matrix.nextcloud }} • PHP ${{ matrix.php-version }} • PgSQL ${{ matrix.pgsql-version }} + if: "!contains(github.event.head_commit.message, '[docs]')" + strategy: + fail-fast: false + matrix: + nextcloud: [ "25.0.2" ] + php-version: [ "7.4", "8.0" ] + pgsql-version: [ "13", "14", "15" ] + + services: + postgres: + image: postgres:${{ matrix.pgsql-version }} + env: + POSTGRES_USER: root + POSTGRES_PASSWORD: rootpassword + POSTGRES_DB: nextcloud + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Set up php ${{ matrix.php-version }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, fileinfo, intl, pdo_pgsql, zip, gd + + - uses: actions/setup-python@v4 + with: + python-version: '3.9' + + - name: cache-nextcloud + id: nextcloud_setup + uses: actions/cache@v3 + with: + path: nextcloud-${{ matrix.nextcloud }}.tar.bz2 + key: ${{ matrix.nextcloud }} + + - name: Download Nextcloud + if: steps.nextcloud_setup.outputs.cache-hit != 'true' + run: wget -q https://download.nextcloud.com/server/releases/nextcloud-${{ matrix.nextcloud }}.tar.bz2 + + - name: Set up Nextcloud + run: | + tar -xjf nextcloud-${{ matrix.nextcloud }}.tar.bz2 --strip-components 1 + mkdir data + php occ maintenance:install --verbose --database=pgsql --database-name=nextcloud \ + --database-host=127.0.0.1 --database-user=root --database-pass=rootpassword \ + --admin-user admin --admin-pass adminpassword + php occ config:system:set debug --value=true --type=boolean + php -S localhost:8080 & + + - uses: actions/checkout@v3 + with: + path: apps/${{ env.APP_NAME }} + + - name: Enable App + run: php occ app:enable ${{ env.APP_NAME }} + + - name: Generate coverage report + working-directory: apps/${{ env.APP_NAME }} + run: | + python3 -m pip -v install ".[dev]" + coverage run -m pytest -s && coverage xml && coverage html + env: + SERVER_ROOT: "../.." + CPA_LOGLEVEL: debug + + - name: HTML coverage to artifacts + uses: actions/upload-artifact@v3 + with: + name: coverage_${{ matrix.nextcloud }}_${{ matrix.php-version }}_${{ matrix.pgsql-version }} + path: apps/${{ env.APP_NAME }}/htmlcov + if-no-files-found: error + + - name: Upload report to Codecov + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: apps/${{ env.APP_NAME }}/coverage.xml + fail_ci_if_error: true + verbose: true + + tests-mysql: + needs: [analysis] + runs-on: ubuntu-22.04 + name: ${{ matrix.nextcloud }} • PHP ${{ matrix.php-version }} • MySQL ${{ matrix.mysql-version }} + if: "!contains(github.event.head_commit.message, '[docs]')" + strategy: + fail-fast: false + matrix: + nextcloud: [ "25.0.2" ] + php-version: [ "7.4", "8.0" ] + mysql-version: [ "8" ] + + services: + mysql: + image: mysql:${{ matrix.mysql-version }} + env: + MYSQL_ROOT_PASSWORD: rootpassword + MYSQL_DATABASE: nextcloud + options: >- + --health-cmd mysqladmin ping + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 3306:3306 + + steps: + - name: Set up php ${{ matrix.php-version }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, fileinfo, intl, pdo_mysql, zip, gd + + - uses: actions/setup-python@v4 + with: + python-version: '3.9' + + - name: cache-nextcloud + id: nextcloud_setup + uses: actions/cache@v3 + with: + path: nextcloud-${{ matrix.nextcloud }}.tar.bz2 + key: ${{ matrix.nextcloud }} + + - name: Download Nextcloud + if: steps.nextcloud_setup.outputs.cache-hit != 'true' + run: wget -q https://download.nextcloud.com/server/releases/nextcloud-${{ matrix.nextcloud }}.tar.bz2 + + - name: Set up Nextcloud + run: | + tar -xjf nextcloud-${{ matrix.nextcloud }}.tar.bz2 --strip-components 1 + mkdir data + php occ maintenance:install --verbose --database=mysql --database-name=nextcloud \ + --database-host=127.0.0.1 --database-user=root --database-pass=rootpassword \ + --admin-user admin --admin-pass adminpassword + php occ config:system:set debug --value=true --type=boolean + php -S localhost:8080 & + + - uses: actions/checkout@v3 + with: + path: apps/${{ env.APP_NAME }} + + - name: Enable App + run: php occ app:enable ${{ env.APP_NAME }} + + - name: Generate coverage report + working-directory: apps/${{ env.APP_NAME }} + run: | + python3 -m pip -v install ".[dev]" + coverage run -m pytest -s && coverage xml && coverage html + env: + SERVER_ROOT: "../.." + CPA_LOGLEVEL: debug + + - name: HTML coverage to artifacts + uses: actions/upload-artifact@v3 + with: + name: coverage_${{ matrix.nextcloud }}_${{ matrix.php-version }}_${{ matrix.mysql-version }} + path: apps/${{ env.APP_NAME }}/htmlcov + if-no-files-found: error + + - name: Upload report to Codecov + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: apps/${{ env.APP_NAME }}/coverage.xml + fail_ci_if_error: true + verbose: true + + tests-mariadb: + needs: [analysis] + runs-on: ubuntu-22.04 + name: ${{ matrix.nextcloud }} • PHP ${{ matrix.php-version }} • Maria ${{ matrix.mariadb-version }} + if: "!contains(github.event.head_commit.message, '[docs]')" + strategy: + fail-fast: false + matrix: + nextcloud: [ "25.0.2" ] + php-version: [ "7.4", "8.0" ] + mariadb-version: [ "10.3", "10.6", "10.10" ] + + services: + mariadb: + image: mariadb:${{ matrix.mariadb-version }} + env: + MARIADB_ROOT_PASSWORD: rootpassword + MYSQL_DATABASE: nextcloud + options: >- + --health-cmd mysqladmin ping + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 3306:3306 + + steps: + - name: Set up php ${{ matrix.php-version }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, fileinfo, intl, pdo_mysql, zip, gd + + - uses: actions/setup-python@v4 + with: + python-version: '3.9' + + - name: cache-nextcloud + id: nextcloud_setup + uses: actions/cache@v3 + with: + path: nextcloud-${{ matrix.nextcloud }}.tar.bz2 + key: ${{ matrix.nextcloud }} + + - name: Download Nextcloud + if: steps.nextcloud_setup.outputs.cache-hit != 'true' + run: wget -q https://download.nextcloud.com/server/releases/nextcloud-${{ matrix.nextcloud }}.tar.bz2 + + - name: Set up Nextcloud + run: | + tar -xjf nextcloud-${{ matrix.nextcloud }}.tar.bz2 --strip-components 1 + mkdir data + php occ maintenance:install --verbose --database=mysql --database-name=nextcloud \ + --database-host=127.0.0.1 --database-user=root --database-pass=rootpassword \ + --admin-user admin --admin-pass adminpassword + php occ config:system:set debug --value=true --type=boolean + php -S localhost:8080 & + + - uses: actions/checkout@v3 + with: + path: apps/${{ env.APP_NAME }} + + - name: Enable App + run: php occ app:enable ${{ env.APP_NAME }} + + - name: Generate coverage report + working-directory: apps/${{ env.APP_NAME }} + run: | + python3 -m pip -v install ".[dev]" + coverage run -m pytest -s && coverage xml && coverage html + env: + SERVER_ROOT: "../.." + CPA_LOGLEVEL: debug + + - name: HTML coverage to artifacts + uses: actions/upload-artifact@v3 + with: + name: coverage_${{ matrix.nextcloud }}_${{ matrix.php-version }}_${{ matrix.mariadb-version }} + path: apps/${{ env.APP_NAME }}/htmlcov + if-no-files-found: error + + - name: Upload report to Codecov + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: apps/${{ env.APP_NAME }}/coverage.xml + fail_ci_if_error: true + verbose: true diff --git a/.gitignore b/.gitignore index 638e657b..9ea45cc1 100644 --- a/.gitignore +++ b/.gitignore @@ -79,7 +79,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ lib64/ parts/ sdist/ diff --git a/.nextcloudignore b/.nextcloudignore index d86e775a..9e6ea71e 100644 --- a/.nextcloudignore +++ b/.nextcloudignore @@ -37,3 +37,4 @@ tests /proto /tmp .readthedocs.yaml +.codecov.yml diff --git a/README.md b/README.md index fbae776e..55fb335f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # Nextcloud Python Framework +[![(Py)Analysis & Coverage](https://github.com/cloud-py-api/cloud_py_api/actions/workflows/py_analysis-coverage.yml/badge.svg)](https://github.com/cloud-py-api/cloud_py_api/actions/workflows/py_analysis-coverage.yml) +![PythonVersion](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11-blue) +![impl](https://img.shields.io/pypi/implementation/nc_py_api) +![pypi](https://img.shields.io/pypi/v/nc_py_api.svg) + Framework(App) for Nextcloud to develop apps, that using Python. Consists of PHP part(**cloud_py_api app**) and a Python module(**nc-py-api**). @@ -19,7 +24,7 @@ In your Nextcloud, simply enable the `cloud_py_api` app through the Apps managem The Nextcloud `cloud_py_api` app supports Nextcloud version 25 and higher. -#### More information can be found on [Wiki page](https://github.com/cloud_py_api/cloud_py_api/wiki) +#### More information can be found on [Wiki page](https://github.com/cloud-py-api/cloud_py_api/wiki) ## Maintainers diff --git a/nc_py_api/db_api.py b/nc_py_api/db_api.py index 27449098..1739b184 100644 --- a/nc_py_api/db_api.py +++ b/nc_py_api/db_api.py @@ -54,6 +54,7 @@ def execute_fetchall(query: str, args=None, connection_id: int = 0) -> list: break except Exception: # noqa # pylint: disable=broad-except log.exception("DB: Exception during executing fetchall.") + log.debug(query) close_connection(connection_id) return result @@ -87,5 +88,6 @@ def execute_commit(query: str, args=None, connection_id: int = 0) -> int: break except Exception: # noqa # pylint: disable=broad-except log.exception("DB: Exception during executing commit.") + log.debug(query) close_connection(connection_id) return result diff --git a/nc_py_api/db_connectors.py b/nc_py_api/db_connectors.py index 9f7e2f31..af89a45e 100644 --- a/nc_py_api/db_connectors.py +++ b/nc_py_api/db_connectors.py @@ -58,7 +58,7 @@ def create_connection(config: dict, log_errors=True): def connection_test(config: dict, log_errors=False) -> bool: - if environ.get("LOGLEVEL", "").upper() == "DEBUG": + if environ.get("CPA_LOGLEVEL", "").upper() == "DEBUG": log_errors = True connection = create_connection(config, log_errors=log_errors) if connection is not None: diff --git a/nc_py_api/db_requests.py b/nc_py_api/db_requests.py index a13fad64..317cc67f 100644 --- a/nc_py_api/db_requests.py +++ b/nc_py_api/db_requests.py @@ -60,7 +60,7 @@ def get_storages_info(num_id: Optional[int] = None) -> list: def get_mimetype_id(mimetype: str) -> int: """For string mimetype returns it number representation.""" - query = f"SELECT id FROM {TABLES.mimetypes} WHERE mimetype = {mimetype};" + query = f"SELECT id FROM {TABLES.mimetypes} WHERE mimetype = '{mimetype}';" result = execute_fetchall(query) if not result: return 0 diff --git a/nc_py_api/mimetype.py b/nc_py_api/mimetype.py index 7f86e63c..cd9914d3 100644 --- a/nc_py_api/mimetype.py +++ b/nc_py_api/mimetype.py @@ -1,5 +1,5 @@ from .db_requests import get_mimetype_id -DIR = get_mimetype_id("'httpd/unix-directory'") -IMAGE = get_mimetype_id("'image'") -VIDEO = get_mimetype_id("'video'") +DIR = get_mimetype_id("httpd/unix-directory") +IMAGE = get_mimetype_id("image") +VIDEO = get_mimetype_id("video") diff --git a/pyproject.toml b/pyproject.toml index a43f2b62..090f88e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,8 +50,9 @@ strict_optional = true [tool.pytest.ini_options] minversion = "6.0" testpaths = [ - "tests", + "tests/nc_py_api", ] filterwarnings = [ "ignore::DeprecationWarning", ] +log_cli = true diff --git a/setup.cfg b/setup.cfg index 52840318..81033dd5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,6 +32,13 @@ install_requires = pg8000 pymysql +[options.extras_require] +dev = + pytest + pre-commit + pylint + coverage + [flake8] max-line-length = 120 target-version = ["py39"] diff --git a/tests/nc_py_api/mimetype_test.py b/tests/nc_py_api/mimetype_test.py new file mode 100644 index 00000000..ec31b9da --- /dev/null +++ b/tests/nc_py_api/mimetype_test.py @@ -0,0 +1,13 @@ +import pytest + +from nc_py_api import get_mimetype_id + + +@pytest.mark.parametrize("mimetype", ["httpd", "httpd/unix-directory", "application", "text", "image", "video"]) +def test_get_mimetype_id(mimetype): + assert get_mimetype_id(mimetype) + + +@pytest.mark.parametrize("mimetype", ["", "invalid_mime", None, "'invalid_mime'"]) +def test_get_mimetype_id_invalid(mimetype): + assert not get_mimetype_id(mimetype) diff --git a/tests/nc_py_api/occ_test.py b/tests/nc_py_api/occ_test.py new file mode 100644 index 00000000..aac667fb --- /dev/null +++ b/tests/nc_py_api/occ_test.py @@ -0,0 +1,53 @@ +from unittest import mock + +import nc_py_api + + +def test_occ_call(): + assert nc_py_api.occ_call("--version").decode("utf-8").lower().find("nextcloud ") != -1 + + +def test_occ_call_invalid_command(): + assert nc_py_api.occ_call("invalid command") is None + + +def test_occ_call_with_param(): + assert nc_py_api.occ_call("config:system:get", "installed").decode("utf-8").lower() == "true\n" + + +def test_occ_call_decode(): + assert nc_py_api.occ_call_decode("--version").lower().find("nextcloud ") != -1 + + +def test_occ_call_decode_invalid_command(): + assert nc_py_api.occ_call_decode("invalid command") is None + + +def test_occ_call_decode_with_param(): + assert nc_py_api.occ_call_decode("config:system:get", "installed").lower() == "true" + + +def test_get_cloud_app_config_value(): + assert nc_py_api.get_cloud_app_config_value("core", "vendor") == "nextcloud" + + +def test_get_cloud_app_config_invalid_name(): + assert nc_py_api.get_cloud_app_config_value("core", "invalid_name") is None + + +def test_get_cloud_app_config_default_value(): + assert nc_py_api.get_cloud_app_config_value("core", "invalid_name", default=3) == 3 + + +@mock.patch("nc_py_api.occ._PHP_PATH", "no_php") +@mock.patch("nc_py_api.log.cpa_logger.exception") +def test_no_php_log_on(log_exception_mock): + assert nc_py_api.occ_call("--version") is None + log_exception_mock.assert_called_once_with("php_call exception:") + + +@mock.patch("nc_py_api.occ._PHP_PATH", "no_php") +@mock.patch("nc_py_api.log.cpa_logger.exception") +def test_no_php_log_off(log_exception_mock): + assert nc_py_api.occ_call("--version", log_error=False) is None + log_exception_mock.assert_not_called() From 3fbd54d3808e51b8f3adf3d1a156980ac06fbacf Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 12 Dec 2022 17:58:27 +0300 Subject: [PATCH 2/2] py: dev 0.7.0, fs api, tests --- .gitattributes | 13 +++- .github/workflows/py_analysis-coverage.yml | 21 ++++-- .pre-commit-config.yaml | 6 +- nc_py_api/db_requests.py | 2 +- nc_py_api/files.py | 62 ++++++++++++++---- tests/nc_py_api/files_test.py | 38 +++++++++++ tests/nc_py_api/occ_test.py | 7 ++ .../test_dir/empty_dir/empty_file.bin | 0 tests/nc_py_api/test_dir/hopper.png | Bin 0 -> 30604 bytes 9 files changed, 126 insertions(+), 23 deletions(-) create mode 100644 tests/nc_py_api/files_test.py create mode 100644 tests/nc_py_api/test_dir/empty_dir/empty_file.bin create mode 100644 tests/nc_py_api/test_dir/hopper.png diff --git a/.gitattributes b/.gitattributes index f6dba825..ca873dd7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,7 +5,18 @@ js/* binary screenshots/* binary lib/TProto/* binary -img/* binary +*.bin binary +*.heif binary +*.heic binary +*.hif binary +*.avif binary +*.png binary +*.gif binary +*.webp binary +*.tiff binary +*.jpeg binary +*.jpg binary +*.svg binary # Files to exclude from GitHub Languages statistics *.Dockerfile linguist-vendored=true diff --git a/.github/workflows/py_analysis-coverage.yml b/.github/workflows/py_analysis-coverage.yml index 2d9552ba..f7a4d9e6 100644 --- a/.github/workflows/py_analysis-coverage.yml +++ b/.github/workflows/py_analysis-coverage.yml @@ -113,8 +113,11 @@ jobs: with: path: apps/${{ env.APP_NAME }} - - name: Enable App - run: php occ app:enable ${{ env.APP_NAME }} + - name: Enable App & Test Data + run: | + php occ app:enable ${{ env.APP_NAME }} + cp -R apps/${{ env.APP_NAME }}/tests/nc_py_api/test_dir ./data/admin/files/ + php occ files:scan admin - name: Generate coverage report working-directory: apps/${{ env.APP_NAME }} @@ -202,8 +205,11 @@ jobs: with: path: apps/${{ env.APP_NAME }} - - name: Enable App - run: php occ app:enable ${{ env.APP_NAME }} + - name: Enable App & Test Data + run: | + php occ app:enable ${{ env.APP_NAME }} + cp -R apps/${{ env.APP_NAME }}/tests/nc_py_api/test_dir ./data/admin/files/ + php occ files:scan admin - name: Generate coverage report working-directory: apps/${{ env.APP_NAME }} @@ -291,8 +297,11 @@ jobs: with: path: apps/${{ env.APP_NAME }} - - name: Enable App - run: php occ app:enable ${{ env.APP_NAME }} + - name: Enable App & Test Data + run: | + php occ app:enable ${{ env.APP_NAME }} + cp -R apps/${{ env.APP_NAME }}/tests/nc_py_api/test_dir ./data/admin/files/ + php occ files:scan admin - name: Generate coverage report working-directory: apps/${{ env.APP_NAME }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d57595f8..e344c7df 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,20 +17,20 @@ repos: rev: 5.10.1 hooks: - id: isort - exclude: (^3rdparty) + files: nc_py_api/ - repo: https://github.com/psf/black rev: 22.10.0 hooks: - id: black - exclude: (^3rdparty) + files: nc_py_api/ - repo: https://github.com/PyCQA/flake8 rev: 6.0.0 hooks: - id: flake8 types: [file, python] - exclude: (^3rdparty) + files: nc_py_api/ - repo: https://github.com/pre-commit/mirrors-mypy rev: v0.991 diff --git a/nc_py_api/db_requests.py b/nc_py_api/db_requests.py index 317cc67f..4e2fb7ef 100644 --- a/nc_py_api/db_requests.py +++ b/nc_py_api/db_requests.py @@ -11,7 +11,7 @@ ) -def get_paths_by_ids(file_ids: list) -> list: +def get_paths_by_ids(file_ids: list[int]) -> list: """For each element of list in file_ids return [path, fileid, storage]. Order of file_ids is not preserved.""" query = ( diff --git a/nc_py_api/files.py b/nc_py_api/files.py index 9f621bab..33bc8c22 100644 --- a/nc_py_api/files.py +++ b/nc_py_api/files.py @@ -4,7 +4,7 @@ from fnmatch import fnmatch from os import environ, path from pathlib import Path -from typing import Literal, Optional, TypedDict +from typing import Literal, Optional, TypedDict, Union from . import mimetype from .config import CONFIG @@ -62,12 +62,31 @@ def fs_get_objs_info(file_ids: list[int]) -> list[FsNodeInfo]: return [db_record_to_fs_node(i) for i in raw_result] -def fs_list_directory(file_id: int, user_id=USER_ID) -> list[FsNodeInfo]: - _ = user_id # noqa # will be used in 0.4.0 version - dir_info = get_paths_by_ids([file_id]) +def fs_list_directory(file_id: Optional[Union[int, FsNodeInfo]] = None, user_id=USER_ID) -> list[FsNodeInfo]: + """Get listing of the directory. + + :param file_id: `fileid` or :py:data:`FsNodeInfo` of the directory. Can be `None` to list `root` directory. + :param user_id: `uid` of user. Optional, in most cases you should not specify it. + + :returns: list of :py:data:`FsNodeInfo` dictionaries.""" + + storage_id = internal_path = None + if file_id is None: # get user root `files` folder + file_id = get_files_root_node(user_id) + if file_id is None: + return [] + if not isinstance(file_id, int): # FsNodeInfo + storage_id = file_id["storageId"] + internal_path = file_id["internal_path"] + file_id = file_id["id"] + else: + dir_info = get_paths_by_ids([file_id]) + if dir_info: + storage_id = dir_info[0]["storage"] + internal_path = dir_info[0]["path"] file_mounts = [] - if dir_info: - file_mounts = get_mounts_to(dir_info[0]["storage"], dir_info[0]["path"]) + if storage_id and internal_path: + file_mounts = get_mounts_to(storage_id, internal_path) raw_result = get_directory_list(file_id, file_mounts) return [db_record_to_fs_node(i) for i in raw_result] @@ -132,29 +151,36 @@ def fs_get_file_data(file_info: FsNodeInfo) -> bytes: return request_file_from_php(file_info) -def get_storage_info(storage_id: int) -> dict: +def get_storage_by_id(storage_id: int) -> dict: for storage_info in STORAGES_INFO: if storage_info["numeric_id"] == storage_id: return storage_info return {} +def get_storage_by_user_id(user_id: str) -> dict: + for storage_info in STORAGES_INFO: + if storage_info["user_id"] == user_id: + return storage_info + return {} + + def get_storage_mount_point(storage_id: int) -> str: - storage_info = get_storage_info(storage_id) + storage_info = get_storage_by_id(storage_id) if storage_info: return storage_info["mount_point"] return "" def get_storage_user_id(storage_id: int) -> str: - storage_info = get_storage_info(storage_id) + storage_info = get_storage_by_id(storage_id) if storage_info: return storage_info["user_id"] return "" def get_storage_root_id(storage_id: int) -> int: - storage_info = get_storage_info(storage_id) + storage_info = get_storage_by_id(storage_id) if storage_info: return storage_info["root_id"] return 0 @@ -171,7 +197,7 @@ def request_file_from_php(file_info: FsNodeInfo) -> bytes: def get_file_full_path(storage_id: int, relative_path: str) -> str: - storage_info = get_storage_info(storage_id) + storage_info = get_storage_by_id(storage_id) if not storage_info: return "" path_data = storage_info["id"].split(sep="::", maxsplit=1) @@ -186,7 +212,7 @@ def get_file_full_path(storage_id: int, relative_path: str) -> str: def is_local_storage(storage_id: int) -> bool: - storage_info = get_storage_info(storage_id) + storage_info = get_storage_by_id(storage_id) if not storage_info: return False if storage_info["available"] == 0: @@ -249,3 +275,15 @@ def is_path_in_exclude(fs_path: str, exclude_patterns: list[str]) -> bool: if fnmatch(name, pattern): return True return False + + +def get_files_root_node(user_id: str) -> Union[FsNodeInfo, None]: + root_id = get_storage_by_user_id(user_id).get("root_id", 0) + if not root_id: + log.debug("can not find storage for specified user: %s", user_id) + return None + for i in get_directory_list(root_id, []): + if i["name"] == "files" and i["mimetype"] == mimetype.DIR: + return db_record_to_fs_node(i) + log.debug("can not find `files` directory inside root_id dir") + return None diff --git a/tests/nc_py_api/files_test.py b/tests/nc_py_api/files_test.py new file mode 100644 index 00000000..55caa203 --- /dev/null +++ b/tests/nc_py_api/files_test.py @@ -0,0 +1,38 @@ +import pytest + +import nc_py_api + + +@pytest.mark.parametrize("user_id", ["admin"]) +def test_fs_list_directory(user_id): + root_dir_listing = nc_py_api.fs_list_directory(user_id=user_id) + # `Documents`, `Photos`, `Templates`, `test_files` folders + assert len(root_dir_listing) >= 4 + assert any(fs_obj["name"] == "Documents" for fs_obj in root_dir_listing) + assert any(fs_obj["name"] == "Photos" for fs_obj in root_dir_listing) + assert any(fs_obj["name"] == "Templates" for fs_obj in root_dir_listing) + assert any(fs_obj["name"] == "test_dir" for fs_obj in root_dir_listing) + test_dir = [fs_obj for fs_obj in root_dir_listing if fs_obj["name"] == "test_dir"][0] + assert test_dir["is_dir"] + assert test_dir["is_local"] + assert test_dir["mimetype"] == nc_py_api.mimetype.DIR + assert test_dir["mimepart"] == nc_py_api.get_mimetype_id("httpd") + assert test_dir["internal_path"] == "files/test_dir" + assert test_dir["permissions"] == 31 + assert test_dir["ownerName"] == user_id + test_dir_listing = nc_py_api.fs_list_directory(test_dir["id"]) + assert test_dir_listing == nc_py_api.fs_list_directory(test_dir) # results should be the same + empty_dir = [fs_obj for fs_obj in test_dir_listing if fs_obj["name"] == "empty_dir"][0] + assert empty_dir["size"] == 0 + # directory should be with one empty file + assert len(nc_py_api.fs_list_directory(empty_dir)) == 1 # pass FsNodeInfo as fileid + assert len(nc_py_api.fs_list_directory(empty_dir["id"])) == 1 # pass fileid as fileid + hopper_img = [fs_obj for fs_obj in test_dir_listing if fs_obj["name"] == "hopper.png"][0] + assert not hopper_img["is_dir"] + assert hopper_img["is_local"] + assert hopper_img["mimetype"] == nc_py_api.get_mimetype_id("image/png") + assert hopper_img["mimepart"] == nc_py_api.mimetype.IMAGE + assert hopper_img["internal_path"] == "files/test_dir/hopper.png" + assert hopper_img["permissions"] == 27 + assert hopper_img["ownerName"] == user_id + # probably tests should be divided into smaller parts, need api for getting FsNode by internal_path + user_id... diff --git a/tests/nc_py_api/occ_test.py b/tests/nc_py_api/occ_test.py index aac667fb..972864b8 100644 --- a/tests/nc_py_api/occ_test.py +++ b/tests/nc_py_api/occ_test.py @@ -1,3 +1,4 @@ +import logging from unittest import mock import nc_py_api @@ -8,7 +9,9 @@ def test_occ_call(): def test_occ_call_invalid_command(): + logging.disable(logging.CRITICAL) assert nc_py_api.occ_call("invalid command") is None + logging.disable(logging.NOTSET) def test_occ_call_with_param(): @@ -20,7 +23,9 @@ def test_occ_call_decode(): def test_occ_call_decode_invalid_command(): + logging.disable(logging.CRITICAL) assert nc_py_api.occ_call_decode("invalid command") is None + logging.disable(logging.NOTSET) def test_occ_call_decode_with_param(): @@ -32,7 +37,9 @@ def test_get_cloud_app_config_value(): def test_get_cloud_app_config_invalid_name(): + logging.disable(logging.CRITICAL) assert nc_py_api.get_cloud_app_config_value("core", "invalid_name") is None + logging.disable(logging.NOTSET) def test_get_cloud_app_config_default_value(): diff --git a/tests/nc_py_api/test_dir/empty_dir/empty_file.bin b/tests/nc_py_api/test_dir/empty_dir/empty_file.bin new file mode 100644 index 00000000..e69de29b diff --git a/tests/nc_py_api/test_dir/hopper.png b/tests/nc_py_api/test_dir/hopper.png new file mode 100644 index 0000000000000000000000000000000000000000..eddf10cf248c2f24f04cd274c9464d6ba7bf5aef GIT binary patch literal 30604 zcmV)>K!d-DP)0Dy!50Qvv`0D$NK0Cg|`0P0`>06Lfe02gqax=}m;000JJ zOGiWi{{a60|De66lK=n!32;bRa{vGi!vFvd!vV){sAK>Dc2`M6K~#9!jQw|*Wyw(| z3`ay}9(Qf|s`hSrT+e8%?2I(BqmhIbtpJG?9zHFsb{De?%VOSTamyMOqeW;zfDl4} zgpol)c4S8y?`L{?+S~F~*B+Od5$_-8R&{mvi1*9qKJ}b?>fCeh$;|M-h``CI*LrXB zJg+w*r4*nYrX58|Bi@4&Wkt{VEQ$gF=bSS-(z;EkO4rYez88y#Kqd@ogt0iEr@dwk zeYWtgzW455`uTtJjW2%eKvPzFJwJ2klV5%2Pyg(bt@aE!XIGzn>CWCS{o+sl`k#O1 zE03KXYhU$U-}$}Y`}Vi*+ci;dId9jP*&>#no_hYmkNm_hMfIHr552NGbY2T6FroHd zYNlB_<$c&s`^^RxWh$(LC}9u>z=BQ~00=B%ro|{}8w@<=0uH~{6cM5z3IH&mh&VSH(T-}$@0{|A5jh5I3x48pP1mHwdHZ-uJGmGoIzE&PNa_Yp9 zV}pJ-HhyAqOe+JRJmYX+|Ndj28uW{q$yuWyGLrUI`zzFd!b-DHbFW07gI}LL@*0A^<=HK|ulpK@b8!K|}#XvSr3`V=*>o z91&$>0Dx8+5fKDH2oX_;2oZ=75s?T005{$cX#hr&joS$`Fgrre;1R4B%itM2A_^da zVs@w)&;fb?Mnpjo@JZ5ig>&8q3bhKlgW<9)Ikq z`Ll;_x_UB7;=C+MOV&eR5?lJ5N)qgJI!Y<6>PGpp00IocT3KdG%bnrC#>w~x{^*a^ z*Vk`2^rCv0N}kJ?PU=GW`=4?H?Lnf>UdLe&O9R269=2oj^n#s`!L2rvL3 z3BbioAtIgLdK}g@gk?EU;*Xy=GwgQMp>4f%tv~E2GwmGI0u=60?#b-cB7==d+r&>}ko(KgQkd-n@ zVNrU|VvHILhKSm+BuPXDbr2bfy|^;Zq;zxbL}{l(3PPhr56cWmd0z~Now4y+SWo`T zFa4Lob6Q%ZgTm!^z4GP1_xu05HZcJd-hI#OKl`~aL=(;1UUB=iHy?V}JAVWXe&?HC z^0|Nd+<*C{|E)4Q`}EiLU4L7A$XeTgJvpcq9Vk&9xB$=bUFjL_tJ`5i{QyM8XP1BPm1@LIx2O z5%CP{Mf}E<3V;AK0wE|65>ddaVof3v$FcX`TB{U-5Cfq$fGW*X(wL>Cv!J!E)#4ze zZf~`;gVGKGc$oG*=Z&ToD#oYl4&Cvyr{4T8-}+c7ah z5s7GXkpNKuJc#w;7(@^N1rUHnlPsb~w?{$5irpi75%JywDY#^LD{Vm$X+#G?AR1XA z0e~6~23iHiC`3;4w3TQQT@)6TGA1r91+|*68V>uRQb}B&o10Dt{WMLrPNG;@+edM) zd;Q)2_TT@|{sTL;3jW7${kCO${h_Npmp}M}?>KN^+x-0bzx}(9Zr{HD6|cVgWp}*7 zLH3z%eC^>cem(1Tu06Cj%iTkd9KZFp8^3Vxy?_6)5B=D?-}#C=Z^%k`_^H(|f9vt* zPAm+pS2{uvkHVrvJRnt*0*C|(000<;K)@q_kRVjtpP8?e0oD16ZCuogWUG1#L?pmi zNfiVEz%9}k0JKIxf_HuIyFUHt&vdiYi&IL8^U9zxVP2qju3oDZ8RWiDO0n~C7}=bS z3QJ!iQdxS^J`6(3 zN<{2D5fK@YY8!h*1VR=7FANT#Qei5l0vg2Cllr^9_q!JsE8dr?>ek_wG}BSrEFo5Ff@vUJ+QT<)+z~V;)cwUTPGe+2p9o00B>v> zCNu~N$*-=QyYsa#d*A>0z1gV=W5A{zlW1>;ot5)q^Ds0@sK363E?Yl;+NkD_{KSuf znY^jqfEob9i*9~-asE`j7L>id(Xeyd%!T{q7)3)8XRM%CR%^=T@J4 z;^dhV=Z33GJvVsxiN}mF>1rqMbaza*CmTT=cm>v4ZY7aoh=8|E6yNdMtAG3l-@1FI zZX^q+P~`HgKR!O54l`jC!Afo27?)UBmkQg9TYmHJNK`5$g39zoMMQ+C1eKs-AVCr& z0NUW^NJdFupx0ZPmHwyw;E{Z1GtXEV=55GW*{=edX=C}t+~ff2+Uus~t~hvbPM2%2qcUKnXI}7JdKljvdfDB*|2E{Fh zCf@x6Z)?V$^N!f8-KurE>pBd)uorg1;8~c(Gc$|lO6A)W0Glg;SB%k)-jL6N8}`A5 z0Ke?YksT0)l&2l`{dyh#{lELstjvy|IJUmFWHi{)lYmlWw7mS~H=jBC%vZnmjWk`Z zCCxm~M0~xjgTO2R*2&Qc;VD#+3zilRUL zz#nKD1Tk@M=~Ey5ht-9(rG@1OANuC$g;Rsv#&IKxYh~#ys5gGczZ4xl^!WX{KC^4@ zGz8!k2Brb|n$Lz0KJv|1-udb~UUS#IUwqhRsvguH`^MKk@W1V=U-O#T9Xn?ZUI*-4 zDQKX~hS%(=z3rY?e)#V`pR+0oG^S?RR>TTWnPUXNB)p;jS2h$^_U)$fv%zyVi30(U z1-UZ6SP+#Uitr}sFc1JJ#Ld;vaiV|pM}K(l-nlI8p|WwrXHGqL{PIYLMA7v)y)c zve#dK;GqXs)@E+H<=S>LTwXjgwc}us*+BFB(dUp?5ANU7?JW2DYekW@o7=jbrG>@b zpZxI$KJwu|yX!Ttf8E{Ral@^*&2Ha|0VD|l_?O&z@JrwL)Qza`^w*b0^a=g*M8Ucy`w(S4r+0?*K4&J&JN%5=DQB;Y7f_rJ@A#k>z;oyQGM;Yoy7|Q z$j#SXd(Hm6V28Dsva-XKOA`oC>n!_{~K|+lHgzBPEgF(+m*qAE@_K*Jj zLm&V6XNK!pD~!J9yWcUi>9-#H#y9T!>QDXbe*oXV_Udcewf34cQxb2 zKYZ+?&pq+YYp%cUZRg(gy6^a|nxffea`le!vulf?YAK4WS2A+2(8$Yzf`YKc1pEIS zoB9vX$W{OV0s@67BdhwdkLY*5`#;Rgj=$lJcm6;B^_QKc=MEnmA3v~t`?aTzKB1@# zbpP6GuloLXeb=>D%?f8x$jYvz-TaT<|3{h{!=eo0xZhu9ag%K`(}M53=fy{#x_9U9 z9e3RM`hWV|{VK>wD;)+7Z0@|0!UXKHOmwg$(Kn7+5H(A*u-H9%?5uP8!3UoB)&Ke%dG7k1%rQ@o z)qna&{^LFOykf`p>7wWbQOwSjg?-=e|K8&HMaL1MSy@@#wrzUYTa1kW4_xm~Gb&1wu2&P)?_V`5WjcHfg|&Ts$0-~HXE z-~6Vxl~zCb zM;~5VU3WyCem=K-Kd8tPvRG{Zg^3haKD$e17&jRG#hIrqLqIOO5-Y#oMtEVX(EtEi z>%=--U(e2*Tm0Hr?^hHtbD9stmvJ0^^3(rx-?zSU^;HLc@fZK|ZMVEgkXvoM^A*#t zdexKv@X60Ll4N40dGpQNZocuVdtP%l3NOx|CxVZE{8MMo%nMBRdqZ~CXb@lOz@wC9 znR%az&DPUit!5Np>B`&|VHniw?ZWxEF*U!`IlplH$nm8;yS5cB{n!8I7l_CMC^FW{ zFfEd}Rl2if;X1v(F9x5)vU4kt)_iNR{0*;^~)C zz$mD4nPGgHOy2r-D+O$xS7Yr%q;#!OYqrKd{prsOD9_*-flcX3ZD24Qrn!6Tcf5UU zZ1PwB>wkazvBP8Sxm#|1>3#Rz-|4J1>T0~Lt~t1Cc4}Ia)N8@g!t#gy@^8~r5F7Q7 z?|$iyGiOgda&!%X7NN`2o|HL?3u9$n3L;?~Vt~#uis^O-I;vYx0-*`(XBU=5#f8JQ zZsu4Gatjn%q_Eb^N*gA%L6JgW*r@e>>X1CC#r5v;dasw+f%Uz@zwEBpyy^}2JahEq zVyE}`;Ui!8+Se~EuVke!O3q8mVui3bNOfFqPi&9tEe{9?0$w~L5GhsdZzLSO5JqP% zZsJPYtfD4Dimsr5N)5e)^K7nx@@`ugg)IKqvE!>NT@!?*wa&Vv)<#8IW1Q5|p?k;o ze*fvy7vB4S{_p+wKl-VE{QQ}-OGzy`e&YEoTR(a7*@qvzzg278eAA1J)}3BjI(_)a zDTWw)@1B?MTwcEL@R5$!HOFO93=}&+Te>ug!qV9^OT$`RI2NzQ+LQf3rgg-E-Tokm zYI$J`>nA5>jEUJp>AVPa`#tX&5Q?Jk0BgNIX-(R6y8SRo1gLbfxU#0hh{Q(7WA(C55WP&Kl%hUv+L(*Zr-ku(xoC7pU>uS3b8l?+%_lD`@^p3%xXMJB8WxX}VEU&IlOpev-lZeS*{mowv zQUQu6NWS&JW2D#wG#vH=qt{l4r_P*fw#J`&@)=aEw}037eCZpH9XYbx9Mjg?T9POw z))tmCmQovKEk{vM*fNMhSIDriXac2z#g)~1oMgj6k|g!GvA(`KJvL#jy>M9~5>2yz?I1?sOnx;_{4SW4GXAGH%&?px4 z-j`+RWRTP+GMf=|6qsBHwf5kNG=V`N0l(D=F;Y)ArWb`SbChl_(`9mdgGw%%0wY7L zD9Rv+yhrwwrKQ$E6h(PimSyf-S!*=Y%&MUA+|je2`{LI{g@w?daG90fATON_qS!kk z)!4Ru?^?I`?ce#6cmMcLKltdAZ+^>LYOUrlA3EoP$Y`ydl-3rAOhy5+V_$j)K!A#L zz<}0ET(5cXD$@OY;LwkajW4dRfFd*j5SGrTdA72)n%gofi$RtaMd8_IS=Q}#^E~f% zyGu(eK@d302r7(ggMNmjtP|^DdUm?B9x2EROUe|sY&M%o6c^c0yi)|4kg9+y0HP8G z=F9oUmHwQqo4N`fY>vwBdzm}$vN)PXg=sz{rL{4RMH4w+ngD}9JLgJk>-B~gdhZ{+ zA5}2O(kP0ZfG7>1g-uar1Tlh)Pwu_%o6p{N|C9YJ{jJ~ry@{DwChyqxhJ8Y9*;?;G z5fSFzlF<&zAdCy^1+;hU8N@LWSk85Tm|)5|I*6OK)aMqg1Eq{&1|iW}S@E8LQS^o) zA|l2Z04Pf*0*vBI7X*shP7+Cu3d48uXm3Pd3)Bo?raqu5x6 zD2lAhH&Hz!Yf82_*JyJgHt1tC*9-uPkcbdQ%!-AXJpdpP0-+rX9wtRmAPNzP5L6y}@FHTZ zr7F;eqAFuZp$ItdHIfhtYscO>N2rxDrDe8`FhB*u?0lgD9ho4{b6`d;!GjXrkpvaew%bcCpBCv@W5FxB|L)3~k_8kMWuz)a#geLIf#fu^x znt(wBh>;=!D_~GZnmmj3-g|Go&GUTZAX7-PslUtS0^k)`plZTli-TH3HefTFZVLdOE77Y*nGO`eoxDa*2o zrT9_U&Jii4P=H1O^Gj9ujlF+4VB#|IaM|9!OmRX)jpC6#3LqnjLPV5Nln4AM9%qbU zk6J5aRHy&|xM1(aI~EJ*5Oq?o2~bwJByM)P%Y>y;idcvRSx}gSk%cyZetDiZqQq!o z?+8UHkh0)BZy+=yOq#XO1VIo)VW_oMfMr?cdF}<7Im0q?TALuvi*A3o(&?|PcRRg7X?@8q4FWQxS&Mk@9HK^r05BX5c@zv_Qh>xth;Spe zwFLr!sPH0IZj&IO+8kGq$mp0iNMx&e3RRpIkU_L?6}Lx3B0yJKrA?r8g<@hn~tQ3M$8v@sl zfTNeXeXd|8)ya#i^gDQm6w zwt~>$Mr4hoB3qv%k+U{1(Qvq?HAhjs$h$$5q{B>WA}bm^3ZQ2cVr$3i^*EBm$e}~K z?|9iQ2Y2nQD;+@T^3)c2y^%DVt1)8`>y)h{f|HS_!DWKdDJPO04pTYS_MV~Ju@I9 zAff{_NK!?~u5f3L9MK{ku?is&Kn1l1021+=B6^eK0|EkW3S>mox8Jh&=<^r)rHM>5 zEJ~#b0_D9ZaHufQAz+YZy`<5!Y#m6bm^}j{p+bm&2Z&ao^{xz)rYn76ZCq<*!*vf# zp&67msx{KoqRI(MtyG{RazOU91gNU z97~iH2dBs5Tdu!uTpu}e_FGS!QWT(}tSp0OA`}Xubw1W1FoRa?8Gx#|JOCh#7|4jB zSBpXdB7hh16dSUEw=y51EzC!$O>cz$PygZFKk}nL83!Tbz;Us6=l+G|^}==nQr_kH zP#Ud7Sc)w71f*2S1pyIiMgUO=M!*38ktWjGuyZUTs1<<}-WJ}C)tig}0C5xzd#SVb z;I6&5-EgQTR{LPzw(+@k+zcsz(icS(XarPB8x;g5HYOyk5HTxD08}Opg20!qEQ%s6 zz4vjWS+aoh3d7J)cdeg!4{5f%4W;p_IVGW7~H&fTs*X~wOWYBuFTgW*#C3? z>BoQLH~*;F3V-BZ|KL^E9sKE^`L|`-Z??z6APi9pBZ)CODSRdj&KE=!Y?y!oNPK9Qdp%SFH;r>f<{pc;wafRJ8{+C-LB~Mi{75e=Jf0Ypg89ug8~#p zu~JH@kU$kB5AuO?jzMa*7`0M}BH+C^3rd6;me$rH6X?>HgH%M;yX!0c-k{9FI1=$= zwFaT&>$wTFD2jAEEXyEbU^sAK*E*~nTUfcUw%V*Uq9hCz2pUT|#K4jzG^n-rp79b; z;^s!)3Rk(vXW$Yi-R1&pMKEsMq_P9Vz2(hsc;wM1f9T!+e*2EAfBqMK$(Msr;i2mf z-u$9l|Kd;oR%-*OxXeg-2FFq$7{SVTsJ@s0NGT&if~2aT9XU_{=#*w8K?T;sa9B(< zYH?tEK47DoH8nFgt^(=}`hu)oOaNz=7Oi)-aCzo@>4dJEFtq#rd+I+LsqQ)2z?cTj}X=Qm>08ETDCPzK>LM-MW8?qx zfBsr`V4r#V;Rhf1dXWvnfRd2k{iE;r`WL^nwqEpyMVgkgJ0^#Pv*Hj5z%hGfha2BP zs4JkVQXt@+D~LqM6>y2iE=-FeZTr_QhXm)beui0801X=+G)_Nf{K_r6Cl@+hw zI^9=lnY1B!MWn(iUjn8Z`GqPTg#dsXK59hN?dDj2&@WlM$G`f^KhKA~iFRvo{@m1f ztrp8q{P_3&)^Gi86ohYj%bRX}$t~~w!}q5vDUbkr0f)e7_0 z`}XfKMtd7OUpnWwUJtbjJgfS`6Q6f%a6O6Z8+5me(4Wftw`MG_^pC?gXrEX)TQyPajC?Co!V&7+Us_qx}1o4c3$}2Fk{FS2>}30yWZ$@))bMjE7N||YVX@QT@R$!8{`m>sf`D< zT*7kDYf4ax3;~v{UiB)s1OQ2(+hM&?ubn=-;5^PwLL5fGfF&5#I#lFxD|xTC)*JSF z`5-lPdUfT@xy703+1c4`)8i9^ys#*%S$h2BQQPai{&jC%TV8wY(TAqD?=*QWj>j|x z%_JNjYY=kjOBQ8;oy)v+hLpaToOIFd1*m*Jo3@y2hymHK21fc%mCSL`h*P8UBVYu8 zHX%o~D2N-Yo$MEX@t1$(N8jyiIp{4hJgndb|w)o-G(cLm>|$ zB}8t$xBA$VhppiL>u>lM@BIGrXBL`CtrkXM9G|*y?v~wi|LdK9@N2*EKR)`gf0!Dd z0jF12yGhWfH*j`xvS7V?X0nwS3tEDxR8aU5m`xOe^_T5#xgta6%FTZ1@kj_?CY(q2 zdBa^d&dhB)c5)sto|u@Mnceo;&;H}V1AG4SfBNZ}neig)7iC^L`GY_B)6*x{vP_eX z5j1*;G-QLtJ6>^96c!WXwSWEpd-q+hdeuMu(`Uuoycimd?72?at8d)K&OY+oN+~#A zr#oMKXy45EV0~p}t#e^%^@(Rr4s-7T+4(^=axf< z-t@ZHyyDhd%CuYdJ4X*csi3rFZ)&oM$`5)ggU-V8{MmD-jvsygXc))e`JLZgYmMcM zfBW~JeEjjJpLyo+xszwlpFKA{J<;#4udU3#{`IeW_@O6kVQ;zqS^#kD#+$XPw{Nc} z(d^Ehonc17m=$#3OrTMOnF)ws6c4Vj>mvvd00!C?*Lz(#aLu8YyyV4q-SwK2C!P*WlxL-v0)!S87aw})fjp}bh2DWO z8o;4&7%1@Rov*lUYHITOYxchX{T~|im-3R6DDv!pMTit=>oUde>OK2*O@}k>q!_FZ zyPfXZdY9xt97bedB?hdk;Lh_|(``yW8z_Ru_l; zmFex{&prFdCqDYoZOzGcpf8*`vb?(Dw4P3IW~P19)z?Qmx1AaWkDc!%^=$|~w>DHd zFi}|)S6afGg7-3xf0=C9s-#{<0h^LTz2UA`Me+29{`}L!%slq^^AA1z=*g2uy1msn zHaFgIsMTs}6Y3yY>*miNo%gJe!q7zGJaY=Zr**z(_heZNpMU+W*@ zv4wACSWs|0~=_f(H%B` z6nb&Ved<$R?+>*xVUkQ{S$~)f6{Uas_n&ynTi#T!H!dtJP0#Io$2;Hk*)Kdczp`q= zF%^WUlvs$OpxJCDNz&_ezVOAb%rC6RwP|DGPQQnW0;K?`(;tjCrb0yt(GC0e4Oh;2 zV-D`w=Q#)(1Mqa1%CV7`fzaInuBxOw>v2pFPt22wh~oygy)OJH{EsD@-Um27{C6SEYCB}^H@tO z)RCrUD``!SjqRMA8jF$}53a1NbvtYM@|o^%?d)^UtUvM8UG;5=-j`aZynsS2s-F*| zB<2^=Yc6B-7bU?JTL0EO%S|b=In+e!;E9u~*2Zg{%rZu`(7IH>0{C})&pW>Gh5LTt z7k>5l$;H|2dq@Y*96mxiQ7Rw=TMlf|o0}cqHaFR7j!#bRc>0?1t<2&yB^|`ce%bTU%=zJr<~` zIM_Ek@ygo|?cX-tFtB%S{Kl*HUw`$!y}Ra;B!2qv)7Rc`U9xRf)nX&^>@&|^d)1-H zL{noE-dWUm;Htfou0)F*eZ>B%9~;#$o!T0s>CLfoT&cAFFdfePG9)kqyPBXhbL#pFd$*ciZs+Fj1q9M^KkCM@{S!l zUUbuo-?UtQ{)^vaQ3R-r@!nfu69su$6lu|lYx5^gOKNv)+ipxyXc!j58?U=M3Y6<{ zb8h^?k+aG&57V(Y+_`=GwKrTpvu$pe4Z5qVwa7@GZJ(K{O;4_0IPZmv()lb^wOTwj zdF`QF($cnC&Gl{{1G)M7>rz`B*uTAAi_3o6Sy^7XaDLnNIW&50*b$>FVxC!MP>!$K zcOXjQ)z$vCsR<>Hv?_rOAuG(q$8pV-#S1F-m$@Y_o`=hA-D+$Ga4uUmy|TKlgMhSn zRTQ@LoCyFbtxSCX15bVJYtPn_ZNl|lZ)K3L3~knEwgkyD0}D7f^8Bf1pL?v)XuSCL z?%cM$Vf+k|>ZEI79Eb)MufjkF;62QrIcM6Y=x1Z~c7NC_iYyEfvh}gn_)LRLtxl6; zMK4#)+SKgS?5-XA_U;10Mr|_Ds<1mUEW6wSL z^zFCay!*QSa_02W(}(x$*%fM=uJwxk(4|8GH?&2N7!IQ#NZ5&J(`-wB$VuFq+qFHn zrhJB?Mu|MCpI7mR!X0FJWeFKRoNrE**r*ip^azS5RRIxq4t z3KfBKsQ_yVPMkPD?146|tYB>dk4`D&N{PZ4L99(txy%kr>+(`gonAV2@|+h9T3P3; zcZd>WdfS0UJ2nqIc3kV^`n|h53+Hb-bnV>iG%8rQaAvZZ1mG1oWSE(pi^343+_7t0 z;hZ2YuB;qCb@KF?vpO&@e)V05&|O$KeB}9kS6wqXyA8wGm>|pZ=J@1=^XGIBFv#51 zSBSU2;OKZ!FnEA3XHHM_RYklV=b(Wp4lR0a$EaBU6A+=EnWplb^BeNAZvWT~?hc5k)bif`WQ`17F^C*O@ z)0B@LJ4aLp9*Tz2l?)J2tUv`0QZ6Lm)cO)t5Hw25hA|%&Oh#j91p^bISn?7AE0m#y z`Sq2tsfpEkI6a=EX)o`0A~Fq%c<5Wnc-IetTAhdee61|mveFk;Rw#_4_QW+e-8#2x z8;El@8;Es=rgm&&Au=_vMcilt!R`YGFPu4hc=j+ck>D z#>TYPTn=jna+jiaD1Pzm(y_xwYpv$wo_*u{u3pG3#oB;pQobm|AO=myzRFyO4N&4% z$Xn%6sJt=EOo&t!k&w&bDjRgL1xQpa04Nkd5;eMtJ+NbD_9#q@jNlkNi)BG(3Q(Z{ zATzQjV5I<*BJq}jFv_fRKCjp7Wsxd`fH01tC<=NR%n~BI%(A=W_Prwwnfb`l{6qUkPcm$>6z)J!_V%&_Ua%`Jc2F?NMhi^ zX2ZFC``1@HE6eNC)6=RJLpNj8wbnQr6Vz)8Q3AVyT(kxx#MS+ zmzLUl_BH2r4sk|>Ap#;Zpa8Qke4yzCAwHX>HMT+xza0$X;=vUZKvi)BM)1H4mccT* zjX301FexAk5DEf=RLBh!(1Kb}i;9C#tRof>PatJJAn>Fh%Tmk!<`->y&%gbNH{A2a zxxKrFWp0wl3QupJOJCl$LaW3}u?-{b8AA!~P&046U_UQ7*5p zH(Fyr`kL#m-Mx2Dv)Nc#>sU%sF`2{CqiAiyC@`VcW-IgAI(Jw4;BX`Ft`fSN`DIn? zcXSy6(xve9Fy4lsK{OE%InSx*98nZS2%0EF(iK7g2voKG6_s2#DLq?pHUtfhBOThp z1#tkt%!O8rVj*AJKjrUu`HOcRxOK;_ef1=W6i<)W&zwFTw_8_Vdu@_LOUsMH;Xr9^ z(IvHTc5K`hHc1+-MjKQJK+x&t>+9Wx`LEo2uUJ1bJ*`j&ae|=AG*d=lU_b}jx+BjX ze)6%$4QlV~u+#0VuGyljC$(m?5eJcTelQsJhnYhRYe}=#7_W`hl4P~J0=4$KnO?(o zUp1^s5J-@mfc4HO+JZf~3>0)RP_yYSz0~`EF^&7uk6+x`8bkvq0buYRtVhdW0l~2) z3{V7_k%X&c5!^taRW)-2xGF=y=nAKZG%3r4a9K-&wA-DTY2SGBjgD;`84&N46eRs3 zYfp`}!eo4AqSsx8P@}cZ7P)X!)n>*fY$shhbry4(p4+9AIeGHr^5VkTbEjOH-+9-a zbqpg$Z~$=>I05G&cU)K<*YCRXu7~fx|M-#T&zw+e-3}_;xqHW+{d>S!QV5>YVa7Tz zIQT>{?fhHCuga1}bqXCS`boxhuGwW#i>Yh>Jdn(R#>56#J--(C8*x$ptpg zXd*?`1OqTTX3JtnvO-3MjTlC_Ul10N3Ok_yJ)$N;AaTM7%8<2%R>JIi>+6$^@cZ8R zw(GCeic8nYlPJVMK^*kE-R9VMH_daKqtW$tvvk7Nd2r5!N~yV-+4C2czxw5`cn9@* z6M&HY`O~NFx##X3Guww*>PoAILsX%-vf6>pIm$~{+H0=5=CMZ~zVXnFjYh4oWzwjj zg4MOSDH2*Z%(VirumRGDPs_koeN{56kb?anw^~8 zabQn#YD!VqtTpnyJa^`FS`MeS&7MAgZdhdP$ti7Z9mILb@?nwXkur{6w_uR84@!SNyTdb{RovO3|0VG zycg{&BJs$cxbUSih76QtrorBL=<2uL^ZK&a&HNx%paGOHDq88T2Z8Dg29q<>o!aGu)t#%NEW$9UbS(b4aDkXu@Ua7Q90iD*wA_eDxi5%yFA*Jx; zx7_|+Z+_imEeSExAkG#>2ZYie3=oNxGGTQ5_;D}pp~oNDyKUE0oPcu*FljXUk`}fM zf+QZwIww(8){NXL)VZ@rSqJs^je zyKMF3#CT_I!6=Oobh`u9YPVXgvPiA9CLr%TkuJ+J5X$q?a)!i!Uf2^-trq2Z5k(Oa zzvaz$zxh=!i@7x9i5-e3K!psC|n~gLbyzaHHZ$|a*>E+Jyf@L2#KQ=bj zS?w63TxL6i)y`UHVgCG0H{CMUY{l1JyLaDyR#;en?8Nb>pL+J_@iQ}1?b~j>b=R(4 zaS*ceQ55aiv-|Mj!w*0Ds0qWlZL?t<3(<0@ zQG2Nh_(@f950Ft1SV%U->Luf{yqB%?vH5nBM{T*_;>W84RX3s0s@)u$o}L=^yPfsb zDAI9~6q(Z|ilQV610rVc1Q0=#AhYvO3VNeafn%2|5%0@(tNtJV<4@i3^4rtS@^rf% zi&ws9cvZv>ax*qQTe7pZEc*kEG&M01gu(dOL@jE@LDK1V1#PXKgeG*Q^M%zy%jf40 zKlRM?*yN3eZbX2xv~g5(g>BaAlkKrx+qNCpxBI%Quim$3k9Z%30gLbVx-&B~jb`)M z^G64R0THG}CWIyqbZAg3j|@P8j+0t_VRfN7yM4_j$2-`e$r7U~GlP(^DmOzU07Y<2QD*Kk);!7{M_}>xNy6MKf?K*z{zk1uuWDCW&Tdh1TiZq*=7|YUbS@eps zpXURmM8rGi7(8jxS`m9A&ahWSVQjH;V(kCV zEpL5mlPL6_QN$OuTI_uhg#oehC5u8dsJvp!%d2ba?Dy^3X_RKhVOrkz;3KVO(n@OG zL3eihSl;bL&G`9e&YYS*v+KYfXUGSN0-ZElxpnpWge%GYmx)ce7kO@n+h!Q9YSrX5?$HrcN+wBLYXBvpc+h!bjTc-Ii zNMZ_%5CS2S2%<5@dl?qx;_B*s4?KAC)ah=g|AjAoRnGl;_%z34^8sJ*-Q zOifIVjkg-LdbisZk;fl<>NB7D{K-@27S_6uG+aDBz?ujWGqSK3&)^uGfERVyEPuP5 zzB%ND#@5QmTYY%|pgW!UB$7uT`nq>nJxSJkJ%xDV4ToO!>en1OcC;u5{XrkFT}uLI z^CB-)9Ev7H2G1c$GmKiww1|cm=679vAoJ|Ayh;+H(Db@P1P6*(Vd<=<5D|m0wr*Dj zX?FPeBP9C1`yc5S&@YO#D2Kz$0F5=88OwBgJl>`2IwVqAw?8cG*%N2dyogNfi@Y9% zdv@$h!l1Ld+8y*egI?IIgI0yNK@hZ?t>GY_o}S*hb7!;J9B+?_1_n_`OP$`r((=ke z-pPwQzVlT_hi0iSIffA_#o_=MsVX=LpaluIs$z3xk%AXiD=*>qvQhQ<1qU)9>PFNU za(BEvHh*r=A9Ugn$8U+|5h>B$x*gkgc9+)cCbrhr;$ZQ@{NW?VE-Wm@QH?+m0I`3*HI_ud^5TLQ zW=*BIem)4}*b8slwryf;e0FyBmRoO3l6Y!%{QT0=O1~#TRBKJ7g_|93>^^w#)cpFn zBGmP(#c3f8pa@kJD-=W_00ODJFfT*_S4!p=To`~CmL=8DyQi-YdF}4kyz#li9|!`K zb5?TB;=b{XuSHRmrtBE&^{Gy;k4AYZV}d}yd#`k0T^0~dX}xs#$?Fg9$h%A7WK9So zGFV_hf|g@nBcBB>4<#>$e(C%vT^6Cx+FI|_^6-mK%|n>Y8h#`bFF}AWoad;PeLv1vuibHri01(`Cbs#iqbHJN{j|U zK~LaOyg02!S%sS@hf0%CO7kd}bd(A?N{b|QF;|H<97kKylCY}HRaps$`qQ8JdV73& z|H13`Uv=<-M;?xv_4UE(wrz90LE7n@j>B=!SY$Q~L&pxmYXD&p64Xk0Ma*Qg&=sAt zr?(&6#q5BUR0SJZh(!yEV?-ncok4$fZOQojz+C&`X1GnwnT#oZq>3yS15T+v%pJIos`*>uKLB)Q!XkF~-wb5tCNVdaoh@L(Bvo zfJwk3)5yE<0%E{>zg6FdO-uNNq6e1+(YDfr3Mu*R_x^zuz5nWKpF4W2I~j2ZzUK|Z_x4H7G+VgUF)qaE}b7_JrkPt#5j=Z^}4NEQmZ9POUoW~ zub^I*KY!v`M}BPEuAsHe2GeI&IK!5whEM>=2+UCBeL|HqOk0xcwvNq7-&82CoZKn` zUmb43p*NY*$Q}KS$Dda9u}^&Ni%F8Cr4Pa=OS9w0k6*R_z{J>io{2&Uj0WeuD|Kuf zdmu)!;0j`Ej8-V=eqByo)g;JR1uXziZ6 z?G=i@bR8!SY24__sVuX&hp{f>hf5##epteQMjVkjCjML754Rov2T}%+!uIzIbP@rTkH~gQW#87n3PV0v}5PIb1pyn{E@x8 z_guKJnCG4u!YD3n0pNnjl;Q|kfe*@b%$IYq9#?|4&;USc?|}n43@xyrk3f9c1L#@E z)7-Uc4YIDkx)_Lqa#%Cq2kW~gliRP|w|{%1zq)+>Og=latx==MsC<}*VZ6TDQDg!g zuwb(_7KVumqQcs>_1HZ}OyG*rTUQirkb72* zV;2@64#UaWuB~yhJ;e!#r&!kt=TV6e12RvA@t)I|+-s}6c;)c4z& zau=6Dvx$0{3xir-7PctkPzAbXOaJ*V-dp4>0HtFz-V5tcm#$CUDpos zd=QwiiQ#a#Fu&aGcK7Vst(96|Ute48#c_=V6`8#U_K%NE>L4hJLW(@i227adgM7HY z-dzvlq&YE;S}RgTmQJ?Xr%#`!sL5U}EOZ)sx0(75U)0NBJe81wxb$9#5j|86HXvY4 zf&_@-886w(o0C|%=kSX11ea+?aH;;faeQ$_HR-4vbhVOtz3z;pgY`V`HfpuwCr>2x zF{K)oS)6l#gyd{N3b4$Z3_DD?6U>+}I)@4YgtOx0!;j6(%;?FwB#Ma`JaG{sM;K~H zSg`UC5r&Av-jKW(&EmbyQdN|7pGRde(VSRW?TJXxYR=9yJd;v1GrI#=^1QUp&(4ic z&CVgHyeu@e6}Ym@F*M#g=Z1*vm_6wE+3%}%wNNw2dO#8FyWWx~8DgE$!u zhr=vQ)4}rcqEZ+}+Ll?F4?wvn(mFAywY9!1oeE5nB&2oe*sIIho#q1 zk^}C(`jCpI9Zq97llho}2B?6v!U|mr3;=>8uu+(dF0rAu&UK{)RigEB@Xw`I!XqzP`HJ92?86>*VaBRv%(W=3FptRRe>jr^o|ot=T(G~~Ut=T4oRK7Y2pcUz#6m0pL(vM550d4F}jEJR!mqP(~D$@=2? zW36Un;*h->Fb?Bn?uNbF_g}TRaH^kXMNx38DH8S4OozdQF-2MCd2ZQL5Q<3Y*jh;k zzE*E(2g~anl0v`@hQ&&!*Xd@*kDqKN;ih-UFbOcC5DFqG zt+lp=DE6^ZAqjckbarptnD*Qvj@To4B-RYbsL3d0m~f@Ldi?q4Z`nDeN*@6@n*llI zK{_n+6w9)--msF)4%_1mfHJN{>#KvO&z=3&xuxI#{oi1_5+$u#bL!-=qsLC1Yd7i$ z+#3`CVH8CkV-LRD_x<5Oq+jID=vu=WGI?GM!R3U$)9cPJubo?5?e&LePOrCGjq7i@ zy)`v)Z$J35SHHd97%z~$LuRVirqZkn2&5cV1yi^Lsp?voZ#QulV`NtZ z=IGkC13;+ZzfeW^mvi8$lZkh1NOnI$#GoUl_+0(v1NPb`%K zU1e-dXwIKJc0>0D5#Np@C63FKr4VF6Dr}jTWe`VUf{TkQ?XmHNOwZ@x$L>AMV`GQz zxZ^8-^?#?M+T_ISxw8w$j-7h0cedFK##)X3&`nNE>oCYm+Z&{vPFHDdOq|4CmU`A( z_J!gcT%P9HAT1V`*Vj7zb|bv~_LoddO_h#4LgtF&C!RaE`e(1c`yIRY-!?1;7}Sf* zMPbc40W1J5Ry+WZg@BpAjU#6(1;`bpXujR`o9`|zfK=V}1iT<4jQac`0*eL-K>~L5 zvTR^E9jkq>X(6z8k!>B2%g*uk7On@C_)9Osl36c)a$ zim4ZtQ;v1G^K75q`!|m+=kTNN{+?r}kDohxp}l(!MD5#qRi4?09(uy}%3z4)3}M`8*x_)c7=m{Sbz^0^6|TeXmrx&FpmmzU83*yHrRH)^qIfO^X$d1yvL}<^i+G86^KefDB^&D z2^^DS9}s@KHNZ;>$z3iCwmPn_Sc9z;0OSDyysw%=R;$J+;KdeoRhz6gCR{e7alFiI zZyhl~A_E>kJb_~=0ZPJBVQ7>F2}#$O*3ZmqfL?!yK?@+NN7fDcK4oi7;c6$BWg?mS z_?N!%*s+4+dWkU-?A*2+1{W6QPekz@MJe5GPlvo~@3vu9cvM*-X~`XHIrlcR&f~Bs zTwxVLJq~J(db<|R&5Z5XF&%524u?8w)RWq<->W6=5)>=!fpc^PiUkgJQVz43xaRu( z)s>Yj%dL>ZhAjh6wH#D|x@*-6CnpEt2mbU+LxF*cio%3Jl%@T+(HI}!Imk2XOCTX7 zwJ>bfqG7)`Ha;CE&_YvKzqa19#rn`Y9h$fvQCZfbpqa#z&1Ae;542(S&bel@IX6A! zZ0?c7n1eUm^wp1lK?k)ow{aA)WX`AcBs~7ygMa$|`D+fn^u;fGole>+sv{^CCPbr@ zK?BZinJ%h)JpzEJHgd8rCP9j9R;jt%k5mrtEn^s-V^ ztzRYs9$&Pbj+r`qYVp*<@Xx+^B!eJ_*lSaY@(i9*)dZ~RAZY~cvGc2IV~r+))~*Ok zKb_R2v?>S+YiB}jjA~4_d(JJ|EVpG{QC<6XV8@J|ss}ZLD{DP%pwS4mcEc=9;)$8v z2hH@ZHI6b}C&$i}NT^UV`v`=47oPs+ryu+3r{{KEb=!+yal=h7tv4rnX@-T0lB6W? z9zCils3!QGwbo&x|vJ0z+{?N-Hvgn#w|)%?c9- ziHV?eYgt)f(6thbZF~Iq+I>gPJ@Vx94?eoQo`F-4vI3L13iOD8-oMwQMgTQgCKCO5t@m1qaFoCK46!(nyq#- z?hU%l$*EGCYi@gK-_#aXyADW$2M}TqL}5|DN^%9(m|(DS^7EfM`_(Uf?#5eR_VQP} zc5-5Rb$wMw4J6b^Uc7e>P#L2WlN8PhFbNQ7R!;mT1F9O=DnuHUI28b3s_L{cN!43y zY9(#p_kqMXm>3;dm2N-fpN-|UI~M< zPP~wyVXVb0010v7>0OAS3c(8?~ z%ty^|@xt=r%u2d_I5#o1W?iSdGAR1ZiDq6n@118G#!<-}l8ggQOpetBI8Vbs$8oHb zlF~OnP~UbH$u|(1@^mL@>axG1>rWkG$I3{C*ic;3Brbk;^D8~d*7G8 ze8Y{m+;P`yr{}_gSs20~1Z_NnwRu|Rq!oLx2udkHRrPI9O4%Yu+{h0Ws=h-66=?y- zz{0qZbjBj04QY>Tg&q3RXQ7s665a9QTO!4Se#et9G>mQEzURj4PiF()OL>?gPzfNU ztU;^Dg15|zxf^S?>$Pwt&(AI{&P&=`KG#R~WV~nz$+M&mgjpMnq!ECzN6O(fbOPxRm;!5Ysdb4J=XA;dSG)fzzwb6k~NMbbG>b1Z-HpGa=7;UJbYLJ*H z3NSDwP`f>|=g_Tdy^O>9!nv~s*+Eql?_!x@06;(u(oP}Fn#RoCI^BafYU{;A2i zowvQ@71v#N$d^Ul_d!yN!>}kh2*T2N1|&kqTzU~<#ZZ;?9JQ#FYP)YnC@aAEhUox+ zC|aQwaD}r>UWYm{M7}U!YdS!ZEK<4^L$kK?rW=d?;OVd4mvCT2lQM;ZuJ~p03$E+| zOU>A_YkRhIoGWxuV~xw4m4X<>7-P@?3TuS`P!yRa6lMa^qzM#}UR+q`tAn5a#b5ic zKl7e*XP2&;n~39ZwYQ7`1STXDVJZvnm?92BrbL;kAS6*GiANm;hCoVh1C2x!H5z9- z*^92f*#~t}CM(?WBhQ5jy|568RKChl^&TZc?90*;={QQXGNsF1o)YQKy!+b^J=LC^ ze$}gAbMtL42_wjhp#f#nl95OmQh_EAgGJ6nJ+jlwV_ zZ_6YJ#T7>nKl9Lo-#T~Zlp$y}YH<`AO-3nU>uo9Ql|oI3!UTjs0#zGH*)Y*6ZiG@* z{J_f1gt}zGKobGj0=zFc4IA|+3XBm(&|$=kr4ChNY^*h5_V2y_^vRU%z?B1b8A2H- zs{$rc%9MpuLhQs_25-YKW`=y&F;N%=T36s#a3CB7B)%$4EeL467ezkeA zcidH~h#^YL@{3>mQWQn5>d{+OJ-G4-<*3G{#^B;rh__`107esg&&*nnRJ5w;w-822oVqe-)-uOq2d=*M_LqF~ zp|8E^l{ZgL&2?9oTvjBtIyyqQ19E?vF z6bfjC>Y;}oveu&_1V$i{s*gMXfUk&vC=oy4Tm{BPh;Z6Of3=k1b%2-<% zA_gQD5Msfcxdc#g%%T|Z;y{UL0TfaN8!{s7**O5O zU}#ee1h53LN(l&oX9Y;8*+LLSrL&5Z^ImI{=6QeEBcpqHS#P!9^`7_q`yc$;@%g2z zX2x4ddwq4ym1Vox8ZNGeS`YjE<>kex$%E_pkes%~aI9S;f;3GFz!PT|UiG$j?7i`J zU7t)dFYJrFc;>06Nd=|1JaYIVVC8eE`Xp&Dl9g5|rIZFD%LvFSj6_5Mkg2leoOq{D zg+vIs_|lN}0=@ZD-#UN%@o#;l)|!0zU2nYh(9I0!okui*30#(kL1MkDic2GE@QlbK zA>>jafYYFe zz&S4#18o9BmOYfcY|Vc9AO8B@&wuW9Z+PQ%H{O`r!WCs0ghiQ>3K>RK&Je2Naf}Eg zTx#@ebH$*Llrn{N+9VT`bIZ%UU-+e8&2pFJHn+AhKA9KRu#P+hOj?u@LQt&GE41i6 zF#w}*z#Kb{tqYtN)(oLQC=#)R)iFY(RT8K~F=P0Ao4B!A9APag_9u*6EK?Y(35+Gp`CU8c)@=kH7 z#Rg;pwB$3+dsOuE-qII8^RbWr&7a%hY8(i15h?`nwBb|oAXTq81|dUUffHYPE*(44 zs&wEO`@`bT{`|vh>pi5vdhtj_Svp~;2v}gV&!qq`p+|5dXLjYpVgf)RL?aLY1b_jH zVj(Y3;f6>E1;jJ3fcRmz_rSOAPY1)K*2r>;V^hJw>%QxU-u?8+^J3ze?Yn{~P8#** zSUb0+1#Y&+iqc!eXlz_J+o=vaUjN5m`ER;49kyqgR2auaQJg({_St8j6++a=;1Lyp zs@jPo2@xR)k~ne9EE^aIMgkyV997lcOwFx|<%$P(n%F1=w%+F83WJhF)Qmv;VbNPY zdib&T{?2cH=uiHzx3;K=Q5aZ|Mfj3%v6@Jl%&w}MZmR>fZ+P8n3F!BJ|9!3Y=-Fh84~N0t9+? zym@Y6fs_HQU6vz=QpOhUlOO+-FkDz%8k?HV^0L$GMh3M~IeP#S7A1fJw9z?z6Fz`K zmBl9{0wl}`zM}d{hpB4&Dgr=ca9$)(+A|vyKls2Sr%#^Rf7OA0H;rqN4ia$rZQu8U zD{Cvq&mTLmdv1EFW!Bdz2&1?$F*a=rFpVe|cVT_--{1Eqw0$oZOi3aDL?8lrp8vxq zKcRHM?8JLeTB=Rrd9yV}}A9fi$kC>Pz_R4!4^~j*=RaG(;L65`;3fN$2uq4b7Gr^(Y z&|`|B6=N-xCEG&0MFbP2rTxmiUo}c2qtZHO5*-4nH~#ofw5Dd}7ni#Io?{o*lIGYL zSZUW9K@c>?+CTRzzedwLoK;k-0fKX&34F=--h1zflcznR2UHpr2$2>vfNH~Kh7}9a z1PVQh6E1~I5U0eeQAnPuC}l;*Ea(9pg9CJcp3wt&AS-AcH|nv05;?O)4_qD@(n=nE z_<_pZbkRp$eJhZD!)xz&@R0{rPn}^D4J^W?b@PiWXU?7*I;WehBrwjoN}G-2I291w zj64v-WpRcLN~jDzU`;>(f?kO@W&mOILYQS%YXhP|QFXh#ETSNM_PN8FR60oO?V2Vk zoR5MS2;cOh|K=b5_Jsb_Ut+G{PUhblpe_?0q_g}qKT_I?Na^h5l`QW79P=Sm4t>Do8WASF&W*nY6O7_ z02LCERtPigu}^&bJ&j$c@q|K0cg?wN%P zdkTEmQoRFFpR)rok+pPLF+v~^BX3abrfP46y-n%sw|u_Mzb$> z%uT-ZmK(oz-~Gl!AjoXymzEt?2D(SUsyIG^^1#Sg1$`J~Ywpbpz?CX>qRRKIaQ2KN zKj9UzI+jWU#NKf5^fS-A_O-7S&#oxLB(NnLqb!EM^M~(y;G5qn%W`>fsnhBB(v~)7 z1XRgmPak>1-S>R>um1Ybp+hDh0Ei;}XMgr*l{Kpfh)8PT&hLEYbDw?knWs;jI=Q;ORydp6GS72sZM)rGSy}mk zANYZjCr^q%QIu!T&VTAtpCY0P#;la84!#}6ZG_ak0>wsGjOtdGJ+F)!7gihWVnf5x zokPSnl9*l;l>VDQH!s==D_xCv%o&fhg3Ds zqRXKcmu>JXm*GlYbR}AE>r-9%ctq;)C!d^OTEYJaZJs*4j>| zliR#-h4p2<(I|={2%<)#ktE4*I6Qgs#w;+ z5xLTCofi?@x^XV!B%4$G&uo^h7i@)+ZQkJ`vIC^BHqG--efrZ%sUTE8`XlfD#3w&l zg&PNhLDiX(nVoYYlIM9v2U(U`YqKmHA0Pkx=Rf}gKkx(d^YeM0$8o$l>6i5qziien zqsXm~cI6J+IyMV7RaPCmKxGL*@rwMz4}3j|6#CK_W5^U)5k(2HD+zs>r`{GjcWnpY zFburiRM$q1s7updFBbz9OW8_{-)_sl;4a(z7ZEx3KzQG`zUhVk(|`J$ZUAf(l%>hgjcu_%fl2+FbmfU>m4Xj@vP6w6k= zSA`@A0IC*oo4=o#S^T9sj$EAFExm#-BlOBVMvxKCt%|%MTIaPfN(cK7UgH?Or!cNN z2c!_h`#_N^a&4$qtEFig$MM#$l+7AyN^O-&x0U{<2VQcqqHVfuh*G5j>E~x@kXODK0ZDG?EyFETj65)|Jf6o6|+rra%#4VYswj%m1G;U<(Dn zm7Hhuv!kpl8a*x%m1S88Jt7(ohe|1D-Ov2Y&s=xibvt+NY`2?fnnor{({#1dJ%9fE zfBBXFIv5O;QbkeVsLd^Gm{FJbAUA%k005!>HtZh%`6t3lVdx?<@^e+W7L{!Su;D(< zMO@lnTV1?RAK#}mF|$%eSC3a(Cn7+Y+fu6gRvJ|WaTx&@MSw}E%eeUqa?&eBG;a7p zue24me%x8B0#mgl3$%%%h!Cgt#7 z{qmQ;{N*&ySeQ231#Sf2M`fKiG8CD)dP)KRg2utdoW8K;dL;)I5fO(<1BlS5^`3|z zgCHY2Bu)qGCr=!`p}j{VWNei-xu zdIU$j*jfu4saVzDJkP^0y!-CE$HvB*trjU&76kxQe8XBRB6D+d)yxJ#aOKZ~7gAuZ zRBtwyXH$jUs=V=*ahZOxF<8V4I1!f0tHBBwwL%RHVo#qs7U<0$D1JnJj3A`?gVeDH zWiC??s}93RfIzZgvu|GTf^X%Y1;t-BDO;a`h^%wSk|YU;FUwLZ8f&(SqIkyRfst*TY$ljAt;76W@haY~pCJ8h zL_}{;{AJ5fl`g(=&sD*dQJoag6;mQWLh&MzrlkuPhycVBX=X===r^+cR##U=ureL^ zg=&^wmNmF_Y&9dd&hNJySMH$d*jii7rnOdUokX!OZI)%bckjOQ&O0Y3Csm-qdv9$u zYrb^-{vglu2OoU!*=L_c#3+g?5wca@zTmTLy=GG**xdUssN`Js`>&vM7Vsjx+5dAC zwE+_G!X=^fdYvpCAdV7aG)qP1Bp|D+>(zRU;y@L600CHDaKCTeyqjyY36uYJzy8}% z2_jZCM3gcbA_x>ZQI-r|KJZDy8hbj+qQuh(3N9{*?AS3vhS9cmp}Ny4~mFV z%6ng#1r-HsYLS=y&MiOe628Ax7ur;iD!q9#FYMwEzH|ezFk-a~qwcm9c>#D7AR^B! zV%L^eW*Tz>o|GnGw^<#tHy8jaAVs7{-EpgO(<2=A2m*|w=9Z(sxFyC7m-3>2o(S-Y zp^%hP`EVd2jYcC3W9!|{-MfGM$A99bFMrwg?c0?xpwJZsFoPE|ChKPNC(oQdcH-d& z9t^Yz!>}j{GFoWFe>OB4u3ne;e5leja5KvVJqtS#VW^5d5}~L~GNMafo3Kv=2JtLz z^t@GUYGgEd58?%p0<9wOtVwk{>k0)tBPeot7AgV&)TTEWT0taTT3=Bnh!=L%Z-P=|wX$GvgBz>`DO8K?p!z6z9Bf;&is!`Gf!UJFyOksIVm{5M&)F zAz&T_@7P&(7TB}6VKx8&$G&tWnmmJvQel((7Q5ALBu&H8Yon( zgpq~BgJ%RlCSZjO?1=zL1ChdjKq+D(0R^ndQ&c*oD9|9?)ddn4M-5?3noz)3e4{^1 zy$It*rRWj)Q z#H-LjJ(wemKY3W%Qu+!^G6)@3NpFXC`^k9~Lw-*KGXMgr*!!RV$Yin!6;c#_zHP7?G zV6e8bIv5PZj|%L0@8dW&-dFB~LATdvG>kE6nr`S}R|p0##+Y7jV1l4>v_103BQJaD zi>z0^_5tt zhYgz--a1B)$QC^0W!+9=EDG_f}phd(B3xKsYO;c1_He_ifPjF=0jV5Sj zYHD?PDL{4Gi*A19>)rqWE=~XVkN>!GNFCs{!y8W&fCL&jrMz1g!SB$ll^||S}B&yZoI+PxiI&tje z%F^oW?5udmhWR>of*@+OTFrWEtTjF}H7y9&Uvn)enjD|{_$NQHy1J^A(qV9!b_7(p z##B+*Ac%-`fGFYzgTbQ@KfG`Ij_!J=8AVw(TwGlK`Zw=;;_;`ZCMNHDfj&WecCCh z9V0-i5Rt5bR{Kz|#tXms+MoXHuLe?O)HRHzFhLqqP=pxkN&spyB}gt2oI^AS1p5QD zFm)mzOGpYYW~?T|(P$v;Rt%1qL6A_<=EfGGMn(JY?S62W#RmZZ330B~US3`<%kti> z+dJ!@ySjPq%vla`zSCVkzI5!^vH7`<0%RW`vQauXPk>hGuYL8EufKYsJJ&JBAWvQN z|COeRI3%K`X_(n+qsd&pe3_XyH`aHy9}oKdkFH(c-q<|7x_YQPzrMbXK*)eW2oVJp zPyH4V1u;VU>%ahvFqjO`$l^j9qbsX%ZfuTfvFHHV28e8-O+i9hj8WFPVA^v5q%)Vb zB(xi!^+(GtF7EB+{oPhQ&dC)SSfz={qRJF0z@%UKVtyw|DFvbpDUC6PL2pMR5TRy4 zrA!ohu>J@XO$7ndeTP#KX1bs0!glB97Zw(kGD}NO*RFYdf9K@Nsqy&z5ZIWk)0s~X zEF!w9vDQS0d8-H^)UH`uTYKY;Hk6W9MVmYYWMSlAD;F#)TcJs>KhaY^zO&>MXh(JgnDe>po)F2`Pj>Z^f z2@=}_?=5aAT;4=jt!!t^0%5ziGG1GrHATe|WZCn5sJW!r5nE zT04An>G7kQYwXwcL3mgOi05MIp{RB$!re4)g~3RRaGIX^g%Q{@KHEIM~^LMJ{Y4M z-faJ0R&1LV1Q--hSjRq+Ytx^tb;J3nv`{2InOcKnn{mF^H|MyQHZu?Gqx3_ivtG{vO z=7%NM8>6ig&#t`vi=UgkxbngK&1j5Uy?1`{53O~73X~lUCgYwm`R0uqpFh53UOa#8 z$_FDi@mVAyj-r47kO+knT#;amw19`TRzYO6H#DRKL5C=SwA=5Gt2!-)5n)2)$cU1# zLlG97_E&0am?g4Dg;rYts9aMv^;C7k2TU0eDI`q*EXJ62XHF}dwThxBhQr|jTrm?X z&zAkkj0uTph5`!%un*y_ciyh*$#_yJZQlN`cLNGbbSyD#@tv+LWl)3&gh-Ky0lim& zyUHBz99o&j?_NCp@=IsDE~wo$4$wsf3>#V)<>(!T^$xpBR^_xHW*0)|?TYcu` z7ta0BkAHmZ_=!swULWOoJ?X2DKRrItd0h$%Wrb)HL(mmG)m|8J$(;;h&}3ZX#HJk7 zP6z-50wRH#jmfeAH^7EDa-y85yaU> z8$p2$}wX+Qu$p^ykNA`(u~W)uO4Q6d{sb4B~m!ouRBE)FmHPO(+{prR`@15T`b zHr$%06PcyMPakCikG3{eSC)?~A3t>DaPUn%9+fUwlezg~Q+z3;@^+@P5~Flm6Gm)h z(T|$Ohl;f}h-%8615KwF;4HD6(LvRu)TEloIM8OCm9EC+I6rh^J<6(c)@YY*{&XrGg z276Rt{Cw9!Ycw|1XcumN;M)1kd$-@Z{GZ10+Nl%w@7&hP{?q^b(v3#Cx0ShuSd@ZB zCLBUXk!x6tQn&9s984Tw)+&_N(aXM-MOdb;Ng_cAl>moGpaf#wlt8&gMDOBmub1o? z6AuNFCRWeTnwUDk01+5im_TW*Q!8PLIa78U)RQ(95knYbM4}iYfRttFVmKi8zvuw~ zk_5l|KxJwSLLvqrrI2Xj@wRiJ-RTAwIB&H}A~Z21P77h9W&{926dwYrj0Co~w~s8H(xg>W)_Z$Rmd7= zu@7J$LIWTnq!@xV)&)n3(CErHS-Vx%6)HtS2FMCoQV0mFA}|RB5~idR;fBO21h8Q9 z5V%zs(KNI^`d}MpP_xl_o}&+LRBE)XcDKYh*y|TYF$$m(G;G^GGLXtLI~Z5Swu~{P z#zbJyN7slb)Ow1iJ;6qc2%(D686svzjm-S$!DmY=t2!|lbzLVUfSFNQnd19|0*I6n zAwfb!=NuyJldh@U+_&AYVmnEfDI#TAw%Z+RZJy^`#w;zc>@bEXyX7 zi6YR}_{wRekRh`LDL?@m!5|q{9NDvPBsz+06dD2|C#)sC%qFlBR*tcX+y~!jXUGU5 zwQoc`v0CiR`-vY9$ECrnJqNBbxse)rgE6%-rL+%HmY{UO0^W=0tSpCFVH}Dw*83E$ z6{L+3n9?~$L?1#z5);e4y}hlJ`aht)GJmEW5?KHM04#JxSaf4=ZEa<4bO1(aY-J#K za%psQWo{s1c_2Y#Z(?O2P-t&-Z*ypGaHt?OE--iYT#f($03~!qSaf7zbY(hYa%Ew3 zWdJfTF*GeOIV~_WR4_R@GdDUgI4dwSIxsNtTZ@bU001R)MObuXVRU6WZEs|0W_bWI rFflYOFgYzSG*mD-Ix{yqFgPnPGdeIZfCe)#00000NkvXXu0mjf4y9YW literal 0 HcmV?d00001